appview-less bluesky client
24
fork

Configure Feed

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

refactor timelines, fix following timeline showing threads that are only replies

dawn be6ded99 30d6d992

+340 -358
+2 -12
src/components/FeedTimelineView.svelte
··· 16 16 checkForNewPosts, 17 17 fetchInteractionsToFeedTimelineEnd 18 18 } from '$lib/state.svelte'; 19 - import Icon from '@iconify/svelte'; 20 19 import NotLoggedIn from './NotLoggedIn.svelte'; 21 20 import { fetchFeedGenerator } from '$lib/at/feeds'; 22 21 import LoadingSpinner from './LoadingSpinner.svelte'; 23 22 import EndOfList from './EndOfList.svelte'; 24 23 import LoadError from './LoadError.svelte'; 24 + import LoadNewPosts from './LoadNewPosts.svelte'; 25 25 26 26 interface Props { 27 27 client?: AtpClient | null; ··· 168 168 class="min-h-full p-2 [scrollbar-color:var(--nucleus-accent)_transparent] {className}" 169 169 bind:this={scrollContainer} 170 170 > 171 - {#if newPostsAvailable} 172 - <div class="sticky top-2 z-20 mb-4 flex w-full justify-center"> 173 - <button 174 - class="flex action-button items-center gap-2 bg-(--nucleus-bg) hover:scale-115! enabled:hover:bg-(--nucleus-bg)!" 175 - onclick={clearFeed} 176 - > 177 - <Icon icon="heroicons:arrow-up-16-solid" width="20" /> 178 - load new posts 179 - </button> 180 - </div> 181 - {/if} 171 + <LoadNewPosts visible={newPostsAvailable} onclick={clearFeed} /> 182 172 {#if userDid || $accounts.length > 0} 183 173 <InfiniteLoader {loaderState} triggerLoad={loadMore} loopDetectionTimeout={0}> 184 174 {@render feedPostsView()}
+57 -138
src/components/FollowingTimelineView.svelte
··· 1 1 <script lang="ts"> 2 - import BskyPost from './BskyPost.svelte'; 3 2 import { type State as PostComposerState } from './PostComposer.svelte'; 4 3 import { AtpClient } from '$lib/at/client.svelte'; 5 4 import { accounts } from '$lib/accounts'; 6 - import { type ResourceUri } from '@atcute/lexicons'; 7 5 import { SvelteSet } from 'svelte/reactivity'; 8 - import { InfiniteLoader, LoaderState } from 'svelte-infinite'; 9 6 import { 10 - followingCursors, 11 7 fetchFollowingTimeline, 12 8 allPosts, 13 9 followingFeed, 14 10 accountPreferences, 15 11 fetchInteractionsToFollowingTimelineEnd, 16 - follows 12 + follows, 13 + followingCursors 17 14 } from '$lib/state.svelte'; 18 - import Icon from '@iconify/svelte'; 19 - import { buildThreads, filterThreads, type ThreadPost } from '$lib/thread'; 15 + import { buildThreads, filterThreads } from '$lib/thread'; 20 16 import type { Did } from '@atcute/lexicons/syntax'; 21 - import NotLoggedIn from './NotLoggedIn.svelte'; 22 - import LoadingSpinner from './LoadingSpinner.svelte'; 23 - import EndOfList from './EndOfList.svelte'; 24 - import LoadError from './LoadError.svelte'; 17 + import GenericTimelineView from './GenericTimelineView.svelte'; 25 18 26 19 interface Props { 27 20 client?: AtpClient | null; ··· 37 30 targetDid = undefined 38 31 }: Props = $props(); 39 32 40 - let reverseChronological = $state(true); 41 33 let viewOwnPosts = $state(true); 42 - const expandedThreads = new SvelteSet<ResourceUri>(); 43 34 44 35 const userDid = $derived(targetDid ?? client?.user?.did); 45 36 ··· 50 41 if (!userDid) return new Set<Did>(); 51 42 const map = follows.get(userDid); 52 43 if (!map) return new Set<Did>(); 53 - return new Set(Array.from(map.values()).map((f) => f.subject)); 44 + return new Set(map.keys()); 54 45 }); 55 46 56 47 const threads = $derived( ··· 61 52 $accounts, 62 53 { 63 54 viewOwnPosts, 55 + filterReplies: true, 64 56 filterRootsToDids: followedDids 65 57 } 66 58 ) 67 59 ); 68 60 69 - const loaderState = new LoaderState(); 70 - let scrollContainer = $state<HTMLDivElement>(); 71 - let loading = $state(false); 72 - let loadError = $state(''); 61 + const isComplete = $derived.by(() => { 62 + if (!userDid) return false; 63 + const cursors = followingCursors.get(userDid); 64 + const subjects = follows.get(userDid); 65 + 66 + // if no cursors yet, we haven't started 67 + if (!cursors) return false; 68 + 69 + // Check self 70 + if (cursors.get(userDid) !== null) return false; 71 + 72 + // Check follows 73 + if (subjects) { 74 + for (const subject of subjects.keys()) { 75 + // if checking logic in state.svelte.ts: 76 + // undefined means "not fetched", null means "exhausted" 77 + // if any cursor is undefined or string, it's not complete. 78 + if (cursors.get(subject) !== null) return false; 79 + } 80 + } 81 + 82 + return true; 83 + }); 73 84 74 85 let fetchingInteractions = $state(false); 75 86 let scheduledFetchInteractions = $state(false); 76 87 77 88 const loadMore = async () => { 78 - if (loading || !client || !userDid) return; 79 - 80 - loading = true; 81 - loaderState.status = 'LOADING'; 89 + if (!client || !userDid) return; 82 90 83 - try { 84 - await fetchFollowingTimeline(client, userDid); 91 + await fetchFollowingTimeline(client, userDid); 85 92 86 - if (client.user && userDid) { 87 - if (!fetchingInteractions) { 88 - scheduledFetchInteractions = false; 89 - fetchingInteractions = true; 90 - await fetchInteractionsToFollowingTimelineEnd(client, userDid); 91 - fetchingInteractions = false; 92 - } else { 93 - scheduledFetchInteractions = true; 94 - } 93 + if (client.user && userDid) { 94 + if (!fetchingInteractions) { 95 + scheduledFetchInteractions = false; 96 + fetchingInteractions = true; 97 + await fetchInteractionsToFollowingTimelineEnd(client, userDid); 98 + fetchingInteractions = false; 99 + } else { 100 + scheduledFetchInteractions = true; 95 101 } 96 - 97 - loaderState.loaded(); 98 - } catch (error) { 99 - loadError = `${error}`; 100 - loaderState.error(); 101 - loading = false; 102 - return; 103 102 } 104 - 105 - loading = false; 106 103 }; 107 104 108 105 $effect(() => { 109 - const isEmpty = threads.length === 0; 110 - if (isEmpty && !loading && userDid) { 111 - loadMore(); 106 + if (client && scheduledFetchInteractions && userDid) { 107 + if (!fetchingInteractions) { 108 + scheduledFetchInteractions = false; 109 + fetchingInteractions = true; 110 + fetchInteractionsToFollowingTimelineEnd(client, userDid).finally( 111 + () => (fetchingInteractions = false) 112 + ); 113 + } 112 114 } 113 115 }); 114 - 115 - $effect(() => { 116 - userDid; 117 - loaderState.reset(); 118 - }); 119 116 </script> 120 117 121 - {#snippet replyPost(post: ThreadPost, reverse: boolean = reverseChronological)} 122 - <span 123 - class="mb-1.5 flex items-center gap-1.5 overflow-hidden text-nowrap wrap-break-word overflow-ellipsis" 124 - > 125 - <span class="text-sm text-nowrap opacity-60">{reverse ? '↱' : '↳'}</span> 126 - <BskyPost mini client={client!} {...post} /> 127 - </span> 128 - {/snippet} 129 - 130 - {#snippet threadsView()} 131 - {#each threads as thread, i (thread.rootUri)} 132 - <div class="flex w-full shrink-0 {reverseChronological ? 'flex-col' : 'flex-col-reverse'}"> 133 - {#if thread.branchParentPost} 134 - {@render replyPost(thread.branchParentPost)} 135 - {/if} 136 - {#each thread.posts as post, idx (post.data.uri)} 137 - {@const mini = 138 - !expandedThreads.has(thread.rootUri) && 139 - thread.posts.length > 4 && 140 - idx > 0 && 141 - idx < thread.posts.length - 2} 142 - {#if !mini} 143 - <div class="mb-1.5"> 144 - <BskyPost 145 - client={client!} 146 - onQuote={(post) => { 147 - postComposerState.focus = 'focused'; 148 - postComposerState.quoting = post; 149 - }} 150 - onReply={(post) => { 151 - postComposerState.focus = 'focused'; 152 - postComposerState.replying = post; 153 - }} 154 - {...post} 155 - /> 156 - </div> 157 - {:else if mini} 158 - {#if idx === 1} 159 - {@render replyPost(post, !reverseChronological)} 160 - <button 161 - 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)" 162 - onclick={() => expandedThreads.add(thread.rootUri)} 163 - > 164 - <div class="mr-1 h-px w-20 rounded border-y-2 border-dashed opacity-50"></div> 165 - <Icon 166 - class="shrink-0" 167 - icon={reverseChronological 168 - ? 'heroicons:bars-arrow-up-solid' 169 - : 'heroicons:bars-arrow-down-solid'} 170 - width={32} 171 - /><span class="shrink-0 pb-1">view full chain</span> 172 - <div class="ml-1 h-px w-full rounded border-y-2 border-dashed opacity-50"></div> 173 - </button> 174 - {:else if idx === thread.posts.length - 3} 175 - {@render replyPost(post)} 176 - {/if} 177 - {/if} 178 - {/each} 179 - </div> 180 - {#if i < threads.length - 1} 181 - <div 182 - class="mx-8 mt-3 mb-4 h-px bg-linear-to-r from-(--nucleus-accent)/30 to-(--nucleus-accent2)/30" 183 - ></div> 184 - {/if} 185 - {/each} 186 - {/snippet} 187 - 188 - <div 189 - class="min-h-full p-2 [scrollbar-color:var(--nucleus-accent)_transparent] {className}" 190 - bind:this={scrollContainer} 191 - > 192 - {#if userDid || $accounts.length > 0} 193 - <InfiniteLoader {loaderState} triggerLoad={loadMore} loopDetectionTimeout={0}> 194 - {@render threadsView()} 195 - {#snippet noData()} 196 - <EndOfList /> 197 - {/snippet} 198 - {#snippet loading()} 199 - <LoadingSpinner /> 200 - {/snippet} 201 - {#snippet error()} 202 - <LoadError error={loadError} onRetry={loadMore} /> 203 - {/snippet} 204 - </InfiniteLoader> 205 - {:else} 206 - <NotLoggedIn /> 207 - {/if} 208 - </div> 118 + <GenericTimelineView 119 + {client} 120 + {threads} 121 + bind:postComposerState 122 + class={className} 123 + isLoggedIn={!!(userDid || $accounts.length > 0)} 124 + canLoad={!!(client && userDid)} 125 + onLoadMore={loadMore} 126 + {isComplete} 127 + />
+4 -17
src/components/FollowingView.svelte
··· 49 49 50 50 const interactionScores = 51 51 followingSort === 'conversational' 52 - ? calculateInteractionScores( 53 - selectedDid, 54 - followsMap, 55 - allPosts, 56 - allBacklinks, 57 - replyIndex, 58 - staticNow 59 - ) 52 + ? calculateInteractionScores(selectedDid, allPosts, allBacklinks, replyIndex, staticNow) 60 53 : null; 61 54 62 - const userStatsList = followsMap.values().map((f) => ({ 63 - did: f.subject, 64 - data: calculateFollowedUserStats( 65 - followingSort, 66 - f.subject, 67 - allPosts, 68 - interactionScores, 69 - staticNow 70 - ) 55 + const userStatsList = followsMap.keys().map((did) => ({ 56 + did, 57 + data: calculateFollowedUserStats(followingSort, did, allPosts, interactionScores, staticNow) 71 58 })); 72 59 73 60 const following = userStatsList.filter((u) => u.data !== null);
+193
src/components/GenericTimelineView.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 Icon from '@iconify/svelte'; 10 + import { type ThreadPost, type Thread } from '$lib/thread'; 11 + import type { Did } from '@atcute/lexicons/syntax'; 12 + import NotLoggedIn from './NotLoggedIn.svelte'; 13 + import LoadingSpinner from './LoadingSpinner.svelte'; 14 + import EndOfList from './EndOfList.svelte'; 15 + import LoadError from './LoadError.svelte'; 16 + import LoadNewPosts from './LoadNewPosts.svelte'; 17 + 18 + interface Props { 19 + client?: AtpClient | null; 20 + threads: Thread[]; 21 + postComposerState: PostComposerState; 22 + class?: string; 23 + isLoggedIn?: boolean; // Controls rendering of the list vs NotLoggedIn 24 + canLoad?: boolean; // Controls whether we can actually load more data 25 + onLoadMore: () => Promise<void>; 26 + isComplete?: boolean; 27 + } 28 + 29 + let { 30 + client = null, 31 + threads, 32 + postComposerState = $bindable(), 33 + class: className = '', 34 + isLoggedIn = false, 35 + canLoad = undefined, 36 + onLoadMore, 37 + isComplete = false 38 + }: Props = $props(); 39 + 40 + // Default canLoad to isLoggedIn if not provided, for backward compatibility/simpler usage 41 + const shouldLoad = $derived(canLoad ?? isLoggedIn); 42 + 43 + let reverseChronological = $state(true); 44 + const expandedThreads = new SvelteSet<ResourceUri>(); 45 + 46 + let isAtTop = $state(true); 47 + let boundaryTime = $state<number | null>(null); 48 + 49 + const visibleThreads = $derived.by(() => { 50 + if (boundaryTime === null) return threads; 51 + return threads.filter((t) => t.newestTime <= boundaryTime!); 52 + }); 53 + 54 + $effect(() => { 55 + if (threads.length > 0) { 56 + if (isAtTop) { 57 + boundaryTime = threads[0].newestTime; 58 + } else if (boundaryTime === null) { 59 + boundaryTime = threads[0].newestTime; 60 + } 61 + } 62 + }); 63 + 64 + const showNewPosts = () => { 65 + boundaryTime = threads[0]?.newestTime ?? null; 66 + window.scrollTo({ top: 0, behavior: 'instant' }); 67 + isAtTop = true; 68 + }; 69 + 70 + const onScroll = () => (isAtTop = window.scrollY < 300); 71 + 72 + const loaderState = new LoaderState(); 73 + let loading = $state(false); 74 + let loadError = $state(''); 75 + 76 + const loadMore = async () => { 77 + if (loading || !shouldLoad) return; 78 + 79 + loading = true; 80 + loaderState.status = 'LOADING'; 81 + loadError = ''; 82 + 83 + try { 84 + await onLoadMore(); 85 + loaderState.loaded(); 86 + if (isComplete) loaderState.complete(); 87 + } catch (error) { 88 + loadError = `${error}`; 89 + loaderState.error(); 90 + loading = false; 91 + return; 92 + } 93 + 94 + loading = false; 95 + }; 96 + 97 + $effect(() => { 98 + const isEmpty = threads.length === 0; 99 + if (isEmpty && !loading && shouldLoad && !isComplete) loadMore(); 100 + }); 101 + </script> 102 + 103 + <svelte:window onscroll={onScroll} /> 104 + 105 + {#snippet replyPost(post: ThreadPost, reverse: boolean = reverseChronological)} 106 + <span 107 + class="mb-1.5 flex items-center gap-1.5 overflow-hidden text-nowrap wrap-break-word overflow-ellipsis" 108 + > 109 + <span class="text-sm text-nowrap opacity-60">{reverse ? '↱' : '↳'}</span> 110 + <BskyPost mini client={client!} {...post} /> 111 + </span> 112 + {/snippet} 113 + 114 + {#snippet threadsView()} 115 + {#each visibleThreads as thread, i (thread.rootUri)} 116 + <div class="flex w-full shrink-0 {reverseChronological ? 'flex-col' : 'flex-col-reverse'}"> 117 + {#if thread.branchParentPost} 118 + {@render replyPost(thread.branchParentPost)} 119 + {/if} 120 + {#each thread.posts as post, idx (post.data.uri)} 121 + {@const mini = 122 + !expandedThreads.has(thread.rootUri) && 123 + thread.posts.length > 4 && 124 + idx > 0 && 125 + idx < thread.posts.length - 2} 126 + {#if !mini} 127 + <div class="mb-1.5"> 128 + <BskyPost 129 + client={client!} 130 + onQuote={(post) => { 131 + postComposerState.focus = 'focused'; 132 + postComposerState.quoting = post; 133 + }} 134 + onReply={(post) => { 135 + postComposerState.focus = 'focused'; 136 + postComposerState.replying = post; 137 + }} 138 + {...post} 139 + /> 140 + </div> 141 + {:else if mini} 142 + {#if idx === 1} 143 + {@render replyPost(post, !reverseChronological)} 144 + <button 145 + 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)" 146 + onclick={() => expandedThreads.add(thread.rootUri)} 147 + > 148 + <div class="mr-1 h-px w-20 rounded border-y-2 border-dashed opacity-50"></div> 149 + <Icon 150 + class="shrink-0" 151 + icon={reverseChronological 152 + ? 'heroicons:bars-arrow-up-solid' 153 + : 'heroicons:bars-arrow-down-solid'} 154 + width={32} 155 + /><span class="shrink-0 pb-1">view full chain</span> 156 + <div class="ml-1 h-px w-full rounded border-y-2 border-dashed opacity-50"></div> 157 + </button> 158 + {:else if idx === thread.posts.length - 3} 159 + {@render replyPost(post)} 160 + {/if} 161 + {/if} 162 + {/each} 163 + </div> 164 + {#if i < visibleThreads.length - 1} 165 + <div 166 + class="mx-8 mt-3 mb-4 h-px bg-linear-to-r from-(--nucleus-accent)/30 to-(--nucleus-accent2)/30" 167 + ></div> 168 + {/if} 169 + {/each} 170 + {/snippet} 171 + 172 + <div class="min-h-full p-2 [scrollbar-color:var(--nucleus-accent)_transparent] {className}"> 173 + <LoadNewPosts 174 + visible={threads.length > 0 && boundaryTime !== null && threads[0].newestTime > boundaryTime} 175 + onclick={showNewPosts} 176 + /> 177 + {#if isLoggedIn} 178 + <InfiniteLoader {loaderState} triggerLoad={loadMore} loopDetectionTimeout={0}> 179 + {@render threadsView()} 180 + {#snippet noData()} 181 + <EndOfList /> 182 + {/snippet} 183 + {#snippet loading()} 184 + <LoadingSpinner /> 185 + {/snippet} 186 + {#snippet error()} 187 + <LoadError error={loadError} onRetry={loadMore} /> 188 + {/snippet} 189 + </InfiniteLoader> 190 + {:else} 191 + <NotLoggedIn /> 192 + {/if} 193 + </div>
+22
src/components/LoadNewPosts.svelte
··· 1 + <script lang="ts"> 2 + import Icon from '@iconify/svelte'; 3 + 4 + interface Props { 5 + visible: boolean; 6 + onclick: () => void; 7 + } 8 + 9 + let { visible, onclick }: Props = $props(); 10 + </script> 11 + 12 + {#if visible} 13 + <div class="sticky top-2 z-20 mb-4 flex w-full justify-center"> 14 + <button 15 + class="flex action-button items-center gap-2 bg-(--nucleus-bg) hover:scale-115! enabled:hover:bg-(--nucleus-bg)!" 16 + {onclick} 17 + > 18 + <Icon icon="heroicons:arrow-up-16-solid" width="20" /> 19 + load new posts 20 + </button> 21 + </div> 22 + {/if}
-2
src/components/PostComposer.svelte
··· 322 322 }; 323 323 324 324 const doPost = () => { 325 - if (_state.text.length === 0 || _state.text.length > 300) return; 326 - 327 325 postError = ''; 328 326 posting = true; 329 327 post(_state.text)
+5 -10
src/components/ProfileActions.svelte
··· 8 8 createBlock, 9 9 deleteBlock, 10 10 follows, 11 - setAccountPreferences, 12 11 updateAccountPreferences 13 12 } from '$lib/state.svelte'; 14 13 import { generateColorForDid } from '$lib/accounts'; ··· 33 32 let actionsPos = $state({ x: 0, y: 0 }); 34 33 35 34 const followsMap = $derived(userDid ? follows.get(userDid) : undefined); 36 - const follow = $derived( 37 - followsMap 38 - ? Array.from(followsMap.entries()).find(([, follow]) => follow.subject === targetDid) 39 - : undefined 40 - ); 35 + const follow = $derived(followsMap ? followsMap.get(targetDid) : undefined); 41 36 42 37 const currentPrefs = $derived(userDid ? accountPreferences.get(userDid) : null); 43 38 const mutes = $derived(currentPrefs?.mutes ?? []); ··· 55 50 if (!userDid || !client.user) return; 56 51 57 52 if (follow) { 58 - const [uri] = follow; 59 - followsMap?.delete(uri); 53 + const { uri } = follow; 54 + followsMap?.delete(targetDid); 60 55 61 56 // extract rkey from uri 62 57 const parsedUri = parseCanonicalResourceUri(uri); ··· 85 80 rkey 86 81 }); 87 82 88 - if (!followsMap) follows.set(userDid, new SvelteMap([[uri, record]])); 89 - else followsMap.set(uri, record); 83 + if (!followsMap) follows.set(userDid, new SvelteMap([[targetDid, { uri, record }]])); 84 + else followsMap.set(targetDid, { uri, record }); 90 85 91 86 await client.user.atcute.post('com.atproto.repo.createRecord', { 92 87 input: {
+24 -148
src/components/ReplyTimelineView.svelte
··· 1 1 <script lang="ts"> 2 - import BskyPost from './BskyPost.svelte'; 3 2 import { type State as PostComposerState } from './PostComposer.svelte'; 4 3 import { AtpClient } from '$lib/at/client.svelte'; 5 4 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 5 import { 10 6 postCursors, 11 7 fetchTimeline, ··· 14 10 fetchInteractionsToTimelineEnd, 15 11 accountPreferences 16 12 } from '$lib/state.svelte'; 17 - import Icon from '@iconify/svelte'; 18 - import { buildThreads, filterThreads, type ThreadPost } from '$lib/thread'; 13 + import { buildThreads, filterThreads } from '$lib/thread'; 19 14 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'; 15 + import GenericTimelineView from './GenericTimelineView.svelte'; 24 16 25 17 interface Props { 26 18 client?: AtpClient | null; ··· 38 30 class: className = '' 39 31 }: Props = $props(); 40 32 41 - let reverseChronological = $state(true); 42 33 let viewOwnPosts = $state(true); 43 - const expandedThreads = new SvelteSet<ResourceUri>(); 44 34 45 35 const userDid = $derived(client?.user?.did); 46 36 const did = $derived(targetDid ?? userDid); ··· 56 46 ) 57 47 ); 58 48 59 - const loaderState = new LoaderState(); 60 - let scrollContainer = $state<HTMLDivElement>(); 61 - let loading = $state(false); 62 - let loadError = $state(''); 63 - 64 49 let fetchingInteractions = $state(false); 65 50 let scheduledFetchInteractions = $state(false); 66 51 67 52 const loadMore = async () => { 68 - if (loading || !client || !userDid || !did) return; 53 + if (!client || !userDid || !did) return; 69 54 70 - loading = true; 71 - loaderState.status = 'LOADING'; 72 - 73 - try { 74 - await fetchTimeline(client, did, 7, showReplies, { 75 - downwards: userDid === did ? 'sameAuthor' : 'none' 76 - }); 77 - if (client.user && userDid) { 78 - if (!fetchingInteractions) { 79 - scheduledFetchInteractions = false; 80 - fetchingInteractions = true; 81 - await fetchInteractionsToTimelineEnd(client, userDid, did); 82 - fetchingInteractions = false; 83 - } else { 84 - scheduledFetchInteractions = true; 85 - } 55 + await fetchTimeline(client, did, 7, showReplies, { 56 + downwards: userDid === did ? 'sameAuthor' : 'none' 57 + }); 58 + if (client.user && userDid) { 59 + if (!fetchingInteractions) { 60 + scheduledFetchInteractions = false; 61 + fetchingInteractions = true; 62 + await fetchInteractionsToTimelineEnd(client, userDid, did); 63 + fetchingInteractions = false; 64 + } else { 65 + scheduledFetchInteractions = true; 86 66 } 87 - loaderState.loaded(); 88 - const cursor = postCursors.get(did); 89 - if (cursor?.end) loaderState.complete(); 90 - } catch (error) { 91 - loadError = `${error}`; 92 - loaderState.error(); 93 - loading = false; 94 - return; 95 67 } 96 - 97 - loading = false; 98 68 }; 99 69 100 70 $effect(() => { 101 - const isEmpty = threads.length === 0; 102 - if (isEmpty && !loading && userDid && did) { 103 - const cursor = postCursors.get(did); 104 - if (!cursor?.end) loadMore(); 105 - } 106 - }); 107 - 108 - $effect(() => { 109 - did; 110 - loaderState.reset(); 111 - }); 112 - 113 - // we want to load interactions when changing logged in user 114 - // only on timelines that arent logged in users, because those are already 115 - // loaded by loadMore 116 - $effect(() => { 117 71 if (client && scheduledFetchInteractions && userDid && did && did !== userDid) { 118 72 if (!fetchingInteractions) { 119 73 scheduledFetchInteractions = false; ··· 128 82 }); 129 83 </script> 130 84 131 - {#snippet replyPost(post: ThreadPost, reverse: boolean = reverseChronological)} 132 - <span 133 - class="mb-1.5 flex items-center gap-1.5 overflow-hidden text-nowrap wrap-break-word overflow-ellipsis" 134 - > 135 - <span class="text-sm text-nowrap opacity-60">{reverse ? '↱' : '↳'}</span> 136 - <BskyPost mini client={client!} {...post} /> 137 - </span> 138 - {/snippet} 139 - 140 - {#snippet threadsView()} 141 - {#each threads as thread, i (thread.rootUri)} 142 - <div class="flex w-full shrink-0 {reverseChronological ? 'flex-col' : 'flex-col-reverse'}"> 143 - {#if thread.branchParentPost} 144 - {@render replyPost(thread.branchParentPost)} 145 - {/if} 146 - {#each thread.posts as post, idx (post.data.uri)} 147 - {@const mini = 148 - !expandedThreads.has(thread.rootUri) && 149 - thread.posts.length > 4 && 150 - idx > 0 && 151 - idx < thread.posts.length - 2} 152 - {#if !mini} 153 - <div class="mb-1.5"> 154 - <BskyPost 155 - client={client!} 156 - onQuote={(post) => { 157 - postComposerState.focus = 'focused'; 158 - postComposerState.quoting = post; 159 - }} 160 - onReply={(post) => { 161 - postComposerState.focus = 'focused'; 162 - postComposerState.replying = post; 163 - }} 164 - {...post} 165 - /> 166 - </div> 167 - {:else if mini} 168 - {#if idx === 1} 169 - {@render replyPost(post, !reverseChronological)} 170 - <button 171 - 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)" 172 - onclick={() => expandedThreads.add(thread.rootUri)} 173 - > 174 - <div class="mr-1 h-px w-20 rounded border-y-2 border-dashed opacity-50"></div> 175 - <Icon 176 - class="shrink-0" 177 - icon={reverseChronological 178 - ? 'heroicons:bars-arrow-up-solid' 179 - : 'heroicons:bars-arrow-down-solid'} 180 - width={32} 181 - /><span class="shrink-0 pb-1">view full chain</span> 182 - <div class="ml-1 h-px w-full rounded border-y-2 border-dashed opacity-50"></div> 183 - </button> 184 - {:else if idx === thread.posts.length - 3} 185 - {@render replyPost(post)} 186 - {/if} 187 - {/if} 188 - {/each} 189 - </div> 190 - {#if i < threads.length - 1} 191 - <div 192 - class="mx-8 mt-3 mb-4 h-px bg-linear-to-r from-(--nucleus-accent)/30 to-(--nucleus-accent2)/30" 193 - ></div> 194 - {/if} 195 - {/each} 196 - {/snippet} 197 - 198 - <div 199 - class="min-h-full p-2 [scrollbar-color:var(--nucleus-accent)_transparent] {className}" 200 - bind:this={scrollContainer} 201 - > 202 - {#if did || $accounts.length > 0} 203 - <InfiniteLoader {loaderState} triggerLoad={loadMore} loopDetectionTimeout={0}> 204 - {@render threadsView()} 205 - {#snippet noData()} 206 - <EndOfList /> 207 - {/snippet} 208 - {#snippet loading()} 209 - <LoadingSpinner /> 210 - {/snippet} 211 - {#snippet error()} 212 - <LoadError error={loadError} onRetry={loadMore} /> 213 - {/snippet} 214 - </InfiniteLoader> 215 - {:else} 216 - <NotLoggedIn /> 217 - {/if} 218 - </div> 85 + <GenericTimelineView 86 + {client} 87 + {threads} 88 + bind:postComposerState 89 + class={className} 90 + isLoggedIn={!!(did || $accounts.length > 0)} 91 + canLoad={!!(client && userDid && did)} 92 + onLoadMore={loadMore} 93 + isComplete={did ? postCursors.get(did)?.end : false} 94 + />
-1
src/lib/following.ts
··· 136 136 137 137 export const calculateInteractionScores = ( 138 138 user: Did, 139 - followsMap: Map<ResourceUri, AppBskyGraphFollow.Main>, 140 139 allPosts: Map<Did, Map<ResourceUri, PostWithUri>>, 141 140 allBacklinks: Map<BacklinksSource, Map<ResourceUri, Map<Did, Set<string>>>>, 142 141 replyIndex: Map<Did, Set<ResourceUri>>,
+1
src/lib/index.ts
··· 31 31 export const replyRootSource: BacklinksSource = 'app.bsky.feed.post:reply.root.uri'; 32 32 export const blockSource: BacklinksSource = 'app.bsky.graph.block:subject'; 33 33 34 + // returns nanos 34 35 export const timestampFromCursor = (cursor: string | undefined) => { 35 36 if (!cursor) return undefined; 36 37 try {
+20 -26
src/lib/state.svelte.ts
··· 348 348 await syncAccountPreferences(did); 349 349 }; 350 350 351 - export const follows = new SvelteMap<Did, SvelteMap<ResourceUri, AppBskyGraphFollow.Main>>(); 351 + type FollowWithUri = { 352 + uri: ResourceUri; 353 + record: AppBskyGraphFollow.Main; 354 + }; 355 + export const follows = new SvelteMap<Did, SvelteMap<Did, FollowWithUri>>(); 352 356 353 357 export const addFollows = ( 354 358 did: Did, 355 - followMap: Iterable<[ResourceUri, AppBskyGraphFollow.Main]> 359 + followList: Iterable<FollowWithUri> 356 360 ) => { 357 361 let map = follows.get(did)!; 358 362 if (!map) { 359 - map = new SvelteMap(followMap); 363 + map = new SvelteMap(); 360 364 follows.set(did, map); 361 - return; 362 365 } 363 - for (const [uri, record] of followMap) map.set(uri, record); 366 + for (const follow of followList) map.set(follow.record.subject, follow); 364 367 }; 365 368 366 369 export const fetchFollows = async ( ··· 374 377 } 375 378 addFollows( 376 379 account.did, 377 - res.value.records.map((follow) => [follow.uri, follow.value as AppBskyGraphFollow.Main]) 380 + res.value.records.map((follow) => ({ 381 + uri: follow.uri, 382 + record: follow.value as AppBskyGraphFollow.Main 383 + })) 378 384 ); 379 385 return res.value.records.values().map((follow) => follow.value as AppBskyGraphFollow.Main); 380 386 }; ··· 461 467 return; 462 468 } 463 469 464 - const followsMap = follows.get(userDid); 465 - const subjects = new Set<Did>(); 466 - if (followsMap) 467 - for (const follow of followsMap.values()) subjects.add(follow.subject); 470 + const subjects = new Set(follows.get(userDid)?.keys()); 468 471 subjects.add(userDid); 469 472 470 473 // 2. Find the "newest" cursor(s) ··· 1000 1003 addPosts(hydrated.value.values()); 1001 1004 addTimeline(did, hydrated.value.keys()); 1002 1005 1006 + if (record.reply) { 1007 + const parentDid = extractDidFromUri(record.reply.parent.uri)!; 1008 + addTimeline(parentDid, [uri]); 1009 + } 1010 + 1003 1011 // Broadcast to following feeds of local accounts 1004 1012 for (const account of get(accounts)) { 1005 1013 // does this account follow the author? 1006 1014 let isFollowing = account.did === did; 1007 1015 if (!isFollowing) { 1008 1016 const accountFollows = follows.get(account.did); 1009 - if (accountFollows) { 1010 - for (const follow of accountFollows.values()) { 1011 - if (follow.subject === did) { 1012 - isFollowing = true; 1013 - break; 1014 - } 1015 - } 1016 - } 1017 + if (accountFollows?.has(did)) isFollowing = true; 1017 1018 } 1018 1019 1019 1020 if (isFollowing) { 1020 1021 const feed = followingFeed.get(account.did); 1021 - if (feed) { 1022 - for (const uri of hydrated.value.keys()) feed.add(uri); 1023 - } 1022 + if (feed) for (const uri of hydrated.value.keys()) feed.add(uri); 1024 1023 } 1025 - } 1026 - 1027 - if (record.reply) { 1028 - const parentDid = extractDidFromUri(record.reply.parent.uri)!; 1029 - addTimeline(parentDid, [uri]); 1030 1024 } 1031 1025 } else if (commit.operation === 'delete') { 1032 1026 deletePost(uri);
+6 -1
src/lib/thread.ts
··· 5 5 import { expect } from './result'; 6 6 import type { PostWithUri } from './at/fetch'; 7 7 import { isBlockedBy } from './state.svelte'; 8 + import { timestampFromCursor } from '$lib'; 8 9 9 10 export type ThreadPost = { 10 11 data: PostWithUri; ··· 42 43 const rootUri = (data.record.reply?.root.uri as ResourceUri) || uri; 43 44 const parentUri = (data.record.reply?.parent.uri as ResourceUri) || null; 44 45 46 + const cursorTime = timestampFromCursor(parsedUri.rkey); 45 47 const post: ThreadPost = { 46 48 data, 47 49 account, ··· 49 51 rkey: parsedUri.rkey, 50 52 parentUri, 51 53 depth: 0, 52 - newestTime: new Date(data.record.createdAt).getTime(), 54 + newestTime: cursorTime ? cursorTime / 1000 : new Date(data.record.createdAt).getTime(), 53 55 isBlocked: isBlockedBy(parsedUri.repo, account), 54 56 isMuted: mutes.includes(parsedUri.repo), 55 57 }; ··· 169 171 170 172 export type FilterOptions = { 171 173 viewOwnPosts: boolean; 174 + filterReplies?: boolean; 172 175 filterRootsToDids?: Set<Did>; 173 176 }; 174 177 175 178 export const filterThreads = (threads: Thread[], accounts: Account[], opts: FilterOptions) => 176 179 threads.filter((thread) => { 177 180 if (thread.posts.length === 0) return false; 181 + if (opts.filterReplies && thread.posts[0].data.record.reply) return false; 182 + 178 183 if (!opts.viewOwnPosts) if (hasNonOwnPost(thread.posts, accounts)) return false; 179 184 180 185 if (opts.filterRootsToDids) {
+6 -3
src/routes/[...catchall]/+page.svelte
··· 35 35 import type { Sort } from '$lib/following'; 36 36 import { SvelteMap } from 'svelte/reactivity'; 37 37 import FeedSelector from '$components/FeedSelector.svelte'; 38 + import { extractDidFromUri } from '$lib'; 38 39 39 40 const { data: loadData }: PageProps = $props(); 40 41 ··· 166 167 167 168 $effect(() => { 168 169 const wantedDids: Did[] = ['did:web:guestbook.gaze.systems']; 169 - const followDids = follows 170 - .values() 171 - .flatMap((followMap) => followMap.values().map((follow) => follow.subject)); 170 + const followDids = follows.values().flatMap((followMap) => followMap.keys()); 172 171 const accountDids = $accounts.values().map((account) => account.did); 173 172 wantedDids.push(...followDids, ...accountDids); 174 173 // console.log('updating jetstream options:', wantedDids); ··· 311 310 onPostSent={(post) => { 312 311 addPosts([post]); 313 312 addTimeline(selectedDid!, [post.uri]); 313 + if (post.record.reply) { 314 + const parentDid = extractDidFromUri(post.record.reply.parent.uri)!; 315 + addTimeline(parentDid, [post.uri]); 316 + } 314 317 }} 315 318 bind:_state={postComposerState} 316 319 />