replies timeline only, appview-less bluesky client

refactor fetching, state and dropdown hover logic

Replace fetchPostsWithBacklinks with fetchPosts(withBacklinks) and
adjust hydratePosts to accept a cacheFn. Add caching for listRecords in
AtpClient. Overhaul state management to handle post chains, hydration
caching and timeline traversal. Improve Dropdown hover logic to prevent
premature closes when moving between trigger and content. Introduce
explicit Ok/Err result types and update helpers accordingly.

ptr.pet 18b0c29f 81777bf4

verified
+6 -6
src/components/BskyPost.svelte
··· 16 16 type ResourceUri 17 17 } from '@atcute/lexicons'; 18 18 import { expect, ok } from '$lib/result'; 19 - import { generateColorForDid } from '$lib/accounts'; 19 + import { accounts, generateColorForDid } from '$lib/accounts'; 20 20 import ProfilePicture from './ProfilePicture.svelte'; 21 21 import { isBlob } from '@atcute/lexicons/interfaces'; 22 22 import { blob, img } from '$lib/cdn'; 23 23 import BskyPost from './BskyPost.svelte'; 24 24 import Icon from '@iconify/svelte'; 25 25 import { 26 - clients, 27 26 allPosts, 28 27 pulsingPostId, 29 28 currentTime, ··· 33 32 } from '$lib/state.svelte'; 34 33 import type { PostWithUri } from '$lib/at/fetch'; 35 34 import { onMount } from 'svelte'; 36 - import { type AtprotoDid } from '@atcute/lexicons/syntax'; 37 35 import { derived } from 'svelte/store'; 38 36 import Device from 'svelte-device-info'; 39 37 import Dropdown from './Dropdown.svelte'; ··· 71 69 }: Props = $props(); 72 70 73 71 const selectedDid = $derived(client.user?.did ?? null); 74 - const actionClient = $derived(clients.get(did as AtprotoDid)); 72 + const isLoggedInUser = $derived($accounts.some((acc) => acc.did === did)); 75 73 76 74 const aturi = $derived(`at://${did}/app.bsky.feed.post/${rkey}` as CanonicalResourceUri); 77 75 const color = $derived(generateColorForDid(did)); ··· 153 151 return; 154 152 } 155 153 156 - actionClient?.atcute 154 + client?.atcute 157 155 ?.post('com.atproto.repo.deleteRecord', { 158 156 input: { 159 157 collection: 'app.bsky.feed.post', ··· 212 210 style="background: {color}36; border-color: {color}99;" 213 211 bind:isOpen={profileOpen} 214 212 trigger={profileInline} 213 + onMouseEnter={() => (profileOpen = true)} 214 + onMouseLeave={() => (profileOpen = false)} 215 215 > 216 216 <ProfileInfo {client} {did} {handle} {profile} /> 217 217 </Dropdown> ··· 444 444 {@render dropdownItem('heroicons:clipboard-20-solid', 'copy post text', () => 445 445 navigator.clipboard.writeText(post.record.text) 446 446 )} 447 - {#if actionClient} 447 + {#if isLoggedInUser} 448 448 <div class="my-0.75 h-px w-full opacity-60" style="background: {color};"></div> 449 449 {@render dropdownItem( 450 450 deleteState === 'confirm' ? 'heroicons:check-20-solid' : 'heroicons:trash-20-solid',
+59 -5
src/components/Dropdown.svelte
··· 20 20 placement?: Placement; 21 21 offsetDistance?: number; 22 22 position?: { x: number; y: number }; 23 + onMouseEnter?: () => void; 24 + onMouseLeave?: () => void; 23 25 } 24 26 25 27 let { ··· 29 31 placement = 'bottom-start', 30 32 offsetDistance = 2, 31 33 position = $bindable(), 34 + onMouseEnter, 35 + onMouseLeave, 32 36 ...restProps 33 37 }: Props = $props(); 34 38 35 39 let triggerRef: HTMLElement | undefined = $state(); 36 40 let contentRef: HTMLElement | undefined = $state(); 37 41 let cleanup: (() => void) | null = null; 42 + 43 + // State-based tracking for hover logic 44 + let isTriggerHovered = false; 45 + let isContentHovered = false; 46 + let closeTimer: ReturnType<typeof setTimeout>; 38 47 39 48 const updatePosition = async () => { 40 49 const { x, y } = await computePosition(triggerRef!, contentRef!, { ··· 70 79 71 80 const handleScroll = handleClose; 72 81 82 + // The central check: "Should we close now?" 83 + const scheduleCloseCheck = () => { 84 + clearTimeout(closeTimer); 85 + closeTimer = setTimeout(() => { 86 + // Only close if we are NOT on the trigger AND NOT on the content 87 + if (!isTriggerHovered && !isContentHovered) if (isOpen && onMouseLeave) onMouseLeave(); 88 + }, 30); // Small buffer to handle the physical gap between elements 89 + }; 90 + 91 + const handleTriggerEnter = () => { 92 + isTriggerHovered = true; 93 + clearTimeout(closeTimer); // We are safe, cancel any pending close 94 + if (!isOpen && onMouseEnter) onMouseEnter(); 95 + }; 96 + 97 + const handleTriggerLeave = () => { 98 + isTriggerHovered = false; 99 + scheduleCloseCheck(); // We left the trigger, check if we should close 100 + }; 101 + 102 + const handleContentEnter = () => { 103 + isContentHovered = true; 104 + clearTimeout(closeTimer); // We made it to the content, cancel close 105 + }; 106 + 107 + const handleContentLeave = () => { 108 + isContentHovered = false; 109 + scheduleCloseCheck(); // We left the content, check if we should close 110 + }; 111 + 112 + // Reset state if the menu is closed externally 113 + $effect(() => { 114 + if (!isOpen) { 115 + isContentHovered = false; 116 + clearTimeout(closeTimer); 117 + } 118 + }); 119 + 73 120 $effect(() => { 74 121 if (isOpen) { 75 122 cleanup = autoUpdate(triggerRef!, contentRef!, updatePosition); ··· 79 126 } 80 127 }); 81 128 82 - onMount(() => { 83 - return () => { 84 - if (cleanup) cleanup(); 85 - }; 129 + onMount(() => () => { 130 + if (cleanup) cleanup(); 131 + clearTimeout(closeTimer); 86 132 }); 87 133 </script> 88 134 89 135 <svelte:window onkeydown={handleEscape} onmousedown={handleClickOutside} onscroll={handleScroll} /> 90 136 91 - <div role="button" tabindex="0" bind:this={triggerRef}> 137 + <div 138 + role="button" 139 + tabindex="0" 140 + bind:this={triggerRef} 141 + onmouseenter={handleTriggerEnter} 142 + onmouseleave={handleTriggerLeave} 143 + > 92 144 {@render trigger?.()} 93 145 </div> 94 146 ··· 100 152 style={restProps.style} 101 153 role="menu" 102 154 tabindex="-1" 155 + onmouseenter={handleContentEnter} 156 + onmouseleave={handleContentLeave} 103 157 > 104 158 {@render children?.()} 105 159 </div>
+7 -2
src/components/FollowingView.svelte
··· 16 16 selectedDid: Did; 17 17 selectedClient: AtpClient; 18 18 onProfileClick: (did: AtprotoDid) => void; 19 + followingSort: Sort; 19 20 } 20 21 21 - const { selectedDid, selectedClient, onProfileClick }: Props = $props(); 22 + let { 23 + selectedDid, 24 + selectedClient, 25 + onProfileClick, 26 + followingSort = $bindable('active') 27 + }: Props = $props(); 22 28 23 - let followingSort: Sort = $state('active' as Sort); 24 29 const followsMap = $derived(follows.get(selectedDid)); 25 30 26 31 // eslint-disable-next-line @typescript-eslint/no-explicit-any
+1 -2
src/components/ProfileInfo.svelte
··· 11 11 did: Did; 12 12 handle?: string; 13 13 profile?: AppBskyActorProfile.Main | null; 14 - interactive?: boolean; 15 14 } 16 15 17 - let { client, did, handle, profile = $bindable(null), interactive = true }: Props = $props(); 16 + let { client, did, handle, profile = $bindable(null) }: Props = $props(); 18 17 19 18 onMount(async () => { 20 19 await Promise.all([
+7 -1
src/components/ProfileView.svelte
··· 77 77 78 78 <div class="my-4 h-px bg-white/10"></div> 79 79 80 - <TimelineView {client} targetDid={did} bind:postComposerState class="min-h-[50vh]" /> 80 + <TimelineView 81 + showReplies={false} 82 + {client} 83 + targetDid={did} 84 + bind:postComposerState 85 + class="min-h-[50vh]" 86 + /> 81 87 </div> 82 88 {/if} 83 89 </div>
+5 -1
src/components/TimelineView.svelte
··· 22 22 targetDid?: AtprotoDid; 23 23 postComposerState: PostComposerState; 24 24 class?: string; 25 + // whether to show replies that are not the user's own posts 26 + showReplies?: boolean; 25 27 } 26 28 27 29 let { 28 30 client = null, 29 31 targetDid = undefined, 32 + showReplies = true, 30 33 postComposerState = $bindable(), 31 34 class: className = '' 32 35 }: Props = $props(); ··· 38 41 const did = $derived(targetDid ?? client?.user?.did); 39 42 40 43 const threads = $derived( 44 + // todo: apply showReplies here 41 45 filterThreads( 42 46 did && timelines.has(did) ? buildThreads(did, timelines.get(did)!, allPosts) : [], 43 47 $accounts, ··· 58 62 loaderState.status = 'LOADING'; 59 63 60 64 try { 61 - await fetchTimeline(did as AtprotoDid); 65 + await fetchTimeline(did as AtprotoDid, 7, showReplies); 62 66 // interaction fetching is done lazily so we dont block loading posts 63 67 fetchMoreInteractions = true; 64 68 loaderState.loaded();
+14 -38
src/lib/at/client.ts
··· 80 80 return res.value; 81 81 }); 82 82 83 - type ListRecordsParams = { 84 - atcute: AtcuteClient; 85 - did: Did; 86 - collection: Nsid; 87 - cursor?: string; 88 - limit?: number; 89 - }; 90 - const cacheWithListRecords = cacheWithRecords.define( 91 - 'listRecords', 92 - async (params: ListRecordsParams) => { 93 - const res = await params.atcute.get('com.atproto.repo.listRecords', { 94 - params: { 95 - repo: params.did, 96 - collection: params.collection, 97 - cursor: params.cursor, 98 - limit: params.limit ?? 100, 99 - reverse: false 100 - } 101 - }); 102 - if (!res.ok) return err(`${res.data.error}: ${res.data.message ?? 'no details'}`); 103 - 104 - for (const record of res.data.records) 105 - await cache.set('fetchRecord', `fetchRecord~${record.uri}`, record, 60 * 60 * 24); 106 - 107 - return ok(res.data); 108 - } 109 - ); 110 - 111 - const cache = cacheWithListRecords; 83 + const cache = cacheWithRecords; 112 84 113 85 export class AtpClient { 114 86 public atcute: AtcuteClient | null = null; ··· 190 162 Result<InferXRPCBodyOutput<(typeof ComAtprotoRepoListRecords.mainSchema)['output']>, string> 191 163 > { 192 164 if (!this.atcute || !this.user) return err('not authenticated'); 193 - try { 194 - return (await cache.listRecords({ 195 - atcute: this.atcute, 196 - did: this.user.did, 165 + const res = await this.atcute.get('com.atproto.repo.listRecords', { 166 + params: { 167 + repo: this.user.did, 197 168 collection, 198 169 cursor, 199 - limit 200 - })) as Awaited<ReturnType<typeof this.listRecords>>; 201 - } catch (e) { 202 - return err(String(e)); 203 - } 170 + limit, 171 + reverse: false 172 + } 173 + }); 174 + if (!res.ok) return err(`${res.data.error}: ${res.data.message ?? 'no details'}`); 175 + 176 + for (const record of res.data.records) 177 + await cache.set('fetchRecord', `fetchRecord~${record.uri}`, record, 60 * 60 * 24); 178 + 179 + return ok(res.data); 204 180 } 205 181 206 182 async listRecordsUntil<Collection extends keyof Records>(
+43 -18
src/lib/at/fetch.ts
··· 5 5 type ResourceUri 6 6 } from '@atcute/lexicons'; 7 7 import { type AtpClient } from './client'; 8 - import { err, expect, ok, type Result } from '$lib/result'; 8 + import { err, expect, ok, type Ok, type Result } from '$lib/result'; 9 9 import type { Backlinks } from './constellation'; 10 10 import { AppBskyFeedPost } from '@atcute/bluesky'; 11 - import type { AtprotoDid } from '@atcute/lexicons/syntax'; 11 + import type { AtprotoDid, Did, RecordKey } from '@atcute/lexicons/syntax'; 12 12 import { replySource } from '$lib'; 13 13 14 14 export type PostWithUri = { uri: ResourceUri; cid: Cid | undefined; record: AppBskyFeedPost.Main }; 15 15 export type PostWithBacklinks = PostWithUri & { 16 - replies: Backlinks; 16 + replies?: Backlinks; 17 17 }; 18 - export type PostsWithReplyBacklinks = PostWithBacklinks[]; 19 18 20 - export const fetchPostsWithBacklinks = async ( 19 + export const fetchPosts = async ( 21 20 client: AtpClient, 22 21 cursor?: string, 23 - limit?: number 24 - ): Promise<Result<{ posts: PostsWithReplyBacklinks; cursor?: string }, string>> => { 22 + limit?: number, 23 + withBacklinks: boolean = true 24 + ): Promise<Result<{ posts: PostWithBacklinks[]; cursor?: string }, string>> => { 25 25 const recordsList = await client.listRecords('app.bsky.feed.post', cursor, limit); 26 26 if (!recordsList.ok) return err(`can't retrieve posts: ${recordsList.error}`); 27 27 cursor = recordsList.value.cursor; 28 28 const records = recordsList.value.records; 29 29 30 + if (!withBacklinks) { 31 + return ok({ 32 + posts: records.map((r) => ({ 33 + uri: r.uri, 34 + cid: r.cid, 35 + record: r.value as AppBskyFeedPost.Main 36 + })), 37 + cursor 38 + }); 39 + } 40 + 30 41 try { 31 42 const allBacklinks = await Promise.all( 32 43 records.map(async (r): Promise<PostWithBacklinks> => { ··· 50 61 export const hydratePosts = async ( 51 62 client: AtpClient, 52 63 repo: AtprotoDid, 53 - data: PostsWithReplyBacklinks 64 + data: PostWithBacklinks[], 65 + cacheFn: (did: Did, rkey: RecordKey) => Ok<PostWithUri> | undefined 54 66 ): Promise<Result<Map<ResourceUri, PostWithUri>, string>> => { 55 67 let posts: Map<ResourceUri, PostWithUri> = new Map(); 56 68 try { 57 69 const allPosts = await Promise.all( 58 70 data.map(async (post) => { 59 71 const result: PostWithUri[] = [post]; 60 - const replies = await Promise.all( 61 - post.replies.records.map(async (r) => { 62 - const reply = await client.getRecord(AppBskyFeedPost.mainSchema, r.did, r.rkey); 63 - if (!reply.ok) throw `cant fetch reply: ${reply.error}`; 64 - return reply.value; 65 - }) 66 - ); 67 - result.push(...replies); 72 + if (post.replies) { 73 + const replies = await Promise.all( 74 + post.replies.records.map(async (r) => { 75 + const reply = 76 + cacheFn(r.did, r.rkey) ?? 77 + (await client.getRecord(AppBskyFeedPost.mainSchema, r.did, r.rkey)); 78 + if (!reply.ok) throw `cant fetch reply: ${reply.error}`; 79 + return reply.value; 80 + }) 81 + ); 82 + result.push(...replies); 83 + } 68 84 return result; 69 85 }) 70 86 ); ··· 79 95 const parentUri = parent.uri as CanonicalResourceUri; 80 96 // if we already have this parent, then we already fetched this chain / are fetching it 81 97 if (posts.has(parentUri)) return; 82 - const p = await client.getRecordUri(AppBskyFeedPost.mainSchema, parentUri); 98 + const parsedParentUri = expect(parseCanonicalResourceUri(parentUri)); 99 + const p = 100 + cacheFn(parsedParentUri.repo, parsedParentUri.rkey) ?? 101 + (await client.getRecord( 102 + AppBskyFeedPost.mainSchema, 103 + parsedParentUri.repo, 104 + parsedParentUri.rkey 105 + )); 83 106 if (p.ok) { 84 107 posts.set(p.value.uri, p.value); 85 108 parent = p.value.record.reply?.parent; ··· 105 128 if (reply.did !== postRepo) continue; 106 129 // if we already have this reply, then we already fetched this chain / are fetching it 107 130 if (posts.has(`at://${reply.did}/${reply.collection}/${reply.rkey}`)) continue; 108 - const record = await client.getRecord(AppBskyFeedPost.mainSchema, reply.did, reply.rkey); 131 + const record = 132 + cacheFn(reply.did, reply.rkey) ?? 133 + (await client.getRecord(AppBskyFeedPost.mainSchema, reply.did, reply.rkey)); 109 134 if (!record.ok) break; // TODO: this doesnt handle deleted posts in between 110 135 posts.set(record.value.uri, record.value); 111 136 promises.push(fetchDownwardsChain(record.value));
+11 -11
src/lib/result.ts
··· 1 - export type Result<T, E> = 2 - | { 3 - ok: true; 4 - value: T; 5 - } 6 - | { 7 - ok: false; 8 - error: E; 9 - }; 1 + export type Ok<T> = { 2 + ok: true; 3 + value: T; 4 + }; 5 + export type Err<E> = { 6 + ok: false; 7 + error: E; 8 + }; 9 + export type Result<T, E> = Ok<T> | Err<E>; 10 10 11 - export const ok = <T, E>(value: T): Result<T, E> => { 11 + export const ok = <T>(value: T): Ok<T> => { 12 12 return { ok: true, value }; 13 13 }; 14 - export const err = <T, E>(error: E): Result<T, E> => { 14 + export const err = <E>(error: E): Err<E> => { 15 15 return { ok: false, error }; 16 16 }; 17 17 export const expect = <T, E>(v: Result<T, E>, msg: string = 'expected result to not be error:') => {
+57 -22
src/lib/state.svelte.ts
··· 1 - import { writable } from 'svelte/store'; 1 + import { get, writable } from 'svelte/store'; 2 2 import { 3 3 AtpClient, 4 4 newPublicClient, ··· 6 6 type NotificationsStreamEvent 7 7 } from './at/client'; 8 8 import { SvelteMap, SvelteDate, SvelteSet } from 'svelte/reactivity'; 9 - import type { Did, InferOutput, Nsid, ResourceUri } from '@atcute/lexicons'; 10 - import { fetchPostsWithBacklinks, hydratePosts, type PostWithUri } from './at/fetch'; 9 + import type { Did, InferOutput, Nsid, RecordKey, ResourceUri } from '@atcute/lexicons'; 10 + import { fetchPosts, hydratePosts, type PostWithUri } from './at/fetch'; 11 11 import { parseCanonicalResourceUri, type AtprotoDid } from '@atcute/lexicons/syntax'; 12 12 import { AppBskyFeedPost, type AppBskyGraphFollow } from '@atcute/bluesky'; 13 13 import type { ComAtprotoRepoListRecords } from '@atcute/atproto'; 14 14 import type { JetstreamSubscription, JetstreamEvent } from '@atcute/jetstream'; 15 - import { expect } from './result'; 15 + import { expect, ok } from './result'; 16 16 import type { Backlink, BacklinksSource } from './at/constellation'; 17 17 import { now as tidNow } from '@atcute/tid'; 18 18 import type { Records } from '@atcute/lexicons/ambient'; ··· 247 247 // did -> post uris that are replies to that did 248 248 export const replyIndex = new SvelteMap<Did, SvelteSet<ResourceUri>>(); 249 249 250 + export const getPost = (did: Did, rkey: RecordKey) => 251 + allPosts.get(did)?.get(`at://${did}/app.bsky.feed.post/${rkey}`); 252 + const hydrateCacheFn: Parameters<typeof hydratePosts>[3] = (did, rkey) => { 253 + const cached = getPost(did, rkey); 254 + return cached ? ok(cached) : undefined; 255 + }; 256 + 250 257 export const addPostsRaw = ( 251 258 did: AtprotoDid, 252 259 newPosts: InferOutput<ComAtprotoRepoListRecords.mainSchema['output']['schema']> 253 260 ) => { 254 - const postsWithUri = newPosts.records.map((post): [ResourceUri, PostWithUri] => [ 255 - post.uri, 256 - { cid: post.cid, uri: post.uri, record: post.value as AppBskyFeedPost.Main } as PostWithUri 257 - ]); 261 + const postsWithUri = newPosts.records.map( 262 + (post) => 263 + ({ cid: post.cid, uri: post.uri, record: post.value as AppBskyFeedPost.Main }) as PostWithUri 264 + ); 258 265 addPosts(postsWithUri); 259 266 }; 260 267 261 - export const addPosts = (newPosts: Iterable<[ResourceUri, PostWithUri]>) => { 262 - for (const [uri, post] of newPosts) { 263 - const parsedUri = expect(parseCanonicalResourceUri(uri)); 268 + export const addPosts = (newPosts: Iterable<PostWithUri>) => { 269 + for (const post of newPosts) { 270 + const parsedUri = expect(parseCanonicalResourceUri(post.uri)); 264 271 let posts = allPosts.get(parsedUri.repo); 265 272 if (!posts) { 266 273 posts = new SvelteMap(); 267 274 allPosts.set(parsedUri.repo, posts); 268 275 } 269 - posts.set(uri, post); 276 + posts.set(post.uri, post); 270 277 const link: Backlink = { 271 278 did: parsedUri.repo, 272 279 collection: parsedUri.collection, ··· 283 290 set = new SvelteSet(); 284 291 replyIndex.set(parentDid, set); 285 292 } 286 - set.add(uri); 293 + set.add(post.uri); 287 294 } 288 295 } 289 296 } ··· 292 299 export const timelines = new SvelteMap<Did, SvelteSet<ResourceUri>>(); 293 300 export const postCursors = new SvelteMap<Did, { value?: string; end: boolean }>(); 294 301 302 + const traversePostChain = (post: PostWithUri) => { 303 + const result = [post.uri]; 304 + const parentUri = post.record.reply?.parent.uri; 305 + if (parentUri) { 306 + const parentPost = allPosts.get(extractDidFromUri(parentUri)!)?.get(parentUri); 307 + if (parentPost) result.push(...traversePostChain(parentPost)); 308 + } 309 + return result; 310 + }; 295 311 export const addTimeline = (did: Did, uris: Iterable<ResourceUri>) => { 296 312 let timeline = timelines.get(did); 297 313 if (!timeline) { 298 314 timeline = new SvelteSet(); 299 315 timelines.set(did, timeline); 300 316 } 301 - for (const uri of uris) timeline.add(uri); 317 + for (const uri of uris) { 318 + const post = allPosts.get(did)?.get(uri); 319 + // we need to traverse the post chain to add all posts in the chain to the timeline 320 + // because the parent posts might not be in the timeline yet 321 + const chain = post ? traversePostChain(post) : []; 322 + for (const uri of chain) timeline.add(uri); 323 + } 302 324 }; 303 325 304 - export const fetchTimeline = async (did: AtprotoDid, limit: number = 6) => { 326 + export const fetchTimeline = async ( 327 + did: AtprotoDid, 328 + limit: number = 6, 329 + withBacklinks: boolean = true 330 + ) => { 305 331 const targetClient = await getClient(did); 306 332 307 333 const cursor = postCursors.get(did); 308 334 if (cursor && cursor.end) return; 309 335 310 - const accPosts = await fetchPostsWithBacklinks(targetClient, cursor?.value, limit); 336 + const accPosts = await fetchPosts(targetClient, cursor?.value, limit, withBacklinks); 311 337 if (!accPosts.ok) throw `cant fetch posts ${did}: ${accPosts.error}`; 312 338 313 339 // if the cursor is undefined, we've reached the end of the timeline 314 340 postCursors.set(did, { value: accPosts.value.cursor, end: !accPosts.value.cursor }); 315 - const hydrated = await hydratePosts(targetClient, did, accPosts.value.posts); 341 + const hydrated = await hydratePosts(targetClient, did, accPosts.value.posts, hydrateCacheFn); 316 342 if (!hydrated.ok) throw `cant hydrate posts ${did}: ${hydrated.error}`; 317 343 318 - addPosts(hydrated.value); 344 + addPosts(hydrated.value.values()); 319 345 addTimeline(did, hydrated.value.keys()); 320 346 321 347 console.log(`${did}: fetchTimeline`, accPosts.value.cursor); ··· 342 368 // assume record is valid, we trust the jetstream 343 369 record: record as AppBskyFeedPost.Main 344 370 }; 345 - addPosts([[uri, post]]); 371 + addPosts([post]); 346 372 addTimeline(did, [uri]); 347 373 } else if (commit.operation === 'delete') { 348 374 allPosts.get(did)?.delete(uri); ··· 362 388 if (!subjectPost.ok) return; 363 389 364 390 const parsedSourceUri = expect(parseCanonicalResourceUri(event.data.link.source_record)); 365 - const hydrated = await hydratePosts(client, did, [ 391 + const posts = [ 366 392 { 367 393 record: subjectPost.value.record, 368 394 uri: event.data.link.subject, ··· 379 405 ] 380 406 } 381 407 } 382 - ]); 408 + ]; 409 + const hydrated = await hydratePosts(client, did, posts, hydrateCacheFn); 383 410 if (!hydrated.ok) { 384 411 console.error(`cant hydrate posts ${did}: ${hydrated.error}`); 385 412 return; 386 413 } 387 414 388 415 // console.log(hydrated); 389 - addPosts(hydrated.value); 416 + addPosts(hydrated.value.values()); 390 417 addTimeline(did, hydrated.value.keys()); 391 418 }; 392 419 ··· 414 441 setInterval(() => { 415 442 currentTime.setTime(Date.now()); 416 443 }, 1000); 444 + 445 + export type View = 'timeline' | 'notifications' | 'following' | 'settings' | 'profile'; 446 + export const currentView = writable<View>('timeline'); 447 + export const previousView = writable<View>('timeline'); 448 + export const setView = (view: View) => { 449 + previousView.set(get(currentView)); 450 + currentView.set(view); 451 + };
+1 -3
src/lib/theme.ts
··· 18 18 const id = input.split(':').pop() || input; 19 19 20 20 hash = 0; 21 - for (let i = 0; i < Math.min(10, id.length); i++) { 22 - hash = (hash << 4) + id.charCodeAt(i); 23 - } 21 + for (let i = 0; i < Math.min(10, id.length); i++) hash = (hash << 4) + id.charCodeAt(i); 24 22 hash = hash >>> 0; 25 23 26 24 // magic mixing
+44 -39
src/routes/+page.svelte
··· 17 17 fetchFollows, 18 18 follows, 19 19 notificationStream, 20 - allPosts, 21 20 viewClient, 22 21 jetstream, 23 22 handleJetstreamEvent, 24 - handleNotification 23 + handleNotification, 24 + addPosts, 25 + addTimeline, 26 + type View, 27 + currentView, 28 + previousView, 29 + setView 25 30 } from '$lib/state.svelte'; 26 31 import { get } from 'svelte/store'; 27 32 import Icon from '@iconify/svelte'; ··· 30 35 import type { PageProps } from './+page'; 31 36 import { JetstreamSubscription } from '@atcute/jetstream'; 32 37 import { settings } from '$lib/settings'; 38 + import type { Sort } from '$lib/following'; 33 39 34 40 const { data: loadData }: PageProps = $props(); 35 41 ··· 69 75 handleAccountSelected(newAccounts[0]?.did); 70 76 }; 71 77 72 - type View = 'timeline' | 'notifications' | 'following' | 'settings' | 'profile'; 73 - let currentView = $state<View>('timeline'); 78 + let followingSort = $state('active' as Sort); 79 + 74 80 let animClass = $state('animate-fade-in-scale'); 75 81 let scrollPositions = new SvelteMap<View, number>(); 76 82 let viewingProfileDid = $state<AtprotoDid | null>(null); 77 - let previousView = $state<View>('timeline'); 78 83 79 84 const viewOrder: Record<View, number> = { 80 85 timeline: 0, ··· 84 89 profile: 4 85 90 }; 86 91 87 - const switchView = async (newView: View) => { 88 - if (currentView === newView) return; 89 - scrollPositions.set(currentView, window.scrollY); 92 + const switchView = async () => { 93 + scrollPositions.set($previousView, window.scrollY); 90 94 91 - const direction = viewOrder[newView] > viewOrder[currentView] ? 'right' : 'left'; 92 - // Profile always slides in from right unless going back 93 - if (newView === 'profile') animClass = 'animate-slide-in-right'; 94 - else if (currentView === 'profile') animClass = 'animate-slide-in-left'; 95 + const direction = viewOrder[$previousView] > viewOrder[$currentView] ? 'right' : 'left'; 96 + // profile always slides in from right unless going back 97 + if ($currentView === 'profile') animClass = 'animate-slide-in-left'; 98 + else if ($previousView === 'profile') animClass = 'animate-slide-in-right'; 95 99 else animClass = direction === 'right' ? 'animate-slide-in-right' : 'animate-slide-in-left'; 96 - // Don't overwrite previousView if we're just going to profile 97 - if (newView !== 'profile' && currentView !== 'profile') previousView = currentView; 98 - else if (newView === 'profile' && currentView !== 'profile') previousView = currentView; 99 - currentView = newView; 100 100 101 101 await tick(); 102 102 103 - window.scrollTo({ top: scrollPositions.get(newView) || 0, behavior: 'instant' }); 103 + window.scrollTo({ top: scrollPositions.get($currentView) || 0, behavior: 'instant' }); 104 104 }; 105 + currentView.subscribe(switchView); 105 106 106 107 const goToProfile = (did: AtprotoDid) => { 107 108 viewingProfileDid = did; 108 - switchView('profile'); 109 + setView('profile'); 109 110 }; 110 111 111 112 let postComposerState = $state<PostComposerState>({ type: 'null' }); 112 113 let showScrollToTop = $state(false); 113 114 const handleScroll = () => { 114 - if (currentView === 'timeline') showScrollToTop = window.scrollY > 300; 115 + if ($currentView === 'timeline') showScrollToTop = window.scrollY > 300; 115 116 }; 116 - const scrollToTop = () => { 117 - window.scrollTo({ top: 0, behavior: 'smooth' }); 118 - }; 117 + const scrollToTop = () => window.scrollTo({ top: 0, behavior: 'smooth' }); 119 118 120 119 onMount(() => { 121 120 window.addEventListener('scroll', handleScroll); ··· 130 129 'app.bsky.feed.post:reply.parent.uri', 131 130 'app.bsky.feed.post:embed.record.record.uri', 132 131 'app.bsky.feed.post:embed.record.uri', 133 - 'app.bsky.feed.repost:subject.uri' 132 + 'app.bsky.feed.repost:subject.uri', 133 + 'app.bsky.feed.like:subject.uri', 134 + 'app.bsky.graph.follow:subject' 134 135 ) 135 136 ); 136 137 }); ··· 214 215 <div class="flex-1"> 215 216 <!-- timeline --> 216 217 <TimelineView 217 - class={currentView === 'timeline' ? `${animClass}` : 'hidden'} 218 + class={$currentView === 'timeline' ? `${animClass}` : 'hidden'} 218 219 client={selectedClient} 219 220 bind:postComposerState 220 221 /> 221 222 222 - {#if currentView === 'settings'} 223 + {#if $currentView === 'settings'} 223 224 <div class={animClass}> 224 225 <SettingsView /> 225 226 </div> 226 227 {/if} 227 - {#if currentView === 'notifications'} 228 + {#if $currentView === 'notifications'} 228 229 <div class={animClass}> 229 230 <NotificationsView /> 230 231 </div> 231 232 {/if} 232 - {#if currentView === 'following'} 233 + {#if $currentView === 'following'} 233 234 <div class={animClass}> 234 235 <FollowingView 235 236 selectedClient={selectedClient!} 236 237 selectedDid={selectedDid!} 237 238 onProfileClick={goToProfile} 239 + bind:followingSort 238 240 /> 239 241 </div> 240 242 {/if} 241 - {#if currentView === 'profile' && viewingProfileDid} 243 + {#if $currentView === 'profile' && viewingProfileDid} 242 244 <div class={animClass}> 243 245 <ProfileView 244 246 client={selectedClient!} 245 247 did={viewingProfileDid} 246 - onBack={() => switchView(previousView)} 248 + onBack={() => setView($previousView)} 247 249 bind:postComposerState 248 250 /> 249 251 </div> ··· 276 278 277 279 <div 278 280 class=" 279 - {currentView === 'timeline' || currentView === 'following' || currentView === 'profile' 281 + {$currentView === 'timeline' || $currentView === 'following' || $currentView === 'profile' 280 282 ? '' 281 283 : 'hidden'} 282 284 z-20 w-full max-w-2xl p-2.5 px-4 pb-1 transition-all ··· 297 299 <div class="flex-1"> 298 300 <PostComposer 299 301 client={selectedClient} 300 - onPostSent={(post) => allPosts.get(selectedDid!)?.set(post.uri, post)} 302 + onPostSent={(post) => { 303 + addPosts([post]); 304 + addTimeline(selectedDid!, [post.uri]); 305 + }} 301 306 bind:_state={postComposerState} 302 307 /> 303 308 </div> ··· 330 335 </div> 331 336 <div class="grow"></div> 332 337 {@render appButton( 333 - () => switchView('timeline'), 338 + () => setView('timeline'), 334 339 'heroicons:home', 335 340 'timeline', 336 - currentView === 'timeline', 341 + $currentView === 'timeline', 337 342 'heroicons:home-solid' 338 343 )} 339 344 {@render appButton( 340 - () => switchView('following'), 345 + () => setView('following'), 341 346 'heroicons:users', 342 347 'following', 343 - currentView === 'following', 348 + $currentView === 'following', 344 349 'heroicons:users-solid' 345 350 )} 346 351 {@render appButton( 347 - () => switchView('notifications'), 352 + () => setView('notifications'), 348 353 'heroicons:bell', 349 354 'notifications', 350 - currentView === 'notifications', 355 + $currentView === 'notifications', 351 356 'heroicons:bell-solid' 352 357 )} 353 358 {@render appButton( 354 - () => switchView('settings'), 359 + () => setView('settings'), 355 360 'heroicons:cog-6-tooth', 356 361 'settings', 357 - currentView === 'settings', 362 + $currentView === 'settings', 358 363 'heroicons:cog-6-tooth-solid' 359 364 )} 360 365 </div>