appview-less bluesky client
24
fork

Configure Feed

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

add following timeline

dawn de2208d2 83cfafbd

+500 -10
+14 -1
src/components/FeedSelector.svelte
··· 10 10 onSelect: (feedUri: string | null) => void; 11 11 } 12 12 13 - let { selectedFeed, onSelect }: Props = $props(); 13 + let { selectedFeed = $bindable(), onSelect }: Props = $props(); 14 14 15 15 let isOpen = $state(false); 16 16 ··· 67 67 > 68 68 <Icon icon="heroicons:chat-bubble-left-ellipsis-16-solid" width="20" /> 69 69 <span>replies</span> 70 + </button> 71 + <button 72 + onclick={() => { 73 + onSelect('following'); 74 + isOpen = false; 75 + }} 76 + 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 === 77 + 'following' 78 + ? 'bg-(--nucleus-accent)/20' 79 + : ''}" 80 + > 81 + <Icon icon="heroicons:users-solid" width="20" /> 82 + <span>following</span> 70 83 </button> 71 84 {#each sortedFeeds as savedFeed (savedFeed.feed.uri)} 72 85 <button
+17 -1
src/components/FeedTimelineView.svelte
··· 13 13 feedCursors, 14 14 fetchFeed, 15 15 resetFeed, 16 - checkForNewPosts 16 + checkForNewPosts, 17 + fetchInteractionsToFeedTimelineEnd 17 18 } from '$lib/state.svelte'; 18 19 import Icon from '@iconify/svelte'; 19 20 import NotLoggedIn from './NotLoggedIn.svelte'; ··· 70 71 let loading = $state(false); 71 72 let loadError = $state(''); 72 73 74 + let fetchingInteractions = $state(false); 75 + let scheduledFetchInteractions = $state(false); 76 + 73 77 export const clearFeed = () => { 74 78 if (!userDid) return; 75 79 scrollContainer?.scrollTo({ top: 0, behavior: 'smooth' }); ··· 98 102 99 103 try { 100 104 const result = await fetchFeed(client, selectedFeed, feedServiceDid); 105 + 106 + if (client.user && userDid) { 107 + if (!fetchingInteractions) { 108 + scheduledFetchInteractions = false; 109 + fetchingInteractions = true; 110 + await fetchInteractionsToFeedTimelineEnd(client, userDid, selectedFeed); 111 + fetchingInteractions = false; 112 + } else { 113 + scheduledFetchInteractions = true; 114 + } 115 + } 116 + 101 117 loaderState.loaded(); 102 118 if (result?.end) loaderState.complete(); 103 119 } catch (error) {
+201
src/components/FollowingTimelineView.svelte
··· 1 + <script lang="ts"> 2 + import BskyPost from './BskyPost.svelte'; 3 + import { type State as PostComposerState } from './PostComposer.svelte'; 4 + import { AtpClient } from '$lib/at/client.svelte'; 5 + import { accounts } from '$lib/accounts'; 6 + import { type ResourceUri } from '@atcute/lexicons'; 7 + import { SvelteSet } from 'svelte/reactivity'; 8 + import { InfiniteLoader, LoaderState } from 'svelte-infinite'; 9 + import { 10 + followingCursors, 11 + fetchFollowingTimeline, 12 + allPosts, 13 + followingFeed, 14 + accountPreferences, 15 + fetchInteractionsToFollowingTimelineEnd 16 + } from '$lib/state.svelte'; 17 + import Icon from '@iconify/svelte'; 18 + import { buildThreads, filterThreads, type ThreadPost } from '$lib/thread'; 19 + import type { Did } from '@atcute/lexicons/syntax'; 20 + import NotLoggedIn from './NotLoggedIn.svelte'; 21 + import LoadingSpinner from './LoadingSpinner.svelte'; 22 + import EndOfList from './EndOfList.svelte'; 23 + import LoadError from './LoadError.svelte'; 24 + 25 + interface Props { 26 + client?: AtpClient | null; 27 + postComposerState: PostComposerState; 28 + class?: string; 29 + targetDid?: Did; 30 + } 31 + 32 + let { 33 + client = null, 34 + postComposerState = $bindable(), 35 + class: className = '', 36 + targetDid = undefined 37 + }: Props = $props(); 38 + 39 + let reverseChronological = $state(true); 40 + let viewOwnPosts = $state(true); 41 + const expandedThreads = new SvelteSet<ResourceUri>(); 42 + 43 + const userDid = $derived(targetDid ?? client?.user?.did); 44 + 45 + const currentPrefs = $derived(userDid ? accountPreferences.get(userDid) : null); 46 + const mutes = $derived(currentPrefs?.mutes ?? []); 47 + 48 + // We use userDid as the 'root' for buildThreads merely to provide a context, 49 + // but we are passing `followingFeed.get(userDid)` (merged set) as the source. 50 + const threads = $derived( 51 + filterThreads( 52 + userDid 53 + ? buildThreads(userDid, followingFeed.get(userDid) ?? new SvelteSet(), allPosts, mutes) 54 + : [], 55 + $accounts, 56 + { 57 + viewOwnPosts 58 + } 59 + ) 60 + ); 61 + 62 + const loaderState = new LoaderState(); 63 + let scrollContainer = $state<HTMLDivElement>(); 64 + let loading = $state(false); 65 + let loadError = $state(''); 66 + 67 + let fetchingInteractions = $state(false); 68 + let scheduledFetchInteractions = $state(false); 69 + 70 + const loadMore = async () => { 71 + if (loading || !client || !userDid) return; 72 + 73 + loading = true; 74 + loaderState.status = 'LOADING'; 75 + 76 + try { 77 + await fetchFollowingTimeline(client, userDid); 78 + 79 + if (client.user && userDid) { 80 + if (!fetchingInteractions) { 81 + scheduledFetchInteractions = false; 82 + fetchingInteractions = true; 83 + await fetchInteractionsToFollowingTimelineEnd(client, userDid); 84 + fetchingInteractions = false; 85 + } else { 86 + scheduledFetchInteractions = true; 87 + } 88 + } 89 + 90 + loaderState.loaded(); 91 + } catch (error) { 92 + loadError = `${error}`; 93 + loaderState.error(); 94 + loading = false; 95 + return; 96 + } 97 + 98 + loading = false; 99 + }; 100 + 101 + $effect(() => { 102 + const isEmpty = threads.length === 0; 103 + if (isEmpty && !loading && userDid) { 104 + loadMore(); 105 + } 106 + }); 107 + 108 + $effect(() => { 109 + userDid; 110 + loaderState.reset(); 111 + }); 112 + </script> 113 + 114 + {#snippet replyPost(post: ThreadPost, reverse: boolean = reverseChronological)} 115 + <span 116 + class="mb-1.5 flex items-center gap-1.5 overflow-hidden text-nowrap wrap-break-word overflow-ellipsis" 117 + > 118 + <span class="text-sm text-nowrap opacity-60">{reverse ? '↱' : '↳'}</span> 119 + <BskyPost mini client={client!} {...post} /> 120 + </span> 121 + {/snippet} 122 + 123 + {#snippet threadsView()} 124 + {#each threads as thread, i (thread.rootUri)} 125 + <div class="flex w-full shrink-0 {reverseChronological ? 'flex-col' : 'flex-col-reverse'}"> 126 + {#if thread.branchParentPost} 127 + {@render replyPost(thread.branchParentPost)} 128 + {/if} 129 + {#each thread.posts as post, idx (post.data.uri)} 130 + {@const mini = 131 + !expandedThreads.has(thread.rootUri) && 132 + thread.posts.length > 4 && 133 + idx > 0 && 134 + idx < thread.posts.length - 2} 135 + {#if !mini} 136 + <div class="mb-1.5"> 137 + <BskyPost 138 + client={client!} 139 + onQuote={(post) => { 140 + postComposerState.focus = 'focused'; 141 + postComposerState.quoting = post; 142 + }} 143 + onReply={(post) => { 144 + postComposerState.focus = 'focused'; 145 + postComposerState.replying = post; 146 + }} 147 + {...post} 148 + /> 149 + </div> 150 + {:else if mini} 151 + {#if idx === 1} 152 + {@render replyPost(post, !reverseChronological)} 153 + <button 154 + class="mx-1.5 mt-1.5 mb-2.5 flex items-center gap-1.5 text-[color-mix(in_srgb,var(--nucleus-fg)_50%,var(--nucleus-accent))]/70 transition-colors hover:text-(--nucleus-accent)" 155 + onclick={() => expandedThreads.add(thread.rootUri)} 156 + > 157 + <div class="mr-1 h-px w-20 rounded border-y-2 border-dashed opacity-50"></div> 158 + <Icon 159 + class="shrink-0" 160 + icon={reverseChronological 161 + ? 'heroicons:bars-arrow-up-solid' 162 + : 'heroicons:bars-arrow-down-solid'} 163 + width={32} 164 + /><span class="shrink-0 pb-1">view full chain</span> 165 + <div class="ml-1 h-px w-full rounded border-y-2 border-dashed opacity-50"></div> 166 + </button> 167 + {:else if idx === thread.posts.length - 3} 168 + {@render replyPost(post)} 169 + {/if} 170 + {/if} 171 + {/each} 172 + </div> 173 + {#if i < threads.length - 1} 174 + <div 175 + class="mx-8 mt-3 mb-4 h-px bg-linear-to-r from-(--nucleus-accent)/30 to-(--nucleus-accent2)/30" 176 + ></div> 177 + {/if} 178 + {/each} 179 + {/snippet} 180 + 181 + <div 182 + class="min-h-full p-2 [scrollbar-color:var(--nucleus-accent)_transparent] {className}" 183 + bind:this={scrollContainer} 184 + > 185 + {#if userDid || $accounts.length > 0} 186 + <InfiniteLoader {loaderState} triggerLoad={loadMore} loopDetectionTimeout={0}> 187 + {@render threadsView()} 188 + {#snippet noData()} 189 + <EndOfList /> 190 + {/snippet} 191 + {#snippet loading()} 192 + <LoadingSpinner /> 193 + {/snippet} 194 + {#snippet error()} 195 + <LoadError error={loadError} onRetry={loadMore} /> 196 + {/snippet} 197 + </InfiniteLoader> 198 + {:else} 199 + <NotLoggedIn /> 200 + {/if} 201 + </div>
+9 -1
src/components/TimelineView.svelte
··· 4 4 import type { Did } from '@atcute/lexicons/syntax'; 5 5 import FeedTimelineView from './FeedTimelineView.svelte'; 6 6 import ReplyTimelineView from './ReplyTimelineView.svelte'; 7 + import FollowingTimelineView from './FollowingTimelineView.svelte'; 7 8 8 9 interface Props { 9 10 client?: AtpClient | null; ··· 30 31 }; 31 32 </script> 32 33 33 - {#if selectedFeed} 34 + {#if selectedFeed === 'following'} 35 + <FollowingTimelineView 36 + {client} 37 + bind:postComposerState 38 + class={className} 39 + targetDid={targetDid ?? undefined} 40 + /> 41 + {:else if selectedFeed} 34 42 <FeedTimelineView 35 43 {client} 36 44 {selectedFeed}
+3 -2
src/lib/at/client.svelte.ts
··· 244 244 ident: ActorIdentifier, 245 245 collection: Collection, 246 246 cursor?: string, 247 - timestamp: number = -1 247 + timestamp: number = -1, 248 + limit: number = 100 248 249 ): Promise<ReturnType<typeof this.listRecords>> { 249 250 const data: OkType<Awaited<ReturnType<typeof this.listRecords>>> = { 250 251 records: [], ··· 253 254 254 255 let end = false; 255 256 while (!end) { 256 - const res = await this.listRecords(ident, collection, data.cursor); 257 + const res = await this.listRecords(ident, collection, data.cursor, limit); 257 258 if (!res.ok) return res; 258 259 data.cursor = res.value.cursor; 259 260 data.records.push(...res.value.records);
+255 -4
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 setRecordCache, ··· 31 31 toCanonicalUri 32 32 } from '$lib'; 33 33 import { Router } from './router.svelte'; 34 - import type { Account } from './accounts'; 34 + import { accounts, type Account } from './accounts'; 35 35 import { 36 36 getPreferences, 37 37 putPreferences, ··· 381 381 382 382 // this fetches up to three days of posts and interactions for using in following list 383 383 export const fetchForInteractions = async (client: AtpClient, subject: Did) => { 384 + const userDid = client.user?.did; 385 + if (!userDid) return; 386 + 384 387 const threeDaysAgo = (Date.now() - 3 * 24 * 60 * 60 * 1000) * 1000; 385 388 389 + // fetch only 1 item to prompt the cursor 386 390 const res = await client.listRecordsUntil(subject, 'app.bsky.feed.post', undefined, threeDaysAgo); 387 391 if (!res.ok) return; 388 392 const postsWithUri = res.value.records.map( ··· 391 395 ); 392 396 addPosts(postsWithUri); 393 397 398 + // add to following buffer (not feed directly) 399 + let buffer = followingBuffer.get(userDid); 400 + if (!buffer) { 401 + buffer = new SvelteSet(); 402 + followingBuffer.set(userDid, buffer); 403 + } 404 + for (const post of postsWithUri) buffer.add(post.uri); 405 + 406 + if (res.value.cursor) { 407 + let userCursors = followingCursors.get(userDid); 408 + if (!userCursors) { 409 + userCursors = new SvelteMap(); 410 + followingCursors.set(userDid, userCursors); 411 + } 412 + userCursors.set(subject, res.value.cursor); 413 + } 414 + 394 415 const cursorTimestamp = timestampFromCursor(res.value.cursor) ?? -1; 395 416 const timestamp = Math.min(cursorTimestamp, threeDaysAgo); 396 417 console.log(`${subject}: fetchForInteractions`, res.value.cursor, timestamp); 397 418 await Promise.all([repostSource].map((s) => fetchLinksUntil(subject, client, s, timestamp))); 398 419 }; 399 420 421 + export const fetchFollowingTimeline = async (client: AtpClient, targetDid?: Did, limit: number = 10) => { 422 + // 1. Identify candidates (active follows + self) 423 + const userDid = targetDid ?? client.user?.did; 424 + if (!userDid) return; 425 + 426 + let buffer = followingBuffer.get(userDid); 427 + if (!buffer) { 428 + buffer = new SvelteSet(); 429 + followingBuffer.set(userDid, buffer); 430 + } 431 + 432 + let userFeed = followingFeed.get(userDid); 433 + if (!userFeed) { 434 + userFeed = new SvelteSet(); 435 + followingFeed.set(userDid, userFeed); 436 + } 437 + 438 + let userCursors = followingCursors.get(userDid); 439 + if (!userCursors) { 440 + userCursors = new SvelteMap(); 441 + followingCursors.set(userDid, userCursors); 442 + } 443 + 444 + // 0. Drain buffer first 445 + if (buffer.size > 0) { 446 + const sorted = Array.from(buffer).sort((a, b) => { 447 + const postA = getPostFromUri(a); 448 + const postB = getPostFromUri(b); 449 + return ( 450 + new Date(postB?.record.createdAt ?? 0).getTime() - 451 + new Date(postA?.record.createdAt ?? 0).getTime() 452 + ); 453 + }); 454 + 455 + const toAdd = sorted.slice(0, limit); 456 + for (const uri of toAdd) { 457 + userFeed.add(uri); 458 + buffer.delete(uri); 459 + } 460 + 461 + // If we had enough in buffer, return. If we exhausted buffer but needed more? 462 + // For simplicity, just return. The UI will call loadMore again if needed/short. 463 + return; 464 + } 465 + 466 + const followsMap = follows.get(userDid); 467 + const subjects = new Set<Did>(); 468 + if (followsMap) { 469 + for (const follow of followsMap.values()) subjects.add(follow.subject); 470 + } 471 + subjects.add(userDid); 472 + 473 + // 2. Find the "newest" cursor(s) 474 + let maxCursor: string | undefined = undefined; 475 + let candidates: Did[] = []; 476 + 477 + for (const subject of subjects) { 478 + const cursor = userCursors.get(subject); 479 + 480 + // null means exhausted 481 + if (cursor === null) continue; 482 + 483 + // if we haven't fetched this user yet (undefined cursor), they are a candidate (newest) 484 + if (cursor === undefined) { 485 + if (maxCursor !== undefined) { 486 + maxCursor = undefined; 487 + candidates = [subject]; 488 + } else { 489 + candidates.push(subject); 490 + } 491 + continue; 492 + } 493 + 494 + // If maxCursor is undefined (meaning we have some 'now' candidates), skip checked cursors 495 + if (maxCursor === undefined && candidates.length > 0) continue; 496 + 497 + if (maxCursor === undefined || cursor > maxCursor) { 498 + maxCursor = cursor; 499 + candidates = [subject]; 500 + } else if (cursor === maxCursor) { 501 + candidates.push(subject); 502 + } 503 + } 504 + 505 + if (candidates.length === 0) return; // Everyone exhausted? 506 + 507 + // 3. Fetch from candidates 508 + console.log('fetching following timeline from', candidates, maxCursor); 509 + const results = await Promise.all( 510 + candidates.map(async (did) => { 511 + const cursor = userCursors!.get(did) ?? undefined; 512 + // fetch limit is 4th argument, cursor is 3rd 513 + const res = await client.listRecords(did, 'app.bsky.feed.post', cursor, limit); 514 + if (!res.ok) { 515 + console.error(`fetchFollowingTimeline failed for ${did}:`, res.error); 516 + return null; 517 + } 518 + return { did, res: res.value }; 519 + }) 520 + ); 521 + 522 + // 4. Update state 523 + const newPosts: ResourceUri[] = []; 524 + for (const result of results) { 525 + if (!result) continue; 526 + const { did, res } = result; 527 + 528 + // update cursor 529 + if (res.cursor) userCursors!.set(did, res.cursor); 530 + else userCursors!.set(did, null); // null = exhausted 531 + 532 + for (const record of res.records) { 533 + newPosts.push(record.uri); 534 + } 535 + } 536 + 537 + if (newPosts.length === 0) return; 538 + 539 + // fetch each post record 540 + const posts = await Promise.all( 541 + newPosts.map(async (uri) => { 542 + const result = await client.getRecordUri(AppBskyFeedPost.mainSchema, uri); 543 + if (!result.ok) return null; 544 + return { uri: result.value.uri, cid: result.value.cid, record: result.value.record }; 545 + }) 546 + ); 547 + 548 + const validPosts = posts.filter((p): p is PostWithUri => p !== null); 549 + addPosts(validPosts); 550 + 551 + for (const post of validPosts) userFeed.add(post.uri); 552 + 553 + // check if any of the post authors block the user 554 + await fetchBlocksForPosts( 555 + client, 556 + validPosts.map((p) => p.uri) 557 + ); 558 + }; 559 + 400 560 // if did is in set, we have fetched blocks for them already (against logged in users) 401 561 export const blockFlags = new SvelteMap<Did, SvelteSet<Did>>(); 402 562 ··· 532 692 533 693 export const getPost = (did: Did, rkey: RecordKey) => 534 694 allPosts.get(did)?.get(toCanonicalUri({ did, collection: 'app.bsky.feed.post', rkey })); 695 + 696 + export const getPostFromUri = (uri: ResourceUri) => { 697 + const did = extractDidFromUri(uri); 698 + return did ? allPosts.get(did)?.get(uri) : undefined; 699 + }; 700 + 535 701 const hydrateCacheFn: Parameters<typeof hydratePosts>[3] = (did, rkey) => { 536 702 const cached = getPost(did, rkey); 537 703 return cached ? ok(cached) : undefined; ··· 587 753 export const feedTimelines = new SvelteMap<Did, SvelteMap<string, ResourceUri[]>>(); 588 754 export const feedCursors = new SvelteMap<Did, SvelteMap<string, { value?: string; end: boolean }>>(); 589 755 756 + export const followingFeed = new SvelteMap<Did, SvelteSet<ResourceUri>>(); // merged timeline: UserDid -> Set<Uri> 757 + export const followingBuffer = new SvelteMap<Did, SvelteSet<ResourceUri>>(); // buffer: UserDid -> Set<Uri> 758 + export const followingCursors = new SvelteMap<Did, SvelteMap<Did, string | null>>(); // cursors: UserDid -> SubjectDid -> Cursor 759 + 590 760 export const fetchFeed = async ( 591 761 client: AtpClient, 592 762 feedUri: string, ··· 741 911 ); 742 912 }; 743 913 914 + export const fetchInteractionsToFollowingTimelineEnd = async ( 915 + client: AtpClient, 916 + userDid: Did 917 + ) => { 918 + const userCursors = followingCursors.get(userDid); 919 + if (!userCursors) return; 920 + 921 + const feed = followingFeed.get(userDid); 922 + if (!feed || feed.size === 0) return; 923 + 924 + let minTimestamp = Date.now() * 1000; 925 + let found = false; 926 + 927 + for (const uri of feed) { 928 + const post = getPostFromUri(uri); 929 + if (post) { 930 + const ts = new Date(post.record.createdAt).getTime() * 1000; 931 + if (ts < minTimestamp) minTimestamp = ts; 932 + found = true; 933 + } 934 + } 935 + 936 + if (!found) return; 937 + 938 + await Promise.all( 939 + [likeSource, repostSource].map((s) => fetchLinksUntil(userDid, client, s, minTimestamp)) 940 + ); 941 + }; 942 + 943 + export const fetchInteractionsToFeedTimelineEnd = async ( 944 + client: AtpClient, 945 + userDid: Did, 946 + feedUri: string 947 + ) => { 948 + const userFeedTimelines = feedTimelines.get(userDid); 949 + if (!userFeedTimelines) return; 950 + const posts = userFeedTimelines.get(feedUri); 951 + if (!posts || posts.length === 0) return; 952 + 953 + let minTimestamp = Date.now() * 1000; 954 + let found = false; 955 + 956 + for (const uri of posts) { 957 + const post = getPostFromUri(uri); 958 + if (post) { 959 + const ts = new Date(post.record.createdAt).getTime() * 1000; 960 + if (ts < minTimestamp) minTimestamp = ts; 961 + found = true; 962 + } 963 + } 964 + 965 + if (!found) return; 966 + 967 + await Promise.all( 968 + [likeSource, repostSource].map((s) => fetchLinksUntil(userDid, client, s, minTimestamp)) 969 + ); 970 + }; 971 + 744 972 export const fetchInitial = async (account: Account) => { 745 973 const client = clients.get(account.did)!; 746 974 await Promise.all([ ··· 776 1004 } 777 1005 addPosts(hydrated.value.values()); 778 1006 addTimeline(did, hydrated.value.keys()); 1007 + 1008 + // Broadcast to following feeds of local accounts 1009 + for (const account of get(accounts)) { 1010 + // does this account follow the author? 1011 + let isFollowing = account.did === did; 1012 + if (!isFollowing) { 1013 + const accountFollows = follows.get(account.did); 1014 + if (accountFollows) { 1015 + for (const follow of accountFollows.values()) { 1016 + if (follow.subject === did) { 1017 + isFollowing = true; 1018 + break; 1019 + } 1020 + } 1021 + } 1022 + } 1023 + 1024 + if (isFollowing) { 1025 + const feed = followingFeed.get(account.did); 1026 + if (feed) { 1027 + for (const uri of hydrated.value.keys()) feed.add(uri); 1028 + } 1029 + } 1030 + } 1031 + 779 1032 if (record.reply) { 780 1033 const parentDid = extractDidFromUri(record.reply.parent.uri)!; 781 1034 addTimeline(parentDid, [uri]); 782 - // const rootDid = extractDidFromUri(record.reply.root.uri)!; 783 - // addTimeline(rootDid, [uri]); 784 1035 } 785 1036 } else if (commit.operation === 'delete') { 786 1037 deletePost(uri);
+1 -1
src/routes/[...catchall]/+page.svelte
··· 333 333 <div class="flex items-center gap-1.5 px-2 py-1"> 334 334 <div class="flex items-center gap-1.5"> 335 335 <FeedSelector {selectedFeed} onSelect={(uri) => (selectedFeed = uri)} /> 336 - {#if selectedFeed} 336 + {#if selectedFeed && selectedFeed !== 'following'} 337 337 <button 338 338 onclick={() => { 339 339 if (timelineView) timelineView.clearFeed();