replies timeline only, appview-less bluesky client

Compare changes

Choose any two refs to compare.

Changed files
+68 -40
src
+3 -1
src/components/TimelineView.svelte
··· 63 63 loaderState.status = 'LOADING'; 64 64 65 65 try { 66 - await fetchTimeline(client, did, 7, showReplies); 66 + await fetchTimeline(client, did, 7, showReplies, { 67 + downwards: userDid === did ? 'sameAuthor' : 'none' 68 + }); 67 69 // only fetch interactions if logged in (because if not who is the interactor) 68 70 if (client.user && userDid) { 69 71 if (!fetchingInteractions) {
+32 -25
src/lib/at/fetch.ts
··· 59 59 } 60 60 }; 61 61 62 + export type HydrateOptions = { 63 + downwards: 'sameAuthor' | 'none'; 64 + }; 65 + 62 66 export const hydratePosts = async ( 63 67 client: AtpClient, 64 68 repo: Did, 65 69 data: PostWithBacklinks[], 66 - cacheFn: (did: Did, rkey: RecordKey) => Ok<PostWithUri> | undefined 70 + cacheFn: (did: Did, rkey: RecordKey) => Ok<PostWithUri> | undefined, 71 + options?: Partial<HydrateOptions> 67 72 ): Promise<Result<Map<ResourceUri, PostWithUri>, string>> => { 68 73 let posts: Map<ResourceUri, PostWithUri> = new Map(); 69 74 try { ··· 115 120 }; 116 121 await Promise.all(posts.values().map(fetchUpwardsChain)); 117 122 118 - try { 119 - const fetchDownwardsChain = async (post: PostWithUri) => { 120 - const { repo: postRepo } = expect(parseCanonicalResourceUri(post.uri)); 121 - if (repo === postRepo) return; 123 + if (options?.downwards !== 'none') { 124 + try { 125 + const fetchDownwardsChain = async (post: PostWithUri) => { 126 + const { repo: postRepo } = expect(parseCanonicalResourceUri(post.uri)); 127 + if (repo === postRepo) return; 122 128 123 - // get chains that are the same author until we exhaust them 124 - const backlinks = await client.getBacklinks(post.uri, replySource); 125 - if (!backlinks.ok) return; 129 + // get chains that are the same author until we exhaust them 130 + const backlinks = await client.getBacklinks(post.uri, replySource); 131 + if (!backlinks.ok) return; 126 132 127 - const promises = []; 128 - for (const reply of backlinks.value.records) { 129 - if (reply.did !== postRepo) continue; 130 - // if we already have this reply, then we already fetched this chain / are fetching it 131 - if (posts.has(toCanonicalUri(reply))) continue; 132 - const record = 133 - cacheFn(reply.did, reply.rkey) ?? 134 - (await client.getRecord(AppBskyFeedPost.mainSchema, reply.did, reply.rkey)); 135 - if (!record.ok) break; // TODO: this doesnt handle deleted posts in between 136 - posts.set(record.value.uri, record.value); 137 - promises.push(fetchDownwardsChain(record.value)); 138 - } 133 + const promises = []; 134 + for (const reply of backlinks.value.records) { 135 + if (reply.did !== postRepo) continue; 136 + // if we already have this reply, then we already fetched this chain / are fetching it 137 + if (posts.has(toCanonicalUri(reply))) continue; 138 + const record = 139 + cacheFn(reply.did, reply.rkey) ?? 140 + (await client.getRecord(AppBskyFeedPost.mainSchema, reply.did, reply.rkey)); 141 + if (!record.ok) break; // TODO: this doesnt handle deleted posts in between 142 + posts.set(record.value.uri, record.value); 143 + promises.push(fetchDownwardsChain(record.value)); 144 + } 139 145 140 - await Promise.all(promises); 141 - }; 142 - await Promise.all(posts.values().map(fetchDownwardsChain)); 143 - } catch (error) { 144 - return err(`cant fetch post reply chain: ${error}`); 146 + await Promise.all(promises); 147 + }; 148 + await Promise.all(posts.values().map(fetchDownwardsChain)); 149 + } catch (error) { 150 + return err(`cant fetch post reply chain: ${error}`); 151 + } 145 152 } 146 153 147 154 return ok(posts);
+33 -14
src/lib/state.svelte.ts
··· 7 7 } from './at/client.svelte'; 8 8 import { SvelteMap, SvelteDate, SvelteSet } from 'svelte/reactivity'; 9 9 import type { Did, Handle, Nsid, RecordKey, ResourceUri } from '@atcute/lexicons'; 10 - import { fetchPosts, hydratePosts, type PostWithUri } from './at/fetch'; 10 + import { fetchPosts, hydratePosts, type HydrateOptions, type PostWithUri } from './at/fetch'; 11 11 import { parseCanonicalResourceUri, type AtprotoDid } from '@atcute/lexicons/syntax'; 12 12 import { 13 13 AppBskyActorProfile, ··· 406 406 }; 407 407 408 408 export const allPosts = new SvelteMap<Did, SvelteMap<ResourceUri, PostWithUri>>(); 409 + export type DeletedPostInfo = { reply?: PostWithUri['record']['reply'] }; 410 + export const deletedPosts = new SvelteMap<ResourceUri, DeletedPostInfo>(); 409 411 // did -> post uris that are replies to that did 410 412 export const replyIndex = new SvelteMap<Did, SvelteSet<ResourceUri>>(); 411 413 ··· 448 450 } 449 451 }; 450 452 453 + export const deletePost = (uri: ResourceUri) => { 454 + const did = extractDidFromUri(uri)!; 455 + const post = allPosts.get(did)?.get(uri); 456 + if (!post) return; 457 + allPosts.get(did)?.delete(uri); 458 + // remove reply from index 459 + const subjectDid = extractDidFromUri(post.record.reply?.parent.uri ?? ''); 460 + if (subjectDid) replyIndex.get(subjectDid)?.delete(uri); 461 + deletedPosts.set(uri, { reply: post.record.reply }); 462 + }; 463 + 451 464 export const timelines = new SvelteMap<Did, SvelteSet<ResourceUri>>(); 452 465 export const postCursors = new SvelteMap<Did, { value?: string; end: boolean }>(); 453 466 ··· 479 492 client: AtpClient, 480 493 subject: Did, 481 494 limit: number = 6, 482 - withBacklinks: boolean = true 495 + withBacklinks: boolean = true, 496 + hydrateOptions?: Partial<HydrateOptions> 483 497 ) => { 484 498 const cursor = postCursors.get(subject); 485 499 if (cursor && cursor.end) return; ··· 490 504 // if the cursor is undefined, we've reached the end of the timeline 491 505 const newCursor = { value: accPosts.value.cursor, end: !accPosts.value.cursor }; 492 506 postCursors.set(subject, newCursor); 493 - const hydrated = await hydratePosts(client, subject, accPosts.value.posts, hydrateCacheFn); 507 + const hydrated = await hydratePosts( 508 + client, 509 + subject, 510 + accPosts.value.posts, 511 + hydrateCacheFn, 512 + hydrateOptions 513 + ); 494 514 if (!hydrated.ok) throw `cant hydrate posts ${subject}: ${hydrated.error}`; 495 515 496 516 addPosts(hydrated.value.values()); ··· 543 563 const uri: ResourceUri = toCanonicalUri({ did, ...commit }); 544 564 if (commit.collection === 'app.bsky.feed.post') { 545 565 if (commit.operation === 'create') { 566 + const record = commit.record as AppBskyFeedPost.Main; 546 567 const posts = [ 547 568 { 548 - record: commit.record as AppBskyFeedPost.Main, 569 + record, 549 570 uri, 550 571 cid: commit.cid 551 572 } 552 573 ]; 553 - await setRecordCache(uri, commit.record); 574 + await setRecordCache(uri, record); 554 575 const client = clients.get(did) ?? viewClient; 555 576 const hydrated = await hydratePosts(client, did, posts, hydrateCacheFn); 556 577 if (!hydrated.ok) { ··· 559 580 } 560 581 addPosts(hydrated.value.values()); 561 582 addTimeline(did, hydrated.value.keys()); 583 + if (record.reply) { 584 + const parentDid = extractDidFromUri(record.reply.parent.uri)!; 585 + addTimeline(parentDid, [uri]); 586 + // const rootDid = extractDidFromUri(record.reply.root.uri)!; 587 + // addTimeline(rootDid, [uri]); 588 + } 562 589 } else if (commit.operation === 'delete') { 563 - const post = allPosts.get(did)?.get(uri); 564 - if (post) { 565 - allPosts.get(did)?.delete(uri); 566 - // remove from timeline 567 - timelines.get(did)?.delete(uri); 568 - // remove reply from index 569 - const subjectDid = extractDidFromUri(post.record.reply?.parent.uri ?? ''); 570 - if (subjectDid) replyIndex.get(subjectDid)?.delete(uri); 571 - } 590 + deletePost(uri); 572 591 } 573 592 } 574 593 };