replies timeline only, appview-less bluesky client

refactor fetching, state and dropdown hover logic

Replace fetchPostsWithBacklinks with fetchPosts(withBacklinks) and
adjust hydratePosts to accept a cacheFn. Add caching for listRecords in
AtpClient. Overhaul state management to handle post chains, hydration
caching and timeline traversal. Improve Dropdown hover logic to prevent
premature closes when moving between trigger and content. Introduce
explicit Ok/Err result types and update helpers accordingly.

ptr.pet 18b0c29f 81777bf4

verified
+6 -6
src/components/BskyPost.svelte
··· 16 type ResourceUri 17 } from '@atcute/lexicons'; 18 import { expect, ok } from '$lib/result'; 19 - import { generateColorForDid } from '$lib/accounts'; 20 import ProfilePicture from './ProfilePicture.svelte'; 21 import { isBlob } from '@atcute/lexicons/interfaces'; 22 import { blob, img } from '$lib/cdn'; 23 import BskyPost from './BskyPost.svelte'; 24 import Icon from '@iconify/svelte'; 25 import { 26 - clients, 27 allPosts, 28 pulsingPostId, 29 currentTime, ··· 33 } from '$lib/state.svelte'; 34 import type { PostWithUri } from '$lib/at/fetch'; 35 import { onMount } from 'svelte'; 36 - import { type AtprotoDid } from '@atcute/lexicons/syntax'; 37 import { derived } from 'svelte/store'; 38 import Device from 'svelte-device-info'; 39 import Dropdown from './Dropdown.svelte'; ··· 71 }: Props = $props(); 72 73 const selectedDid = $derived(client.user?.did ?? null); 74 - const actionClient = $derived(clients.get(did as AtprotoDid)); 75 76 const aturi = $derived(`at://${did}/app.bsky.feed.post/${rkey}` as CanonicalResourceUri); 77 const color = $derived(generateColorForDid(did)); ··· 153 return; 154 } 155 156 - actionClient?.atcute 157 ?.post('com.atproto.repo.deleteRecord', { 158 input: { 159 collection: 'app.bsky.feed.post', ··· 212 style="background: {color}36; border-color: {color}99;" 213 bind:isOpen={profileOpen} 214 trigger={profileInline} 215 > 216 <ProfileInfo {client} {did} {handle} {profile} /> 217 </Dropdown> ··· 444 {@render dropdownItem('heroicons:clipboard-20-solid', 'copy post text', () => 445 navigator.clipboard.writeText(post.record.text) 446 )} 447 - {#if actionClient} 448 <div class="my-0.75 h-px w-full opacity-60" style="background: {color};"></div> 449 {@render dropdownItem( 450 deleteState === 'confirm' ? 'heroicons:check-20-solid' : 'heroicons:trash-20-solid',
··· 16 type ResourceUri 17 } from '@atcute/lexicons'; 18 import { expect, ok } from '$lib/result'; 19 + import { accounts, generateColorForDid } from '$lib/accounts'; 20 import ProfilePicture from './ProfilePicture.svelte'; 21 import { isBlob } from '@atcute/lexicons/interfaces'; 22 import { blob, img } from '$lib/cdn'; 23 import BskyPost from './BskyPost.svelte'; 24 import Icon from '@iconify/svelte'; 25 import { 26 allPosts, 27 pulsingPostId, 28 currentTime, ··· 32 } from '$lib/state.svelte'; 33 import type { PostWithUri } from '$lib/at/fetch'; 34 import { onMount } from 'svelte'; 35 import { derived } from 'svelte/store'; 36 import Device from 'svelte-device-info'; 37 import Dropdown from './Dropdown.svelte'; ··· 69 }: Props = $props(); 70 71 const selectedDid = $derived(client.user?.did ?? null); 72 + const isLoggedInUser = $derived($accounts.some((acc) => acc.did === did)); 73 74 const aturi = $derived(`at://${did}/app.bsky.feed.post/${rkey}` as CanonicalResourceUri); 75 const color = $derived(generateColorForDid(did)); ··· 151 return; 152 } 153 154 + client?.atcute 155 ?.post('com.atproto.repo.deleteRecord', { 156 input: { 157 collection: 'app.bsky.feed.post', ··· 210 style="background: {color}36; border-color: {color}99;" 211 bind:isOpen={profileOpen} 212 trigger={profileInline} 213 + onMouseEnter={() => (profileOpen = true)} 214 + onMouseLeave={() => (profileOpen = false)} 215 > 216 <ProfileInfo {client} {did} {handle} {profile} /> 217 </Dropdown> ··· 444 {@render dropdownItem('heroicons:clipboard-20-solid', 'copy post text', () => 445 navigator.clipboard.writeText(post.record.text) 446 )} 447 + {#if isLoggedInUser} 448 <div class="my-0.75 h-px w-full opacity-60" style="background: {color};"></div> 449 {@render dropdownItem( 450 deleteState === 'confirm' ? 'heroicons:check-20-solid' : 'heroicons:trash-20-solid',
+59 -5
src/components/Dropdown.svelte
··· 20 placement?: Placement; 21 offsetDistance?: number; 22 position?: { x: number; y: number }; 23 } 24 25 let { ··· 29 placement = 'bottom-start', 30 offsetDistance = 2, 31 position = $bindable(), 32 ...restProps 33 }: Props = $props(); 34 35 let triggerRef: HTMLElement | undefined = $state(); 36 let contentRef: HTMLElement | undefined = $state(); 37 let cleanup: (() => void) | null = null; 38 39 const updatePosition = async () => { 40 const { x, y } = await computePosition(triggerRef!, contentRef!, { ··· 70 71 const handleScroll = handleClose; 72 73 $effect(() => { 74 if (isOpen) { 75 cleanup = autoUpdate(triggerRef!, contentRef!, updatePosition); ··· 79 } 80 }); 81 82 - onMount(() => { 83 - return () => { 84 - if (cleanup) cleanup(); 85 - }; 86 }); 87 </script> 88 89 <svelte:window onkeydown={handleEscape} onmousedown={handleClickOutside} onscroll={handleScroll} /> 90 91 - <div role="button" tabindex="0" bind:this={triggerRef}> 92 {@render trigger?.()} 93 </div> 94 ··· 100 style={restProps.style} 101 role="menu" 102 tabindex="-1" 103 > 104 {@render children?.()} 105 </div>
··· 20 placement?: Placement; 21 offsetDistance?: number; 22 position?: { x: number; y: number }; 23 + onMouseEnter?: () => void; 24 + onMouseLeave?: () => void; 25 } 26 27 let { ··· 31 placement = 'bottom-start', 32 offsetDistance = 2, 33 position = $bindable(), 34 + onMouseEnter, 35 + onMouseLeave, 36 ...restProps 37 }: Props = $props(); 38 39 let triggerRef: HTMLElement | undefined = $state(); 40 let contentRef: HTMLElement | undefined = $state(); 41 let cleanup: (() => void) | null = null; 42 + 43 + // State-based tracking for hover logic 44 + let isTriggerHovered = false; 45 + let isContentHovered = false; 46 + let closeTimer: ReturnType<typeof setTimeout>; 47 48 const updatePosition = async () => { 49 const { x, y } = await computePosition(triggerRef!, contentRef!, { ··· 79 80 const handleScroll = handleClose; 81 82 + // The central check: "Should we close now?" 83 + const scheduleCloseCheck = () => { 84 + clearTimeout(closeTimer); 85 + closeTimer = setTimeout(() => { 86 + // Only close if we are NOT on the trigger AND NOT on the content 87 + if (!isTriggerHovered && !isContentHovered) if (isOpen && onMouseLeave) onMouseLeave(); 88 + }, 30); // Small buffer to handle the physical gap between elements 89 + }; 90 + 91 + const handleTriggerEnter = () => { 92 + isTriggerHovered = true; 93 + clearTimeout(closeTimer); // We are safe, cancel any pending close 94 + if (!isOpen && onMouseEnter) onMouseEnter(); 95 + }; 96 + 97 + const handleTriggerLeave = () => { 98 + isTriggerHovered = false; 99 + scheduleCloseCheck(); // We left the trigger, check if we should close 100 + }; 101 + 102 + const handleContentEnter = () => { 103 + isContentHovered = true; 104 + clearTimeout(closeTimer); // We made it to the content, cancel close 105 + }; 106 + 107 + const handleContentLeave = () => { 108 + isContentHovered = false; 109 + scheduleCloseCheck(); // We left the content, check if we should close 110 + }; 111 + 112 + // Reset state if the menu is closed externally 113 + $effect(() => { 114 + if (!isOpen) { 115 + isContentHovered = false; 116 + clearTimeout(closeTimer); 117 + } 118 + }); 119 + 120 $effect(() => { 121 if (isOpen) { 122 cleanup = autoUpdate(triggerRef!, contentRef!, updatePosition); ··· 126 } 127 }); 128 129 + onMount(() => () => { 130 + if (cleanup) cleanup(); 131 + clearTimeout(closeTimer); 132 }); 133 </script> 134 135 <svelte:window onkeydown={handleEscape} onmousedown={handleClickOutside} onscroll={handleScroll} /> 136 137 + <div 138 + role="button" 139 + tabindex="0" 140 + bind:this={triggerRef} 141 + onmouseenter={handleTriggerEnter} 142 + onmouseleave={handleTriggerLeave} 143 + > 144 {@render trigger?.()} 145 </div> 146 ··· 152 style={restProps.style} 153 role="menu" 154 tabindex="-1" 155 + onmouseenter={handleContentEnter} 156 + onmouseleave={handleContentLeave} 157 > 158 {@render children?.()} 159 </div>
+7 -2
src/components/FollowingView.svelte
··· 16 selectedDid: Did; 17 selectedClient: AtpClient; 18 onProfileClick: (did: AtprotoDid) => void; 19 } 20 21 - const { selectedDid, selectedClient, onProfileClick }: Props = $props(); 22 23 - let followingSort: Sort = $state('active' as Sort); 24 const followsMap = $derived(follows.get(selectedDid)); 25 26 // eslint-disable-next-line @typescript-eslint/no-explicit-any
··· 16 selectedDid: Did; 17 selectedClient: AtpClient; 18 onProfileClick: (did: AtprotoDid) => void; 19 + followingSort: Sort; 20 } 21 22 + let { 23 + selectedDid, 24 + selectedClient, 25 + onProfileClick, 26 + followingSort = $bindable('active') 27 + }: Props = $props(); 28 29 const followsMap = $derived(follows.get(selectedDid)); 30 31 // eslint-disable-next-line @typescript-eslint/no-explicit-any
+1 -2
src/components/ProfileInfo.svelte
··· 11 did: Did; 12 handle?: string; 13 profile?: AppBskyActorProfile.Main | null; 14 - interactive?: boolean; 15 } 16 17 - let { client, did, handle, profile = $bindable(null), interactive = true }: Props = $props(); 18 19 onMount(async () => { 20 await Promise.all([
··· 11 did: Did; 12 handle?: string; 13 profile?: AppBskyActorProfile.Main | null; 14 } 15 16 + let { client, did, handle, profile = $bindable(null) }: Props = $props(); 17 18 onMount(async () => { 19 await Promise.all([
+7 -1
src/components/ProfileView.svelte
··· 77 78 <div class="my-4 h-px bg-white/10"></div> 79 80 - <TimelineView {client} targetDid={did} bind:postComposerState class="min-h-[50vh]" /> 81 </div> 82 {/if} 83 </div>
··· 77 78 <div class="my-4 h-px bg-white/10"></div> 79 80 + <TimelineView 81 + showReplies={false} 82 + {client} 83 + targetDid={did} 84 + bind:postComposerState 85 + class="min-h-[50vh]" 86 + /> 87 </div> 88 {/if} 89 </div>
+5 -1
src/components/TimelineView.svelte
··· 22 targetDid?: AtprotoDid; 23 postComposerState: PostComposerState; 24 class?: string; 25 } 26 27 let { 28 client = null, 29 targetDid = undefined, 30 postComposerState = $bindable(), 31 class: className = '' 32 }: Props = $props(); ··· 38 const did = $derived(targetDid ?? client?.user?.did); 39 40 const threads = $derived( 41 filterThreads( 42 did && timelines.has(did) ? buildThreads(did, timelines.get(did)!, allPosts) : [], 43 $accounts, ··· 58 loaderState.status = 'LOADING'; 59 60 try { 61 - await fetchTimeline(did as AtprotoDid); 62 // interaction fetching is done lazily so we dont block loading posts 63 fetchMoreInteractions = true; 64 loaderState.loaded();
··· 22 targetDid?: AtprotoDid; 23 postComposerState: PostComposerState; 24 class?: string; 25 + // whether to show replies that are not the user's own posts 26 + showReplies?: boolean; 27 } 28 29 let { 30 client = null, 31 targetDid = undefined, 32 + showReplies = true, 33 postComposerState = $bindable(), 34 class: className = '' 35 }: Props = $props(); ··· 41 const did = $derived(targetDid ?? client?.user?.did); 42 43 const threads = $derived( 44 + // todo: apply showReplies here 45 filterThreads( 46 did && timelines.has(did) ? buildThreads(did, timelines.get(did)!, allPosts) : [], 47 $accounts, ··· 62 loaderState.status = 'LOADING'; 63 64 try { 65 + await fetchTimeline(did as AtprotoDid, 7, showReplies); 66 // interaction fetching is done lazily so we dont block loading posts 67 fetchMoreInteractions = true; 68 loaderState.loaded();
+14 -38
src/lib/at/client.ts
··· 80 return res.value; 81 }); 82 83 - type ListRecordsParams = { 84 - atcute: AtcuteClient; 85 - did: Did; 86 - collection: Nsid; 87 - cursor?: string; 88 - limit?: number; 89 - }; 90 - const cacheWithListRecords = cacheWithRecords.define( 91 - 'listRecords', 92 - async (params: ListRecordsParams) => { 93 - const res = await params.atcute.get('com.atproto.repo.listRecords', { 94 - params: { 95 - repo: params.did, 96 - collection: params.collection, 97 - cursor: params.cursor, 98 - limit: params.limit ?? 100, 99 - reverse: false 100 - } 101 - }); 102 - if (!res.ok) return err(`${res.data.error}: ${res.data.message ?? 'no details'}`); 103 - 104 - for (const record of res.data.records) 105 - await cache.set('fetchRecord', `fetchRecord~${record.uri}`, record, 60 * 60 * 24); 106 - 107 - return ok(res.data); 108 - } 109 - ); 110 - 111 - const cache = cacheWithListRecords; 112 113 export class AtpClient { 114 public atcute: AtcuteClient | null = null; ··· 190 Result<InferXRPCBodyOutput<(typeof ComAtprotoRepoListRecords.mainSchema)['output']>, string> 191 > { 192 if (!this.atcute || !this.user) return err('not authenticated'); 193 - try { 194 - return (await cache.listRecords({ 195 - atcute: this.atcute, 196 - did: this.user.did, 197 collection, 198 cursor, 199 - limit 200 - })) as Awaited<ReturnType<typeof this.listRecords>>; 201 - } catch (e) { 202 - return err(String(e)); 203 - } 204 } 205 206 async listRecordsUntil<Collection extends keyof Records>(
··· 80 return res.value; 81 }); 82 83 + const cache = cacheWithRecords; 84 85 export class AtpClient { 86 public atcute: AtcuteClient | null = null; ··· 162 Result<InferXRPCBodyOutput<(typeof ComAtprotoRepoListRecords.mainSchema)['output']>, string> 163 > { 164 if (!this.atcute || !this.user) return err('not authenticated'); 165 + const res = await this.atcute.get('com.atproto.repo.listRecords', { 166 + params: { 167 + repo: this.user.did, 168 collection, 169 cursor, 170 + limit, 171 + reverse: false 172 + } 173 + }); 174 + if (!res.ok) return err(`${res.data.error}: ${res.data.message ?? 'no details'}`); 175 + 176 + for (const record of res.data.records) 177 + await cache.set('fetchRecord', `fetchRecord~${record.uri}`, record, 60 * 60 * 24); 178 + 179 + return ok(res.data); 180 } 181 182 async listRecordsUntil<Collection extends keyof Records>(
+43 -18
src/lib/at/fetch.ts
··· 5 type ResourceUri 6 } from '@atcute/lexicons'; 7 import { type AtpClient } from './client'; 8 - import { err, expect, ok, type Result } from '$lib/result'; 9 import type { Backlinks } from './constellation'; 10 import { AppBskyFeedPost } from '@atcute/bluesky'; 11 - import type { AtprotoDid } from '@atcute/lexicons/syntax'; 12 import { replySource } from '$lib'; 13 14 export type PostWithUri = { uri: ResourceUri; cid: Cid | undefined; record: AppBskyFeedPost.Main }; 15 export type PostWithBacklinks = PostWithUri & { 16 - replies: Backlinks; 17 }; 18 - export type PostsWithReplyBacklinks = PostWithBacklinks[]; 19 20 - export const fetchPostsWithBacklinks = async ( 21 client: AtpClient, 22 cursor?: string, 23 - limit?: number 24 - ): Promise<Result<{ posts: PostsWithReplyBacklinks; cursor?: string }, string>> => { 25 const recordsList = await client.listRecords('app.bsky.feed.post', cursor, limit); 26 if (!recordsList.ok) return err(`can't retrieve posts: ${recordsList.error}`); 27 cursor = recordsList.value.cursor; 28 const records = recordsList.value.records; 29 30 try { 31 const allBacklinks = await Promise.all( 32 records.map(async (r): Promise<PostWithBacklinks> => { ··· 50 export const hydratePosts = async ( 51 client: AtpClient, 52 repo: AtprotoDid, 53 - data: PostsWithReplyBacklinks 54 ): Promise<Result<Map<ResourceUri, PostWithUri>, string>> => { 55 let posts: Map<ResourceUri, PostWithUri> = new Map(); 56 try { 57 const allPosts = await Promise.all( 58 data.map(async (post) => { 59 const result: PostWithUri[] = [post]; 60 - const replies = await Promise.all( 61 - post.replies.records.map(async (r) => { 62 - const reply = await client.getRecord(AppBskyFeedPost.mainSchema, r.did, r.rkey); 63 - if (!reply.ok) throw `cant fetch reply: ${reply.error}`; 64 - return reply.value; 65 - }) 66 - ); 67 - result.push(...replies); 68 return result; 69 }) 70 ); ··· 79 const parentUri = parent.uri as CanonicalResourceUri; 80 // if we already have this parent, then we already fetched this chain / are fetching it 81 if (posts.has(parentUri)) return; 82 - const p = await client.getRecordUri(AppBskyFeedPost.mainSchema, parentUri); 83 if (p.ok) { 84 posts.set(p.value.uri, p.value); 85 parent = p.value.record.reply?.parent; ··· 105 if (reply.did !== postRepo) continue; 106 // if we already have this reply, then we already fetched this chain / are fetching it 107 if (posts.has(`at://${reply.did}/${reply.collection}/${reply.rkey}`)) continue; 108 - const record = await client.getRecord(AppBskyFeedPost.mainSchema, reply.did, reply.rkey); 109 if (!record.ok) break; // TODO: this doesnt handle deleted posts in between 110 posts.set(record.value.uri, record.value); 111 promises.push(fetchDownwardsChain(record.value));
··· 5 type ResourceUri 6 } from '@atcute/lexicons'; 7 import { type AtpClient } from './client'; 8 + import { err, expect, ok, type Ok, type Result } from '$lib/result'; 9 import type { Backlinks } from './constellation'; 10 import { AppBskyFeedPost } from '@atcute/bluesky'; 11 + import type { AtprotoDid, Did, RecordKey } from '@atcute/lexicons/syntax'; 12 import { replySource } from '$lib'; 13 14 export type PostWithUri = { uri: ResourceUri; cid: Cid | undefined; record: AppBskyFeedPost.Main }; 15 export type PostWithBacklinks = PostWithUri & { 16 + replies?: Backlinks; 17 }; 18 19 + export const fetchPosts = async ( 20 client: AtpClient, 21 cursor?: string, 22 + limit?: number, 23 + withBacklinks: boolean = true 24 + ): Promise<Result<{ posts: PostWithBacklinks[]; cursor?: string }, string>> => { 25 const recordsList = await client.listRecords('app.bsky.feed.post', cursor, limit); 26 if (!recordsList.ok) return err(`can't retrieve posts: ${recordsList.error}`); 27 cursor = recordsList.value.cursor; 28 const records = recordsList.value.records; 29 30 + if (!withBacklinks) { 31 + return ok({ 32 + posts: records.map((r) => ({ 33 + uri: r.uri, 34 + cid: r.cid, 35 + record: r.value as AppBskyFeedPost.Main 36 + })), 37 + cursor 38 + }); 39 + } 40 + 41 try { 42 const allBacklinks = await Promise.all( 43 records.map(async (r): Promise<PostWithBacklinks> => { ··· 61 export const hydratePosts = async ( 62 client: AtpClient, 63 repo: AtprotoDid, 64 + data: PostWithBacklinks[], 65 + cacheFn: (did: Did, rkey: RecordKey) => Ok<PostWithUri> | undefined 66 ): Promise<Result<Map<ResourceUri, PostWithUri>, string>> => { 67 let posts: Map<ResourceUri, PostWithUri> = new Map(); 68 try { 69 const allPosts = await Promise.all( 70 data.map(async (post) => { 71 const result: PostWithUri[] = [post]; 72 + if (post.replies) { 73 + const replies = await Promise.all( 74 + post.replies.records.map(async (r) => { 75 + const reply = 76 + cacheFn(r.did, r.rkey) ?? 77 + (await client.getRecord(AppBskyFeedPost.mainSchema, r.did, r.rkey)); 78 + if (!reply.ok) throw `cant fetch reply: ${reply.error}`; 79 + return reply.value; 80 + }) 81 + ); 82 + result.push(...replies); 83 + } 84 return result; 85 }) 86 ); ··· 95 const parentUri = parent.uri as CanonicalResourceUri; 96 // if we already have this parent, then we already fetched this chain / are fetching it 97 if (posts.has(parentUri)) return; 98 + const parsedParentUri = expect(parseCanonicalResourceUri(parentUri)); 99 + const p = 100 + cacheFn(parsedParentUri.repo, parsedParentUri.rkey) ?? 101 + (await client.getRecord( 102 + AppBskyFeedPost.mainSchema, 103 + parsedParentUri.repo, 104 + parsedParentUri.rkey 105 + )); 106 if (p.ok) { 107 posts.set(p.value.uri, p.value); 108 parent = p.value.record.reply?.parent; ··· 128 if (reply.did !== postRepo) continue; 129 // if we already have this reply, then we already fetched this chain / are fetching it 130 if (posts.has(`at://${reply.did}/${reply.collection}/${reply.rkey}`)) continue; 131 + const record = 132 + cacheFn(reply.did, reply.rkey) ?? 133 + (await client.getRecord(AppBskyFeedPost.mainSchema, reply.did, reply.rkey)); 134 if (!record.ok) break; // TODO: this doesnt handle deleted posts in between 135 posts.set(record.value.uri, record.value); 136 promises.push(fetchDownwardsChain(record.value));
+11 -11
src/lib/result.ts
··· 1 - export type Result<T, E> = 2 - | { 3 - ok: true; 4 - value: T; 5 - } 6 - | { 7 - ok: false; 8 - error: E; 9 - }; 10 11 - export const ok = <T, E>(value: T): Result<T, E> => { 12 return { ok: true, value }; 13 }; 14 - export const err = <T, E>(error: E): Result<T, E> => { 15 return { ok: false, error }; 16 }; 17 export const expect = <T, E>(v: Result<T, E>, msg: string = 'expected result to not be error:') => {
··· 1 + export type Ok<T> = { 2 + ok: true; 3 + value: T; 4 + }; 5 + export type Err<E> = { 6 + ok: false; 7 + error: E; 8 + }; 9 + export type Result<T, E> = Ok<T> | Err<E>; 10 11 + export const ok = <T>(value: T): Ok<T> => { 12 return { ok: true, value }; 13 }; 14 + export const err = <E>(error: E): Err<E> => { 15 return { ok: false, error }; 16 }; 17 export const expect = <T, E>(v: Result<T, E>, msg: string = 'expected result to not be error:') => {
+57 -22
src/lib/state.svelte.ts
··· 1 - import { writable } from 'svelte/store'; 2 import { 3 AtpClient, 4 newPublicClient, ··· 6 type NotificationsStreamEvent 7 } from './at/client'; 8 import { SvelteMap, SvelteDate, SvelteSet } from 'svelte/reactivity'; 9 - import type { Did, InferOutput, Nsid, ResourceUri } from '@atcute/lexicons'; 10 - import { fetchPostsWithBacklinks, hydratePosts, type PostWithUri } from './at/fetch'; 11 import { parseCanonicalResourceUri, type AtprotoDid } from '@atcute/lexicons/syntax'; 12 import { AppBskyFeedPost, type AppBskyGraphFollow } from '@atcute/bluesky'; 13 import type { ComAtprotoRepoListRecords } from '@atcute/atproto'; 14 import type { JetstreamSubscription, JetstreamEvent } from '@atcute/jetstream'; 15 - import { expect } from './result'; 16 import type { Backlink, BacklinksSource } from './at/constellation'; 17 import { now as tidNow } from '@atcute/tid'; 18 import type { Records } from '@atcute/lexicons/ambient'; ··· 247 // did -> post uris that are replies to that did 248 export const replyIndex = new SvelteMap<Did, SvelteSet<ResourceUri>>(); 249 250 export const addPostsRaw = ( 251 did: AtprotoDid, 252 newPosts: InferOutput<ComAtprotoRepoListRecords.mainSchema['output']['schema']> 253 ) => { 254 - const postsWithUri = newPosts.records.map((post): [ResourceUri, PostWithUri] => [ 255 - post.uri, 256 - { cid: post.cid, uri: post.uri, record: post.value as AppBskyFeedPost.Main } as PostWithUri 257 - ]); 258 addPosts(postsWithUri); 259 }; 260 261 - export const addPosts = (newPosts: Iterable<[ResourceUri, PostWithUri]>) => { 262 - for (const [uri, post] of newPosts) { 263 - const parsedUri = expect(parseCanonicalResourceUri(uri)); 264 let posts = allPosts.get(parsedUri.repo); 265 if (!posts) { 266 posts = new SvelteMap(); 267 allPosts.set(parsedUri.repo, posts); 268 } 269 - posts.set(uri, post); 270 const link: Backlink = { 271 did: parsedUri.repo, 272 collection: parsedUri.collection, ··· 283 set = new SvelteSet(); 284 replyIndex.set(parentDid, set); 285 } 286 - set.add(uri); 287 } 288 } 289 } ··· 292 export const timelines = new SvelteMap<Did, SvelteSet<ResourceUri>>(); 293 export const postCursors = new SvelteMap<Did, { value?: string; end: boolean }>(); 294 295 export const addTimeline = (did: Did, uris: Iterable<ResourceUri>) => { 296 let timeline = timelines.get(did); 297 if (!timeline) { 298 timeline = new SvelteSet(); 299 timelines.set(did, timeline); 300 } 301 - for (const uri of uris) timeline.add(uri); 302 }; 303 304 - export const fetchTimeline = async (did: AtprotoDid, limit: number = 6) => { 305 const targetClient = await getClient(did); 306 307 const cursor = postCursors.get(did); 308 if (cursor && cursor.end) return; 309 310 - const accPosts = await fetchPostsWithBacklinks(targetClient, cursor?.value, limit); 311 if (!accPosts.ok) throw `cant fetch posts ${did}: ${accPosts.error}`; 312 313 // if the cursor is undefined, we've reached the end of the timeline 314 postCursors.set(did, { value: accPosts.value.cursor, end: !accPosts.value.cursor }); 315 - const hydrated = await hydratePosts(targetClient, did, accPosts.value.posts); 316 if (!hydrated.ok) throw `cant hydrate posts ${did}: ${hydrated.error}`; 317 318 - addPosts(hydrated.value); 319 addTimeline(did, hydrated.value.keys()); 320 321 console.log(`${did}: fetchTimeline`, accPosts.value.cursor); ··· 342 // assume record is valid, we trust the jetstream 343 record: record as AppBskyFeedPost.Main 344 }; 345 - addPosts([[uri, post]]); 346 addTimeline(did, [uri]); 347 } else if (commit.operation === 'delete') { 348 allPosts.get(did)?.delete(uri); ··· 362 if (!subjectPost.ok) return; 363 364 const parsedSourceUri = expect(parseCanonicalResourceUri(event.data.link.source_record)); 365 - const hydrated = await hydratePosts(client, did, [ 366 { 367 record: subjectPost.value.record, 368 uri: event.data.link.subject, ··· 379 ] 380 } 381 } 382 - ]); 383 if (!hydrated.ok) { 384 console.error(`cant hydrate posts ${did}: ${hydrated.error}`); 385 return; 386 } 387 388 // console.log(hydrated); 389 - addPosts(hydrated.value); 390 addTimeline(did, hydrated.value.keys()); 391 }; 392 ··· 414 setInterval(() => { 415 currentTime.setTime(Date.now()); 416 }, 1000);
··· 1 + import { get, writable } from 'svelte/store'; 2 import { 3 AtpClient, 4 newPublicClient, ··· 6 type NotificationsStreamEvent 7 } from './at/client'; 8 import { SvelteMap, SvelteDate, SvelteSet } from 'svelte/reactivity'; 9 + import type { Did, InferOutput, Nsid, RecordKey, ResourceUri } from '@atcute/lexicons'; 10 + import { fetchPosts, hydratePosts, type PostWithUri } from './at/fetch'; 11 import { parseCanonicalResourceUri, type AtprotoDid } from '@atcute/lexicons/syntax'; 12 import { AppBskyFeedPost, type AppBskyGraphFollow } from '@atcute/bluesky'; 13 import type { ComAtprotoRepoListRecords } from '@atcute/atproto'; 14 import type { JetstreamSubscription, JetstreamEvent } from '@atcute/jetstream'; 15 + import { expect, ok } from './result'; 16 import type { Backlink, BacklinksSource } from './at/constellation'; 17 import { now as tidNow } from '@atcute/tid'; 18 import type { Records } from '@atcute/lexicons/ambient'; ··· 247 // did -> post uris that are replies to that did 248 export const replyIndex = new SvelteMap<Did, SvelteSet<ResourceUri>>(); 249 250 + export const getPost = (did: Did, rkey: RecordKey) => 251 + allPosts.get(did)?.get(`at://${did}/app.bsky.feed.post/${rkey}`); 252 + const hydrateCacheFn: Parameters<typeof hydratePosts>[3] = (did, rkey) => { 253 + const cached = getPost(did, rkey); 254 + return cached ? ok(cached) : undefined; 255 + }; 256 + 257 export const addPostsRaw = ( 258 did: AtprotoDid, 259 newPosts: InferOutput<ComAtprotoRepoListRecords.mainSchema['output']['schema']> 260 ) => { 261 + const postsWithUri = newPosts.records.map( 262 + (post) => 263 + ({ cid: post.cid, uri: post.uri, record: post.value as AppBskyFeedPost.Main }) as PostWithUri 264 + ); 265 addPosts(postsWithUri); 266 }; 267 268 + export const addPosts = (newPosts: Iterable<PostWithUri>) => { 269 + for (const post of newPosts) { 270 + const parsedUri = expect(parseCanonicalResourceUri(post.uri)); 271 let posts = allPosts.get(parsedUri.repo); 272 if (!posts) { 273 posts = new SvelteMap(); 274 allPosts.set(parsedUri.repo, posts); 275 } 276 + posts.set(post.uri, post); 277 const link: Backlink = { 278 did: parsedUri.repo, 279 collection: parsedUri.collection, ··· 290 set = new SvelteSet(); 291 replyIndex.set(parentDid, set); 292 } 293 + set.add(post.uri); 294 } 295 } 296 } ··· 299 export const timelines = new SvelteMap<Did, SvelteSet<ResourceUri>>(); 300 export const postCursors = new SvelteMap<Did, { value?: string; end: boolean }>(); 301 302 + const traversePostChain = (post: PostWithUri) => { 303 + const result = [post.uri]; 304 + const parentUri = post.record.reply?.parent.uri; 305 + if (parentUri) { 306 + const parentPost = allPosts.get(extractDidFromUri(parentUri)!)?.get(parentUri); 307 + if (parentPost) result.push(...traversePostChain(parentPost)); 308 + } 309 + return result; 310 + }; 311 export const addTimeline = (did: Did, uris: Iterable<ResourceUri>) => { 312 let timeline = timelines.get(did); 313 if (!timeline) { 314 timeline = new SvelteSet(); 315 timelines.set(did, timeline); 316 } 317 + for (const uri of uris) { 318 + const post = allPosts.get(did)?.get(uri); 319 + // we need to traverse the post chain to add all posts in the chain to the timeline 320 + // because the parent posts might not be in the timeline yet 321 + const chain = post ? traversePostChain(post) : []; 322 + for (const uri of chain) timeline.add(uri); 323 + } 324 }; 325 326 + export const fetchTimeline = async ( 327 + did: AtprotoDid, 328 + limit: number = 6, 329 + withBacklinks: boolean = true 330 + ) => { 331 const targetClient = await getClient(did); 332 333 const cursor = postCursors.get(did); 334 if (cursor && cursor.end) return; 335 336 + const accPosts = await fetchPosts(targetClient, cursor?.value, limit, withBacklinks); 337 if (!accPosts.ok) throw `cant fetch posts ${did}: ${accPosts.error}`; 338 339 // if the cursor is undefined, we've reached the end of the timeline 340 postCursors.set(did, { value: accPosts.value.cursor, end: !accPosts.value.cursor }); 341 + const hydrated = await hydratePosts(targetClient, did, accPosts.value.posts, hydrateCacheFn); 342 if (!hydrated.ok) throw `cant hydrate posts ${did}: ${hydrated.error}`; 343 344 + addPosts(hydrated.value.values()); 345 addTimeline(did, hydrated.value.keys()); 346 347 console.log(`${did}: fetchTimeline`, accPosts.value.cursor); ··· 368 // assume record is valid, we trust the jetstream 369 record: record as AppBskyFeedPost.Main 370 }; 371 + addPosts([post]); 372 addTimeline(did, [uri]); 373 } else if (commit.operation === 'delete') { 374 allPosts.get(did)?.delete(uri); ··· 388 if (!subjectPost.ok) return; 389 390 const parsedSourceUri = expect(parseCanonicalResourceUri(event.data.link.source_record)); 391 + const posts = [ 392 { 393 record: subjectPost.value.record, 394 uri: event.data.link.subject, ··· 405 ] 406 } 407 } 408 + ]; 409 + const hydrated = await hydratePosts(client, did, posts, hydrateCacheFn); 410 if (!hydrated.ok) { 411 console.error(`cant hydrate posts ${did}: ${hydrated.error}`); 412 return; 413 } 414 415 // console.log(hydrated); 416 + addPosts(hydrated.value.values()); 417 addTimeline(did, hydrated.value.keys()); 418 }; 419 ··· 441 setInterval(() => { 442 currentTime.setTime(Date.now()); 443 }, 1000); 444 + 445 + export type View = 'timeline' | 'notifications' | 'following' | 'settings' | 'profile'; 446 + export const currentView = writable<View>('timeline'); 447 + export const previousView = writable<View>('timeline'); 448 + export const setView = (view: View) => { 449 + previousView.set(get(currentView)); 450 + currentView.set(view); 451 + };
+1 -3
src/lib/theme.ts
··· 18 const id = input.split(':').pop() || input; 19 20 hash = 0; 21 - for (let i = 0; i < Math.min(10, id.length); i++) { 22 - hash = (hash << 4) + id.charCodeAt(i); 23 - } 24 hash = hash >>> 0; 25 26 // magic mixing
··· 18 const id = input.split(':').pop() || input; 19 20 hash = 0; 21 + for (let i = 0; i < Math.min(10, id.length); i++) hash = (hash << 4) + id.charCodeAt(i); 22 hash = hash >>> 0; 23 24 // magic mixing
+44 -39
src/routes/+page.svelte
··· 17 fetchFollows, 18 follows, 19 notificationStream, 20 - allPosts, 21 viewClient, 22 jetstream, 23 handleJetstreamEvent, 24 - handleNotification 25 } from '$lib/state.svelte'; 26 import { get } from 'svelte/store'; 27 import Icon from '@iconify/svelte'; ··· 30 import type { PageProps } from './+page'; 31 import { JetstreamSubscription } from '@atcute/jetstream'; 32 import { settings } from '$lib/settings'; 33 34 const { data: loadData }: PageProps = $props(); 35 ··· 69 handleAccountSelected(newAccounts[0]?.did); 70 }; 71 72 - type View = 'timeline' | 'notifications' | 'following' | 'settings' | 'profile'; 73 - let currentView = $state<View>('timeline'); 74 let animClass = $state('animate-fade-in-scale'); 75 let scrollPositions = new SvelteMap<View, number>(); 76 let viewingProfileDid = $state<AtprotoDid | null>(null); 77 - let previousView = $state<View>('timeline'); 78 79 const viewOrder: Record<View, number> = { 80 timeline: 0, ··· 84 profile: 4 85 }; 86 87 - const switchView = async (newView: View) => { 88 - if (currentView === newView) return; 89 - scrollPositions.set(currentView, window.scrollY); 90 91 - const direction = viewOrder[newView] > viewOrder[currentView] ? 'right' : 'left'; 92 - // Profile always slides in from right unless going back 93 - if (newView === 'profile') animClass = 'animate-slide-in-right'; 94 - else if (currentView === 'profile') animClass = 'animate-slide-in-left'; 95 else animClass = direction === 'right' ? 'animate-slide-in-right' : 'animate-slide-in-left'; 96 - // Don't overwrite previousView if we're just going to profile 97 - if (newView !== 'profile' && currentView !== 'profile') previousView = currentView; 98 - else if (newView === 'profile' && currentView !== 'profile') previousView = currentView; 99 - currentView = newView; 100 101 await tick(); 102 103 - window.scrollTo({ top: scrollPositions.get(newView) || 0, behavior: 'instant' }); 104 }; 105 106 const goToProfile = (did: AtprotoDid) => { 107 viewingProfileDid = did; 108 - switchView('profile'); 109 }; 110 111 let postComposerState = $state<PostComposerState>({ type: 'null' }); 112 let showScrollToTop = $state(false); 113 const handleScroll = () => { 114 - if (currentView === 'timeline') showScrollToTop = window.scrollY > 300; 115 }; 116 - const scrollToTop = () => { 117 - window.scrollTo({ top: 0, behavior: 'smooth' }); 118 - }; 119 120 onMount(() => { 121 window.addEventListener('scroll', handleScroll); ··· 130 'app.bsky.feed.post:reply.parent.uri', 131 'app.bsky.feed.post:embed.record.record.uri', 132 'app.bsky.feed.post:embed.record.uri', 133 - 'app.bsky.feed.repost:subject.uri' 134 ) 135 ); 136 }); ··· 214 <div class="flex-1"> 215 <!-- timeline --> 216 <TimelineView 217 - class={currentView === 'timeline' ? `${animClass}` : 'hidden'} 218 client={selectedClient} 219 bind:postComposerState 220 /> 221 222 - {#if currentView === 'settings'} 223 <div class={animClass}> 224 <SettingsView /> 225 </div> 226 {/if} 227 - {#if currentView === 'notifications'} 228 <div class={animClass}> 229 <NotificationsView /> 230 </div> 231 {/if} 232 - {#if currentView === 'following'} 233 <div class={animClass}> 234 <FollowingView 235 selectedClient={selectedClient!} 236 selectedDid={selectedDid!} 237 onProfileClick={goToProfile} 238 /> 239 </div> 240 {/if} 241 - {#if currentView === 'profile' && viewingProfileDid} 242 <div class={animClass}> 243 <ProfileView 244 client={selectedClient!} 245 did={viewingProfileDid} 246 - onBack={() => switchView(previousView)} 247 bind:postComposerState 248 /> 249 </div> ··· 276 277 <div 278 class=" 279 - {currentView === 'timeline' || currentView === 'following' || currentView === 'profile' 280 ? '' 281 : 'hidden'} 282 z-20 w-full max-w-2xl p-2.5 px-4 pb-1 transition-all ··· 297 <div class="flex-1"> 298 <PostComposer 299 client={selectedClient} 300 - onPostSent={(post) => allPosts.get(selectedDid!)?.set(post.uri, post)} 301 bind:_state={postComposerState} 302 /> 303 </div> ··· 330 </div> 331 <div class="grow"></div> 332 {@render appButton( 333 - () => switchView('timeline'), 334 'heroicons:home', 335 'timeline', 336 - currentView === 'timeline', 337 'heroicons:home-solid' 338 )} 339 {@render appButton( 340 - () => switchView('following'), 341 'heroicons:users', 342 'following', 343 - currentView === 'following', 344 'heroicons:users-solid' 345 )} 346 {@render appButton( 347 - () => switchView('notifications'), 348 'heroicons:bell', 349 'notifications', 350 - currentView === 'notifications', 351 'heroicons:bell-solid' 352 )} 353 {@render appButton( 354 - () => switchView('settings'), 355 'heroicons:cog-6-tooth', 356 'settings', 357 - currentView === 'settings', 358 'heroicons:cog-6-tooth-solid' 359 )} 360 </div>
··· 17 fetchFollows, 18 follows, 19 notificationStream, 20 viewClient, 21 jetstream, 22 handleJetstreamEvent, 23 + handleNotification, 24 + addPosts, 25 + addTimeline, 26 + type View, 27 + currentView, 28 + previousView, 29 + setView 30 } from '$lib/state.svelte'; 31 import { get } from 'svelte/store'; 32 import Icon from '@iconify/svelte'; ··· 35 import type { PageProps } from './+page'; 36 import { JetstreamSubscription } from '@atcute/jetstream'; 37 import { settings } from '$lib/settings'; 38 + import type { Sort } from '$lib/following'; 39 40 const { data: loadData }: PageProps = $props(); 41 ··· 75 handleAccountSelected(newAccounts[0]?.did); 76 }; 77 78 + let followingSort = $state('active' as Sort); 79 + 80 let animClass = $state('animate-fade-in-scale'); 81 let scrollPositions = new SvelteMap<View, number>(); 82 let viewingProfileDid = $state<AtprotoDid | null>(null); 83 84 const viewOrder: Record<View, number> = { 85 timeline: 0, ··· 89 profile: 4 90 }; 91 92 + const switchView = async () => { 93 + scrollPositions.set($previousView, window.scrollY); 94 95 + const direction = viewOrder[$previousView] > viewOrder[$currentView] ? 'right' : 'left'; 96 + // profile always slides in from right unless going back 97 + if ($currentView === 'profile') animClass = 'animate-slide-in-left'; 98 + else if ($previousView === 'profile') animClass = 'animate-slide-in-right'; 99 else animClass = direction === 'right' ? 'animate-slide-in-right' : 'animate-slide-in-left'; 100 101 await tick(); 102 103 + window.scrollTo({ top: scrollPositions.get($currentView) || 0, behavior: 'instant' }); 104 }; 105 + currentView.subscribe(switchView); 106 107 const goToProfile = (did: AtprotoDid) => { 108 viewingProfileDid = did; 109 + setView('profile'); 110 }; 111 112 let postComposerState = $state<PostComposerState>({ type: 'null' }); 113 let showScrollToTop = $state(false); 114 const handleScroll = () => { 115 + if ($currentView === 'timeline') showScrollToTop = window.scrollY > 300; 116 }; 117 + const scrollToTop = () => window.scrollTo({ top: 0, behavior: 'smooth' }); 118 119 onMount(() => { 120 window.addEventListener('scroll', handleScroll); ··· 129 'app.bsky.feed.post:reply.parent.uri', 130 'app.bsky.feed.post:embed.record.record.uri', 131 'app.bsky.feed.post:embed.record.uri', 132 + 'app.bsky.feed.repost:subject.uri', 133 + 'app.bsky.feed.like:subject.uri', 134 + 'app.bsky.graph.follow:subject' 135 ) 136 ); 137 }); ··· 215 <div class="flex-1"> 216 <!-- timeline --> 217 <TimelineView 218 + class={$currentView === 'timeline' ? `${animClass}` : 'hidden'} 219 client={selectedClient} 220 bind:postComposerState 221 /> 222 223 + {#if $currentView === 'settings'} 224 <div class={animClass}> 225 <SettingsView /> 226 </div> 227 {/if} 228 + {#if $currentView === 'notifications'} 229 <div class={animClass}> 230 <NotificationsView /> 231 </div> 232 {/if} 233 + {#if $currentView === 'following'} 234 <div class={animClass}> 235 <FollowingView 236 selectedClient={selectedClient!} 237 selectedDid={selectedDid!} 238 onProfileClick={goToProfile} 239 + bind:followingSort 240 /> 241 </div> 242 {/if} 243 + {#if $currentView === 'profile' && viewingProfileDid} 244 <div class={animClass}> 245 <ProfileView 246 client={selectedClient!} 247 did={viewingProfileDid} 248 + onBack={() => setView($previousView)} 249 bind:postComposerState 250 /> 251 </div> ··· 278 279 <div 280 class=" 281 + {$currentView === 'timeline' || $currentView === 'following' || $currentView === 'profile' 282 ? '' 283 : 'hidden'} 284 z-20 w-full max-w-2xl p-2.5 px-4 pb-1 transition-all ··· 299 <div class="flex-1"> 300 <PostComposer 301 client={selectedClient} 302 + onPostSent={(post) => { 303 + addPosts([post]); 304 + addTimeline(selectedDid!, [post.uri]); 305 + }} 306 bind:_state={postComposerState} 307 /> 308 </div> ··· 335 </div> 336 <div class="grow"></div> 337 {@render appButton( 338 + () => setView('timeline'), 339 'heroicons:home', 340 'timeline', 341 + $currentView === 'timeline', 342 'heroicons:home-solid' 343 )} 344 {@render appButton( 345 + () => setView('following'), 346 'heroicons:users', 347 'following', 348 + $currentView === 'following', 349 'heroicons:users-solid' 350 )} 351 {@render appButton( 352 + () => setView('notifications'), 353 'heroicons:bell', 354 'notifications', 355 + $currentView === 'notifications', 356 'heroicons:bell-solid' 357 )} 358 {@render appButton( 359 + () => setView('settings'), 360 'heroicons:cog-6-tooth', 361 'settings', 362 + $currentView === 'settings', 363 'heroicons:cog-6-tooth-solid' 364 )} 365 </div>