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