replies timeline only, appview-less bluesky client

add profile viewing, also from following list

ptr.pet 76571d5b 4434b1e7

verified
+6 -47
src/components/BskyPost.svelte
··· 42 import RichText from './RichText.svelte'; 43 import { getRelativeTime } from '$lib/date'; 44 import { likeSource, repostSource } from '$lib'; 45 46 interface Props { 47 client: AtpClient; ··· 72 const selectedDid = $derived(client.user?.did ?? null); 73 const actionClient = $derived(clients.get(did as AtprotoDid)); 74 75 - const aturi: CanonicalResourceUri = `at://${did}/app.bsky.feed.post/${rkey}`; 76 - const color = generateColorForDid(did); 77 78 - let handle: ActorIdentifier = $state(did); 79 const didDoc = resolveDidDoc(did).then((res) => { 80 if (res.ok) handle = res.value.handle; 81 return res; ··· 91 // console.log(profile.description); 92 }); 93 94 - const postId = `timeline-post-${aturi}-${quoteDepth}`; 95 const isPulsing = derived(pulsingPostId, (pulsingPostId) => pulsingPostId === postId); 96 97 const scrollToAndPulse = (targetUri: ResourceUri) => { ··· 169 }; 170 171 let profileOpen = $state(false); 172 - let profilePopoutShowDid = $state(false); 173 </script> 174 175 {#snippet embedBadge(embed: AppBskyEmbeds)} ··· 207 208 <!-- eslint-disable svelte/no-navigation-without-resolve --> 209 {#snippet profilePopout()} 210 - {@const profileDesc = profile?.description?.trim() ?? ''} 211 <Dropdown 212 class="post-dropdown max-w-xl gap-2! p-2.5! backdrop-blur-3xl! backdrop-brightness-25!" 213 style="background: {color}36; border-color: {color}99;" 214 bind:isOpen={profileOpen} 215 trigger={profileInline} 216 > 217 - <div class="flex items-center gap-2"> 218 - <ProfilePicture {client} {did} size={20} /> 219 - 220 - <div class="flex flex-col items-start overflow-hidden overflow-ellipsis"> 221 - <span class="mb-1.5 min-w-0 overflow-hidden text-2xl text-nowrap overflow-ellipsis"> 222 - {profile?.displayName ?? handle} 223 - {#if profile?.pronouns} 224 - <span class="shrink-0 text-sm text-nowrap opacity-60">({profile.pronouns})</span> 225 - {/if} 226 - </span> 227 - <button 228 - oncontextmenu={(e) => { 229 - const node = e.target as Node; 230 - const selection = window.getSelection() ?? new Selection(); 231 - const range = document.createRange(); 232 - range.selectNodeContents(node); 233 - selection.removeAllRanges(); 234 - selection.addRange(range); 235 - e.stopPropagation(); 236 - }} 237 - onclick={() => (profilePopoutShowDid = !profilePopoutShowDid)} 238 - class="mb-0.5 text-nowrap opacity-85 select-text hover:underline" 239 - > 240 - {profilePopoutShowDid ? did : `@${handle}`} 241 - </button> 242 - {#if profile?.website} 243 - <a 244 - target="_blank" 245 - rel="noopener noreferrer" 246 - href={profile.website} 247 - class="text-sm text-nowrap opacity-60">{profile.website}</a 248 - > 249 - {/if} 250 - </div> 251 - </div> 252 - 253 - {#if profileDesc.length > 0} 254 - <p class="rounded-sm bg-black/25 p-1.5 text-wrap wrap-break-word"> 255 - <RichText text={profileDesc} /> 256 - </p> 257 - {/if} 258 </Dropdown> 259 {/snippet} 260
··· 42 import RichText from './RichText.svelte'; 43 import { getRelativeTime } from '$lib/date'; 44 import { likeSource, repostSource } from '$lib'; 45 + import ProfileInfo from './ProfileInfo.svelte'; 46 47 interface Props { 48 client: AtpClient; ··· 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)); 78 79 + let handle: ActorIdentifier = $state('handle.invalid'); 80 const didDoc = resolveDidDoc(did).then((res) => { 81 if (res.ok) handle = res.value.handle; 82 return res; ··· 92 // console.log(profile.description); 93 }); 94 95 + const postId = $derived(`timeline-post-${aturi}-${quoteDepth}`); 96 const isPulsing = derived(pulsingPostId, (pulsingPostId) => pulsingPostId === postId); 97 98 const scrollToAndPulse = (targetUri: ResourceUri) => { ··· 170 }; 171 172 let profileOpen = $state(false); 173 </script> 174 175 {#snippet embedBadge(embed: AppBskyEmbeds)} ··· 207 208 <!-- eslint-disable svelte/no-navigation-without-resolve --> 209 {#snippet profilePopout()} 210 <Dropdown 211 class="post-dropdown max-w-xl gap-2! p-2.5! backdrop-blur-3xl! backdrop-brightness-25!" 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> 218 {/snippet} 219
+6 -2
src/components/FollowingItem.svelte
··· 21 client: AtpClient; 22 sort: Sort; 23 currentTime: Date; 24 } 25 26 - let { style, did, stats, client, sort, currentTime }: Props = $props(); 27 28 // svelte-ignore state_referenced_locally 29 const cached = profileCache.get(did); ··· 96 </script> 97 98 <div {style} class="box-border w-full pb-2"> 99 <div 100 - class="group flex items-center gap-2 rounded-sm bg-(--nucleus-accent)/7 p-3 transition-colors hover:bg-(--post-color)/20" 101 style={`--post-color: ${color};`} 102 > 103 <ProfilePicture {client} {did} size={10} />
··· 21 client: AtpClient; 22 sort: Sort; 23 currentTime: Date; 24 + onClick?: (did: AtprotoDid) => void; 25 } 26 27 + let { style, did, stats, client, sort, currentTime, onClick }: Props = $props(); 28 29 // svelte-ignore state_referenced_locally 30 const cached = profileCache.get(did); ··· 97 </script> 98 99 <div {style} class="box-border w-full pb-2"> 100 + <!-- svelte-ignore a11y_click_events_have_key_events --> 101 + <!-- svelte-ignore a11y_no_static_element_interactions --> 102 <div 103 + onclick={() => onClick?.(did as AtprotoDid)} 104 + class="group flex cursor-pointer items-center gap-2 rounded-sm bg-(--nucleus-accent)/7 p-3 transition-colors hover:bg-(--post-color)/20" 105 style={`--post-color: ${color};`} 106 > 107 <ProfilePicture {client} {did} size={10} />
+4 -1
src/components/FollowingView.svelte
··· 1 <script lang="ts"> 2 import { follows, allPosts, allBacklinks, currentTime, replyIndex } from '$lib/state.svelte'; 3 import type { Did } from '@atcute/lexicons'; 4 import { type AtpClient } from '$lib/at/client'; 5 import VirtualList from '@tutorlatin/svelte-tiny-virtual-list'; 6 import { ··· 14 interface Props { 15 selectedDid: Did; 16 selectedClient: AtpClient; 17 } 18 19 - const { selectedDid, selectedClient }: Props = $props(); 20 21 let followingSort: Sort = $state('active' as Sort); 22 const followsMap = $derived(follows.get(selectedDid)); ··· 155 client={selectedClient} 156 sort={followingSort} 157 {currentTime} 158 /> 159 {/snippet} 160 </VirtualList>
··· 1 <script lang="ts"> 2 import { follows, allPosts, allBacklinks, currentTime, replyIndex } from '$lib/state.svelte'; 3 import type { Did } from '@atcute/lexicons'; 4 + import type { AtprotoDid } from '@atcute/lexicons/syntax'; 5 import { type AtpClient } from '$lib/at/client'; 6 import VirtualList from '@tutorlatin/svelte-tiny-virtual-list'; 7 import { ··· 15 interface Props { 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)); ··· 157 client={selectedClient} 158 sort={followingSort} 159 {currentTime} 160 + onClick={onProfileClick} 161 /> 162 {/snippet} 163 </VirtualList>
+88
src/components/ProfileInfo.svelte
···
··· 1 + <script lang="ts"> 2 + import { AtpClient, resolveDidDoc } from '$lib/at/client'; 3 + import type { Did } from '@atcute/lexicons/syntax'; 4 + import type { AppBskyActorProfile } from '@atcute/bluesky'; 5 + import ProfilePicture from './ProfilePicture.svelte'; 6 + import RichText from './RichText.svelte'; 7 + import { onMount } from 'svelte'; 8 + 9 + interface Props { 10 + client: AtpClient; 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([ 21 + (async () => { 22 + if (!profile) { 23 + const res = await client.getProfile(did); 24 + if (res.ok) profile = res.value; 25 + } 26 + })(), 27 + (async () => { 28 + if (!handle) { 29 + const res = await resolveDidDoc(did); 30 + if (res.ok) handle = res.value.handle; 31 + } 32 + })() 33 + ]); 34 + }); 35 + 36 + let displayHandle = $derived(handle ?? 'handle.invalid'); 37 + let profileDesc = $derived(profile?.description?.trim() ?? ''); 38 + let showDid = $state(false); 39 + </script> 40 + 41 + <div class="flex flex-col gap-2"> 42 + <div class="flex items-center gap-2"> 43 + <ProfilePicture {client} {did} size={20} /> 44 + 45 + <div class="flex min-w-0 flex-col items-start overflow-hidden overflow-ellipsis"> 46 + <span class="mb-1.5 min-w-0 overflow-hidden text-2xl text-nowrap overflow-ellipsis"> 47 + {profile?.displayName ?? displayHandle} 48 + {#if profile?.pronouns} 49 + <span class="shrink-0 text-sm text-nowrap opacity-60">({profile.pronouns})</span> 50 + {/if} 51 + </span> 52 + <button 53 + oncontextmenu={(e) => { 54 + e.stopPropagation(); 55 + const node = e.target as Node; 56 + const selection = window.getSelection() ?? new Selection(); 57 + const range = document.createRange(); 58 + range.selectNodeContents(node); 59 + selection.removeAllRanges(); 60 + selection.addRange(range); 61 + }} 62 + onmousedown={(e) => { 63 + // disable double clicks to disable "double click to select text" 64 + // since it doesnt work with us toggling did vs handle 65 + if (e.detail > 1) e.preventDefault(); 66 + }} 67 + onclick={() => (showDid = !showDid)} 68 + class="mb-0.5 text-nowrap opacity-85 select-text hover:underline" 69 + > 70 + {showDid ? did : `@${displayHandle}`} 71 + </button> 72 + {#if profile?.website} 73 + <a 74 + target="_blank" 75 + rel="noopener noreferrer" 76 + href={profile.website} 77 + class="text-sm text-nowrap opacity-60 hover:underline">{profile.website}</a 78 + > 79 + {/if} 80 + </div> 81 + </div> 82 + 83 + {#if profileDesc.length > 0} 84 + <div class="rounded-sm bg-black/25 p-1.5 text-wrap wrap-break-word"> 85 + <RichText text={profileDesc} /> 86 + </div> 87 + {/if} 88 + </div>
+83
src/components/ProfileView.svelte
···
··· 1 + <script lang="ts"> 2 + import { AtpClient } from '$lib/at/client'; 3 + import type { AtprotoDid } from '@atcute/lexicons/syntax'; 4 + import TimelineView from './TimelineView.svelte'; 5 + import ProfileInfo from './ProfileInfo.svelte'; 6 + import type { State as PostComposerState } from './PostComposer.svelte'; 7 + import Icon from '@iconify/svelte'; 8 + import { generateColorForDid } from '$lib/accounts'; 9 + import { img } from '$lib/cdn'; 10 + import { isBlob } from '@atcute/lexicons/interfaces'; 11 + import type { AppBskyActorProfile } from '@atcute/bluesky'; 12 + import { onMount } from 'svelte'; 13 + 14 + interface Props { 15 + client: AtpClient; 16 + did: AtprotoDid; 17 + onBack: () => void; 18 + postComposerState?: PostComposerState; 19 + } 20 + 21 + let { client, did, onBack, postComposerState = $bindable({ type: 'null' }) }: Props = $props(); 22 + 23 + let profile = $state<AppBskyActorProfile.Main | null>(null); 24 + let loading = $state(true); 25 + let error = $state<string | null>(null); 26 + 27 + onMount(async () => { 28 + const res = await client.getProfile(did); 29 + if (res.ok) profile = res.value; 30 + else error = res.error; 31 + loading = false; 32 + }); 33 + 34 + const color = $derived(generateColorForDid(did)); 35 + const bannerUrl = $derived( 36 + profile && isBlob(profile.banner) ? img('feed_fullsize', did, profile.banner.ref.$link) : null 37 + ); 38 + </script> 39 + 40 + <div class="flex min-h-dvh flex-col"> 41 + <!-- Header --> 42 + <div 43 + class="sticky top-0 z-20 flex items-center gap-4 border-b-2 bg-(--nucleus-bg)/80 p-4 backdrop-blur-md" 44 + style="border-color: {color}40;" 45 + > 46 + <button 47 + onclick={onBack} 48 + class="rounded-full p-1 text-(--nucleus-fg) transition-all hover:bg-(--nucleus-fg)/10" 49 + > 50 + <Icon icon="heroicons:arrow-left-20-solid" width={24} /> 51 + </button> 52 + <h2 class="text-xl font-bold"> 53 + {profile?.displayName ?? (loading ? 'loading...' : 'profile')} 54 + </h2> 55 + </div> 56 + 57 + {#if error} 58 + <div class="p-8 text-center text-red-500"> 59 + <p>failed to load profile: {error}</p> 60 + </div> 61 + {:else} 62 + <!-- Banner --> 63 + <div class="relative h-32 w-full overflow-hidden bg-(--nucleus-fg)/5 md:h-48"> 64 + {#if bannerUrl} 65 + <img src={bannerUrl} alt="banner" class="h-full w-full object-cover" /> 66 + {/if} 67 + <div 68 + class="absolute inset-0 bg-linear-to-b from-transparent to-(--nucleus-bg)" 69 + style="opacity: 0.8;" 70 + ></div> 71 + </div> 72 + 73 + <div class="px-4 pb-4"> 74 + <div class="relative z-10 -mt-12 mb-4"> 75 + <ProfileInfo {client} {did} bind:profile /> 76 + </div> 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>
+28 -7
src/components/TimelineView.svelte
··· 6 import { type ResourceUri } from '@atcute/lexicons'; 7 import { SvelteSet } from 'svelte/reactivity'; 8 import { InfiniteLoader, LoaderState } from 'svelte-infinite'; 9 - import { postCursors, fetchTimeline, allPosts, timelines } from '$lib/state.svelte'; 10 import Icon from '@iconify/svelte'; 11 import { buildThreads, filterThreads, type ThreadPost } from '$lib/thread'; 12 import type { AtprotoDid } from '@atcute/lexicons/syntax'; 13 14 interface Props { 15 client?: AtpClient | null; 16 postComposerState: PostComposerState; 17 class?: string; 18 } 19 20 - let { client = null, postComposerState = $bindable(), class: className = '' }: Props = $props(); 21 22 let reverseChronological = $state(true); 23 let viewOwnPosts = $state(true); 24 const expandedThreads = new SvelteSet<ResourceUri>(); 25 26 - const did = $derived(client?.user?.did); 27 28 const threads = $derived( 29 filterThreads( ··· 36 const loaderState = new LoaderState(); 37 let scrollContainer = $state<HTMLDivElement>(); 38 let loading = $state(false); 39 let loadError = $state(''); 40 41 const loadMore = async () => { 42 - if (loading || $accounts.length === 0 || !did) return; 43 44 loading = true; 45 loaderState.status = 'LOADING'; 46 47 try { 48 await fetchTimeline(did as AtprotoDid); 49 loaderState.loaded(); 50 } catch (error) { 51 loadError = `${error}`; ··· 55 } 56 57 loading = false; 58 - if (postCursors.values().every((cursor) => cursor.end)) loaderState.complete(); 59 }; 60 61 $effect(() => { 62 - if (threads.length === 0 && !loading) loadMore(); 63 }); 64 </script> 65 ··· 128 class="min-h-full p-2 [scrollbar-color:var(--nucleus-accent)_transparent] {className}" 129 bind:this={scrollContainer} 130 > 131 - {#if $accounts.length > 0} 132 <InfiniteLoader 133 {loaderState} 134 triggerLoad={loadMore}
··· 6 import { type ResourceUri } from '@atcute/lexicons'; 7 import { SvelteSet } from 'svelte/reactivity'; 8 import { InfiniteLoader, LoaderState } from 'svelte-infinite'; 9 + import { 10 + postCursors, 11 + fetchTimeline, 12 + allPosts, 13 + timelines, 14 + fetchInteractionsUntil 15 + } from '$lib/state.svelte'; 16 import Icon from '@iconify/svelte'; 17 import { buildThreads, filterThreads, type ThreadPost } from '$lib/thread'; 18 import type { AtprotoDid } from '@atcute/lexicons/syntax'; 19 20 interface Props { 21 client?: AtpClient | null; 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(); 33 34 let reverseChronological = $state(true); 35 let viewOwnPosts = $state(true); 36 const expandedThreads = new SvelteSet<ResourceUri>(); 37 38 + const did = $derived(targetDid ?? client?.user?.did); 39 40 const threads = $derived( 41 filterThreads( ··· 48 const loaderState = new LoaderState(); 49 let scrollContainer = $state<HTMLDivElement>(); 50 let loading = $state(false); 51 + let fetchMoreInteractions: boolean | undefined = $state(false); 52 let loadError = $state(''); 53 54 const loadMore = async () => { 55 + if (loading || !client || !did) return; 56 57 loading = true; 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(); 65 } catch (error) { 66 loadError = `${error}`; ··· 70 } 71 72 loading = false; 73 + const cursor = postCursors.get(did as AtprotoDid); 74 + if (cursor && cursor.end) loaderState.complete(); 75 }; 76 77 $effect(() => { 78 + if (threads.length === 0 && !loading && did) loadMore(); 79 + if (client && did && fetchMoreInteractions) { 80 + // set to false so it doesnt attempt to fetch again while its already fetching 81 + fetchMoreInteractions = false; 82 + fetchInteractionsUntil(client, did).then(() => (fetchMoreInteractions = undefined)); 83 + } 84 }); 85 </script> 86 ··· 149 class="min-h-full p-2 [scrollbar-color:var(--nucleus-accent)_transparent] {className}" 150 bind:this={scrollContainer} 151 > 152 + {#if targetDid || $accounts.length > 0} 153 <InfiniteLoader 154 {loaderState} 155 triggerLoad={loadMore}
+39 -15
src/lib/at/client.ts
··· 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>( ··· 204 data.cursor 205 ); 206 end = true; 207 - } else if (cursorTimestamp < timestamp) { 208 end = true; 209 } else { 210 console.info(
··· 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>( ··· 228 data.cursor 229 ); 230 end = true; 231 + } else if (cursorTimestamp <= timestamp) { 232 end = true; 233 } else { 234 console.info(
+1
src/lib/cache.ts
··· 203 } 204 205 // noops 206 async getTTL(key: string): Promise<void> { 207 return; 208 }
··· 203 } 204 205 // noops 206 + // eslint-disable-next-line @typescript-eslint/no-unused-vars 207 async getTTL(key: string): Promise<void> { 208 return; 209 }
+19 -15
src/lib/state.svelte.ts
··· 114 const [_collection, source] = backlinkSource.split(':'); 115 const collection = _collection as keyof Records; 116 const cursor = cursorMap.get(backlinkSource); 117 console.log(`${did}: fetchLinksUntil`, backlinkSource, cursor, timestamp); 118 const result = await client.listRecordsUntil(collection, cursor, timestamp); 119 ··· 193 export const pulsingPostId = writable<string | null>(null); 194 195 export const viewClient = new AtpClient(); 196 - export const clients = new SvelteMap<AtprotoDid, AtpClient>(); 197 - export const getClient = async (did: AtprotoDid): Promise<AtpClient> => { 198 if (!clients.has(did)) clients.set(did, await newPublicClient(did)); 199 return clients.get(did)!; 200 }; ··· 232 const cursorTimestamp = timestampFromCursor(res.value.cursor) ?? -1; 233 const threeDaysAgo = (Date.now() - 3 * 24 * 60 * 60 * 1000) * 1000; 234 const timestamp = Math.min(cursorTimestamp, threeDaysAgo); 235 - console.log(`${did}: fetchFollowPosts`, res.value.cursor, timestamp); 236 await Promise.all([repostSource].map((s) => fetchLinksUntil(client, s, timestamp))); 237 }; 238 ··· 249 { cid: post.cid, uri: post.uri, record: post.value as AppBskyFeedPost.Main } as PostWithUri 250 ]); 251 addPosts(postsWithUri); 252 - postCursors.set(did, { value: newPosts.cursor, end: newPosts.cursor === undefined }); 253 }; 254 255 export const addPosts = (newPosts: Iterable<[ResourceUri, PostWithUri]>) => { ··· 296 }; 297 298 export const fetchTimeline = async (did: AtprotoDid, limit: number = 6) => { 299 - const client = await getClient(did); 300 301 const cursor = postCursors.get(did); 302 if (cursor && cursor.end) return; 303 304 - const accPosts = await fetchPostsWithBacklinks(client, cursor?.value, limit); 305 if (!accPosts.ok) throw `cant fetch posts ${did}: ${accPosts.error}`; 306 307 // if the cursor is undefined, we've reached the end of the timeline 308 - if (!accPosts.value.cursor) { 309 - postCursors.set(did, { ...cursor, end: true }); 310 - return; 311 - } 312 - 313 - postCursors.set(did, { value: accPosts.value.cursor, end: false }); 314 - const hydrated = await hydratePosts(client, did, accPosts.value.posts); 315 if (!hydrated.ok) throw `cant hydrate posts ${did}: ${hydrated.error}`; 316 317 addPosts(hydrated.value); 318 addTimeline(did, hydrated.value.keys()); 319 320 - const timestamp = timestampFromCursor(accPosts.value.cursor); 321 - console.log(`${did}: fetchTimeline`, accPosts.value.cursor, timestamp); 322 await Promise.all([likeSource, repostSource].map((s) => fetchLinksUntil(client, s, timestamp))); 323 }; 324
··· 114 const [_collection, source] = backlinkSource.split(':'); 115 const collection = _collection as keyof Records; 116 const cursor = cursorMap.get(backlinkSource); 117 + 118 + // if already fetched we dont need to fetch again 119 + const cursorTimestamp = timestampFromCursor(cursor); 120 + if (cursorTimestamp && cursorTimestamp <= timestamp) return; 121 + 122 console.log(`${did}: fetchLinksUntil`, backlinkSource, cursor, timestamp); 123 const result = await client.listRecordsUntil(collection, cursor, timestamp); 124 ··· 198 export const pulsingPostId = writable<string | null>(null); 199 200 export const viewClient = new AtpClient(); 201 + export const clients = new SvelteMap<Did, AtpClient>(); 202 + export const getClient = async (did: Did): Promise<AtpClient> => { 203 if (!clients.has(did)) clients.set(did, await newPublicClient(did)); 204 return clients.get(did)!; 205 }; ··· 237 const cursorTimestamp = timestampFromCursor(res.value.cursor) ?? -1; 238 const threeDaysAgo = (Date.now() - 3 * 24 * 60 * 60 * 1000) * 1000; 239 const timestamp = Math.min(cursorTimestamp, threeDaysAgo); 240 + console.log(`${did}: fetchForInteractions`, res.value.cursor, timestamp); 241 await Promise.all([repostSource].map((s) => fetchLinksUntil(client, s, timestamp))); 242 }; 243 ··· 254 { cid: post.cid, uri: post.uri, record: post.value as AppBskyFeedPost.Main } as PostWithUri 255 ]); 256 addPosts(postsWithUri); 257 }; 258 259 export const addPosts = (newPosts: Iterable<[ResourceUri, PostWithUri]>) => { ··· 300 }; 301 302 export const fetchTimeline = async (did: AtprotoDid, limit: number = 6) => { 303 + const targetClient = await getClient(did); 304 305 const cursor = postCursors.get(did); 306 if (cursor && cursor.end) return; 307 308 + const accPosts = await fetchPostsWithBacklinks(targetClient, cursor?.value, limit); 309 if (!accPosts.ok) throw `cant fetch posts ${did}: ${accPosts.error}`; 310 311 // if the cursor is undefined, we've reached the end of the timeline 312 + postCursors.set(did, { value: accPosts.value.cursor, end: !accPosts.value.cursor }); 313 + const hydrated = await hydratePosts(targetClient, did, accPosts.value.posts); 314 if (!hydrated.ok) throw `cant hydrate posts ${did}: ${hydrated.error}`; 315 316 addPosts(hydrated.value); 317 addTimeline(did, hydrated.value.keys()); 318 319 + console.log(`${did}: fetchTimeline`, accPosts.value.cursor); 320 + }; 321 + 322 + export const fetchInteractionsUntil = async (client: AtpClient, did: Did) => { 323 + const cursor = postCursors.get(did); 324 + if (!cursor) return; 325 + const timestamp = timestampFromCursor(cursor.value); 326 await Promise.all([likeSource, repostSource].map((s) => fetchLinksUntil(client, s, timestamp))); 327 }; 328
+36 -5
src/routes/+page.svelte
··· 5 import NotificationsView from '$components/NotificationsView.svelte'; 6 import FollowingView from '$components/FollowingView.svelte'; 7 import TimelineView from '$components/TimelineView.svelte'; 8 import { AtpClient, streamNotifications } from '$lib/at/client'; 9 import { accounts, type Account } from '$lib/accounts'; 10 import { onMount, tick } from 'svelte'; ··· 68 handleAccountSelected(newAccounts[0]?.did); 69 }; 70 71 - type View = 'timeline' | 'notifications' | 'following' | 'settings'; 72 let currentView = $state<View>('timeline'); 73 let animClass = $state('animate-fade-in-scale'); 74 let scrollPositions = new SvelteMap<View, number>(); 75 76 const viewOrder: Record<View, number> = { 77 timeline: 0, 78 following: 1, 79 notifications: 2, 80 - settings: 3 81 }; 82 83 const switchView = async (newView: View) => { ··· 85 scrollPositions.set(currentView, window.scrollY); 86 87 const direction = viewOrder[newView] > viewOrder[currentView] ? 'right' : 'left'; 88 - animClass = direction === 'right' ? 'animate-slide-in-right' : 'animate-slide-in-left'; 89 currentView = newView; 90 91 await tick(); 92 93 window.scrollTo({ top: scrollPositions.get(newView) || 0, behavior: 'instant' }); 94 }; 95 96 let postComposerState = $state<PostComposerState>({ type: 'null' }); ··· 216 {/if} 217 {#if currentView === 'following'} 218 <div class={animClass}> 219 - <FollowingView selectedClient={selectedClient!} selectedDid={selectedDid!} /> 220 </div> 221 {/if} 222 </div> ··· 247 248 <div 249 class=" 250 - {currentView === 'timeline' || currentView === 'following' ? '' : 'hidden'} 251 z-20 w-full max-w-2xl p-2.5 px-4 pb-1 transition-all 252 " 253 >
··· 5 import NotificationsView from '$components/NotificationsView.svelte'; 6 import FollowingView from '$components/FollowingView.svelte'; 7 import TimelineView from '$components/TimelineView.svelte'; 8 + import ProfileView from '$components/ProfileView.svelte'; 9 import { AtpClient, streamNotifications } from '$lib/at/client'; 10 import { accounts, type Account } from '$lib/accounts'; 11 import { onMount, tick } from 'svelte'; ··· 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, 81 following: 1, 82 notifications: 2, 83 + settings: 3, 84 + profile: 4 85 }; 86 87 const switchView = async (newView: View) => { ··· 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' }); ··· 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> 250 {/if} 251 </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 283 " 284 >