replies timeline only, appview-less bluesky client

always refetch the latest profile when loading a profile view, also relax cache ttl

ptr.pet beaa70c6 28e88e0e

verified
+23 -44
src/components/ProfileView.svelte
··· 1 <script lang="ts"> 2 - import { AtpClient, resolveDidDoc, resolveHandle } from '$lib/at/client.svelte'; 3 - import { 4 - isHandle, 5 - type ActorIdentifier, 6 - type AtprotoDid, 7 - type Did, 8 - type Handle 9 - } from '@atcute/lexicons/syntax'; 10 import TimelineView from './TimelineView.svelte'; 11 import ProfileInfo from './ProfileInfo.svelte'; 12 import type { State as PostComposerState } from './PostComposer.svelte'; ··· 14 import { accounts, generateColorForDid } from '$lib/accounts'; 15 import { img } from '$lib/cdn'; 16 import { isBlob } from '@atcute/lexicons/interfaces'; 17 - import type { AppBskyActorProfile } from '@atcute/bluesky'; 18 import { 19 handles, 20 profiles, ··· 34 35 let { client, actor, onBack, postComposerState = $bindable() }: Props = $props(); 36 37 - let profile = $state<AppBskyActorProfile.Main | null>(profiles.get(actor as Did) ?? null); 38 const displayName = $derived(profile?.displayName ?? ''); 39 let loading = $state(true); 40 let error = $state<string | null>(null); 41 - let did = $state<AtprotoDid | null>(null); 42 - let handle = $state<Handle | null>(handles.get(actor as Did) ?? null); 43 44 let userBlocked = $state(false); 45 let blockedByTarget = $state(false); ··· 47 const loadProfile = async (identifier: ActorIdentifier) => { 48 loading = true; 49 error = null; 50 - profile = null; 51 - handle = isHandle(identifier) ? identifier : null; 52 53 - const resDid = await resolveHandle(identifier); 54 - if (resDid.ok) did = resDid.value; 55 - else { 56 - error = resDid.error; 57 - loading = false; 58 return; 59 } 60 61 - if (!handle) handle = handles.get(did) ?? null; 62 - 63 - if (!handle) { 64 - const resHandle = await resolveDidDoc(did); 65 - if (resHandle.ok) { 66 - handle = resHandle.value.handle; 67 - handles.set(did, resHandle.value.handle); 68 - } 69 - } 70 - 71 // check block relationship 72 if (client.user?.did) { 73 let blockRel = getBlockRelationship(client.user.did, did); 74 blockRel = blockFlags.get(client.user.did)?.has(did) 75 ? blockRel 76 - : { 77 - userBlocked: await fetchBlocked(client, did, client.user.did), 78 - blockedByTarget: await fetchBlocked(client, client.user.did, did) 79 - }; 80 userBlocked = blockRel.userBlocked; 81 blockedByTarget = blockRel.blockedByTarget; 82 } ··· 87 return; 88 } 89 90 - const res = await client.getProfile(did); 91 - if (res.ok) { 92 - profile = res.value; 93 - profiles.set(did, res.value); 94 - } else error = res.error; 95 96 loading = false; 97 }; ··· 122 <Icon icon="heroicons:arrow-left-20-solid" width={24} /> 123 </button> 124 <h2 class="text-xl font-bold"> 125 - {displayName.length > 0 126 - ? displayName 127 - : loading 128 - ? 'loading...' 129 - : (handle ?? actor ?? 'profile')} 130 </h2> 131 <div class="grow"></div> 132 {#if did && client.user && client.user.did !== did} ··· 163 {#if did} 164 <div class="px-4 pb-4"> 165 <div class="relative z-10 {bannerUrl ? '-mt-12' : 'mt-4'} mb-4"> 166 - <ProfileInfo {client} {did} bind:profile /> 167 </div> 168 169 <TimelineView
··· 1 <script lang="ts"> 2 + import { AtpClient, resolveDidDoc } from '$lib/at/client.svelte'; 3 + import { isDid, isHandle, type ActorIdentifier, type Did } 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'; ··· 8 import { accounts, generateColorForDid } from '$lib/accounts'; 9 import { img } from '$lib/cdn'; 10 import { isBlob } from '@atcute/lexicons/interfaces'; 11 import { 12 handles, 13 profiles, ··· 27 28 let { client, actor, onBack, postComposerState = $bindable() }: Props = $props(); 29 30 + const profile = $derived(profiles.get(actor as Did)); 31 const displayName = $derived(profile?.displayName ?? ''); 32 + const handle = $derived(isHandle(actor) ? actor : handles.get(actor as Did)); 33 let loading = $state(true); 34 let error = $state<string | null>(null); 35 + let did = $state(isDid(actor) ? actor : null); 36 37 let userBlocked = $state(false); 38 let blockedByTarget = $state(false); ··· 40 const loadProfile = async (identifier: ActorIdentifier) => { 41 loading = true; 42 error = null; 43 44 + const docRes = await resolveDidDoc(identifier); 45 + if (docRes.ok) { 46 + did = docRes.value.did; 47 + handles.set(did, docRes.value.handle); 48 + } else { 49 + error = docRes.error; 50 return; 51 } 52 53 // check block relationship 54 if (client.user?.did) { 55 let blockRel = getBlockRelationship(client.user.did, did); 56 blockRel = blockFlags.get(client.user.did)?.has(did) 57 ? blockRel 58 + : await (async () => { 59 + const [userBlocked, blockedByTarget] = await Promise.all([ 60 + await fetchBlocked(client, did, client.user!.did), 61 + await fetchBlocked(client, client.user!.did, did) 62 + ]); 63 + return { userBlocked, blockedByTarget }; 64 + })(); 65 userBlocked = blockRel.userBlocked; 66 blockedByTarget = blockRel.blockedByTarget; 67 } ··· 72 return; 73 } 74 75 + const res = await client.getProfile(did, true); 76 + if (res.ok) profiles.set(did, res.value); 77 + else error = res.error; 78 79 loading = false; 80 }; ··· 105 <Icon icon="heroicons:arrow-left-20-solid" width={24} /> 106 </button> 107 <h2 class="text-xl font-bold"> 108 + {displayName.length > 0 ? displayName : loading ? 'loading...' : (handle ?? 'handle.invalid')} 109 </h2> 110 <div class="grow"></div> 111 {#if did && client.user && client.user.did !== did} ··· 142 {#if did} 143 <div class="px-4 pb-4"> 144 <div class="relative z-10 {bannerUrl ? '-mt-12' : 'mt-4'} mb-4"> 145 + <ProfileInfo {client} {did} {profile} /> 146 </div> 147 148 <TimelineView
+5 -5
src/components/TimelineView.svelte
··· 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 import NotLoggedIn from './NotLoggedIn.svelte'; 20 21 interface Props { 22 client?: AtpClient | null; 23 - targetDid?: AtprotoDid; 24 postComposerState: PostComposerState; 25 class?: string; 26 // whether to show replies that are not the user's own posts ··· 63 loaderState.status = 'LOADING'; 64 65 try { 66 - await fetchTimeline(client, did as AtprotoDid, 7, showReplies); 67 // only fetch interactions if logged in (because if not who is the interactor) 68 if (client.user) { 69 if (!fetchingInteractions) { ··· 83 } 84 85 loading = false; 86 - const cursor = postCursors.get(did as AtprotoDid); 87 if (cursor && cursor.end) loaderState.complete(); 88 }; 89 ··· 92 // if we saw all posts dont try to load more. 93 // this only really happens if the user has no posts at all 94 // but we do have to handle it to not cause an infinite loop 95 - const cursor = did ? postCursors.get(did as AtprotoDid) : undefined; 96 if (!cursor?.end) loadMore(); 97 } 98 });
··· 15 } from '$lib/state.svelte'; 16 import Icon from '@iconify/svelte'; 17 import { buildThreads, filterThreads, type ThreadPost } from '$lib/thread'; 18 + import type { Did } from '@atcute/lexicons/syntax'; 19 import NotLoggedIn from './NotLoggedIn.svelte'; 20 21 interface Props { 22 client?: AtpClient | null; 23 + targetDid?: Did; 24 postComposerState: PostComposerState; 25 class?: string; 26 // whether to show replies that are not the user's own posts ··· 63 loaderState.status = 'LOADING'; 64 65 try { 66 + await fetchTimeline(client, did, 7, showReplies); 67 // only fetch interactions if logged in (because if not who is the interactor) 68 if (client.user) { 69 if (!fetchingInteractions) { ··· 83 } 84 85 loading = false; 86 + const cursor = postCursors.get(did); 87 if (cursor && cursor.end) loaderState.complete(); 88 }; 89 ··· 92 // if we saw all posts dont try to load more. 93 // this only really happens if the user has no posts at all 94 // but we do have to handle it to not cause an infinite loop 95 + const cursor = did ? postCursors.get(did) : undefined; 96 if (!cursor?.end) loadMore(); 97 } 98 });
+29 -13
src/lib/at/client.svelte.ts
··· 30 import { MiniDocQuery, type MiniDoc } from './slingshot'; 31 import { BacklinksQuery, type Backlinks, type BacklinksSource } from './constellation'; 32 import type { Records, XRPCProcedures } from '@atcute/lexicons/ambient'; 33 - import { cache as rawCache } from '$lib/cache'; 34 import { AppBskyActorProfile } from '@atcute/bluesky'; 35 import { WebSocket } from '@soffinal/websocket'; 36 import type { Notification } from './stardust'; ··· 77 78 const cache = cacheWithRecords; 79 80 export const xhrPost = ( 81 url: string, 82 body: Blob | File, ··· 88 const xhr = new XMLHttpRequest(); 89 xhr.open('POST', url); 90 91 - if (onProgress && xhr.upload) { 92 xhr.upload.onprogress = (event: ProgressEvent) => { 93 if (event.lengthComputable) onProgress(event.loaded, event.total); 94 }; 95 - } 96 97 Object.keys(headers).forEach((key) => xhr.setRequestHeader(key, headers[key])); 98 ··· 145 TKey extends RecordKeySchema, 146 Schema extends RecordSchema<TObject, TKey>, 147 Output extends InferInput<Schema> 148 - >(schema: Schema, uri: ResourceUri): Promise<Result<RecordOutput<Output>, string>> { 149 const parsedUri = expect(parseResourceUri(uri)); 150 if (parsedUri.collection !== schema.object.shape.$type.expected) 151 return err( 152 `collections don't match: ${parsedUri.collection} != ${schema.object.shape.$type.expected}` 153 ); 154 - return await this.getRecord(schema, parsedUri.repo!, parsedUri.rkey!); 155 } 156 157 async getRecord< ··· 163 >( 164 schema: Schema, 165 repo: ActorIdentifier, 166 - rkey: RecordKey 167 ): Promise<Result<RecordOutput<Output>, string>> { 168 const collection = schema.object.shape.$type.expected; 169 170 try { 171 - const rawValue = await cache.fetchRecord( 172 - toResourceUri({ repo, collection, rkey, fragment: undefined }) 173 - ); 174 175 const parsed = safeParse(schema, rawValue.value); 176 if (!parsed.ok) return err(parsed.message); ··· 185 } 186 } 187 188 - async getProfile(repo?: ActorIdentifier): Promise<Result<AppBskyActorProfile.Main, string>> { 189 repo = repo ?? this.user?.did; 190 if (!repo) return err('not authenticated'); 191 - return map(await this.getRecord(AppBskyActorProfile.mainSchema, repo, 'self'), (d) => d.record); 192 } 193 194 async listRecords<Collection extends keyof Records>( ··· 218 }); 219 if (!res.ok) return err(`${res.data.error}: ${res.data.message ?? 'no details'}`); 220 221 - for (const record of res.data.records) 222 - await cache.set('fetchRecord', `fetchRecord~${record.uri}`, record, 60 * 60 * 24); 223 224 return ok(res.data); 225 }
··· 30 import { MiniDocQuery, type MiniDoc } from './slingshot'; 31 import { BacklinksQuery, type Backlinks, type BacklinksSource } from './constellation'; 32 import type { Records, XRPCProcedures } from '@atcute/lexicons/ambient'; 33 + import { cache as rawCache, ttl } from '$lib/cache'; 34 import { AppBskyActorProfile } from '@atcute/bluesky'; 35 import { WebSocket } from '@soffinal/websocket'; 36 import type { Notification } from './stardust'; ··· 77 78 const cache = cacheWithRecords; 79 80 + export const invalidateRecordCache = async (uri: ResourceUri) => { 81 + console.log(`invalidating cached for ${uri}`); 82 + await cache.invalidate('fetchRecord', `fetchRecord~${uri}`); 83 + }; 84 + export const setRecordCache = (uri: ResourceUri, record: unknown) => 85 + cache.set('fetchRecord', `fetchRecord~${uri}`, record, ttl); 86 + 87 export const xhrPost = ( 88 url: string, 89 body: Blob | File, ··· 95 const xhr = new XMLHttpRequest(); 96 xhr.open('POST', url); 97 98 + if (onProgress && xhr.upload) 99 xhr.upload.onprogress = (event: ProgressEvent) => { 100 if (event.lengthComputable) onProgress(event.loaded, event.total); 101 }; 102 103 Object.keys(headers).forEach((key) => xhr.setRequestHeader(key, headers[key])); 104 ··· 151 TKey extends RecordKeySchema, 152 Schema extends RecordSchema<TObject, TKey>, 153 Output extends InferInput<Schema> 154 + >( 155 + schema: Schema, 156 + uri: ResourceUri, 157 + noCache?: boolean 158 + ): Promise<Result<RecordOutput<Output>, string>> { 159 const parsedUri = expect(parseResourceUri(uri)); 160 if (parsedUri.collection !== schema.object.shape.$type.expected) 161 return err( 162 `collections don't match: ${parsedUri.collection} != ${schema.object.shape.$type.expected}` 163 ); 164 + return await this.getRecord(schema, parsedUri.repo!, parsedUri.rkey!, noCache); 165 } 166 167 async getRecord< ··· 173 >( 174 schema: Schema, 175 repo: ActorIdentifier, 176 + rkey: RecordKey, 177 + noCache?: boolean 178 ): Promise<Result<RecordOutput<Output>, string>> { 179 const collection = schema.object.shape.$type.expected; 180 181 try { 182 + const uri = toResourceUri({ repo, collection, rkey, fragment: undefined }); 183 + if (noCache) await invalidateRecordCache(uri); 184 + const rawValue = await cache.fetchRecord(uri); 185 186 const parsed = safeParse(schema, rawValue.value); 187 if (!parsed.ok) return err(parsed.message); ··· 196 } 197 } 198 199 + async getProfile( 200 + repo?: ActorIdentifier, 201 + noCache?: boolean 202 + ): Promise<Result<AppBskyActorProfile.Main, string>> { 203 repo = repo ?? this.user?.did; 204 if (!repo) return err('not authenticated'); 205 + return map( 206 + await this.getRecord(AppBskyActorProfile.mainSchema, repo, 'self', noCache), 207 + (d) => d.record 208 + ); 209 } 210 211 async listRecords<Collection extends keyof Records>( ··· 235 }); 236 if (!res.ok) return err(`${res.data.error}: ${res.data.message ?? 'no details'}`); 237 238 + for (const record of res.data.records) setRecordCache(record.uri, record); 239 240 return ok(res.data); 241 }
+3 -1
src/lib/cache.ts
··· 210 } 211 } 212 213 export const cache = createCache({ 214 storage: { 215 type: 'custom', ··· 217 storage: new IDBStorage() 218 } 219 }, 220 - ttl: 60 * 60 * 24, // 24 hours 221 onError: (err) => console.error(err) 222 });
··· 210 } 211 } 212 213 + export const ttl = 60 * 60 * 3; // 3 hours 214 + 215 export const cache = createCache({ 216 storage: { 217 type: 'custom', ··· 219 storage: new IDBStorage() 220 } 221 }, 222 + ttl, 223 onError: (err) => console.error(err) 224 });
+3 -1
src/lib/state.svelte.ts
··· 1 import { writable } from 'svelte/store'; 2 import { 3 AtpClient, 4 type NotificationsStream, 5 type NotificationsStreamEvent 6 } from './at/client.svelte'; ··· 476 477 export const fetchTimeline = async ( 478 client: AtpClient, 479 - subject: AtprotoDid, 480 limit: number = 6, 481 withBacklinks: boolean = true 482 ) => { ··· 545 cid: commit.cid 546 } 547 ]; 548 const client = clients.get(did) ?? viewClient; 549 const hydrated = await hydratePosts(client, did, posts, hydrateCacheFn); 550 if (!hydrated.ok) {
··· 1 import { writable } from 'svelte/store'; 2 import { 3 AtpClient, 4 + setRecordCache, 5 type NotificationsStream, 6 type NotificationsStreamEvent 7 } from './at/client.svelte'; ··· 477 478 export const fetchTimeline = async ( 479 client: AtpClient, 480 + subject: Did, 481 limit: number = 6, 482 withBacklinks: boolean = true 483 ) => { ··· 546 cid: commit.cid 547 } 548 ]; 549 + await setRecordCache(uri, commit.record); 550 const client = clients.get(did) ?? viewClient; 551 const hydrated = await hydratePosts(client, did, posts, hydrateCacheFn); 552 if (!hydrated.ok) {