appview-less bluesky client
1<script lang="ts">
2 import { settings, type SavedFeed } from '$lib/settings';
3 import { fetchFeedGenerator, type FeedGenerator } from '$lib/at/feeds';
4 import { router, viewClient } from '$lib/state.svelte';
5 import Dropdown from './Dropdown.svelte';
6 import Icon from '@iconify/svelte';
7
8 interface Props {
9 selectedFeed: string | null;
10 onSelect: (feedUri: string | null) => void;
11 }
12
13 let { selectedFeed = $bindable(), onSelect }: Props = $props();
14
15 let isOpen = $state(false);
16
17 const savedFeeds = $derived($settings.feeds);
18 const sortedFeeds = $derived(
19 [...savedFeeds].sort((a, b) => (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0))
20 );
21
22 let feedMeta = $state<Map<string, FeedGenerator>>(new Map());
23
24 $effect(() => {
25 for (const savedFeed of savedFeeds) {
26 if (!feedMeta.has(savedFeed.feed.uri)) {
27 fetchFeedGenerator(viewClient, savedFeed.feed.uri).then((meta) => {
28 if (meta) feedMeta.set(savedFeed.feed.uri, meta);
29 });
30 }
31 }
32 });
33
34 const getDisplayName = (uri: string) => {
35 const meta = feedMeta.get(uri);
36 return meta?.displayName ?? uri.split('/').pop() ?? 'Feed';
37 };
38
39 const selectedName = $derived(selectedFeed === null ? 'replies' : getDisplayName(selectedFeed));
40</script>
41
42{#snippet feedIcon({
43 avatar,
44 replies,
45 following
46}: {
47 avatar?: string;
48 replies?: boolean;
49 following?: boolean;
50})}
51 {#if replies}
52 <Icon icon="heroicons:chat-bubble-left-ellipsis-16-solid" width="20" />
53 {:else if following}
54 <Icon icon="heroicons:users-solid" width="20" />
55 {:else if avatar}
56 <img src={avatar} alt="" class="h-5 w-5 shrink-0 rounded-sm object-cover" />
57 {:else}
58 <Icon icon="heroicons:rss" width="20" />
59 {/if}
60{/snippet}
61
62<Dropdown
63 class="min-w-48 rounded-sm border-2 border-(--nucleus-accent) bg-(--nucleus-bg) shadow-2xl"
64 bind:isOpen
65 placement="bottom-start"
66>
67 {#snippet trigger()}
68 <button
69 onclick={() => (isOpen = !isOpen)}
70 class="flex action-button items-center gap-1.5 p-2 text-sm hover:scale-102!"
71 >
72 {@render feedIcon({
73 replies: selectedFeed === null,
74 following: selectedFeed === 'following',
75 avatar: selectedFeed ? feedMeta.get(selectedFeed)?.avatar : undefined
76 })}
77 <span>{selectedName}</span>
78 <span class="opacity-50">▾</span>
79 </button>
80 {/snippet}
81 <div class="flex flex-col p-1">
82 <button
83 onclick={() => {
84 onSelect(null);
85 isOpen = false;
86 }}
87 class="my-0.5 flex items-center gap-2 rounded px-2 py-1.5 text-left text-sm transition-colors hover:bg-(--nucleus-fg)/10 {selectedFeed ===
88 null
89 ? 'bg-(--nucleus-accent)/20'
90 : ''}"
91 >
92 {@render feedIcon({ replies: true })}
93 <span>replies</span>
94 </button>
95 <button
96 onclick={() => {
97 onSelect('following');
98 isOpen = false;
99 }}
100 class="my-0.5 flex items-center gap-2 rounded px-2 py-1.5 text-left text-sm transition-colors hover:bg-(--nucleus-fg)/10 {selectedFeed ===
101 'following'
102 ? 'bg-(--nucleus-accent)/20'
103 : ''}"
104 >
105 {@render feedIcon({ following: true })}
106 <span>following</span>
107 </button>
108 {#each sortedFeeds as savedFeed (savedFeed.feed.uri)}
109 <button
110 onclick={() => {
111 onSelect(savedFeed.feed.uri);
112 isOpen = false;
113 }}
114 class="my-0.5 flex items-center gap-2 rounded px-2 py-1.5 text-left text-sm transition-colors hover:bg-(--nucleus-fg)/10 {selectedFeed ===
115 savedFeed.feed.uri
116 ? 'bg-(--nucleus-accent)/20'
117 : ''}"
118 >
119 {@render feedIcon({ avatar: savedFeed.feed.avatar })}
120 <span class="truncate">{savedFeed.feed.displayName}</span>
121 </button>
122 {/each}
123 {#if sortedFeeds.length === 0}
124 <a
125 href="/settings/feeds"
126 onclick={(e) => (e.preventDefault(), (isOpen = false), router.navigate('/settings/feeds'))}
127 class="px-3 py-2 text-sm opacity-70">add feeds in settings</a
128 >
129 {/if}
130 </div>
131</Dropdown>