appview-less bluesky client
24
fork

Configure Feed

Select the types of activity you want to include in your feed.

add feed support

dawn 8217eb11 b6ba04a0

+600 -70
+2
.gitignore
··· 24 24 25 25 /result 26 26 /.direnv 27 + 28 + /.scratchpad
+6
src/app.css
··· 58 58 scrollbar-color: var(--nucleus-accent) var(--nucleus-bg); 59 59 } 60 60 61 + html, 62 + body { 63 + background-color: var(--nucleus-bg); 64 + color: var(--nucleus-fg); 65 + } 66 + 61 67 button { 62 68 @apply hover:cursor-pointer; 63 69
+52
src/components/FeedItem.svelte
··· 1 + <script lang="ts"> 2 + import { type FeedGenerator, fetchFeedGenerator } from '$lib/at/feeds'; 3 + import type { SavedFeed } from '$lib/settings'; 4 + import Icon from '@iconify/svelte'; 5 + 6 + interface Props { 7 + style: string; 8 + data: SavedFeed; 9 + onRemove: () => void; 10 + onTogglePin: () => void; 11 + } 12 + 13 + let { style, data, onRemove, onTogglePin }: Props = $props(); 14 + 15 + const feedData = $derived(data.feed); 16 + const uri = $derived(feedData.uri); 17 + const pinned = $derived(data.pinned); 18 + </script> 19 + 20 + <div {style} class="box-border w-full py-0.5"> 21 + <div 22 + class="group flex items-center gap-2 rounded-sm bg-(--nucleus-fg)/5 px-2 py-1.5 transition-colors hover:bg-(--nucleus-fg)/10" 23 + > 24 + {#if feedData.avatar} 25 + <img src={feedData.avatar} alt="" class="h-6 w-6 shrink-0 rounded-sm object-cover" /> 26 + {/if} 27 + {#if feedData} 28 + <span class="semibold flex-1 truncate text-sm"> 29 + {feedData.displayName} 30 + </span> 31 + {:else} 32 + <span class="flex-1 truncate text-sm opacity-50">{uri.split('/').pop()}</span> 33 + {/if} 34 + <button 35 + onclick={onTogglePin} 36 + class="text-sm opacity-50 transition-opacity hover:opacity-100" 37 + title={pinned ? 'unpin' : 'pin'} 38 + > 39 + <Icon 40 + icon={pinned ? 'heroicons:star-solid' : 'heroicons:star'} 41 + width="20" 42 + class={pinned ? 'text-yellow-400' : ''} 43 + /> 44 + </button> 45 + <button 46 + onclick={onRemove} 47 + class="text-sm text-red-400 opacity-0 transition-opacity group-hover:opacity-100 hover:text-red-500" 48 + > 49 + <Icon icon="heroicons:x-mark-16-solid" width="24" /> 50 + </button> 51 + </div> 52 + </div>
+104
src/components/FeedSelector.svelte
··· 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, 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 + <Dropdown 43 + class="min-w-48 rounded-sm border-2 border-(--nucleus-accent) bg-(--nucleus-bg) shadow-2xl" 44 + bind:isOpen 45 + placement="bottom-start" 46 + > 47 + {#snippet trigger()} 48 + <button 49 + onclick={() => (isOpen = !isOpen)} 50 + class="flex action-button items-center gap-1.5 text-sm hover:scale-102!" 51 + > 52 + <Icon icon="heroicons:list-bullet" width="16" /> 53 + <span>{selectedName}</span> 54 + <span class="opacity-50">▾</span> 55 + </button> 56 + {/snippet} 57 + <div class="flex flex-col p-1"> 58 + <button 59 + onclick={() => { 60 + onSelect(null); 61 + isOpen = false; 62 + }} 63 + 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 === 64 + null 65 + ? 'bg-(--nucleus-accent)/20' 66 + : ''}" 67 + > 68 + <Icon icon="heroicons:chat-bubble-left-ellipsis-16-solid" width="20" /> 69 + <span>replies</span> 70 + </button> 71 + {#each sortedFeeds as savedFeed (savedFeed.feed.uri)} 72 + <button 73 + onclick={() => { 74 + onSelect(savedFeed.feed.uri); 75 + isOpen = false; 76 + }} 77 + 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 === 78 + savedFeed.feed.uri 79 + ? 'bg-(--nucleus-accent)/20' 80 + : ''}" 81 + > 82 + {#if savedFeed.feed.avatar} 83 + <img 84 + src={savedFeed.feed.avatar} 85 + alt="" 86 + class="h-5 w-5 shrink-0 rounded-sm object-cover" 87 + /> 88 + {:else if savedFeed.pinned} 89 + <Icon icon="heroicons:star-solid" width="20" class="text-yellow-400" /> 90 + {:else} 91 + <Icon icon="heroicons:rss" width="20" /> 92 + {/if} 93 + <span class="truncate">{savedFeed.feed.displayName}</span> 94 + </button> 95 + {/each} 96 + {#if sortedFeeds.length === 0} 97 + <a 98 + href="/settings/feeds" 99 + onclick={(e) => (e.preventDefault(), (isOpen = false), router.navigate('/settings/feeds'))} 100 + class="px-3 py-2 text-sm opacity-70">add feeds in settings</a 101 + > 102 + {/if} 103 + </div> 104 + </Dropdown>
+70
src/components/SettingsFeedsTab.svelte
··· 1 + <script lang="ts"> 2 + import FeedItem from './FeedItem.svelte'; 3 + import VirtualList from '@tutorlatin/svelte-tiny-virtual-list'; 4 + import type { FeedGenerator } from '$lib/at/feeds'; 5 + import { fetchFeedGenerator, parseFeedUri } from '$lib/at/feeds'; 6 + import type { SavedFeed } from '$lib/settings'; 7 + import { viewClient } from '$lib/state.svelte'; 8 + 9 + interface Props { 10 + feeds: SavedFeed[]; 11 + onAddFeed: (feed: FeedGenerator) => void; 12 + onRemoveFeed: (uri: string) => void; 13 + onTogglePin: (uri: string) => void; 14 + } 15 + 16 + let { feeds, onAddFeed, onRemoveFeed, onTogglePin }: Props = $props(); 17 + 18 + let newFeedInput = $state(''); 19 + 20 + const handleAddFeed = async () => { 21 + const uri = newFeedInput.trim(); 22 + if (!uri) return; 23 + if (!parseFeedUri(uri)) return; 24 + if (feeds.some((f) => f.feed.uri === uri)) return; 25 + const feed = await fetchFeedGenerator(viewClient, uri); 26 + if (!feed) return; 27 + onAddFeed(feed); 28 + newFeedInput = ''; 29 + }; 30 + 31 + const sortedFeeds = $derived([...feeds].sort((a, b) => (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0))); 32 + const isValidUri = $derived(parseFeedUri(newFeedInput.trim()) !== null); 33 + </script> 34 + 35 + <div class="space-y-4 p-4"> 36 + <div> 37 + <h3 class="settings-header">saved feeds</h3> 38 + <div class="settings-box space-y-2"> 39 + <div class="flex gap-2"> 40 + <input 41 + type="text" 42 + bind:value={newFeedInput} 43 + placeholder="https://bsky.app/profile/bsky.app/feed/whats-hot" 44 + class="single-line-input flex-1" 45 + /> 46 + <button disabled={!isValidUri} onclick={handleAddFeed} class="action-button">add</button> 47 + </div> 48 + {#if sortedFeeds.length > 0} 49 + <div class="h-fit"> 50 + <VirtualList 51 + height={Math.min(sortedFeeds.length, 7) * 44} 52 + itemCount={sortedFeeds.length} 53 + itemSize={44} 54 + > 55 + {#snippet item({ index, style }: { index: number; style: string })} 56 + <FeedItem 57 + {style} 58 + data={sortedFeeds[index]} 59 + onRemove={() => onRemoveFeed(sortedFeeds[index].feed.uri)} 60 + onTogglePin={() => onTogglePin(sortedFeeds[index].feed.uri)} 61 + /> 62 + {/snippet} 63 + </VirtualList> 64 + </div> 65 + {:else} 66 + <p class="py-2 text-center text-sm opacity-50">no saved feeds</p> 67 + {/if} 68 + </div> 69 + </div> 70 + </div>
+29 -1
src/components/SettingsView.svelte
··· 17 17 import SettingsAdvancedTab from './SettingsAdvancedTab.svelte'; 18 18 import SettingsStyleTab from './SettingsStyleTab.svelte'; 19 19 import SettingsModerationTab from './SettingsModerationTab.svelte'; 20 + import SettingsFeedsTab from './SettingsFeedsTab.svelte'; 20 21 import type { Did } from '@atcute/lexicons'; 21 22 import type { AtprotoDid } from '@atcute/lexicons/syntax'; 22 23 import Icon from '@iconify/svelte'; 24 + import type { FeedGenerator } from '$lib/at/feeds'; 23 25 24 26 interface Props { 25 27 tab: string; ··· 97 99 syncStatus = 'synced'; 98 100 setTimeout(() => (syncStatus = null), 2000); 99 101 }; 102 + 103 + const feeds = $derived(localSettings.feeds); 104 + 105 + const handleAddFeed = (feed: FeedGenerator) => { 106 + localSettings.feeds = [...localSettings.feeds, { feed, pinned: false }]; 107 + settings.set(localSettings); 108 + }; 109 + 110 + const handleRemoveFeed = (uri: string) => { 111 + localSettings.feeds = localSettings.feeds.filter((f) => f.feed.uri !== uri); 112 + settings.set(localSettings); 113 + }; 114 + 115 + const handleTogglePin = (uri: string) => { 116 + localSettings.feeds = localSettings.feeds.map((f) => 117 + f.feed.uri === uri ? { ...f, pinned: !f.pinned } : f 118 + ); 119 + settings.set(localSettings); 120 + }; 100 121 </script> 101 122 102 123 <div class="flex flex-col"> ··· 166 187 /> 167 188 {:else if tab === 'style'} 168 189 <SettingsStyleTab bind:localSettings /> 190 + {:else if tab === 'feeds'} 191 + <SettingsFeedsTab 192 + {feeds} 193 + onAddFeed={handleAddFeed} 194 + onRemoveFeed={handleRemoveFeed} 195 + onTogglePin={handleTogglePin} 196 + /> 169 197 {/if} 170 198 </div> 171 199 ··· 175 203 z-20 w-full max-w-2xl bg-(--nucleus-bg) p-4 pt-2 pb-1 shadow-[0_-10px_20px_-5px_rgba(0,0,0,0.1)] 176 204 " 177 205 > 178 - <Tabs tabs={['moderation', 'style', 'advanced']} activeTab={tab} {onTabChange} /> 206 + <Tabs tabs={['feeds', 'moderation', 'style', 'advanced']} activeTab={tab} {onTabChange} /> 179 207 </div> 180 208 </div>
+105 -33
src/components/TimelineView.svelte
··· 11 11 fetchTimeline, 12 12 allPosts, 13 13 timelines, 14 + viewClient, 14 15 fetchInteractionsToTimelineEnd, 15 - accountPreferences 16 + accountPreferences, 17 + feedTimelines, 18 + feedCursors, 19 + fetchFeed, 20 + resetFeed 16 21 } from '$lib/state.svelte'; 17 22 import Icon from '@iconify/svelte'; 18 23 import { buildThreads, filterThreads, type ThreadPost } from '$lib/thread'; 19 - import type { Did } from '@atcute/lexicons/syntax'; 24 + import type { Did, RecordKey } from '@atcute/lexicons/syntax'; 20 25 import NotLoggedIn from './NotLoggedIn.svelte'; 26 + import { fetchFeedGenerator } from '$lib/at/feeds'; 21 27 22 28 interface Props { 23 29 client?: AtpClient | null; 24 30 targetDid?: Did; 25 31 postComposerState: PostComposerState; 26 32 class?: string; 27 - // whether to show replies that are not the user's own posts 28 33 showReplies?: boolean; 34 + selectedFeed?: string | null; 29 35 } 30 36 31 37 let { ··· 33 39 targetDid = undefined, 34 40 showReplies = true, 35 41 postComposerState = $bindable(), 42 + selectedFeed = $bindable(null), 36 43 class: className = '' 37 44 }: Props = $props(); 38 45 ··· 46 53 const currentPrefs = $derived(userDid ? accountPreferences.get(userDid) : null); 47 54 const mutes = $derived(currentPrefs?.mutes ?? []); 48 55 56 + let feedServiceDid = $state<string | null>(null); 57 + 58 + $effect(() => { 59 + if (selectedFeed) { 60 + fetchFeedGenerator(client ?? viewClient, selectedFeed).then((meta) => { 61 + feedServiceDid = meta?.did ?? null; 62 + }); 63 + } else { 64 + feedServiceDid = null; 65 + } 66 + }); 67 + 68 + export const clearFeed = () => { 69 + if (!selectedFeed || !userDid) return; 70 + resetFeed(userDid, selectedFeed); 71 + loaderState.reset(); 72 + loadMore(); 73 + }; 74 + 49 75 const threads = $derived( 50 - // todo: apply showReplies here 51 76 filterThreads( 52 77 did && timelines.has(did) ? buildThreads(did, timelines.get(did)!, allPosts, mutes) : [], 53 78 $accounts, ··· 55 80 ) 56 81 ); 57 82 83 + const feedPosts = $derived.by(() => { 84 + if (!selectedFeed || !userDid) return []; 85 + const uris = feedTimelines.get(userDid)?.get(selectedFeed) ?? []; 86 + return uris 87 + .map((uri) => { 88 + const did = uri.split('/')[2] as Did; 89 + return allPosts.get(did)?.get(uri); 90 + }) 91 + .filter((p): p is NonNullable<typeof p> => p !== undefined); 92 + }); 93 + 58 94 const loaderState = new LoaderState(); 59 95 let scrollContainer = $state<HTMLDivElement>(); 60 96 let loading = $state(false); 61 97 let loadError = $state(''); 62 98 63 99 const loadMore = async () => { 64 - if (loading || !client || !did) return; 100 + if (loading || !client || !userDid) return; 65 101 66 102 loading = true; 67 103 loaderState.status = 'LOADING'; 68 104 69 105 try { 70 - await fetchTimeline(client, did, 7, showReplies, { 71 - downwards: userDid === did ? 'sameAuthor' : 'none' 72 - }); 73 - // only fetch interactions if logged in (because if not who is the interactor) 74 - if (client.user && userDid) { 75 - if (!fetchingInteractions) { 76 - scheduledFetchInteractions = false; 77 - fetchingInteractions = true; 78 - await fetchInteractionsToTimelineEnd(client, userDid, did); 79 - fetchingInteractions = false; 80 - } else { 81 - scheduledFetchInteractions = true; 106 + if (selectedFeed && feedServiceDid) { 107 + const result = await fetchFeed(client, selectedFeed, feedServiceDid); 108 + loaderState.loaded(); 109 + if (result?.end) loaderState.complete(); 110 + } else if (did) { 111 + await fetchTimeline(client, did, 7, showReplies, { 112 + downwards: userDid === did ? 'sameAuthor' : 'none' 113 + }); 114 + if (client.user && userDid) { 115 + if (!fetchingInteractions) { 116 + scheduledFetchInteractions = false; 117 + fetchingInteractions = true; 118 + await fetchInteractionsToTimelineEnd(client, userDid, did); 119 + fetchingInteractions = false; 120 + } else { 121 + scheduledFetchInteractions = true; 122 + } 82 123 } 124 + loaderState.loaded(); 125 + const cursor = postCursors.get(did); 126 + if (cursor?.end) loaderState.complete(); 83 127 } 84 - loaderState.loaded(); 85 128 } catch (error) { 86 129 loadError = `${error}`; 87 130 loaderState.error(); ··· 90 133 } 91 134 92 135 loading = false; 93 - const cursor = postCursors.get(did); 94 - if (cursor && cursor.end) loaderState.complete(); 95 136 }; 96 137 97 138 $effect(() => { 98 - if (threads.length === 0 && !loading && userDid && did) { 99 - // if we saw all posts dont try to load more. 100 - // this only really happens if the user has no posts at all 101 - // but we do have to handle it to not cause an infinite loop 102 - const cursor = did ? postCursors.get(did) : undefined; 103 - if (!cursor?.end) loadMore(); 139 + const isEmpty = selectedFeed ? feedPosts.length === 0 : threads.length === 0; 140 + if (isEmpty && !loading && userDid) { 141 + if (selectedFeed) { 142 + const cursor = feedCursors.get(userDid)?.get(selectedFeed); 143 + if (!cursor?.end) loadMore(); 144 + } else if (did) { 145 + const cursor = postCursors.get(did); 146 + if (!cursor?.end) loadMore(); 147 + } 104 148 } 105 149 }); 106 150 ··· 191 235 {/each} 192 236 {/snippet} 193 237 238 + {#snippet feedPostsView()} 239 + {#each feedPosts as post, i (post.uri)} 240 + {@const uriParts = post.uri.split('/')} 241 + {@const postDid = uriParts[2] as Did} 242 + {@const postRkey = uriParts[4] as RecordKey} 243 + <div class="mb-1.5"> 244 + <BskyPost 245 + client={client!} 246 + did={postDid} 247 + rkey={postRkey} 248 + data={post} 249 + onQuote={(p) => { 250 + postComposerState.focus = 'focused'; 251 + postComposerState.quoting = p; 252 + }} 253 + onReply={(p) => { 254 + postComposerState.focus = 'focused'; 255 + postComposerState.replying = p; 256 + }} 257 + /> 258 + </div> 259 + {#if i < feedPosts.length - 1} 260 + <div 261 + class="mx-8 mt-3 mb-4 h-px bg-linear-to-r from-(--nucleus-accent)/30 to-(--nucleus-accent2)/30" 262 + ></div> 263 + {/if} 264 + {/each} 265 + {/snippet} 266 + 194 267 <div 195 268 class="min-h-full p-2 [scrollbar-color:var(--nucleus-accent)_transparent] {className}" 196 269 bind:this={scrollContainer} 197 270 > 198 271 {#if targetDid || $accounts.length > 0} 199 - <InfiniteLoader 200 - {loaderState} 201 - triggerLoad={loadMore} 202 - loopDetectionTimeout={0} 203 - intersectionOptions={{ root: scrollContainer }} 204 - > 205 - {@render threadsView()} 272 + <InfiniteLoader {loaderState} triggerLoad={loadMore} loopDetectionTimeout={0}> 273 + {#if selectedFeed} 274 + {@render feedPostsView()} 275 + {:else} 276 + {@render threadsView()} 277 + {/if} 206 278 {#snippet noData()} 207 279 <div class="flex justify-center py-4"> 208 280 <p class="text-xl opacity-80">
+88
src/lib/at/feeds.ts
··· 1 + import { type ActorIdentifier, type Did, type RecordKey } from '@atcute/lexicons/syntax'; 2 + import { type AtpClient } from './client.svelte'; 3 + import { AppBskyFeedGenerator } from '@atcute/bluesky'; 4 + import { img } from '$lib/cdn'; 5 + import type { Blob as AtprotoBlob } from '@atcute/lexicons'; 6 + 7 + export type FeedGenerator = { 8 + uri: string; 9 + displayName: string; 10 + description?: string; 11 + avatar?: string; 12 + did: string; 13 + }; 14 + 15 + export function parseFeedUri(uri: string): { repo: ActorIdentifier; rkey: RecordKey } | null { 16 + if (uri.startsWith('at://')) { 17 + const match = uri.match(/^at:\/\/([^/]+)\/app\.bsky\.feed\.generator\/([^/]+)$/); 18 + if (!match) return null; 19 + return { repo: match[1] as ActorIdentifier, rkey: match[2] as RecordKey }; 20 + } else if (uri.startsWith('https://') || uri.startsWith('http://')) { 21 + const match = uri.match(/^https?:\/\/(?:[^/]+)\/profile\/([^/]+)\/feed\/([^/]+)$/); 22 + if (!match) return null; 23 + return { repo: match[1] as ActorIdentifier, rkey: match[2] as RecordKey }; 24 + } 25 + return null; 26 + } 27 + 28 + export async function fetchFeedGenerator(client: AtpClient, uri: string): Promise<FeedGenerator | null> { 29 + const parsed = parseFeedUri(uri); 30 + if (!parsed) return null; 31 + 32 + try { 33 + const response = await client.getRecord(AppBskyFeedGenerator.mainSchema, parsed.repo, parsed.rkey); 34 + if (!response.ok) return null; 35 + 36 + const value = response.value.record; 37 + const did = response.value.uri.split('/')[2] as Did; 38 + const avatar = value.avatar ? img('avatar_thumbnail', did, (value.avatar as AtprotoBlob<string>).ref.$link, 'webp') : undefined; 39 + 40 + return { 41 + uri: response.value.uri, 42 + displayName: value.displayName, 43 + description: value.description, 44 + did: value.did, 45 + avatar, 46 + }; 47 + } catch { 48 + return null; 49 + } 50 + } 51 + 52 + export type FeedSkeletonItem = { 53 + post: string; 54 + reason?: { $type: string; repost?: string }; 55 + }; 56 + 57 + export type FeedSkeleton = { 58 + feed: FeedSkeletonItem[]; 59 + cursor?: string; 60 + }; 61 + 62 + export async function fetchFeedSkeleton( 63 + client: AtpClient, 64 + feedUri: string, 65 + feedServiceDid: string, 66 + cursor?: string 67 + ): Promise<FeedSkeleton | null> { 68 + const auth = client.user; 69 + if (!auth) return null; 70 + 71 + const cursorParam = cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''; 72 + const url = `/xrpc/app.bsky.feed.getFeedSkeleton?feed=${encodeURIComponent(feedUri)}${cursorParam}`; 73 + 74 + try { 75 + const response = await auth.atcute.handler(url, { 76 + method: 'GET', 77 + headers: { 78 + 'atproto-proxy': `${feedServiceDid}#bsky_fg` 79 + } 80 + }); 81 + 82 + if (!response.ok) return null; 83 + return (await response.json()) as FeedSkeleton; 84 + } catch { 85 + return null; 86 + } 87 + } 88 +
+1 -1
src/lib/oauth.ts
··· 8 8 logo_uri: `${domain}/favicon.png`, 9 9 redirect_uris: [`${domain}/`], 10 10 scope: 11 - 'atproto repo:*?action=create&action=update&action=delete rpc:com.atproto.repo.uploadBlob?aud=* rpc:net.at-app.pet.ptr.nucleus.getPreferences?aud=* rpc:net.at-app.pet.ptr.nucleus.putPreferences?aud=* blob:*/*', 11 + 'atproto repo:*?action=create&action=update&action=delete rpc:com.atproto.repo.uploadBlob?aud=* rpc:net.at-app.pet.ptr.nucleus.getPreferences?aud=* rpc:net.at-app.pet.ptr.nucleus.putPreferences?aud=* rpc:app.bsky.feed.getFeedSkeleton?aud=* blob:*/*', 12 12 grant_types: ['authorization_code', 'refresh_token'], 13 13 response_types: ['code'], 14 14 token_endpoint_auth_method: 'none',
+15 -10
src/lib/router.svelte.ts
··· 1 1 /* eslint-disable svelte/no-navigation-without-resolve */ 2 2 import { pushState, replaceState } from '$app/navigation'; 3 3 import { SvelteMap } from 'svelte/reactivity'; 4 + import { tick } from 'svelte'; 4 5 5 6 export const routes = [ 6 7 { path: '/', order: 0 }, ··· 16 17 type ExtractParams<Path extends string> = 17 18 // eslint-disable-next-line @typescript-eslint/no-unused-vars 18 19 Path extends `${infer Start}/:${infer Param}/${infer Rest}` 19 - ? { [K in Param | keyof ExtractParams<`/${Rest}`>]: string } 20 - : // eslint-disable-next-line @typescript-eslint/no-unused-vars 21 - Path extends `${infer Start}/:${infer Param}` 22 - ? { [K in Param]: string } 23 - : Record<string, never>; 20 + ? { [K in Param | keyof ExtractParams<`/${Rest}`>]: string } 21 + : // eslint-disable-next-line @typescript-eslint/no-unused-vars 22 + Path extends `${infer Start}/:${infer Param}` 23 + ? { [K in Param]: string } 24 + : Record<string, never>; 24 25 25 26 export type Route<K extends RoutePath = RoutePath> = { 26 27 [T in K]: { ··· 82 83 this._updateState(window.location.pathname); 83 84 // update state on browser navigation 84 85 window.addEventListener('popstate', () => this._updateState(window.location.pathname)); 86 + 87 + // disable browser scroll restoration 88 + if ('scrollRestoration' in history) { 89 + history.scrollRestoration = 'manual'; 90 + } 85 91 } 86 92 87 93 match(urlPath: string): Route | undefined { ··· 118 124 else this.direction = 'left'; 119 125 } 120 126 121 - private _updateState(url: string) { 127 + private async _updateState(url: string) { 122 128 const target = this.match(url); 123 129 if (!target) return; 124 130 ··· 129 135 this.current = target; 130 136 131 137 if (typeof window !== 'undefined') { 132 - setTimeout(() => { 133 - const savedScroll = this.scrollPositions.get(target.url) ?? 0; 134 - window.scrollTo({ top: savedScroll, behavior: 'auto' }); 135 - }, 0); 138 + await tick(); 139 + const savedScroll = this.scrollPositions.get(target.url) ?? 0; 140 + window.scrollTo({ top: savedScroll, behavior: 'instant' }); 136 141 } 137 142 } 138 143
+10 -1
src/lib/settings.ts
··· 1 1 import { writable } from 'svelte/store'; 2 2 import { defaultTheme, type Theme } from './theme'; 3 + import type { FeedGenerator } from './at/feeds'; 3 4 4 5 export type ApiEndpoints = Record<string, string> & { 5 6 slingshot: string; ··· 7 8 constellation: string; 8 9 jetstream: string; 9 10 }; 11 + export type SavedFeed = { 12 + feed: FeedGenerator, 13 + pinned: boolean, 14 + }; 15 + 10 16 export type Settings = { 11 17 endpoints: ApiEndpoints; 12 18 theme: Theme; 13 19 socialAppUrl: string; 20 + feeds: SavedFeed[]; 14 21 }; 15 22 16 23 export const defaultSettings: Settings = { ··· 21 28 jetstream: 'wss://jetstream2.fr.hose.cam' 22 29 }, 23 30 theme: defaultTheme, 24 - socialAppUrl: 'https://bsky.app' 31 + socialAppUrl: 'https://bsky.app', 32 + feeds: [] 25 33 }; 26 34 27 35 const createSettingsStore = () => { ··· 32 40 initial.endpoints = { ...defaultSettings.endpoints, ...initial.endpoints }; 33 41 initial.theme = { ...defaultSettings.theme, ...initial.theme }; 34 42 initial.socialAppUrl = initial.socialAppUrl ?? defaultSettings.socialAppUrl; 43 + initial.feeds = initial.feeds ?? defaultSettings.feeds; 35 44 36 45 const { subscribe, set, update } = writable<Settings>(initial as Settings); 37 46
+83 -11
src/lib/state.svelte.ts
··· 417 417 return res.value.total > 0; 418 418 }; 419 419 420 + export const fetchBlocksForPosts = async (client: AtpClient, postUris: Iterable<ResourceUri>) => { 421 + const userDid = client.user?.did; 422 + if (!userDid) return; 423 + 424 + // check if any of the post authors block the user 425 + // eslint-disable-next-line svelte/prefer-svelte-reactivity 426 + let distinctDids = new Set(Array.from(postUris).map((uri) => extractDidFromUri(uri)!)); 427 + distinctDids.delete(userDid); // dont need to check if user blocks themselves 428 + const alreadyFetched = blockFlags.get(userDid); 429 + if (alreadyFetched) distinctDids = distinctDids.difference(alreadyFetched); 430 + if (distinctDids.size > 0) 431 + await Promise.all(distinctDids.values().map((did) => fetchBlocked(client, userDid, did))); 432 + }; 433 + 420 434 export const fetchBlocks = async (account: Account) => { 421 435 const client = clients.get(account.did)!; 422 436 const res = await client.listRecordsUntil(account.did, 'app.bsky.graph.block'); ··· 569 583 export const timelines = new SvelteMap<Did, SvelteSet<ResourceUri>>(); 570 584 export const postCursors = new SvelteMap<Did, { value?: string; end: boolean }>(); 571 585 586 + // feed state: Did -> feedUri -> post URIs 587 + export const feedTimelines = new SvelteMap<Did, SvelteMap<string, ResourceUri[]>>(); 588 + export const feedCursors = new SvelteMap<Did, SvelteMap<string, { value?: string; end: boolean }>>(); 589 + 590 + export const fetchFeed = async ( 591 + client: AtpClient, 592 + feedUri: string, 593 + feedServiceDid: string, 594 + limit: number = 25 595 + ) => { 596 + const userDid = client.user?.did; 597 + if (!userDid) return; 598 + 599 + let userFeedCursors = feedCursors.get(userDid); 600 + if (!userFeedCursors) { 601 + userFeedCursors = new SvelteMap(); 602 + feedCursors.set(userDid, userFeedCursors); 603 + } 604 + 605 + const cursor = userFeedCursors.get(feedUri); 606 + if (cursor?.end) return; 607 + 608 + const skeleton = await import('./at/feeds').then((m) => 609 + m.fetchFeedSkeleton(client, feedUri, feedServiceDid, cursor?.value) 610 + ); 611 + if (!skeleton) throw `failed to fetch feed skeleton for ${feedUri}`; 612 + 613 + const newCursor = { value: skeleton.cursor, end: !skeleton.cursor }; 614 + userFeedCursors.set(feedUri, newCursor); 615 + 616 + const uris = skeleton.feed.slice(0, limit).map((item) => item.post as ResourceUri); 617 + 618 + let userFeedTimelines = feedTimelines.get(userDid); 619 + if (!userFeedTimelines) { 620 + userFeedTimelines = new SvelteMap(); 621 + feedTimelines.set(userDid, userFeedTimelines); 622 + } 623 + 624 + const existing = userFeedTimelines.get(feedUri) ?? []; 625 + userFeedTimelines.set(feedUri, [...existing, ...uris]); 626 + 627 + // fetch each post record 628 + const posts = await Promise.all( 629 + uris.map(async (uri) => { 630 + const result = await client.getRecordUri(AppBskyFeedPost.mainSchema, uri); 631 + if (!result.ok) return null; 632 + return { uri: result.value.uri, cid: result.value.cid, record: result.value.record }; 633 + }) 634 + ); 635 + 636 + const validPosts = posts.filter((p): p is PostWithUri => p !== null); 637 + addPosts(validPosts); 638 + 639 + // check if any of the post authors block the user 640 + await fetchBlocksForPosts( 641 + client, 642 + validPosts.map((p) => p.uri) 643 + ); 644 + 645 + return newCursor; 646 + }; 647 + 648 + export const resetFeed = (did: Did, feedUri: string) => { 649 + feedTimelines.get(did)?.delete(feedUri); 650 + feedCursors.get(did)?.delete(feedUri); 651 + }; 652 + 653 + 572 654 const traversePostChain = (post: PostWithUri) => { 573 655 const result = [post.uri]; 574 656 const parentUri = post.record.reply?.parent.uri; ··· 621 703 addPosts(hydrated.value.values()); 622 704 addTimeline(subject, hydrated.value.keys()); 623 705 624 - if (client.user?.did) { 625 - const userDid = client.user.did; 626 - // check if any of the post authors block the user 627 - // eslint-disable-next-line svelte/prefer-svelte-reactivity 628 - let distinctDids = new Set(hydrated.value.keys().map((uri) => extractDidFromUri(uri)!)); 629 - distinctDids.delete(userDid); // dont need to check if user blocks themselves 630 - const alreadyFetched = blockFlags.get(userDid); 631 - if (alreadyFetched) distinctDids = distinctDids.difference(alreadyFetched); 632 - if (distinctDids.size > 0) 633 - await Promise.all(distinctDids.values().map((did) => fetchBlocked(client, userDid, did))); 634 - } 706 + await fetchBlocksForPosts(client, hydrated.value.keys()); 635 707 636 708 console.log(`${subject}: fetchTimeline`, accPosts.value.cursor); 637 709 return newCursor;
+35 -13
src/routes/[...catchall]/+page.svelte
··· 22 22 addTimeline, 23 23 router, 24 24 fetchInitial, 25 - loadAccountPreferences 25 + loadAccountPreferences, 26 + resetFeed 26 27 } from '$lib/state.svelte'; 27 28 import { get } from 'svelte/store'; 28 29 import Icon from '@iconify/svelte'; ··· 33 34 import { settings } from '$lib/settings'; 34 35 import type { Sort } from '$lib/following'; 35 36 import { SvelteMap } from 'svelte/reactivity'; 37 + import FeedSelector from '$components/FeedSelector.svelte'; 36 38 37 39 const { data: loadData }: PageProps = $props(); 38 40 ··· 89 91 text: '', 90 92 blobsState: new SvelteMap() 91 93 }); 94 + let selectedFeed = $state<string | null>(null); 95 + let timelineView: { clearFeed: () => void } | undefined = $state(); 92 96 let showScrollToTop = $state(false); 93 97 const handleScroll = () => { 94 98 if (currentRoute.path === '/' || currentRoute.path === '/profile/:actor') ··· 213 217 <div class="mx-auto flex min-h-dvh max-w-2xl flex-col"> 214 218 <div class="flex-1"> 215 219 {#if currentRoute.path === '/'} 216 - <TimelineView 217 - class={animClass} 218 - client={selectedClient} 219 - showReplies={true} 220 - bind:postComposerState 221 - /> 220 + <div class={animClass}> 221 + <div class="p-4"> 222 + <h1 class="text-3xl font-bold tracking-tight">nucleus</h1> 223 + <div class="mt-1 flex gap-2"> 224 + <div class="h-1 w-11 rounded-full bg-(--nucleus-accent)"></div> 225 + <div class="h-1 w-8 rounded-full bg-(--nucleus-accent2)"></div> 226 + </div> 227 + </div> 228 + <TimelineView 229 + client={selectedClient} 230 + showReplies={true} 231 + bind:postComposerState 232 + bind:selectedFeed 233 + bind:this={timelineView} 234 + /> 235 + </div> 222 236 {:else if currentRoute.path === '/settings/:tab'} 223 237 <div class={animClass}> 224 238 <SettingsView tab={currentRoute.params.tab} /> ··· 317 331 <div class="footer-border-bg rounded-t-sm px-0.75 pt-0.75"> 318 332 <div class="footer-bg rounded-t-sm"> 319 333 <div class="flex items-center gap-1.5 px-2 py-1"> 320 - <div class="mb-2"> 321 - <h1 class="text-3xl font-bold tracking-tight">nucleus</h1> 322 - <div class="mt-1 flex gap-2"> 323 - <div class="h-1 w-11 rounded-full bg-(--nucleus-accent)"></div> 324 - <div class="h-1 w-8 rounded-full bg-(--nucleus-accent2)"></div> 325 - </div> 334 + <div class="flex items-center gap-1.5"> 335 + <FeedSelector {selectedFeed} onSelect={(uri) => (selectedFeed = uri)} /> 336 + {#if selectedFeed} 337 + <button 338 + onclick={() => { 339 + if (timelineView) timelineView.clearFeed(); 340 + else if (selectedDid && selectedFeed) resetFeed(selectedDid, selectedFeed); 341 + }} 342 + class="action-button p-2" 343 + title="refresh feed" 344 + > 345 + <Icon icon="heroicons:arrow-path" width={20} /> 346 + </button> 347 + {/if} 326 348 </div> 327 349 <div class="grow"></div> 328 350 {@render routeButton({ route: '/', icon: 'heroicons:home' })}