appview-less bluesky client
27
fork

Configure Feed

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

better sorting algos, refactor a bunch of stuff

dawn 8c5c8eeb d28cef81

+517 -304
+26 -127
src/components/BskyPost.svelte
··· 12 12 type ActorIdentifier, 13 13 type CanonicalResourceUri, 14 14 type Did, 15 - type Nsid, 16 15 type RecordKey, 17 16 type ResourceUri 18 17 } from '@atcute/lexicons'; 19 18 import { expect, ok } from '$lib/result'; 20 - import { accounts, generateColorForDid } from '$lib/accounts'; 19 + import { generateColorForDid } from '$lib/accounts'; 21 20 import ProfilePicture from './ProfilePicture.svelte'; 22 21 import { isBlob } from '@atcute/lexicons/interfaces'; 23 22 import { blob, img } from '$lib/cdn'; 24 23 import BskyPost from './BskyPost.svelte'; 25 24 import Icon from '@iconify/svelte'; 26 - import { type Backlink, type BacklinksSource } from '$lib/at/constellation'; 27 25 import { 28 26 clients, 29 - postActions, 30 - posts, 27 + allPosts, 31 28 pulsingPostId, 32 - type PostActions, 33 - currentTime 29 + currentTime, 30 + findBacklinksBy, 31 + deletePostBacklink, 32 + createPostBacklink 34 33 } from '$lib/state.svelte'; 35 - import * as TID from '@atcute/tid'; 36 34 import type { PostWithUri } from '$lib/at/fetch'; 37 35 import { onMount } from 'svelte'; 38 36 import { type AtprotoDid } from '@atcute/lexicons/syntax'; ··· 43 41 import { settings } from '$lib/settings'; 44 42 import RichText from './RichText.svelte'; 45 43 import { getRelativeTime } from '$lib/date'; 44 + import { likeSource, repostSource } from '$lib'; 46 45 47 46 interface Props { 48 47 client: AtpClient; ··· 91 90 profile = p.value; 92 91 // console.log(profile.description); 93 92 }); 94 - // const replies = replyBacklinks 95 - // ? Promise.resolve(ok(replyBacklinks)) 96 - // : client.getBacklinks( 97 - // identifier, 98 - // 'app.bsky.feed.post', 99 - // rkey, 100 - // 'app.bsky.feed.post:reply.parent.uri' 101 - // ); 102 93 103 94 const postId = `timeline-post-${aturi}-${quoteDepth}`; 104 95 const isPulsing = derived(pulsingPostId, (pulsingPostId) => pulsingPostId === postId); ··· 139 130 } 140 131 }; 141 132 142 - const findBacklink = $derived(async (toDid: AtprotoDid, source: BacklinksSource) => { 143 - const backlinks = await client.getBacklinks(did, 'app.bsky.feed.post', rkey, source); 144 - if (!backlinks.ok) return null; 145 - return backlinks.value.records.find((r) => r.did === toDid) ?? null; 146 - }); 147 - 148 - let findAllBacklinks = async (did: AtprotoDid | null) => { 149 - if (!did) return; 150 - if (postActions.has(`${did}:${aturi}`)) return; 151 - const backlinks = await Promise.all([ 152 - findBacklink(did, 'app.bsky.feed.like:subject.uri'), 153 - findBacklink(did, 'app.bsky.feed.repost:subject.uri') 154 - // findBacklink('app.bsky.feed.post:reply.parent.uri'), 155 - // findBacklink('app.bsky.feed.post:embed.record.uri') 156 - ]); 157 - const actions: PostActions = { 158 - like: backlinks[0], 159 - repost: backlinks[1] 160 - // reply: backlinks[2], 161 - // quote: backlinks[3] 162 - }; 163 - // console.log('findAllBacklinks', did, aturi, actions); 164 - postActions.set(`${did}:${aturi}`, actions); 165 - }; 166 - onMount(() => { 167 - // findAllBacklinks($selectedDid); 168 - accounts.subscribe((accs) => { 169 - accs.map((acc) => acc.did).forEach((did) => findAllBacklinks(did)); 170 - }); 171 - }); 172 - 173 - const toggleLink = async (link: Backlink | null, collection: Nsid): Promise<Backlink | null> => { 174 - // console.log('toggleLink', selectedDid, link, collection); 175 - if (!selectedDid) return null; 176 - const _post = await post; 177 - if (!_post.ok) return null; 178 - if (!link) { 179 - if (_post.value.cid) { 180 - const record = { 181 - $type: collection, 182 - subject: { 183 - cid: _post.value.cid, 184 - uri: aturi 185 - }, 186 - createdAt: new Date().toISOString() 187 - }; 188 - const rkey = TID.now(); 189 - // todo: handle errors 190 - client.atcute?.post('com.atproto.repo.createRecord', { 191 - input: { 192 - repo: selectedDid, 193 - collection, 194 - record, 195 - rkey 196 - } 197 - }); 198 - return { 199 - collection, 200 - did: selectedDid, 201 - rkey 202 - }; 203 - } 204 - } else { 205 - // todo: handle errors 206 - client.atcute?.post('com.atproto.repo.deleteRecord', { 207 - input: { 208 - repo: link.did, 209 - collection: link.collection, 210 - rkey: link.rkey 211 - } 212 - }); 213 - return null; 214 - } 215 - return link; 216 - }; 217 - 218 133 let actionsOpen = $state(false); 219 134 let actionsPos = $state({ x: 0, y: 0 }); 220 135 ··· 247 162 }) 248 163 .then((result) => { 249 164 if (!result.ok) return; 250 - posts.get(did)?.delete(aturi); 165 + allPosts.get(did)?.delete(aturi); 251 166 deleteState = 'deleted'; 252 167 }); 253 168 actionsOpen = false; ··· 428 343 </div> 429 344 {/if} 430 345 {#if !isOnPostComposer} 431 - {@const backlinks = postActions.get(`${selectedDid!}:${post.value.uri}`)} 432 - {@render postControls(post.value, backlinks)} 346 + {@render postControls(post.value)} 433 347 {/if} 434 348 </div> 435 349 {:else} ··· 507 421 <!-- todo: implement external link embeds --> 508 422 {/snippet} 509 423 510 - {#snippet postControls(post: PostWithUri, backlinks?: PostActions)} 424 + {#snippet postControls(post: PostWithUri)} 425 + {@const myRepost = findBacklinksBy(post.uri, repostSource, selectedDid!).length > 0} 426 + {@const myLike = findBacklinksBy(post.uri, likeSource, selectedDid!).length > 0} 511 427 {#snippet control( 512 428 name: string, 513 429 icon: string, ··· 529 445 {/snippet} 530 446 <div class="mt-3 flex w-full items-center justify-between"> 531 447 <div class="flex w-fit items-center rounded-sm" style="background: {color}1f;"> 532 - {#snippet label( 533 - name: string, 534 - icon: string, 535 - onClick: (link: Backlink | null | undefined) => void, 536 - backlink?: Backlink | null, 537 - hasSolid?: boolean 538 - )} 539 - {@render control(name, icon, () => onClick(backlink), backlink ? true : false, hasSolid)} 540 - {/snippet} 541 - {@render label('reply', 'heroicons:chat-bubble-left', () => { 542 - onReply?.(post); 543 - })} 544 - {@render label( 448 + {@render control('reply', 'heroicons:chat-bubble-left', () => onReply?.(post), false, true)} 449 + {@render control( 545 450 'repost', 546 451 'heroicons:arrow-path-rounded-square-20-solid', 547 - async (link) => { 548 - if (link === undefined) return; 549 - postActions.set(`${selectedDid!}:${aturi}`, { 550 - ...backlinks!, 551 - repost: await toggleLink(link, 'app.bsky.feed.repost') 552 - }); 452 + () => { 453 + if (!selectedDid) return; 454 + if (myRepost) deletePostBacklink(client, post, repostSource); 455 + else createPostBacklink(client, post, repostSource); 553 456 }, 554 - backlinks?.repost 457 + myRepost 555 458 )} 556 - {@render label('quote', 'heroicons:paper-clip-20-solid', () => { 557 - onQuote?.(post); 558 - })} 559 - {@render label( 459 + {@render control('quote', 'heroicons:paper-clip-20-solid', () => onQuote?.(post), false)} 460 + {@render control( 560 461 'like', 561 462 'heroicons:star', 562 - async (link) => { 563 - if (link === undefined) return; 564 - postActions.set(`${selectedDid!}:${aturi}`, { 565 - ...backlinks!, 566 - like: await toggleLink(link, 'app.bsky.feed.like') 567 - }); 463 + () => { 464 + if (!selectedDid) return; 465 + if (myLike) deletePostBacklink(client, post, likeSource); 466 + else createPostBacklink(client, post, likeSource); 568 467 }, 569 - backlinks?.like, 468 + myLike, 570 469 true 571 470 )} 572 471 </div>
+11 -5
src/components/FollowingView.svelte
··· 1 1 <script lang="ts"> 2 - import { follows, getClient, posts, postActions, currentTime } from '$lib/state.svelte'; 2 + import { follows, getClient, allPosts, allBacklinks, currentTime } from '$lib/state.svelte'; 3 3 import type { Did } from '@atcute/lexicons'; 4 4 import ProfilePicture from './ProfilePicture.svelte'; 5 5 import { type AtpClient, resolveDidDoc } from '$lib/at/client'; ··· 22 22 const { selectedDid, selectedClient }: Props = $props(); 23 23 24 24 let followingSort: Sort = $state('active' as Sort); 25 + const followsMap = $derived(follows.get(selectedDid)); 25 26 26 27 const interactionScores = $derived.by(() => { 27 28 if (followingSort !== 'conversational') return null; 28 - return calculateInteractionScores(selectedDid, posts, postActions, currentTime.getTime()); 29 + return calculateInteractionScores( 30 + selectedDid, 31 + followsMap ?? new Map(), 32 + allPosts, 33 + allBacklinks, 34 + currentTime.getTime() 35 + ); 29 36 }); 30 37 31 38 class FollowedUserStats { ··· 50 57 data = $derived.by(() => 51 58 calculateFollowedUserStats( 52 59 followingSort, 53 - selectedDid, 54 - posts, 60 + this.did, 61 + allPosts, 55 62 interactionScores, 56 63 currentTime.getTime() 57 64 ) 58 65 ); 59 66 } 60 67 61 - const followsMap = $derived(follows.get(selectedDid)); 62 68 const userStatsList = $derived( 63 69 followsMap ? Array.from(followsMap.values()).map((f) => new FollowedUserStats(f.subject)) : [] 64 70 );
+15 -16
src/components/TimelineView.svelte
··· 2 2 import BskyPost from './BskyPost.svelte'; 3 3 import { type State as PostComposerState } from './PostComposer.svelte'; 4 4 import { AtpClient } from '$lib/at/client'; 5 - import { accounts, type Account } from '$lib/accounts'; 5 + import { accounts } from '$lib/accounts'; 6 6 import { type ResourceUri } from '@atcute/lexicons'; 7 7 import { SvelteSet } from 'svelte/reactivity'; 8 8 import { InfiniteLoader, LoaderState } from 'svelte-infinite'; 9 - import { cursors, fetchTimeline, posts, viewClient } from '$lib/state.svelte'; 9 + import { postCursors, fetchTimeline, allPosts, timelines } from '$lib/state.svelte'; 10 10 import Icon from '@iconify/svelte'; 11 11 import { buildThreads, filterThreads, type ThreadPost } from '$lib/thread'; 12 + import type { AtprotoDid } from '@atcute/lexicons/syntax'; 12 13 13 14 interface Props { 14 15 client?: AtpClient | null; ··· 18 19 19 20 let { client = null, postComposerState = $bindable(), class: className = '' }: Props = $props(); 20 21 21 - const effectiveClient = $derived(client ?? viewClient); 22 - 23 22 let reverseChronological = $state(true); 24 23 let viewOwnPosts = $state(true); 25 24 const expandedThreads = new SvelteSet<ResourceUri>(); 26 25 26 + const did = $derived(client?.user?.did); 27 + 27 28 const threads = $derived( 28 29 filterThreads( 29 - buildThreads( 30 - $accounts.map((account) => account.did), 31 - posts 32 - ), 30 + did && timelines.has(did) ? buildThreads(did, timelines.get(did)!, allPosts) : [], 33 31 $accounts, 34 32 { viewOwnPosts } 35 33 ) ··· 40 38 let loading = $state(false); 41 39 let loadError = $state(''); 42 40 43 - const fetchTimelines = (newAccounts: Account[]) => 44 - Promise.all(newAccounts.map((acc) => fetchTimeline(acc.did))); 45 - 46 41 const loadMore = async () => { 47 - if (loading || $accounts.length === 0) return; 42 + if (loading || $accounts.length === 0 || !did) return; 48 43 49 44 loading = true; 50 45 loaderState.status = 'LOADING'; 51 46 52 47 try { 53 - await fetchTimelines($accounts); 48 + await fetchTimeline(did as AtprotoDid); 54 49 loaderState.loaded(); 55 50 } catch (error) { 56 51 loadError = `${error}`; ··· 60 55 } 61 56 62 57 loading = false; 63 - if (cursors.values().every((cursor) => cursor.end)) loaderState.complete(); 58 + if (postCursors.values().every((cursor) => cursor.end)) loaderState.complete(); 64 59 }; 60 + 61 + $effect(() => { 62 + if (threads.length === 0 && !loading) loadMore(); 63 + }); 65 64 </script> 66 65 67 66 {#snippet replyPost(post: ThreadPost, reverse: boolean = reverseChronological)} ··· 69 68 class="mb-1.5 flex items-center gap-1.5 overflow-hidden text-nowrap wrap-break-word overflow-ellipsis" 70 69 > 71 70 <span class="text-sm text-nowrap opacity-60">{reverse ? '↱' : '↳'}</span> 72 - <BskyPost mini client={effectiveClient} {...post} /> 71 + <BskyPost mini client={client!} {...post} /> 73 72 </span> 74 73 {/snippet} 75 74 ··· 88 87 {#if !mini} 89 88 <div class="mb-1.5"> 90 89 <BskyPost 91 - client={effectiveClient} 90 + client={client!} 92 91 onQuote={(post) => (postComposerState = { type: 'focused', quoting: post })} 93 92 onReply={(post) => (postComposerState = { type: 'focused', replying: post })} 94 93 {...post}
+30 -9
src/lib/at/client.ts
··· 1 - import { err, expect, map, ok, type Result } from '$lib/result'; 1 + import { err, expect, map, ok, type OkType, type Result } from '$lib/result'; 2 2 import { 3 3 ComAtprotoIdentityResolveHandle, 4 4 ComAtprotoRepoGetRecord, ··· 37 37 import { get } from 'svelte/store'; 38 38 import { settings } from '$lib/settings'; 39 39 import type { OAuthUserAgent } from '@atcute/oauth-browser-client'; 40 + import { timestampFromCursor } from '$lib'; 40 41 41 42 export const slingshotUrl: URL = new URL(get(settings).endpoints.slingshot); 42 43 export const spacedustUrl: URL = new URL(get(settings).endpoints.spacedust); ··· 166 167 repo: this.user.did, 167 168 collection, 168 169 cursor, 169 - limit 170 + limit, 171 + reverse: false 170 172 } 171 173 }); 172 174 if (!res.ok) return err(`${res.data.error}: ${res.data.message ?? 'no details'}`); 173 175 174 - for (const record of res.data.records) { 176 + for (const record of res.data.records) 175 177 await cache.set('fetchRecord', `fetchRecord~${record.uri}`, record, 60 * 60 * 24); 176 - } 178 + 177 179 return ok(res.data); 178 180 } 179 181 180 - async listRecordsAll<Collection extends keyof Records>( 181 - collection: Collection 182 + async listRecordsUntil<Collection extends keyof Records>( 183 + collection: Collection, 184 + cursor?: string, 185 + timestamp: number = -1 182 186 ): Promise<ReturnType<typeof this.listRecords>> { 183 - const data: InferXRPCBodyOutput<(typeof ComAtprotoRepoListRecords.mainSchema)['output']> = { 184 - records: [] 187 + const data: OkType<Awaited<ReturnType<typeof this.listRecords>>> = { 188 + records: [], 189 + cursor 185 190 }; 186 191 187 192 let end = false; ··· 190 195 if (!res.ok) return res; 191 196 data.cursor = res.value.cursor; 192 197 data.records.push(...res.value.records); 193 - end = !res.value.cursor; 198 + end = !data.cursor; 199 + if (!end && timestamp > 0) { 200 + const cursorTimestamp = timestampFromCursor(data.cursor); 201 + if (cursorTimestamp === undefined) { 202 + console.warn( 203 + 'could not parse timestamp from cursor, stopping fetch to prevent infinite loop:', 204 + data.cursor 205 + ); 206 + end = true; 207 + } else if (cursorTimestamp < timestamp) { 208 + end = true; 209 + } else { 210 + console.info( 211 + `${this.user?.did}: continuing to fetch ${collection}, on ${cursorTimestamp} until ${timestamp}` 212 + ); 213 + } 214 + } 194 215 } 195 216 196 217 return ok(data);
+8 -7
src/lib/at/fetch.ts
··· 9 9 import type { Backlinks } from './constellation'; 10 10 import { AppBskyFeedPost } from '@atcute/bluesky'; 11 11 import type { AtprotoDid } from '@atcute/lexicons/syntax'; 12 + import { replySource } from '$lib'; 12 13 13 14 export type PostWithUri = { uri: ResourceUri; cid: Cid | undefined; record: AppBskyFeedPost.Main }; 14 15 export type PostWithBacklinks = PostWithUri & { 15 16 replies: Backlinks; 16 17 }; 17 18 export type PostsWithReplyBacklinks = PostWithBacklinks[]; 18 - 19 - const replySource = 'app.bsky.feed.post:reply.parent.uri'; 20 19 21 20 export const fetchPostsWithBacklinks = async ( 22 21 client: AtpClient, ··· 31 30 try { 32 31 const allBacklinks = await Promise.all( 33 32 records.map(async (r): Promise<PostWithBacklinks> => { 34 - const replies = await client.getBacklinksUri(r.uri, replySource); 35 - if (!replies.ok) throw `cant fetch replies: ${replies.error}`; 33 + const result = await client.getBacklinksUri(r.uri, replySource); 34 + if (!result.ok) throw `cant fetch replies: ${result.error}`; 35 + const replies = result.value; 36 36 return { 37 37 uri: r.uri, 38 38 cid: r.cid, 39 39 record: r.value as AppBskyFeedPost.Main, 40 - replies: replies.value 40 + replies 41 41 }; 42 42 }) 43 43 ); ··· 76 76 const fetchUpwardsChain = async (post: PostWithUri) => { 77 77 let parent = post.record.reply?.parent; 78 78 while (parent) { 79 + const parentUri = parent.uri as CanonicalResourceUri; 79 80 // if we already have this parent, then we already fetched this chain / are fetching it 80 - if (posts.has(parent.uri as CanonicalResourceUri)) return; 81 - const p = await client.getRecordUri(AppBskyFeedPost.mainSchema, parent.uri); 81 + if (posts.has(parentUri)) return; 82 + const p = await client.getRecordUri(AppBskyFeedPost.mainSchema, parentUri); 82 83 if (p.ok) { 83 84 posts.set(p.value.uri, p.value); 84 85 parent = p.value.record.reply?.parent;
+139 -66
src/lib/following.ts
··· 1 - import type { ActorIdentifier, Did, ResourceUri } from '@atcute/lexicons'; 1 + import { type ActorIdentifier, type Did, type ResourceUri } from '@atcute/lexicons'; 2 2 import type { PostWithUri } from './at/fetch'; 3 - import type { PostActions } from './thread'; 3 + import type { Backlink, BacklinksSource } from './at/constellation'; 4 + import { repostSource } from '$lib'; 5 + import type { AppBskyGraphFollow } from '@atcute/bluesky'; 4 6 5 7 export type Sort = 'recent' | 'active' | 'conversational'; 6 8 ··· 25 27 26 28 export const calculateFollowedUserStats = ( 27 29 sort: Sort, 28 - user: Did, 30 + did: Did, 29 31 posts: Map<Did, Map<ResourceUri, PostWithUri>>, 30 32 interactionScores: Map<ActorIdentifier, number> | null, 31 33 now: number 32 34 ) => { 33 - const postsMap = posts.get(user); 35 + const postsMap = posts.get(did); 34 36 if (!postsMap || postsMap.size === 0) return null; 35 37 36 38 let lastPostAtTime = 0; ··· 53 55 54 56 let conversationalScore = 0; 55 57 if (sort === 'conversational' && interactionScores) 56 - conversationalScore = interactionScores.get(user) || 0; 58 + conversationalScore = interactionScores.get(did) || 0; 57 59 58 60 return { 59 - did: user, 61 + did, 60 62 lastPostAt: new Date(lastPostAtTime), 61 63 activeScore, 62 64 conversationalScore, ··· 64 66 }; 65 67 }; 66 68 69 + // weights 70 + const quoteWeight = 4; 71 + const replyWeight = 6; 72 + const repostWeight = 2; 73 + 74 + // interactions decay over time to prioritize recent conversations. 75 + // half-life of 3 days ensures that inactivity (>1 days) results in a noticeable score drop. 76 + const oneDay = 24 * 60 * 60 * 1000; 77 + const halfLifeMs = 3 * oneDay; 78 + const decayLambda = 0.693 / halfLifeMs; 79 + 80 + // normalization constants 81 + const rateBaseline = 1; 82 + const ratePower = 0.5; 83 + // consider the last 7 days for rate calculation 84 + const windowSize = 7 * oneDay; 85 + 67 86 export const calculateInteractionScores = ( 68 87 user: Did, 69 - posts: Map<Did, Map<ResourceUri, PostWithUri>>, 70 - postActions: Map<`${Did}:${ResourceUri}`, PostActions>, 88 + followsMap: Map<ResourceUri, AppBskyGraphFollow.Main>, 89 + allPosts: Map<Did, Map<ResourceUri, PostWithUri>>, 90 + backlinks_: Map<ResourceUri, Map<BacklinksSource, Set<Backlink>>>, 71 91 now: number 72 92 ) => { 73 - const scores = new Map<ActorIdentifier, number>(); 74 - 75 - // Interactions are full weight for the first 3 days, then start decreasing linearly 76 - // until 2 weeks, after which they decrease exponentially. 77 - // Keep the same overall exponential timescale as before (half-life ~30 days). 78 - const oneDay = 24 * 60 * 60 * 1000; 79 - const halfLifeMs = 30 * oneDay; 80 - const decayLambda = 0.693 / halfLifeMs; 81 - const threeDays = 3 * oneDay; 82 - const twoWeeks = 14 * oneDay; 93 + const scores = new Map<Did, number>(); 83 94 84 95 const decay = (time: number) => { 85 96 const age = Math.max(0, now - time); 97 + return Math.exp(-decayLambda * age); 98 + }; 86 99 87 - // Full weight for recent interactions within 3 days 88 - if (age <= threeDays) return 1; 100 + const postRates = new Map<Did, number>(); 101 + 102 + const processPosts = (did: Did, posts: Map<ResourceUri, PostWithUri>) => { 103 + let volume = 0; 104 + let minTime = now; 105 + let maxTime = 0; 106 + let hasRecentPosts = false; 89 107 90 - // Between 3 days and 2 weeks, linearly interpolate down to the value 91 - // that the exponential would have at 2 weeks to keep continuity. 92 - if (age <= twoWeeks) { 93 - const expAtTwoWeeks = Math.exp(-decayLambda * twoWeeks); 94 - const t = (age - threeDays) / (twoWeeks - threeDays); // 0..1 95 - // linear ramp from 1 -> expAtTwoWeeks 96 - return 1 - t * (1 - expAtTwoWeeks); 97 - } 108 + const seenRoots = new Set<ResourceUri>(); 98 109 99 - // After 2 weeks, exponential decay based on the chosen lambda 100 - return Math.exp(-decayLambda * age); 101 - }; 110 + for (const [, post] of posts) { 111 + const t = new Date(post.record.createdAt).getTime(); 112 + const dec = decay(t); 102 113 103 - const replyWeight = 4; 104 - const repostWeight = 2; 105 - const likeWeight = 1; 114 + // Calculate rate based on raw volume over time frame 115 + // We only care about posts within the relevant window to determine "current" activity rate 116 + if (now - t < windowSize) { 117 + volume += 1; 118 + if (t < minTime) minTime = t; 119 + if (t > maxTime) maxTime = t; 120 + hasRecentPosts = true; 121 + } 106 122 107 - const myPosts = posts.get(user); 108 - if (myPosts) { 109 - for (const post of myPosts.values()) { 123 + const processPostUri = (uri: ResourceUri, weight: number) => { 124 + // only try to extract the DID 125 + const match = uri.match(/^at:\/\/([^/]+)/); 126 + if (!match) return; 127 + const targetDid = match[1] as Did; 128 + let subjectDid = targetDid; 129 + // if we are processing posts of the user 130 + if (did === user) { 131 + // then only process posts where the user is replying to others 132 + if (targetDid === user) return; 133 + } else { 134 + // otherwise only process posts that are replies to the user 135 + if (targetDid !== user) return; 136 + subjectDid = did; 137 + } 138 + // console.log(`${subjectDid} -> ${targetDid}`); 139 + const s = scores.get(subjectDid) ?? 0; 140 + scores.set(subjectDid, s + weight * dec); 141 + }; 110 142 if (post.record.reply) { 111 143 const parentUri = post.record.reply.parent.uri; 112 - // only try to extract the DID 113 - const match = parentUri.match(/^at:\/\/([^/]+)/); 114 - if (match) { 115 - const targetDid = match[1] as Did; 116 - if (targetDid === user) continue; 117 - const s = scores.get(targetDid) || 0; 118 - scores.set(targetDid, s + replyWeight * decay(new Date(post.record.createdAt).getTime())); 144 + const rootUri = post.record.reply.root.uri; 145 + processPostUri(parentUri, replyWeight); 146 + // prevent duplicates 147 + if (parentUri !== rootUri && !seenRoots.has(rootUri)) { 148 + processPostUri(rootUri, replyWeight); 149 + seenRoots.add(rootUri); 119 150 } 120 151 } 152 + if (post.record.embed?.$type === 'app.bsky.embed.record') 153 + processPostUri(post.record.embed.record.uri, quoteWeight); 154 + if (post.record.embed?.$type === 'app.bsky.embed.recordWithMedia') 155 + processPostUri(post.record.embed.record.record.uri, quoteWeight); 121 156 } 157 + 158 + let rate = 0; 159 + if (hasRecentPosts) { 160 + // Rate = Posts / Days 161 + // Use at least 1 day to avoid skewing bursts of <24h too high 162 + const days = Math.max((maxTime - minTime) / oneDay, 1); 163 + rate = volume / days; 164 + } 165 + postRates.set(did, rate); 166 + }; 167 + 168 + // process self 169 + const myPosts = allPosts.get(user); 170 + if (myPosts) processPosts(user, myPosts); 171 + // process following 172 + for (const follow of followsMap.values()) { 173 + const posts = allPosts.get(follow.subject); 174 + if (!posts) continue; 175 + processPosts(follow.subject, posts); 122 176 } 123 177 178 + const followsSet = new Set(followsMap.values().map((follow) => follow.subject)); 124 179 // interactions with others 125 - for (const [key, actions] of postActions) { 126 - const sepIndex = key.indexOf(':'); 127 - if (sepIndex === -1) continue; 128 - const did = key.slice(0, sepIndex) as Did; 129 - const uri = key.slice(sepIndex + 1) as ResourceUri; 130 - 131 - // only try to extract the DID 180 + for (const [uri, backlinks] of backlinks_) { 132 181 const match = uri.match(/^at:\/\/([^/]+)/); 133 182 if (!match) continue; 134 183 const targetDid = match[1] as Did; 135 - 136 - if (did === targetDid) continue; 137 - 138 - let add = 0; 139 - if (actions.like) add += likeWeight; 140 - if (actions.repost) add += repostWeight; 184 + // only process backlinks that target the user 185 + const isSelf = targetDid === user; 186 + // and are from users the user follows 187 + const isFollowing = followsSet.has(targetDid); 188 + if (!isSelf && !isFollowing) continue; 189 + // check if the post exists 190 + const post = allPosts.get(targetDid)?.get(uri); 191 + if (!post) continue; 192 + const reposts = backlinks.get(repostSource) ?? new Set(); 193 + const adds = new Map<Did, { score: number; repostCount: number }>(); 194 + for (const repost of reposts) { 195 + // we dont count "self interactions" 196 + if (isSelf && repost.did === user) continue; 197 + // we dont count interactions that arent the user's 198 + if (isFollowing && repost.did !== user) continue; 199 + // use targetDid for following (because it will be the following did) 200 + // use repost.did for self interactions (because it will be the following did) 201 + const did = isFollowing ? targetDid : repost.did; 202 + const add = adds.get(did) ?? { score: 0, repostCount: 0 }; 203 + // diminish the weight as the number of reposts increases 204 + const diminishFactor = 9; 205 + const weight = repostWeight * (diminishFactor / (add.repostCount + diminishFactor)); 206 + adds.set(did, { 207 + score: add.score + weight, 208 + repostCount: add.repostCount + 1 209 + }); 210 + } 211 + for (const [did, add] of adds.entries()) { 212 + if (add.score === 0) continue; 213 + const time = new Date(post.record.createdAt).getTime(); 214 + scores.set(did, (scores.get(did) ?? 0) + add.score * decay(time)); 215 + } 216 + } 141 217 142 - if (add > 0) { 143 - const targetPosts = posts.get(targetDid); 144 - const post = targetPosts?.get(uri); 145 - if (post) { 146 - const time = new Date(post.record.createdAt).getTime(); 147 - add *= decay(time); 148 - } 149 - scores.set(targetDid, (scores.get(targetDid) || 0) + add); 150 - } 218 + // Apply normalization 219 + for (const [did, score] of scores) { 220 + const rate = postRates.get(did) ?? 0; 221 + // NormalizedScore = DecayScore / (PostRate + Baseline)^alpha 222 + // This penalizes spammers (high rate) and inactivity (score decay vs constant rate) 223 + scores.set(did, score / Math.pow(rate + rateBaseline, ratePower)); 151 224 } 152 225 153 226 return scores;
+28
src/lib/index.ts
··· 1 + import type { 2 + CanonicalResourceUri, 3 + ParsedCanonicalResourceUri, 4 + ParsedResourceUri, 5 + ResourceUri 6 + } from '@atcute/lexicons'; 7 + import type { BacklinksSource } from './at/constellation'; 8 + import { parse as parseTid } from '@atcute/tid'; 9 + 10 + export const toResourceUri = (parsed: ParsedResourceUri): ResourceUri => { 11 + return `at://${parsed.repo}${parsed.collection ? `/${parsed.collection}${parsed.rkey ? `/${parsed.rkey}` : ''}` : ''}`; 12 + }; 13 + export const toCanonicalUri = (parsed: ParsedCanonicalResourceUri): CanonicalResourceUri => { 14 + return `at://${parsed.repo}/${parsed.collection}/${parsed.rkey}${parsed.fragment ? `#${parsed.fragment}` : ''}`; 15 + }; 16 + 17 + export const likeSource: BacklinksSource = 'app.bsky.feed.like:subject.uri'; 18 + export const repostSource: BacklinksSource = 'app.bsky.feed.repost:subject.uri'; 19 + export const replySource: BacklinksSource = 'app.bsky.feed.post:reply.parent.uri'; 20 + 21 + export const timestampFromCursor = (cursor: string | undefined) => { 22 + if (!cursor) return undefined; 23 + try { 24 + return parseTid(cursor).timestamp; 25 + } catch { 26 + return undefined; 27 + } 28 + };
+3
src/lib/result.ts
··· 26 26 } 27 27 return err(v.error); 28 28 }; 29 + 30 + export type OkType<R> = R extends { ok: true; value: infer T } ? T : never; 31 + export type ErrType<R> = R extends { ok: false; error: infer E } ? E : never;
+226 -35
src/lib/state.svelte.ts
··· 5 5 type NotificationsStream, 6 6 type NotificationsStreamEvent 7 7 } from './at/client'; 8 - import { SvelteMap, SvelteDate } from 'svelte/reactivity'; 9 - import type { Did, InferOutput, ResourceUri } from '@atcute/lexicons'; 8 + import { SvelteMap, SvelteDate, SvelteSet } from 'svelte/reactivity'; 9 + import type { Did, InferOutput, Nsid, ResourceUri } from '@atcute/lexicons'; 10 10 import { fetchPostsWithBacklinks, hydratePosts, type PostWithUri } from './at/fetch'; 11 11 import { parseCanonicalResourceUri, type AtprotoDid } from '@atcute/lexicons/syntax'; 12 12 import { AppBskyFeedPost, type AppBskyGraphFollow } from '@atcute/bluesky'; 13 13 import type { ComAtprotoRepoListRecords } from '@atcute/atproto'; 14 14 import type { JetstreamSubscription, JetstreamEvent } from '@atcute/jetstream'; 15 15 import { expect } from './result'; 16 - import type { PostActions } from './thread'; 16 + import type { Backlink, BacklinksSource } from './at/constellation'; 17 + import { now as tidNow } from '@atcute/tid'; 18 + import type { Records } from '@atcute/lexicons/ambient'; 19 + import { likeSource, replySource, repostSource, timestampFromCursor } from '$lib'; 17 20 18 21 export const notificationStream = writable<NotificationsStream | null>(null); 19 22 export const jetstream = writable<JetstreamSubscription | null>(null); 20 23 21 - export const postActions = new SvelteMap<`${Did}:${ResourceUri}`, PostActions>(); 24 + export type BacklinksMap = SvelteMap<BacklinksSource, SvelteSet<Backlink>>; 25 + export const allBacklinks = new SvelteMap<ResourceUri, BacklinksMap>(); 26 + 27 + export const addBacklinks = ( 28 + subject: ResourceUri, 29 + source: BacklinksSource, 30 + links: Iterable<Backlink> 31 + ) => { 32 + let postsMap = allBacklinks.get(subject); 33 + if (!postsMap) { 34 + postsMap = new SvelteMap(); 35 + allBacklinks.set(subject, postsMap); 36 + } 37 + let backlinksSet = postsMap.get(source); 38 + if (!backlinksSet) { 39 + backlinksSet = new SvelteSet(); 40 + postsMap.set(source, backlinksSet); 41 + } 42 + for (const link of links) { 43 + backlinksSet.add(link); 44 + // console.log( 45 + // `added backlink at://${link.did}/${link.collection}/${link.rkey} to ${subject} from ${source}` 46 + // ); 47 + } 48 + }; 49 + 50 + export const removeBacklinks = ( 51 + subject: ResourceUri, 52 + source: BacklinksSource, 53 + links: Iterable<Backlink> 54 + ) => { 55 + const postsMap = allBacklinks.get(subject); 56 + if (!postsMap) return; 57 + const backlinksSet = postsMap.get(source); 58 + if (!backlinksSet) return; 59 + for (const link of links) backlinksSet.delete(link); 60 + }; 61 + 62 + export const findBacklinksBy = ( 63 + subject: ResourceUri, 64 + source: BacklinksSource, 65 + did: Did 66 + ): Backlink[] => { 67 + const postsMap = allBacklinks.get(subject); 68 + if (!postsMap) return []; 69 + const backlinksSet = postsMap.get(source); 70 + if (!backlinksSet) return []; 71 + return Array.from(backlinksSet.values().filter((link) => link.did === did)); 72 + }; 73 + 74 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 75 + const getNestedValue = (obj: any, path: string[]): any => { 76 + return path.reduce((current, key) => current?.[key], obj); 77 + }; 78 + 79 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 80 + const setNestedValue = (obj: any, path: string[], value: any): void => { 81 + const lastKey = path[path.length - 1]; 82 + const parent = path.slice(0, -1).reduce((current, key) => { 83 + if (current[key] === undefined) current[key] = {}; 84 + return current[key]; 85 + }, obj); 86 + parent[lastKey] = value; 87 + }; 88 + 89 + export const backlinksCursors = new SvelteMap< 90 + Did, 91 + SvelteMap<BacklinksSource, string | undefined> 92 + >(); 93 + 94 + export const fetchLinksUntil = async ( 95 + client: AtpClient, 96 + backlinkSource: BacklinksSource, 97 + timestamp: number = -1 98 + ) => { 99 + const did = client.user?.did; 100 + if (!did) return; 101 + 102 + let cursorMap = backlinksCursors.get(did); 103 + if (!cursorMap) { 104 + cursorMap = new SvelteMap<BacklinksSource, string | undefined>(); 105 + backlinksCursors.set(did, cursorMap); 106 + } 107 + 108 + const [_collection, source] = backlinkSource.split(':'); 109 + const collection = _collection as keyof Records; 110 + const cursor = cursorMap.get(backlinkSource); 111 + console.log(`${did}: fetchLinksUntil`, backlinkSource, cursor, timestamp); 112 + const result = await client.listRecordsUntil(collection, cursor, timestamp); 113 + 114 + if (!result.ok) { 115 + console.error('failed to fetch links until', result.error); 116 + return; 117 + } 118 + cursorMap.set(backlinkSource, result.value.cursor); 119 + 120 + const path = source.split('.'); 121 + for (const record of result.value.records) { 122 + const uri = getNestedValue(record.value, path); 123 + const parsedUri = parseCanonicalResourceUri(record.uri); 124 + if (!parsedUri.ok) continue; 125 + addBacklinks(uri, `${collection}:${source}`, [ 126 + { 127 + did: parsedUri.value.repo, 128 + collection: parsedUri.value.collection, 129 + rkey: parsedUri.value.rkey 130 + } 131 + ]); 132 + } 133 + }; 134 + 135 + export const deletePostBacklink = async ( 136 + client: AtpClient, 137 + post: PostWithUri, 138 + source: BacklinksSource 139 + ) => { 140 + const did = client.user?.did; 141 + if (!did) return; 142 + const collection = source.split(':')[0] as Nsid; 143 + const links = findBacklinksBy(post.uri, source, did); 144 + removeBacklinks(post.uri, source, links); 145 + await Promise.allSettled( 146 + links.map((link) => 147 + client.atcute?.post('com.atproto.repo.deleteRecord', { 148 + input: { repo: did, collection, rkey: link.rkey! } 149 + }) 150 + ) 151 + ); 152 + }; 153 + 154 + export const createPostBacklink = async ( 155 + client: AtpClient, 156 + post: PostWithUri, 157 + source: BacklinksSource 158 + ) => { 159 + const did = client.user?.did; 160 + if (!did) return; 161 + const [_collection, subject] = source.split(':'); 162 + const collection = _collection as Nsid; 163 + const rkey = tidNow(); 164 + addBacklinks(post.uri, source, [ 165 + { 166 + did, 167 + collection, 168 + rkey 169 + } 170 + ]); 171 + const record = { 172 + $type: collection, 173 + // eslint-disable-next-line svelte/prefer-svelte-reactivity 174 + createdAt: new Date().toISOString() 175 + }; 176 + setNestedValue(record, subject.split('.'), post.uri); 177 + await client.atcute?.post('com.atproto.repo.createRecord', { 178 + input: { 179 + repo: did, 180 + collection, 181 + rkey, 182 + record 183 + } 184 + }); 185 + }; 22 186 23 187 export const pulsingPostId = writable<string | null>(null); 24 188 ··· 45 209 46 210 export const fetchFollows = async (did: AtprotoDid) => { 47 211 const client = await getClient(did); 48 - const res = await client.listRecordsAll('app.bsky.graph.follow'); 212 + const res = await client.listRecordsUntil('app.bsky.graph.follow'); 49 213 if (!res.ok) return; 50 214 addFollows( 51 215 did, ··· 53 217 ); 54 218 }; 55 219 56 - export const fetchFollowPosts = async (did: AtprotoDid) => { 220 + export const fetchForInteractions = async (did: AtprotoDid) => { 57 221 const client = await getClient(did); 58 222 const res = await client.listRecords('app.bsky.feed.post'); 59 223 if (!res.ok) return; 60 224 addPostsRaw(did, res.value); 225 + 226 + const cursorTimestamp = timestampFromCursor(res.value.cursor) ?? -1; 227 + const threeDaysAgo = (Date.now() - 3 * 24 * 60 * 60 * 1000) * 1000; 228 + const timestamp = Math.min(cursorTimestamp, threeDaysAgo); 229 + console.log(`${did}: fetchFollowPosts`, res.value.cursor, timestamp); 230 + await Promise.all([repostSource].map((s) => fetchLinksUntil(client, s, timestamp))); 61 231 }; 62 232 63 - export const posts = new SvelteMap<Did, SvelteMap<ResourceUri, PostWithUri>>(); 64 - export const cursors = new SvelteMap<Did, { value?: string; end: boolean }>(); 233 + export const allPosts = new SvelteMap<Did, SvelteMap<ResourceUri, PostWithUri>>(); 65 234 66 235 export const addPostsRaw = ( 67 - did: Did, 68 - _posts: InferOutput<ComAtprotoRepoListRecords.mainSchema['output']['schema']> 236 + did: AtprotoDid, 237 + newPosts: InferOutput<ComAtprotoRepoListRecords.mainSchema['output']['schema']> 69 238 ) => { 70 - const postsWithUri = new SvelteMap( 71 - _posts.records.map((post) => [ 72 - post.uri, 73 - { cid: post.cid, uri: post.uri, record: post.value as AppBskyFeedPost.Main } as PostWithUri 74 - ]) 75 - ); 76 - addPosts(did, postsWithUri); 77 - cursors.set(did, { value: _posts.cursor, end: _posts.cursor === undefined }); 239 + const postsWithUri = newPosts.records.map((post): [ResourceUri, PostWithUri] => [ 240 + post.uri, 241 + { cid: post.cid, uri: post.uri, record: post.value as AppBskyFeedPost.Main } as PostWithUri 242 + ]); 243 + addPosts(postsWithUri); 244 + postCursors.set(did, { value: newPosts.cursor, end: newPosts.cursor === undefined }); 245 + }; 246 + 247 + export const addPosts = (newPosts: Iterable<[ResourceUri, PostWithUri]>) => { 248 + for (const [uri, post] of newPosts) { 249 + const parsedUri = expect(parseCanonicalResourceUri(uri)); 250 + let posts = allPosts.get(parsedUri.repo); 251 + if (!posts) { 252 + posts = new SvelteMap(); 253 + allPosts.set(parsedUri.repo, posts); 254 + } 255 + posts.set(uri, post); 256 + const link: Backlink = { 257 + did: parsedUri.repo, 258 + collection: parsedUri.collection, 259 + rkey: parsedUri.rkey 260 + }; 261 + if (post.record.reply) addBacklinks(post.record.reply.parent.uri, replySource, [link]); 262 + } 78 263 }; 79 264 80 - export const addPosts = (did: Did, _posts: Iterable<[ResourceUri, PostWithUri]>) => { 81 - if (!posts.has(did)) { 82 - posts.set(did, new SvelteMap(_posts)); 83 - return; 265 + export const timelines = new SvelteMap<Did, SvelteSet<ResourceUri>>(); 266 + export const postCursors = new SvelteMap<Did, { value?: string; end: boolean }>(); 267 + 268 + export const addTimeline = (did: Did, uris: Iterable<ResourceUri>) => { 269 + let timeline = timelines.get(did); 270 + if (!timeline) { 271 + timeline = new SvelteSet(); 272 + timelines.set(did, timeline); 84 273 } 85 - const map = posts.get(did)!; 86 - for (const [uri, record] of _posts) map.set(uri, record); 274 + for (const uri of uris) timeline.add(uri); 87 275 }; 88 276 89 277 export const fetchTimeline = async (did: AtprotoDid, limit: number = 6) => { 90 278 const client = await getClient(did); 91 279 92 - const cursor = cursors.get(did); 280 + const cursor = postCursors.get(did); 93 281 if (cursor && cursor.end) return; 94 282 95 283 const accPosts = await fetchPostsWithBacklinks(client, cursor?.value, limit); ··· 97 285 98 286 // if the cursor is undefined, we've reached the end of the timeline 99 287 if (!accPosts.value.cursor) { 100 - cursors.set(did, { ...cursor, end: true }); 288 + postCursors.set(did, { ...cursor, end: true }); 101 289 return; 102 290 } 103 291 104 - cursors.set(did, { value: accPosts.value.cursor, end: false }); 292 + postCursors.set(did, { value: accPosts.value.cursor, end: false }); 105 293 const hydrated = await hydratePosts(client, did, accPosts.value.posts); 106 294 if (!hydrated.ok) throw `cant hydrate posts ${did}: ${hydrated.error}`; 107 295 108 - addPosts(did, hydrated.value); 296 + addPosts(hydrated.value); 297 + addTimeline(did, hydrated.value.keys()); 298 + 299 + const timestamp = timestampFromCursor(accPosts.value.cursor); 300 + console.log(`${did}: fetchTimeline`, accPosts.value.cursor, timestamp); 301 + await Promise.all([likeSource, repostSource].map((s) => fetchLinksUntil(client, s, timestamp))); 109 302 }; 110 303 111 304 export const handleJetstreamEvent = (event: JetstreamEvent) => { ··· 118 311 119 312 if (commit.operation === 'create') { 120 313 const { cid, record } = commit; 121 - 122 314 const post: PostWithUri = { 123 315 uri, 124 316 cid, 125 317 // assume record is valid, we trust the jetstream 126 318 record: record as AppBskyFeedPost.Main 127 319 }; 128 - 129 - addPosts(did, [[uri, post]]); 320 + addPosts([[uri, post]]); 321 + addTimeline(did, [uri]); 130 322 } else if (commit.operation === 'delete') { 131 - if (posts.has(did)) { 132 - posts.get(did)?.delete(uri); 133 - } 323 + allPosts.get(did)?.delete(uri); 134 324 } 135 325 }; 136 326 ··· 171 361 } 172 362 173 363 // console.log(hydrated); 174 - addPosts(did, hydrated.value); 364 + addPosts(hydrated.value); 365 + addTimeline(did, hydrated.value.keys()); 175 366 } 176 367 }; 177 368
+20 -28
src/lib/thread.ts
··· 2 2 import type { Account } from './accounts'; 3 3 import { expect } from './result'; 4 4 import type { PostWithUri } from './at/fetch'; 5 - import type { Backlink } from './at/constellation'; 6 - 7 - export type PostActions = { 8 - like: Backlink | null; 9 - repost: Backlink | null; 10 - // reply: Backlink | null; 11 - // quote: Backlink | null; 12 - }; 13 5 14 6 export type ThreadPost = { 15 7 data: PostWithUri; ··· 29 21 }; 30 22 31 23 export const buildThreads = ( 32 - accounts: Did[], 24 + account: Did, 25 + timeline: Set<ResourceUri>, 33 26 posts: Map<Did, Map<ResourceUri, PostWithUri>> 34 27 ): Thread[] => { 35 28 const threadMap = new Map<ResourceUri, ThreadPost[]>(); 36 29 37 30 // group posts by root uri into "thread" chains 38 - for (const account of accounts) { 39 - const timeline = posts.get(account); 40 - if (!timeline) continue; 41 - for (const [uri, data] of timeline) { 42 - const parsedUri = expect(parseCanonicalResourceUri(uri)); 43 - const rootUri = (data.record.reply?.root.uri as ResourceUri) || uri; 44 - const parentUri = (data.record.reply?.parent.uri as ResourceUri) || null; 31 + for (const uri of timeline) { 32 + const parsedUri = expect(parseCanonicalResourceUri(uri)); 33 + const data = posts.get(parsedUri.repo)?.get(uri); 34 + if (!data) continue; 45 35 46 - const post: ThreadPost = { 47 - data, 48 - account, 49 - did: parsedUri.repo, 50 - rkey: parsedUri.rkey, 51 - parentUri, 52 - depth: 0, 53 - newestTime: new Date(data.record.createdAt).getTime() 54 - }; 36 + const rootUri = (data.record.reply?.root.uri as ResourceUri) || uri; 37 + const parentUri = (data.record.reply?.parent.uri as ResourceUri) || null; 55 38 56 - if (!threadMap.has(rootUri)) threadMap.set(rootUri, []); 39 + const post: ThreadPost = { 40 + data, 41 + account, 42 + did: parsedUri.repo, 43 + rkey: parsedUri.rkey, 44 + parentUri, 45 + depth: 0, 46 + newestTime: new Date(data.record.createdAt).getTime() 47 + }; 57 48 58 - threadMap.get(rootUri)!.push(post); 59 - } 49 + if (!threadMap.has(rootUri)) threadMap.set(rootUri, []); 50 + 51 + threadMap.get(rootUri)!.push(post); 60 52 } 61 53 62 54 const threads: Thread[] = [];
+11 -11
src/routes/+page.svelte
··· 11 11 import { SvelteMap } from 'svelte/reactivity'; 12 12 import { 13 13 clients, 14 - cursors, 15 - fetchFollowPosts, 14 + postCursors, 15 + fetchForInteractions, 16 16 fetchFollows, 17 17 follows, 18 18 notificationStream, 19 - posts, 19 + allPosts, 20 20 viewClient, 21 21 jetstream, 22 22 handleJetstreamEvent, ··· 64 64 const newAccounts = $accounts.filter((acc) => acc.did !== did); 65 65 $accounts = newAccounts; 66 66 clients.delete(did); 67 - posts.delete(did); 68 - cursors.delete(did); 67 + postCursors.delete(did); 69 68 handleAccountSelected(newAccounts[0]?.did); 70 69 }; 71 70 ··· 146 145 if (!$accounts.some((account) => account.did === selectedDid)) selectedDid = $accounts[0].did; 147 146 // console.log('onMount selectedDid', selectedDid); 148 147 Promise.all($accounts.map(loginAccount)).then(() => { 149 - $accounts.forEach((account) => 148 + $accounts.forEach((account) => { 150 149 fetchFollows(account.did).then(() => 151 150 follows 152 151 .get(account.did) 153 - ?.forEach((follow) => fetchFollowPosts(follow.subject as AtprotoDid)) 154 - ) 155 - ); 152 + ?.forEach((follow) => fetchForInteractions(follow.subject as AtprotoDid)) 153 + ); 154 + fetchForInteractions(account.did); 155 + }); 156 156 }); 157 157 } else { 158 158 selectedDid = null; ··· 198 198 <!-- timeline --> 199 199 <TimelineView 200 200 class={currentView === 'timeline' ? `${animClass}` : 'hidden'} 201 - client={selectedClient ?? viewClient} 201 + client={selectedClient} 202 202 bind:postComposerState 203 203 /> 204 204 ··· 264 264 <div class="flex-1"> 265 265 <PostComposer 266 266 client={selectedClient} 267 - onPostSent={(post) => posts.get(selectedDid!)?.set(post.uri, post)} 267 + onPostSent={(post) => allPosts.get(selectedDid!)?.set(post.uri, post)} 268 268 bind:_state={postComposerState} 269 269 /> 270 270 </div>