From be6ded99ded26d5baabd0568289e3c85274843a8 Mon Sep 17 00:00:00 2001 From: dawn <90008@gaze.systems> Date: Wed, 21 Jan 2026 14:59:34 +0300 Subject: [PATCH] refactor timelines, fix following timeline showing threads that are only replies --- src/components/FeedTimelineView.svelte | 14 +- src/components/FollowingTimelineView.svelte | 199 ++++++-------------- src/components/FollowingView.svelte | 21 +-- src/components/GenericTimelineView.svelte | 193 +++++++++++++++++++ src/components/LoadNewPosts.svelte | 22 +++ src/components/PostComposer.svelte | 2 - src/components/ProfileActions.svelte | 15 +- src/components/ReplyTimelineView.svelte | 172 +++-------------- src/lib/following.ts | 1 - src/lib/index.ts | 1 + src/lib/state.svelte.ts | 46 ++--- src/lib/thread.ts | 7 +- src/routes/[...catchall]/+page.svelte | 9 +- 13 files changed, 342 insertions(+), 360 deletions(-) create mode 100644 src/components/GenericTimelineView.svelte create mode 100644 src/components/LoadNewPosts.svelte diff --git a/src/components/FeedTimelineView.svelte b/src/components/FeedTimelineView.svelte index 883c403..a9b5ca3 100644 --- a/src/components/FeedTimelineView.svelte +++ b/src/components/FeedTimelineView.svelte @@ -16,12 +16,12 @@ checkForNewPosts, fetchInteractionsToFeedTimelineEnd } from '$lib/state.svelte'; - import Icon from '@iconify/svelte'; import NotLoggedIn from './NotLoggedIn.svelte'; import { fetchFeedGenerator } from '$lib/at/feeds'; import LoadingSpinner from './LoadingSpinner.svelte'; import EndOfList from './EndOfList.svelte'; import LoadError from './LoadError.svelte'; + import LoadNewPosts from './LoadNewPosts.svelte'; interface Props { client?: AtpClient | null; @@ -168,17 +168,7 @@ class="min-h-full p-2 [scrollbar-color:var(--nucleus-accent)_transparent] {className}" bind:this={scrollContainer} > - {#if newPostsAvailable} -
- -
- {/if} + {#if userDid || $accounts.length > 0} {@render feedPostsView()} diff --git a/src/components/FollowingTimelineView.svelte b/src/components/FollowingTimelineView.svelte index 094779d..905a373 100644 --- a/src/components/FollowingTimelineView.svelte +++ b/src/components/FollowingTimelineView.svelte @@ -1,27 +1,20 @@ -{#snippet replyPost(post: ThreadPost, reverse: boolean = reverseChronological)} - - {reverse ? '↱' : '↳'} - - -{/snippet} - -{#snippet threadsView()} - {#each threads as thread, i (thread.rootUri)} -
- {#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} -
- { - postComposerState.focus = 'focused'; - postComposerState.quoting = post; - }} - onReply={(post) => { - postComposerState.focus = 'focused'; - postComposerState.replying = post; - }} - {...post} - /> -
- {:else if mini} - {#if idx === 1} - {@render replyPost(post, !reverseChronological)} - - {:else if idx === thread.posts.length - 3} - {@render replyPost(post)} - {/if} - {/if} - {/each} -
- {#if i < threads.length - 1} -
- {/if} - {/each} -{/snippet} - -
- {#if userDid || $accounts.length > 0} - - {@render threadsView()} - {#snippet noData()} - - {/snippet} - {#snippet loading()} - - {/snippet} - {#snippet error()} - - {/snippet} - - {:else} - - {/if} -
+ 0)} + canLoad={!!(client && userDid)} + onLoadMore={loadMore} + {isComplete} +/> diff --git a/src/components/FollowingView.svelte b/src/components/FollowingView.svelte index 3e17626..b95cb28 100644 --- a/src/components/FollowingView.svelte +++ b/src/components/FollowingView.svelte @@ -49,25 +49,12 @@ const interactionScores = followingSort === 'conversational' - ? calculateInteractionScores( - selectedDid, - followsMap, - allPosts, - allBacklinks, - replyIndex, - staticNow - ) + ? calculateInteractionScores(selectedDid, allPosts, allBacklinks, replyIndex, staticNow) : null; - const userStatsList = followsMap.values().map((f) => ({ - did: f.subject, - data: calculateFollowedUserStats( - followingSort, - f.subject, - allPosts, - interactionScores, - staticNow - ) + const userStatsList = followsMap.keys().map((did) => ({ + did, + data: calculateFollowedUserStats(followingSort, did, allPosts, interactionScores, staticNow) })); const following = userStatsList.filter((u) => u.data !== null); diff --git a/src/components/GenericTimelineView.svelte b/src/components/GenericTimelineView.svelte new file mode 100644 index 0000000..07e772a --- /dev/null +++ b/src/components/GenericTimelineView.svelte @@ -0,0 +1,193 @@ + + + + +{#snippet replyPost(post: ThreadPost, reverse: boolean = reverseChronological)} + + {reverse ? '↱' : '↳'} + + +{/snippet} + +{#snippet threadsView()} + {#each visibleThreads as thread, i (thread.rootUri)} +
+ {#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} +
+ { + postComposerState.focus = 'focused'; + postComposerState.quoting = post; + }} + onReply={(post) => { + postComposerState.focus = 'focused'; + postComposerState.replying = post; + }} + {...post} + /> +
+ {:else if mini} + {#if idx === 1} + {@render replyPost(post, !reverseChronological)} + + {:else if idx === thread.posts.length - 3} + {@render replyPost(post)} + {/if} + {/if} + {/each} +
+ {#if i < visibleThreads.length - 1} +
+ {/if} + {/each} +{/snippet} + +
+ 0 && boundaryTime !== null && threads[0].newestTime > boundaryTime} + onclick={showNewPosts} + /> + {#if isLoggedIn} + + {@render threadsView()} + {#snippet noData()} + + {/snippet} + {#snippet loading()} + + {/snippet} + {#snippet error()} + + {/snippet} + + {:else} + + {/if} +
diff --git a/src/components/LoadNewPosts.svelte b/src/components/LoadNewPosts.svelte new file mode 100644 index 0000000..1b057ab --- /dev/null +++ b/src/components/LoadNewPosts.svelte @@ -0,0 +1,22 @@ + + +{#if visible} +
+ +
+{/if} diff --git a/src/components/PostComposer.svelte b/src/components/PostComposer.svelte index 3496d25..6055e22 100644 --- a/src/components/PostComposer.svelte +++ b/src/components/PostComposer.svelte @@ -322,8 +322,6 @@ }; const doPost = () => { - if (_state.text.length === 0 || _state.text.length > 300) return; - postError = ''; posting = true; post(_state.text) diff --git a/src/components/ProfileActions.svelte b/src/components/ProfileActions.svelte index 9e2a00b..d07cd3b 100644 --- a/src/components/ProfileActions.svelte +++ b/src/components/ProfileActions.svelte @@ -8,7 +8,6 @@ createBlock, deleteBlock, follows, - setAccountPreferences, updateAccountPreferences } from '$lib/state.svelte'; import { generateColorForDid } from '$lib/accounts'; @@ -33,11 +32,7 @@ let actionsPos = $state({ x: 0, y: 0 }); const followsMap = $derived(userDid ? follows.get(userDid) : undefined); - const follow = $derived( - followsMap - ? Array.from(followsMap.entries()).find(([, follow]) => follow.subject === targetDid) - : undefined - ); + const follow = $derived(followsMap ? followsMap.get(targetDid) : undefined); const currentPrefs = $derived(userDid ? accountPreferences.get(userDid) : null); const mutes = $derived(currentPrefs?.mutes ?? []); @@ -55,8 +50,8 @@ if (!userDid || !client.user) return; if (follow) { - const [uri] = follow; - followsMap?.delete(uri); + const { uri } = follow; + followsMap?.delete(targetDid); // extract rkey from uri const parsedUri = parseCanonicalResourceUri(uri); @@ -85,8 +80,8 @@ rkey }); - if (!followsMap) follows.set(userDid, new SvelteMap([[uri, record]])); - else followsMap.set(uri, record); + if (!followsMap) follows.set(userDid, new SvelteMap([[targetDid, { uri, record }]])); + else followsMap.set(targetDid, { uri, record }); await client.user.atcute.post('com.atproto.repo.createRecord', { input: { diff --git a/src/components/ReplyTimelineView.svelte b/src/components/ReplyTimelineView.svelte index b597379..c0f9e36 100644 --- a/src/components/ReplyTimelineView.svelte +++ b/src/components/ReplyTimelineView.svelte @@ -1,11 +1,7 @@ -{#snippet replyPost(post: ThreadPost, reverse: boolean = reverseChronological)} - - {reverse ? '↱' : '↳'} - - -{/snippet} - -{#snippet threadsView()} - {#each threads as thread, i (thread.rootUri)} -
- {#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} -
- { - postComposerState.focus = 'focused'; - postComposerState.quoting = post; - }} - onReply={(post) => { - postComposerState.focus = 'focused'; - postComposerState.replying = post; - }} - {...post} - /> -
- {:else if mini} - {#if idx === 1} - {@render replyPost(post, !reverseChronological)} - - {:else if idx === thread.posts.length - 3} - {@render replyPost(post)} - {/if} - {/if} - {/each} -
- {#if i < threads.length - 1} -
- {/if} - {/each} -{/snippet} - -
- {#if did || $accounts.length > 0} - - {@render threadsView()} - {#snippet noData()} - - {/snippet} - {#snippet loading()} - - {/snippet} - {#snippet error()} - - {/snippet} - - {:else} - - {/if} -
+ 0)} + canLoad={!!(client && userDid && did)} + onLoadMore={loadMore} + isComplete={did ? postCursors.get(did)?.end : false} +/> diff --git a/src/lib/following.ts b/src/lib/following.ts index 6b82474..96d9fe4 100644 --- a/src/lib/following.ts +++ b/src/lib/following.ts @@ -136,7 +136,6 @@ const getPostRate = (did: Did, posts: Map, now: number export const calculateInteractionScores = ( user: Did, - followsMap: Map, allPosts: Map>, allBacklinks: Map>>>, replyIndex: Map>, diff --git a/src/lib/index.ts b/src/lib/index.ts index 0f7b29b..59ace5b 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -31,6 +31,7 @@ export const replySource: BacklinksSource = 'app.bsky.feed.post:reply.parent.uri export const replyRootSource: BacklinksSource = 'app.bsky.feed.post:reply.root.uri'; export const blockSource: BacklinksSource = 'app.bsky.graph.block:subject'; +// returns nanos export const timestampFromCursor = (cursor: string | undefined) => { if (!cursor) return undefined; try { diff --git a/src/lib/state.svelte.ts b/src/lib/state.svelte.ts index a25f223..22a22c9 100644 --- a/src/lib/state.svelte.ts +++ b/src/lib/state.svelte.ts @@ -348,19 +348,22 @@ export const updateAccountPreferences = async ( await syncAccountPreferences(did); }; -export const follows = new SvelteMap>(); +type FollowWithUri = { + uri: ResourceUri; + record: AppBskyGraphFollow.Main; +}; +export const follows = new SvelteMap>(); export const addFollows = ( did: Did, - followMap: Iterable<[ResourceUri, AppBskyGraphFollow.Main]> + followList: Iterable ) => { let map = follows.get(did)!; if (!map) { - map = new SvelteMap(followMap); + map = new SvelteMap(); follows.set(did, map); - return; } - for (const [uri, record] of followMap) map.set(uri, record); + for (const follow of followList) map.set(follow.record.subject, follow); }; export const fetchFollows = async ( @@ -374,7 +377,10 @@ export const fetchFollows = async ( } addFollows( account.did, - res.value.records.map((follow) => [follow.uri, follow.value as AppBskyGraphFollow.Main]) + res.value.records.map((follow) => ({ + uri: follow.uri, + record: follow.value as AppBskyGraphFollow.Main + })) ); return res.value.records.values().map((follow) => follow.value as AppBskyGraphFollow.Main); }; @@ -461,10 +467,7 @@ export const fetchFollowingTimeline = async (client: AtpClient, targetDid?: Did, return; } - const followsMap = follows.get(userDid); - const subjects = new Set(); - if (followsMap) - for (const follow of followsMap.values()) subjects.add(follow.subject); + const subjects = new Set(follows.get(userDid)?.keys()); subjects.add(userDid); // 2. Find the "newest" cursor(s) @@ -1000,34 +1003,25 @@ export const handleJetstreamEvent = async (event: JetstreamEvent) => { addPosts(hydrated.value.values()); addTimeline(did, hydrated.value.keys()); + if (record.reply) { + const parentDid = extractDidFromUri(record.reply.parent.uri)!; + addTimeline(parentDid, [uri]); + } + // 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 (accountFollows?.has(did)) isFollowing = true; } if (isFollowing) { const feed = followingFeed.get(account.did); - if (feed) { - for (const uri of hydrated.value.keys()) feed.add(uri); - } + 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]); - } } else if (commit.operation === 'delete') { deletePost(uri); } diff --git a/src/lib/thread.ts b/src/lib/thread.ts index fa7a2bb..396b8b5 100644 --- a/src/lib/thread.ts +++ b/src/lib/thread.ts @@ -5,6 +5,7 @@ import type { Account } from './accounts'; import { expect } from './result'; import type { PostWithUri } from './at/fetch'; import { isBlockedBy } from './state.svelte'; +import { timestampFromCursor } from '$lib'; export type ThreadPost = { data: PostWithUri; @@ -42,6 +43,7 @@ export const buildThreads = ( const rootUri = (data.record.reply?.root.uri as ResourceUri) || uri; const parentUri = (data.record.reply?.parent.uri as ResourceUri) || null; + const cursorTime = timestampFromCursor(parsedUri.rkey); const post: ThreadPost = { data, account, @@ -49,7 +51,7 @@ export const buildThreads = ( rkey: parsedUri.rkey, parentUri, depth: 0, - newestTime: new Date(data.record.createdAt).getTime(), + newestTime: cursorTime ? cursorTime / 1000 : new Date(data.record.createdAt).getTime(), isBlocked: isBlockedBy(parsedUri.repo, account), isMuted: mutes.includes(parsedUri.repo), }; @@ -169,12 +171,15 @@ export const hasNonOwnPost = (posts: ThreadPost[], accounts: Account[]) => export type FilterOptions = { viewOwnPosts: boolean; + filterReplies?: boolean; filterRootsToDids?: Set; }; export const filterThreads = (threads: Thread[], accounts: Account[], opts: FilterOptions) => threads.filter((thread) => { if (thread.posts.length === 0) return false; + if (opts.filterReplies && thread.posts[0].data.record.reply) return false; + if (!opts.viewOwnPosts) if (hasNonOwnPost(thread.posts, accounts)) return false; if (opts.filterRootsToDids) { diff --git a/src/routes/[...catchall]/+page.svelte b/src/routes/[...catchall]/+page.svelte index 5016df9..f9eb676 100644 --- a/src/routes/[...catchall]/+page.svelte +++ b/src/routes/[...catchall]/+page.svelte @@ -35,6 +35,7 @@ import type { Sort } from '$lib/following'; import { SvelteMap } from 'svelte/reactivity'; import FeedSelector from '$components/FeedSelector.svelte'; + import { extractDidFromUri } from '$lib'; const { data: loadData }: PageProps = $props(); @@ -166,9 +167,7 @@ $effect(() => { const wantedDids: Did[] = ['did:web:guestbook.gaze.systems']; - const followDids = follows - .values() - .flatMap((followMap) => followMap.values().map((follow) => follow.subject)); + const followDids = follows.values().flatMap((followMap) => followMap.keys()); const accountDids = $accounts.values().map((account) => account.did); wantedDids.push(...followDids, ...accountDids); // console.log('updating jetstream options:', wantedDids); @@ -311,6 +310,10 @@ onPostSent={(post) => { addPosts([post]); addTimeline(selectedDid!, [post.uri]); + if (post.record.reply) { + const parentDid = extractDidFromUri(post.record.reply.parent.uri)!; + addTimeline(parentDid, [post.uri]); + } }} bind:_state={postComposerState} /> -- 2.51.2