appview-less bluesky client
24
fork

Configure Feed

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

optimize thread building a bit more

dawn e25c476a 5e834526

+299 -64
+11 -11
src/components/BskyPost.svelte
··· 1 1 <script lang="ts"> 2 2 import { resolveDidDoc, type AtpClient } from '$lib/at/client.svelte'; 3 - import { AppBskyActorProfile, AppBskyEmbedRecord, AppBskyFeedPost } from '@atcute/bluesky'; 3 + import { AppBskyEmbedRecord, AppBskyFeedPost } from '@atcute/bluesky'; 4 4 import { 5 5 parseCanonicalResourceUri, 6 6 type Did, ··· 51 51 onQuote?: (quote: PostWithUri) => void; 52 52 onReply?: (reply: PostWithUri) => void; 53 53 cornerFragment?: Snippet; 54 - isBlocked?: boolean; 54 + blockRelationship?: { userBlocked: boolean; blockedByTarget: boolean }; 55 55 isMuted?: boolean; 56 56 } 57 57 ··· 66 66 onReply, 67 67 isOnPostComposer = false /* replyBacklinks */, 68 68 cornerFragment, 69 - isBlocked = false, 69 + blockRelationship = undefined, 70 70 isMuted = false 71 71 }: Props = $props(); 72 72 ··· 79 79 let expandDisallowed = $state(false); 80 80 const blockRel = $derived( 81 81 user && !isOnPostComposer 82 - ? getBlockRelationship(user.did, did) 82 + ? (blockRelationship ?? getBlockRelationship(user.did, did)) 83 83 : { userBlocked: false, blockedByTarget: false } 84 84 ); 85 85 const showAsBlocked = $derived( 86 - (isBlocked || blockRel.userBlocked || blockRel.blockedByTarget) && !expandDisallowed 86 + (blockRel.userBlocked || blockRel.blockedByTarget) && !expandDisallowed 87 87 ); 88 88 const showAsMuted = $derived(isMuted && !expandDisallowed); 89 89 90 90 const handle = $derived(handles.get(did) ?? 'handle.invalid'); 91 - onMount(() => { 91 + $effect(() => { 92 92 resolveDidDoc(did).then((res) => { 93 93 if (res.ok) handles.set(did, res.value.handle); 94 - return res; 95 94 }); 96 95 }); 97 96 const profile = $derived(profiles.get(did)); 98 - onMount(async () => { 99 - const p = await client.getProfile(did); 100 - if (!p.ok) return; 101 - profiles.set(did, p.value); 97 + $effect(() => { 98 + client.getProfile(did).then((res) => { 99 + if (!res.ok) return; 100 + profiles.set(did, res.value); 101 + }); 102 102 }); 103 103 104 104 // svelte-ignore state_referenced_locally
+15 -13
src/components/FollowingTimelineView.svelte
··· 13 13 followingCursors, 14 14 initialDone 15 15 } from '$lib/state.svelte'; 16 - import { buildThreads, filterThreads } from '$lib/thread'; 16 + import { buildThreadsFiltered } from '$lib/thread'; 17 17 import type { Did } from '@atcute/lexicons/syntax'; 18 18 import GenericTimelineView from './GenericTimelineView.svelte'; 19 19 ··· 32 32 }: Props = $props(); 33 33 34 34 let viewOwnPosts = $state(true); 35 + let displayCount = $state(10); 35 36 36 37 const userDid = $derived(targetDid ?? client?.user?.did); 37 38 38 39 const currentPrefs = $derived(userDid ? accountPreferences.get(userDid) : null); 39 - const mutes = $derived(currentPrefs?.mutes ?? []); 40 + const mutes = $derived(new Set(currentPrefs?.mutes ?? [])); 40 41 41 42 const followedDids = $derived.by(() => { 42 43 if (!userDid) return new Set<Did>(); ··· 46 47 }); 47 48 48 49 const threads = $derived( 49 - filterThreads( 50 - userDid 51 - ? buildThreads(userDid, followingFeed.get(userDid) ?? new SvelteSet(), allPosts, mutes) 52 - : [], 53 - $accounts, 54 - { 55 - viewOwnPosts, 56 - filterReplies: true, 57 - filterRootsToDids: followedDids 58 - } 59 - ) 50 + userDid 51 + ? buildThreadsFiltered( 52 + userDid, 53 + followingFeed.get(userDid) ?? new SvelteSet(), 54 + allPosts, 55 + mutes, 56 + $accounts, 57 + { viewOwnPosts, filterReplies: true, filterRootsToDids: followedDids }, 58 + displayCount 59 + ) 60 + : [] 60 61 ); 61 62 62 63 const isComplete = $derived.by(() => { ··· 121 122 {threads} 122 123 timelineId={`following:${userDid}`} 123 124 bind:postComposerState 125 + bind:displayCount 124 126 class={className} 125 127 isLoggedIn={!!(userDid || $accounts.length > 0)} 126 128 canLoad={!!(client && userDid && initialDone.has(userDid))}
+7 -5
src/components/GenericTimelineView.svelte
··· 23 23 timelineId?: string; 24 24 postComposerState: PostComposerState; 25 25 class?: string; 26 - isLoggedIn?: boolean; // Controls rendering of the list vs NotLoggedIn 27 - canLoad?: boolean; // Controls whether we can actually load more data 26 + isLoggedIn?: boolean; 27 + canLoad?: boolean; 28 28 onLoadMore: () => Promise<void>; 29 29 isComplete?: boolean; 30 + displayCount?: number; 30 31 } 31 32 32 33 let { ··· 38 39 isLoggedIn = false, 39 40 canLoad = undefined, 40 41 onLoadMore, 41 - isComplete = false 42 + isComplete = false, 43 + displayCount = $bindable(15) 42 44 }: Props = $props(); 43 45 44 46 const shouldLoad = $derived(canLoad ?? isLoggedIn); ··· 56 58 return threads.filter((t) => t.newestTime <= boundaryTime!); 57 59 }); 58 60 59 - let displayCount = $state(15); 60 61 $effect(() => { 61 62 timelineId; 62 63 displayCount = 15; ··· 157 158 }; 158 159 159 160 $effect(() => { 160 - const isEmpty = threads.length < 15; 161 + const isEmpty = threads.length < 10; 161 162 if (isEmpty && !loading && shouldLoad && !isComplete) loadMore(); 162 163 }); 163 164 ··· 244 245 postComposerState.replying = post; 245 246 }} 246 247 {...post} 248 + blockRelationship={post.blockRelationship} 247 249 /> 248 250 </div> 249 251 {:else if mini}
+2
src/components/PostComposer.svelte
··· 625 625 <!-- svelte-ignore a11y_no_static_element_interactions --> 626 626 <div 627 627 class="composer relative flex cursor-text items-center gap-0 py-0! transition-all hover:brightness-110" 628 + style="--acc-color: {color};" 628 629 onmousedown={(e) => { 629 630 if (e.defaultPrevented) return; 630 631 _state.focus = 'focused'; ··· 639 640 type="text" 640 641 placeholder="what's on your mind?" 641 642 class="min-w-0 flex-1 border-none bg-transparent outline-none placeholder:text-(--nucleus-fg)/45 focus:ring-0" 643 + style="--acc-color: {color};" 642 644 /> 643 645 {#if _state.quoting} 644 646 {@render attachmentIndicator(_state.quoting, 'quoting')}
+1 -1
src/components/ProfilePicture.svelte
··· 29 29 }; 30 30 31 31 $effect(() => { 32 - client; 32 + if (!client.user) return; 33 33 loadProfile(did); 34 34 }); 35 35
+15 -7
src/components/ReplyTimelineView.svelte
··· 10 10 fetchInteractionsToTimelineEnd, 11 11 accountPreferences 12 12 } from '$lib/state.svelte'; 13 - import { buildThreads, filterThreads } from '$lib/thread'; 13 + import { buildThreadsFiltered } from '$lib/thread'; 14 14 import type { Did } from '@atcute/lexicons/syntax'; 15 15 import GenericTimelineView from './GenericTimelineView.svelte'; 16 16 ··· 31 31 }: Props = $props(); 32 32 33 33 let viewOwnPosts = $state(true); 34 + let displayCount = $state(10); 34 35 35 36 const userDid = $derived(client?.user?.did); 36 37 const did = $derived(targetDid ?? userDid); 37 38 38 39 const currentPrefs = $derived(userDid ? accountPreferences.get(userDid) : null); 39 - const mutes = $derived(currentPrefs?.mutes ?? []); 40 + const mutes = $derived(new Set(currentPrefs?.mutes ?? [])); 40 41 41 42 const threads = $derived( 42 - filterThreads( 43 - did && timelines.has(did) ? buildThreads(did, timelines.get(did)!, allPosts, mutes) : [], 44 - $accounts, 45 - { viewOwnPosts } 46 - ) 43 + did && timelines.has(did) 44 + ? buildThreadsFiltered( 45 + did, 46 + timelines.get(did)!, 47 + allPosts, 48 + mutes, 49 + $accounts, 50 + { viewOwnPosts }, 51 + displayCount 52 + ) 53 + : [] 47 54 ); 48 55 49 56 let fetchingInteractions = $state(false); ··· 87 94 {threads} 88 95 timelineId={`replies:${did}`} 89 96 bind:postComposerState 97 + bind:displayCount 90 98 class={className} 91 99 isLoggedIn={!!(did || $accounts.length > 0)} 92 100 canLoad={!!(client && userDid && did)}
+12 -16
src/lib/state.svelte.ts
··· 519 519 }) 520 520 ); 521 521 522 - // 4. Update state 523 - const newPosts: ResourceUri[] = []; 522 + // 4. Update state - use records directly from listRecords instead of re-fetching 523 + const validPosts: PostWithUri[] = []; 524 524 for (const result of results) { 525 525 if (!result) continue; 526 526 const { did, res } = result; ··· 529 529 if (res.cursor) userCursors!.set(did, res.cursor); 530 530 else userCursors!.set(did, null); // null = exhausted 531 531 532 - for (const record of res.records) newPosts.push(record.uri); 532 + for (const record of res.records) { 533 + validPosts.push({ 534 + uri: record.uri, 535 + cid: record.cid, 536 + record: record.value as AppBskyFeedPost.Main 537 + }); 538 + } 533 539 } 534 540 535 - if (newPosts.length === 0) return; 536 - 537 - // fetch each post record 538 - const posts = await Promise.all( 539 - newPosts.map(async (uri) => { 540 - const result = await client.getRecordUri(AppBskyFeedPost.mainSchema, uri); 541 - if (!result.ok) return null; 542 - return { uri: result.value.uri, cid: result.value.cid, record: result.value.record }; 543 - }) 544 - ); 541 + if (validPosts.length === 0) return; 545 542 546 - const validPosts = posts.filter((p): p is PostWithUri => p !== null); 547 543 addPosts(validPosts); 548 544 549 545 for (const post of validPosts) userFeed.add(post.uri); ··· 970 966 export const initialDone = new SvelteSet<Did>(); 971 967 export const fetchInitial = async (account: Account) => { 972 968 const client = clients.get(account.did)!; 973 - await Promise.all([ 969 + await Promise.allSettled([ 974 970 fetchBlocks(account), 975 971 fetchForInteractions(client, account.did), 976 972 fetchFollows(account).then((follows) => 977 - Promise.all(follows.map((follow) => fetchForInteractions(client, follow.subject)) ?? []) 973 + Promise.allSettled(follows.map((follow) => fetchForInteractions(client, follow.subject)) ?? []) 978 974 ) 979 975 ]); 980 976 initialDone.add(account.did);
+236 -11
src/lib/thread.ts
··· 4 4 import type { Account } from './accounts'; 5 5 import { expect } from './result'; 6 6 import type { PostWithUri } from './at/fetch'; 7 - import { isBlockedBy } from './state.svelte'; 7 + import { isBlockedBy, getBlockRelationship } from './state.svelte'; 8 8 import { timestampFromCursor } from '$lib'; 9 9 10 10 export type ThreadPost = { ··· 15 15 parentUri: ResourceUri | null; 16 16 depth: number; 17 17 newestTime: number; 18 - isBlocked?: boolean; 18 + blockRelationship?: { userBlocked: boolean; blockedByTarget: boolean }; 19 19 isMuted?: boolean; 20 20 }; 21 21 ··· 30 30 account: Did, 31 31 timeline: Set<ResourceUri>, 32 32 posts: Map<Did, Map<ResourceUri, PostWithUri>>, 33 - mutes: Did[], 33 + mutes: Set<Did> 34 34 ): Thread[] => { 35 35 const threadMap = new Map<ResourceUri, ThreadPost[]>(); 36 36 37 + // cache block relationships for this build cycle to avoid re-computation 38 + // (isBlockedBy uses string ops and map lookups) 39 + const blockCache = new Map<Did, { userBlocked: boolean; blockedByTarget: boolean }>(); 40 + const getBlockRel = (target: Did) => { 41 + let rel = blockCache.get(target); 42 + if (!rel) { 43 + rel = getBlockRelationship(account, target); 44 + blockCache.set(target, rel); 45 + } 46 + return rel; 47 + }; 48 + 37 49 // group posts by root uri into "thread" chains 38 50 for (const uri of timeline) { 39 - const parsedUri = expect(parseCanonicalResourceUri(uri)); 40 - const data = posts.get(parsedUri.repo)?.get(uri); 51 + // fast parse to avoid overhead of regex/validator in tight loop 52 + const parts = uri.split('/'); 53 + if (parts.length < 5) continue; 54 + const repo = parts[2] as Did; 55 + const rkey = parts[4]; 56 + 57 + const data = posts.get(repo)?.get(uri); 41 58 if (!data) continue; 42 59 43 60 const rootUri = (data.record.reply?.root.uri as ResourceUri) || uri; 44 61 const parentUri = (data.record.reply?.parent.uri as ResourceUri) || null; 45 62 46 - const cursorTime = timestampFromCursor(parsedUri.rkey); 63 + const cursorTime = timestampFromCursor(rkey); 64 + const blockRel = getBlockRel(repo); 47 65 const post: ThreadPost = { 48 66 data, 49 67 account, 50 - did: parsedUri.repo, 51 - rkey: parsedUri.rkey, 68 + did: repo, 69 + rkey, 52 70 parentUri, 53 71 depth: 0, 54 72 newestTime: cursorTime ? cursorTime / 1000 : new Date(data.record.createdAt).getTime(), 55 - isBlocked: isBlockedBy(parsedUri.repo, account), 56 - isMuted: mutes.includes(parsedUri.repo), 73 + blockRelationship: blockRel, 74 + isMuted: mutes.has(repo) 57 75 }; 58 76 59 77 if (!threadMap.has(rootUri)) threadMap.set(rootUri, []); ··· 196 214 return true; 197 215 }); 198 216 199 - // Helper to extract DID from URI if not already imported from elsewhere 217 + type ThreadGroup = { 218 + rootUri: ResourceUri; 219 + posts: ThreadPost[]; 220 + newestTime: number; 221 + isReply: boolean; 222 + rootDid: Did | null; 223 + }; 224 + 225 + export const buildThreadsFiltered = ( 226 + account: Did, 227 + timeline: Set<ResourceUri>, 228 + posts: Map<Did, Map<ResourceUri, PostWithUri>>, 229 + mutes: Set<Did>, 230 + accounts: Account[], 231 + opts: FilterOptions, 232 + limit?: number 233 + ): Thread[] => { 234 + const blockCache = new Map<Did, { userBlocked: boolean; blockedByTarget: boolean }>(); 235 + const getBlockRel = (target: Did) => { 236 + let rel = blockCache.get(target); 237 + if (!rel) { 238 + rel = getBlockRelationship(account, target); 239 + blockCache.set(target, rel); 240 + } 241 + return rel; 242 + }; 243 + 244 + // phase 1: group posts by root uri, track metadata for pre-filtering 245 + const groups = new Map<ResourceUri, ThreadGroup>(); 246 + 247 + for (const uri of timeline) { 248 + const parts = uri.split('/'); 249 + if (parts.length < 5) continue; 250 + const repo = parts[2] as Did; 251 + const rkey = parts[4]; 252 + 253 + const data = posts.get(repo)?.get(uri); 254 + if (!data) continue; 255 + 256 + const rootUri = (data.record.reply?.root.uri as ResourceUri) || uri; 257 + const parentUri = (data.record.reply?.parent.uri as ResourceUri) || null; 258 + const isReply = !!data.record.reply; 259 + 260 + const cursorTime = timestampFromCursor(rkey); 261 + const postTime = cursorTime ? cursorTime / 1000 : new Date(data.record.createdAt).getTime(); 262 + 263 + let group = groups.get(rootUri); 264 + if (!group) { 265 + group = { 266 + rootUri, 267 + posts: [], 268 + newestTime: postTime, 269 + isReply, 270 + rootDid: extractDidFromUri(rootUri) 271 + }; 272 + groups.set(rootUri, group); 273 + } 274 + 275 + const blockRel = getBlockRel(repo); 276 + group.posts.push({ 277 + data, 278 + account, 279 + did: repo, 280 + rkey, 281 + parentUri, 282 + depth: 0, 283 + newestTime: postTime, 284 + blockRelationship: blockRel, 285 + isMuted: mutes.has(repo) 286 + }); 287 + 288 + if (postTime > group.newestTime) group.newestTime = postTime; 289 + } 290 + 291 + // phase 2: sort groups by newest time descending 292 + const sortedGroups = Array.from(groups.values()).sort((a, b) => b.newestTime - a.newestTime); 293 + 294 + // phase 3: process groups with pre-filtering and early exit 295 + const threads: Thread[] = []; 296 + 297 + const shouldIncludeGroup = (group: ThreadGroup): boolean => { 298 + if (opts.filterReplies && group.isReply) return false; 299 + 300 + if (opts.filterRootsToDids) { 301 + if ( 302 + group.rootDid && 303 + !opts.filterRootsToDids.has(group.rootDid) && 304 + !accounts.some((a) => a.did === group.rootDid) 305 + ) { 306 + return false; 307 + } 308 + } 309 + 310 + return true; 311 + }; 312 + 313 + const processGroup = (group: ThreadGroup): Thread[] => { 314 + const groupPosts = group.posts; 315 + const uriToPost = new Map(groupPosts.map((p) => [p.data.uri, p])); 316 + const childrenMap = new Map<ResourceUri | null, ThreadPost[]>(); 317 + 318 + for (const post of groupPosts) { 319 + let depth = 0; 320 + let currentUri = post.parentUri; 321 + while (currentUri && uriToPost.has(currentUri)) { 322 + depth++; 323 + currentUri = uriToPost.get(currentUri)!.parentUri; 324 + } 325 + post.depth = depth; 326 + 327 + if (!childrenMap.has(post.parentUri)) childrenMap.set(post.parentUri, []); 328 + childrenMap.get(post.parentUri)!.push(post); 329 + } 330 + 331 + childrenMap.values().forEach((children) => children.sort((a, b) => b.newestTime - a.newestTime)); 332 + 333 + const createThread = ( 334 + posts: ThreadPost[], 335 + rootUri: ResourceUri, 336 + branchParentUri?: ResourceUri 337 + ): Thread => ({ 338 + rootUri, 339 + posts, 340 + newestTime: Math.max(...posts.map((p) => p.newestTime)), 341 + branchParentPost: branchParentUri ? uriToPost.get(branchParentUri) : undefined 342 + }); 343 + 344 + const collectSubtree = (startPost: ThreadPost): ThreadPost[] => { 345 + const result: ThreadPost[] = []; 346 + const addWithChildren = (post: ThreadPost) => { 347 + result.push(post); 348 + const children = childrenMap.get(post.data.uri) || []; 349 + children.forEach(addWithChildren); 350 + }; 351 + addWithChildren(startPost); 352 + return result; 353 + }; 354 + 355 + const branchingPoints = Array.from(childrenMap.entries()) 356 + .filter(([, children]) => children.length > 1) 357 + .map(([uri]) => uri); 358 + 359 + const result: Thread[] = []; 360 + 361 + if (branchingPoints.length === 0) { 362 + const roots = childrenMap.get(null) || []; 363 + const allPosts = roots.flatMap((root) => collectSubtree(root)); 364 + result.push(createThread(allPosts, group.rootUri)); 365 + } else { 366 + for (const branchParentUri of branchingPoints) { 367 + const branches = childrenMap.get(branchParentUri) || []; 368 + const sortedBranches = [...branches].sort((a, b) => a.newestTime - b.newestTime); 369 + 370 + sortedBranches.forEach((branchRoot, index) => { 371 + const isOldestBranch = index === 0; 372 + const branchPosts: ThreadPost[] = []; 373 + 374 + if (isOldestBranch && branchParentUri !== null) { 375 + const parentChain: ThreadPost[] = []; 376 + let currentUri: ResourceUri | null = branchParentUri; 377 + while (currentUri && uriToPost.has(currentUri)) { 378 + parentChain.unshift(uriToPost.get(currentUri)!); 379 + currentUri = uriToPost.get(currentUri)!.parentUri; 380 + } 381 + branchPosts.push(...parentChain); 382 + } 383 + 384 + branchPosts.push(...collectSubtree(branchRoot)); 385 + 386 + const minDepth = Math.min(...branchPosts.map((p) => p.depth)); 387 + branchPosts.forEach((p) => (p.depth = p.depth - minDepth)); 388 + 389 + result.push( 390 + createThread( 391 + branchPosts, 392 + branchRoot.data.uri, 393 + isOldestBranch ? undefined : (branchParentUri ?? undefined) 394 + ) 395 + ); 396 + }); 397 + } 398 + } 399 + 400 + return result; 401 + }; 402 + 403 + const passesPostFilter = (thread: Thread): boolean => { 404 + if (thread.posts.length === 0) return false; 405 + if (!opts.viewOwnPosts && hasNonOwnPost(thread.posts, accounts)) return false; 406 + return true; 407 + }; 408 + 409 + for (const group of sortedGroups) { 410 + if (!shouldIncludeGroup(group)) continue; 411 + 412 + const groupThreads = processGroup(group); 413 + 414 + for (const thread of groupThreads) { 415 + if (!passesPostFilter(thread)) continue; 416 + threads.push(thread); 417 + if (limit && threads.length >= limit) return threads; 418 + } 419 + } 420 + 421 + return threads; 422 + }; 423 + 200 424 const extractDidFromUri = (uri: ResourceUri): Did | null => { 201 425 const match = uri.match(/^at:\/\/(did:plc:[a-z0-9]+)/); 202 426 return match ? (match[1] as Did) : null; 203 427 }; 428 +