add following timeline
8 unmodified lines910111213131415161749 unmodified lines67686970717273747576777879808182838485868730 unmodified lines8 unmodified lines selectedFeed: string | null; onSelect: (feedUri: string | null) => void; }
let { selectedFeed, onSelect }: Props = $props(); let { selectedFeed = $bindable(), onSelect }: Props = $props();
let isOpen = $state(false);
const savedFeeds = $derived($settings.feeds);49 unmodified lines > <Icon icon="heroicons:chat-bubble-left-ellipsis-16-solid" width="20" /> <span>replies</span> </button> <button onclick={() => { onSelect('following'); isOpen = false; }} 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 === 'following' ? 'bg-(--nucleus-accent)/20' : ''}" > <Icon icon="heroicons:users-solid" width="20" /> <span>following</span> </button> {#each sortedFeeds as savedFeed (savedFeed.feed.uri)} <button onclick={() => { onSelect(savedFeed.feed.uri);30 unmodified lines11 unmodified lines121314151616171819202148 unmodified lines707172737475767778798020 unmodified lines10110210310410510610710810911011111211311411511611711811912078 unmodified lines11 unmodified lines feedTimelines, feedCursors, fetchFeed, resetFeed, checkForNewPosts checkForNewPosts, fetchInteractionsToFeedTimelineEnd } from '$lib/state.svelte'; import Icon from '@iconify/svelte'; import NotLoggedIn from './NotLoggedIn.svelte'; import { fetchFeedGenerator } from '$lib/at/feeds';48 unmodified lines let scrollContainer = $state<HTMLDivElement>(); let loading = $state(false); let loadError = $state('');
let fetchingInteractions = $state(false); let scheduledFetchInteractions = $state(false);
export const clearFeed = () => { if (!userDid) return; scrollContainer?.scrollTo({ top: 0, behavior: 'smooth' }); newPostsAvailable = false;20 unmodified lines loaderState.status = 'LOADING';
try { const result = await fetchFeed(client, selectedFeed, feedServiceDid);
if (client.user && userDid) { if (!fetchingInteractions) { scheduledFetchInteractions = false; fetchingInteractions = true; await fetchInteractionsToFeedTimelineEnd(client, userDid, selectedFeed); fetchingInteractions = false; } else { scheduledFetchInteractions = true; } }
loaderState.loaded(); if (result?.end) loaderState.complete(); } catch (error) { loadError = `${error}`;78 unmodified lines123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201<script lang="ts"> import BskyPost from './BskyPost.svelte'; import { type State as PostComposerState } from './PostComposer.svelte'; import { AtpClient } from '$lib/at/client.svelte'; import { accounts } from '$lib/accounts'; import { type ResourceUri } from '@atcute/lexicons'; import { SvelteSet } from 'svelte/reactivity'; import { InfiniteLoader, LoaderState } from 'svelte-infinite'; import { followingCursors, fetchFollowingTimeline, allPosts, followingFeed, accountPreferences, fetchInteractionsToFollowingTimelineEnd } from '$lib/state.svelte'; import Icon from '@iconify/svelte'; import { buildThreads, filterThreads, type ThreadPost } from '$lib/thread'; import type { Did } from '@atcute/lexicons/syntax'; import NotLoggedIn from './NotLoggedIn.svelte'; import LoadingSpinner from './LoadingSpinner.svelte'; import EndOfList from './EndOfList.svelte'; import LoadError from './LoadError.svelte';
interface Props { client?: AtpClient | null; postComposerState: PostComposerState; class?: string; targetDid?: Did; }
let { client = null, postComposerState = $bindable(), class: className = '', targetDid = undefined }: Props = $props();
let reverseChronological = $state(true); let viewOwnPosts = $state(true); const expandedThreads = new SvelteSet<ResourceUri>();
const userDid = $derived(targetDid ?? client?.user?.did);
const currentPrefs = $derived(userDid ? accountPreferences.get(userDid) : null); const mutes = $derived(currentPrefs?.mutes ?? []);
// We use userDid as the 'root' for buildThreads merely to provide a context, // but we are passing `followingFeed.get(userDid)` (merged set) as the source. const threads = $derived( filterThreads( userDid ? buildThreads(userDid, followingFeed.get(userDid) ?? new SvelteSet(), allPosts, mutes) : [], $accounts, { viewOwnPosts } ) );
const loaderState = new LoaderState(); let scrollContainer = $state<HTMLDivElement>(); let loading = $state(false); let loadError = $state('');
let fetchingInteractions = $state(false); let scheduledFetchInteractions = $state(false);
const loadMore = async () => { if (loading || !client || !userDid) return;
loading = true; loaderState.status = 'LOADING';
try { await fetchFollowingTimeline(client, userDid);
if (client.user && userDid) { if (!fetchingInteractions) { scheduledFetchInteractions = false; fetchingInteractions = true; await fetchInteractionsToFollowingTimelineEnd(client, userDid); fetchingInteractions = false; } else { scheduledFetchInteractions = true; } }
loaderState.loaded(); } catch (error) { loadError = `${error}`; loaderState.error(); loading = false; return; }
loading = false; };
$effect(() => { const isEmpty = threads.length === 0; if (isEmpty && !loading && userDid) { loadMore(); } });
$effect(() => { userDid; loaderState.reset(); });</script>
{#snippet replyPost(post: ThreadPost, reverse: boolean = reverseChronological)} <span class="mb-1.5 flex items-center gap-1.5 overflow-hidden text-nowrap wrap-break-word overflow-ellipsis" > <span class="text-sm text-nowrap opacity-60">{reverse ? '↱' : '↳'}</span> <BskyPost mini client={client!} {...post} /> </span>{/snippet}
{#snippet threadsView()} {#each threads as thread, i (thread.rootUri)} <div class="flex w-full shrink-0 {reverseChronological ? 'flex-col' : 'flex-col-reverse'}"> {#if thread.branchParentPost} {@render replyPost(thread.branchParentPost)} {/if} {#each thread.posts as post, idx (post.data.uri)} {@const mini = !expandedThreads.has(thread.rootUri) && thread.posts.length > 4 && idx > 0 && idx < thread.posts.length - 2} {#if !mini} <div class="mb-1.5"> <BskyPost client={client!} onQuote={(post) => { postComposerState.focus = 'focused'; postComposerState.quoting = post; }} onReply={(post) => { postComposerState.focus = 'focused'; postComposerState.replying = post; }} {...post} /> </div> {:else if mini} {#if idx === 1} {@render replyPost(post, !reverseChronological)} <button 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)" onclick={() => expandedThreads.add(thread.rootUri)} > <div class="mr-1 h-px w-20 rounded border-y-2 border-dashed opacity-50"></div> <Icon class="shrink-0" icon={reverseChronological ? 'heroicons:bars-arrow-up-solid' : 'heroicons:bars-arrow-down-solid'} width={32} /><span class="shrink-0 pb-1">view full chain</span> <div class="ml-1 h-px w-full rounded border-y-2 border-dashed opacity-50"></div> </button> {:else if idx === thread.posts.length - 3} {@render replyPost(post)} {/if} {/if} {/each} </div> {#if i < threads.length - 1} <div class="mx-8 mt-3 mb-4 h-px bg-linear-to-r from-(--nucleus-accent)/30 to-(--nucleus-accent2)/30" ></div> {/if} {/each}{/snippet}
<div class="min-h-full p-2 [scrollbar-color:var(--nucleus-accent)_transparent] {className}" bind:this={scrollContainer}> {#if userDid || $accounts.length > 0} <InfiniteLoader {loaderState} triggerLoad={loadMore} loopDetectionTimeout={0}> {@render threadsView()} {#snippet noData()} <EndOfList /> {/snippet} {#snippet loading()} <LoadingSpinner /> {/snippet} {#snippet error()} <LoadError error={loadError} onRetry={loadMore} /> {/snippet} </InfiniteLoader> {:else} <NotLoggedIn /> {/if}</div>2 unmodified lines3456789101118 unmodified lines30313233333435363738394041424344456 unmodified lines2 unmodified lines import { AtpClient } from '$lib/at/client.svelte'; import type { Did } from '@atcute/lexicons/syntax'; import FeedTimelineView from './FeedTimelineView.svelte'; import ReplyTimelineView from './ReplyTimelineView.svelte'; import FollowingTimelineView from './FollowingTimelineView.svelte';
interface Props { client?: AtpClient | null; targetDid?: Did;18 unmodified lines feedView?.clearFeed(); };</script>
{#if selectedFeed}{#if selectedFeed === 'following'} <FollowingTimelineView {client} bind:postComposerState class={className} targetDid={targetDid ?? undefined} />{:else if selectedFeed} <FeedTimelineView {client} {selectedFeed} bind:postComposerState6 unmodified lines11234524 unmodified lines30313233343435363738341 unmodified linesExpand all380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563127 unmodified linesExpand all69169269369469569669769869970070170270370447 unmodified lines752753754755756757758759760761762763146 unmodified linesExpand all91091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497527 unmodified lines10031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034782783103510361037103874 unmodified linesimport { writable } from 'svelte/store';import { get, writable } from 'svelte/store';import { AtpClient, setRecordCache, type NotificationsStream,24 unmodified lines timestampFromCursor, toCanonicalUri} from '$lib';import { Router } from './router.svelte';import type { Account } from './accounts';import { accounts, type Account } from './accounts';import { getPreferences, putPreferences, type Preferences341 unmodified linesExpand all};
// this fetches up to three days of posts and interactions for using in following listexport const fetchForInteractions = async (client: AtpClient, subject: Did) => { const userDid = client.user?.did; if (!userDid) return;
const threeDaysAgo = (Date.now() - 3 * 24 * 60 * 60 * 1000) * 1000;
// fetch only 1 item to prompt the cursor const res = await client.listRecordsUntil(subject, 'app.bsky.feed.post', undefined, threeDaysAgo); if (!res.ok) return; const postsWithUri = res.value.records.map( (post) => ({ cid: post.cid, uri: post.uri, record: post.value as AppBskyFeedPost.Main }) as PostWithUri ); addPosts(postsWithUri);
// add to following buffer (not feed directly) let buffer = followingBuffer.get(userDid); if (!buffer) { buffer = new SvelteSet(); followingBuffer.set(userDid, buffer); } for (const post of postsWithUri) buffer.add(post.uri);
if (res.value.cursor) { let userCursors = followingCursors.get(userDid); if (!userCursors) { userCursors = new SvelteMap(); followingCursors.set(userDid, userCursors); } userCursors.set(subject, res.value.cursor); }
const cursorTimestamp = timestampFromCursor(res.value.cursor) ?? -1; const timestamp = Math.min(cursorTimestamp, threeDaysAgo); console.log(`${subject}: fetchForInteractions`, res.value.cursor, timestamp); await Promise.all([repostSource].map((s) => fetchLinksUntil(subject, client, s, timestamp)));};
export const fetchFollowingTimeline = async (client: AtpClient, targetDid?: Did, limit: number = 10) => { // 1. Identify candidates (active follows + self) const userDid = targetDid ?? client.user?.did; if (!userDid) return;
let buffer = followingBuffer.get(userDid); if (!buffer) { buffer = new SvelteSet(); followingBuffer.set(userDid, buffer); }
let userFeed = followingFeed.get(userDid); if (!userFeed) { userFeed = new SvelteSet(); followingFeed.set(userDid, userFeed); }
let userCursors = followingCursors.get(userDid); if (!userCursors) { userCursors = new SvelteMap(); followingCursors.set(userDid, userCursors); }
// 0. Drain buffer first if (buffer.size > 0) { const sorted = Array.from(buffer).sort((a, b) => { const postA = getPostFromUri(a); const postB = getPostFromUri(b); return ( new Date(postB?.record.createdAt ?? 0).getTime() - new Date(postA?.record.createdAt ?? 0).getTime() ); });
const toAdd = sorted.slice(0, limit); for (const uri of toAdd) { userFeed.add(uri); buffer.delete(uri); }
// If we had enough in buffer, return. If we exhausted buffer but needed more? // For simplicity, just return. The UI will call loadMore again if needed/short. return; }
const followsMap = follows.get(userDid); const subjects = new Set<Did>(); if (followsMap) { for (const follow of followsMap.values()) subjects.add(follow.subject); } subjects.add(userDid);
// 2. Find the "newest" cursor(s) let maxCursor: string | undefined = undefined; let candidates: Did[] = [];
for (const subject of subjects) { const cursor = userCursors.get(subject);
// null means exhausted if (cursor === null) continue;
// if we haven't fetched this user yet (undefined cursor), they are a candidate (newest) if (cursor === undefined) { if (maxCursor !== undefined) { maxCursor = undefined; candidates = [subject]; } else { candidates.push(subject); } continue; }
// If maxCursor is undefined (meaning we have some 'now' candidates), skip checked cursors if (maxCursor === undefined && candidates.length > 0) continue;
if (maxCursor === undefined || cursor > maxCursor) { maxCursor = cursor; candidates = [subject]; } else if (cursor === maxCursor) { candidates.push(subject); } }
if (candidates.length === 0) return; // Everyone exhausted?
// 3. Fetch from candidates console.log('fetching following timeline from', candidates, maxCursor); const results = await Promise.all( candidates.map(async (did) => { const cursor = userCursors!.get(did) ?? undefined; // fetch limit is 4th argument, cursor is 3rd const res = await client.listRecords(did, 'app.bsky.feed.post', cursor, limit); if (!res.ok) { console.error(`fetchFollowingTimeline failed for ${did}:`, res.error); return null; } return { did, res: res.value }; }) );
// 4. Update state const newPosts: ResourceUri[] = []; for (const result of results) { if (!result) continue; const { did, res } = result;
// update cursor if (res.cursor) userCursors!.set(did, res.cursor); else userCursors!.set(did, null); // null = exhausted
for (const record of res.records) { newPosts.push(record.uri); } }
if (newPosts.length === 0) return;
// fetch each post record const posts = await Promise.all( newPosts.map(async (uri) => { const result = await client.getRecordUri(AppBskyFeedPost.mainSchema, uri); if (!result.ok) return null; return { uri: result.value.uri, cid: result.value.cid, record: result.value.record }; }) );
const validPosts = posts.filter((p): p is PostWithUri => p !== null); addPosts(validPosts);
for (const post of validPosts) userFeed.add(post.uri);
// check if any of the post authors block the user await fetchBlocksForPosts( client, validPosts.map((p) => p.uri) );};
// if did is in set, we have fetched blocks for them already (against logged in users)export const blockFlags = new SvelteMap<Did, SvelteSet<Did>>();
export const fetchBlocked = async (client: AtpClient, subject: Did, blocker: Did) => {127 unmodified linesExpand allexport const replyIndex = new SvelteMap<Did, SvelteSet<ResourceUri>>();
export const getPost = (did: Did, rkey: RecordKey) => allPosts.get(did)?.get(toCanonicalUri({ did, collection: 'app.bsky.feed.post', rkey }));
export const getPostFromUri = (uri: ResourceUri) => { const did = extractDidFromUri(uri); return did ? allPosts.get(did)?.get(uri) : undefined;};
const hydrateCacheFn: Parameters<typeof hydratePosts>[3] = (did, rkey) => { const cached = getPost(did, rkey); return cached ? ok(cached) : undefined;};47 unmodified lines// feed state: Did -> feedUri -> post URIsexport const feedTimelines = new SvelteMap<Did, SvelteMap<string, ResourceUri[]>>();export const feedCursors = new SvelteMap<Did, SvelteMap<string, { value?: string; end: boolean }>>();
export const followingFeed = new SvelteMap<Did, SvelteSet<ResourceUri>>(); // merged timeline: UserDid -> Set<Uri>export const followingBuffer = new SvelteMap<Did, SvelteSet<ResourceUri>>(); // buffer: UserDid -> Set<Uri>export const followingCursors = new SvelteMap<Did, SvelteMap<Did, string | null>>(); // cursors: UserDid -> SubjectDid -> Cursor
export const fetchFeed = async ( client: AtpClient, feedUri: string, feedServiceDid: string,146 unmodified linesExpand all [likeSource, repostSource].map((s) => fetchLinksUntil(interactor, client, s, timestamp)) );};
export const fetchInteractionsToFollowingTimelineEnd = async ( client: AtpClient, userDid: Did) => { const userCursors = followingCursors.get(userDid); if (!userCursors) return;
const feed = followingFeed.get(userDid); if (!feed || feed.size === 0) return;
let minTimestamp = Date.now() * 1000; let found = false;
for (const uri of feed) { const post = getPostFromUri(uri); if (post) { const ts = new Date(post.record.createdAt).getTime() * 1000; if (ts < minTimestamp) minTimestamp = ts; found = true; } }
if (!found) return;
await Promise.all( [likeSource, repostSource].map((s) => fetchLinksUntil(userDid, client, s, minTimestamp)) );};
export const fetchInteractionsToFeedTimelineEnd = async ( client: AtpClient, userDid: Did, feedUri: string) => { const userFeedTimelines = feedTimelines.get(userDid); if (!userFeedTimelines) return; const posts = userFeedTimelines.get(feedUri); if (!posts || posts.length === 0) return;
let minTimestamp = Date.now() * 1000; let found = false;
for (const uri of posts) { const post = getPostFromUri(uri); if (post) { const ts = new Date(post.record.createdAt).getTime() * 1000; if (ts < minTimestamp) minTimestamp = ts; found = true; } }
if (!found) return;
await Promise.all( [likeSource, repostSource].map((s) => fetchLinksUntil(userDid, client, s, minTimestamp)) );};
export const fetchInitial = async (account: Account) => { const client = clients.get(account.did)!; await Promise.all([ fetchBlocks(account),27 unmodified lines return; } addPosts(hydrated.value.values()); addTimeline(did, hydrated.value.keys());
// Broadcast to following feeds of local accounts for (const account of get(accounts)) { // does this account follow the author? let isFollowing = account.did === did; if (!isFollowing) { const accountFollows = follows.get(account.did); if (accountFollows) { for (const follow of accountFollows.values()) { if (follow.subject === did) { isFollowing = true; break; } } } }
if (isFollowing) { const feed = followingFeed.get(account.did); if (feed) { for (const uri of hydrated.value.keys()) feed.add(uri); } } }
if (record.reply) { const parentDid = extractDidFromUri(record.reply.parent.uri)!; addTimeline(parentDid, [uri]); // const rootDid = extractDidFromUri(record.reply.root.uri)!; // addTimeline(rootDid, [uri]); } } else if (commit.operation === 'delete') { deletePost(uri); }74 unmodified lines242 unmodified linesExpand all243244245246247247248249250251252253254255256256257258259260261246 unmodified linesExpand all242 unmodified linesExpand all async listRecordsUntil<Collection extends keyof Records>( ident: ActorIdentifier, collection: Collection, cursor?: string, timestamp: number = -1 timestamp: number = -1, limit: number = 100 ): Promise<ReturnType<typeof this.listRecords>> { const data: OkType<Awaited<ReturnType<typeof this.listRecords>>> = { records: [], cursor };
let end = false; while (!end) { const res = await this.listRecords(ident, collection, data.cursor); const res = await this.listRecords(ident, collection, data.cursor, limit); if (!res.ok) return res; data.cursor = res.value.cursor; data.records.push(...res.value.records); end = data.records.length === 0 || !data.cursor;246 unmodified linesExpand all331 unmodified linesExpand all33233333433533633633733833934036 unmodified lines331 unmodified linesExpand all <div class="footer-bg rounded-t-sm"> <div class="flex items-center gap-1.5 px-2 py-1"> <div class="flex items-center gap-1.5"> <FeedSelector {selectedFeed} onSelect={(uri) => (selectedFeed = uri)} /> {#if selectedFeed} {#if selectedFeed && selectedFeed !== 'following'} <button onclick={() => { if (timelineView) timelineView.clearFeed(); else if (selectedDid && selectedFeed) resetFeed(selectedDid, selectedFeed);36 unmodified lines