always refetch the latest profile when loading a profile view, also relax cache ttl
1234567892345678910171112131411 unmodified lines262728293730313233344142353637383940414250514353545556574445464748495051526162636465666768697053545556577677787958596061626364656667682 unmodified lines7172737490919293947576777879808122 unmodified lines10410510610712512612712812910810911011111228 unmodified lines14114214314416614514614714814910 unmodified lines<script lang="ts"> import { AtpClient, resolveDidDoc, resolveHandle } from '$lib/at/client.svelte'; import { isHandle, type ActorIdentifier, type AtprotoDid, type Did, type Handle } from '@atcute/lexicons/syntax'; import { AtpClient, resolveDidDoc } from '$lib/at/client.svelte'; import { isDid, isHandle, type ActorIdentifier, type Did } from '@atcute/lexicons/syntax'; import TimelineView from './TimelineView.svelte'; import ProfileInfo from './ProfileInfo.svelte'; import type { State as PostComposerState } from './PostComposer.svelte'; import Icon from '@iconify/svelte'; import { accounts, generateColorForDid } from '$lib/accounts'; import { img } from '$lib/cdn'; import { isBlob } from '@atcute/lexicons/interfaces'; import type { AppBskyActorProfile } from '@atcute/bluesky'; import { handles, profiles, getBlockRelationship,11 unmodified lines }
let { client, actor, onBack, postComposerState = $bindable() }: Props = $props();
let profile = $state<AppBskyActorProfile.Main | null>(profiles.get(actor as Did) ?? null); const profile = $derived(profiles.get(actor as Did)); const displayName = $derived(profile?.displayName ?? ''); const handle = $derived(isHandle(actor) ? actor : handles.get(actor as Did)); let loading = $state(true); let error = $state<string | null>(null); let did = $state<AtprotoDid | null>(null); let handle = $state<Handle | null>(handles.get(actor as Did) ?? null); let did = $state(isDid(actor) ? actor : null);
let userBlocked = $state(false); let blockedByTarget = $state(false);
const loadProfile = async (identifier: ActorIdentifier) => { loading = true; error = null; profile = null; handle = isHandle(identifier) ? identifier : null;
const resDid = await resolveHandle(identifier); if (resDid.ok) did = resDid.value; else { error = resDid.error; loading = false; const docRes = await resolveDidDoc(identifier); if (docRes.ok) { did = docRes.value.did; handles.set(did, docRes.value.handle); } else { error = docRes.error; return; }
if (!handle) handle = handles.get(did) ?? null;
if (!handle) { const resHandle = await resolveDidDoc(did); if (resHandle.ok) { handle = resHandle.value.handle; handles.set(did, resHandle.value.handle); } }
// check block relationship if (client.user?.did) { let blockRel = getBlockRelationship(client.user.did, did); blockRel = blockFlags.get(client.user.did)?.has(did) ? blockRel : { userBlocked: await fetchBlocked(client, did, client.user.did), blockedByTarget: await fetchBlocked(client, client.user.did, did) }; : await (async () => { const [userBlocked, blockedByTarget] = await Promise.all([ await fetchBlocked(client, did, client.user!.did), await fetchBlocked(client, client.user!.did, did) ]); return { userBlocked, blockedByTarget }; })(); userBlocked = blockRel.userBlocked; blockedByTarget = blockRel.blockedByTarget; }
2 unmodified lines loading = false; return; }
const res = await client.getProfile(did); if (res.ok) { profile = res.value; profiles.set(did, res.value); } else error = res.error; const res = await client.getProfile(did, true); if (res.ok) profiles.set(did, res.value); else error = res.error;
loading = false; };
22 unmodified lines > <Icon icon="heroicons:arrow-left-20-solid" width={24} /> </button> <h2 class="text-xl font-bold"> {displayName.length > 0 ? displayName : loading ? 'loading...' : (handle ?? actor ?? 'profile')} {displayName.length > 0 ? displayName : loading ? 'loading...' : (handle ?? 'handle.invalid')} </h2> <div class="grow"></div> {#if did && client.user && client.user.did !== did} <ProfileActions {client} targetDid={did} bind:userBlocked {blockedByTarget} />28 unmodified lines
{#if did} <div class="px-4 pb-4"> <div class="relative z-10 {bannerUrl ? '-mt-12' : 'mt-4'} mb-4"> <ProfileInfo {client} {did} bind:profile /> <ProfileInfo {client} {did} {profile} /> </div>
<TimelineView showReplies={false}10 unmodified lines13 unmodified lines1415161718181920212223232425262734 unmodified lines6263646566666768697011 unmodified lines8283848586868788899091929394959596979899130 unmodified linesExpand all13 unmodified lines fetchInteractionsToTimelineEnd } from '$lib/state.svelte'; import Icon from '@iconify/svelte'; import { buildThreads, filterThreads, type ThreadPost } from '$lib/thread'; import type { AtprotoDid } from '@atcute/lexicons/syntax'; import type { Did } from '@atcute/lexicons/syntax'; import NotLoggedIn from './NotLoggedIn.svelte';
interface Props { client?: AtpClient | null; targetDid?: AtprotoDid; targetDid?: Did; postComposerState: PostComposerState; class?: string; // whether to show replies that are not the user's own posts showReplies?: boolean;34 unmodified lines loading = true; loaderState.status = 'LOADING';
try { await fetchTimeline(client, did as AtprotoDid, 7, showReplies); await fetchTimeline(client, did, 7, showReplies); // only fetch interactions if logged in (because if not who is the interactor) if (client.user) { if (!fetchingInteractions) { scheduledFetchInteractions = false;11 unmodified lines return; }
loading = false; const cursor = postCursors.get(did as AtprotoDid); const cursor = postCursors.get(did); if (cursor && cursor.end) loaderState.complete(); };
$effect(() => { if (threads.length === 0 && !loading && userDid && did) { // if we saw all posts dont try to load more. // this only really happens if the user has no posts at all // but we do have to handle it to not cause an infinite loop const cursor = did ? postCursors.get(did as AtprotoDid) : undefined; const cursor = did ? postCursors.get(did) : undefined; if (!cursor?.end) loadMore(); } });
130 unmodified linesExpand all208 unmodified linesExpand all209210211212213214215216217218219220221220222223224208 unmodified linesExpand all return; }}
export const ttl = 60 * 60 * 3; // 3 hours
export const cache = createCache({ storage: { type: 'custom', options: { storage: new IDBStorage() } }, ttl: 60 * 60 * 24, // 24 hours ttl, onError: (err) => console.error(err)});12345678467 unmodified linesExpand all47647747847947948048148248348460 unmodified lines54554654754854955055155255389 unmodified linesimport { writable } from 'svelte/store';import { AtpClient, setRecordCache, type NotificationsStream, type NotificationsStreamEvent} from './at/client.svelte';import { SvelteMap, SvelteDate, SvelteSet } from 'svelte/reactivity';467 unmodified linesExpand all};
export const fetchTimeline = async ( client: AtpClient, subject: AtprotoDid, subject: Did, limit: number = 6, withBacklinks: boolean = true) => { const cursor = postCursors.get(subject);60 unmodified lines uri, cid: commit.cid } ]; await setRecordCache(uri, commit.record); const client = clients.get(did) ?? viewClient; const hydrated = await hydratePosts(client, did, posts, hydrateCacheFn); if (!hydrated.ok) { console.error(`cant hydrate posts ${did}: ${hydrated.error}`);89 unmodified lines28 unmodified lines2930313233333435363738 unmodified lines7677787980818283848586878889903 unmodified lines949596979198991001019510210310410544 unmodified lines1501511521531541551561571481581591601611621631541641651661671683 unmodified lines1721731741751661761771781791801811711721731821831841851861871886 unmodified lines19519619719818819920020120220320419120520620720820921021121221 unmodified lines234235236237221222238239240241242264 unmodified linesExpand all28 unmodified linesimport * as v from '@atcute/lexicons/validations';import { MiniDocQuery, type MiniDoc } from './slingshot';import { BacklinksQuery, type Backlinks, type BacklinksSource } from './constellation';import type { Records, XRPCProcedures } from '@atcute/lexicons/ambient';import { cache as rawCache } from '$lib/cache';import { cache as rawCache, ttl } from '$lib/cache';import { AppBskyActorProfile } from '@atcute/bluesky';import { WebSocket } from '@soffinal/websocket';import type { Notification } from './stardust';import type { OAuthUserAgent } from '@atcute/oauth-browser-client';38 unmodified lines});
const cache = cacheWithRecords;
export const invalidateRecordCache = async (uri: ResourceUri) => { console.log(`invalidating cached for ${uri}`); await cache.invalidate('fetchRecord', `fetchRecord~${uri}`);};export const setRecordCache = (uri: ResourceUri, record: unknown) => cache.set('fetchRecord', `fetchRecord~${uri}`, record, ttl);
export const xhrPost = ( url: string, body: Blob | File, headers: Record<string, string> = {},3 unmodified lines return new Promise((resolve) => { const xhr = new XMLHttpRequest(); xhr.open('POST', url);
if (onProgress && xhr.upload) { if (onProgress && xhr.upload) xhr.upload.onprogress = (event: ProgressEvent) => { if (event.lengthComputable) onProgress(event.loaded, event.total); }; }
Object.keys(headers).forEach((key) => xhr.setRequestHeader(key, headers[key]));
xhr.onload = () => {44 unmodified lines TObject extends ObjectSchema & { shape: { $type: v.LiteralSchema<Collection> } }, TKey extends RecordKeySchema, Schema extends RecordSchema<TObject, TKey>, Output extends InferInput<Schema> >( schema: Schema, uri: ResourceUri, noCache?: boolean >(schema: Schema, uri: ResourceUri): Promise<Result<RecordOutput<Output>, string>> { ): Promise<Result<RecordOutput<Output>, string>> { const parsedUri = expect(parseResourceUri(uri)); if (parsedUri.collection !== schema.object.shape.$type.expected) return err( `collections don't match: ${parsedUri.collection} != ${schema.object.shape.$type.expected}` ); return await this.getRecord(schema, parsedUri.repo!, parsedUri.rkey!); return await this.getRecord(schema, parsedUri.repo!, parsedUri.rkey!, noCache); }
async getRecord< Collection extends Nsid,3 unmodified lines Output extends InferInput<Schema> >( schema: Schema, repo: ActorIdentifier, rkey: RecordKey rkey: RecordKey, noCache?: boolean ): Promise<Result<RecordOutput<Output>, string>> { const collection = schema.object.shape.$type.expected;
try { const rawValue = await cache.fetchRecord( toResourceUri({ repo, collection, rkey, fragment: undefined }) ); const uri = toResourceUri({ repo, collection, rkey, fragment: undefined }); if (noCache) await invalidateRecordCache(uri); const rawValue = await cache.fetchRecord(uri);
const parsed = safeParse(schema, rawValue.value); if (!parsed.ok) return err(parsed.message);
6 unmodified lines return err(String(e)); } }
async getProfile(repo?: ActorIdentifier): Promise<Result<AppBskyActorProfile.Main, string>> { async getProfile( repo?: ActorIdentifier, noCache?: boolean ): Promise<Result<AppBskyActorProfile.Main, string>> { repo = repo ?? this.user?.did; if (!repo) return err('not authenticated'); return map(await this.getRecord(AppBskyActorProfile.mainSchema, repo, 'self'), (d) => d.record); return map( await this.getRecord(AppBskyActorProfile.mainSchema, repo, 'self', noCache), (d) => d.record ); }
async listRecords<Collection extends keyof Records>( ident: ActorIdentifier,21 unmodified lines } }); if (!res.ok) return err(`${res.data.error}: ${res.data.message ?? 'no details'}`);
for (const record of res.data.records) await cache.set('fetchRecord', `fetchRecord~${record.uri}`, record, 60 * 60 * 24); for (const record of res.data.records) setRecordCache(record.uri, record);
return ok(res.data); }
264 unmodified linesExpand all