implement blocks sync
12345678910111213{ "lsp": { "vtsls": { "settings": { "typescript": { "suggestionActions": { "enabled": false } } } } }}122345678911101311121513141516171814 unmodified lines3334353638394041424344453738394950515253404142434445464756575859606162636465666768697071727374757648787949505152535455565758885960616249 unmodified lines<script lang="ts" module> // Cache for synchronous access during component recycling const profileCache = new SvelteMap<string, { displayName?: string; handle: string }>();</script>
<script lang="ts"> import ProfilePicture from './ProfilePicture.svelte'; import { getRelativeTime } from '$lib/date'; import { generateColorForDid } from '$lib/accounts'; import type { Did } from '@atcute/lexicons'; import type { AtprotoDid } from '@atcute/lexicons/syntax'; import type { calculateFollowedUserStats, Sort } from '$lib/following'; import type { AtpClient } from '$lib/at/client'; import { resolveDidDoc, type AtpClient } from '$lib/at/client'; import { SvelteMap } from 'svelte/reactivity'; import { clients, getClient, router } from '$lib/state.svelte'; import { router } from '$lib/state.svelte'; import { map } from '$lib/result';
interface Props { style: string; did: Did;14 unmodified lines if (profileCache.has(targetDid)) { const c = profileCache.get(targetDid)!; displayName = c.displayName; handle = c.handle; } else { const existingClient = clients.get(targetDid as AtprotoDid); if (existingClient?.user?.handle) { handle = existingClient.user.handle; } else { handle = 'handle.invalid'; displayName = undefined; } }
try { // Optimization: Check clients map first to avoid async overhead if possible // but we need to ensure we have the profile data, not just client existence. const userClient = await getClient(targetDid as AtprotoDid);
// Check if the component has been recycled for a different user while we were awaiting const [profileRes, handleRes] = await Promise.all([ client.getProfile(), resolveDidDoc(targetDid).then((r) => map(r, (doc) => doc.handle)) ]); if (did !== targetDid) return; if (profileRes.ok) displayName = profileRes.value.displayName; if (handleRes.ok) handle = handleRes.value;
let newHandle = handle; let newDisplayName = displayName;
if (userClient.user?.handle) { newHandle = userClient.user.handle; handle = newHandle; } else { newHandle = targetDid; handle = newHandle; }
const profileRes = await userClient.getProfile();
if (did !== targetDid) return;
if (profileRes.ok) { newDisplayName = profileRes.value.displayName; displayName = newDisplayName; }
// Update cache profileCache.set(targetDid, { handle: newHandle, displayName: newDisplayName handle, displayName }); } catch (e) { if (did !== targetDid) return; console.error(`failed to load profile for ${targetDid}`, e); handle = 'error'; } };
// Re-run whenever `did` changes $effect(() => { loadProfile(did); });
49 unmodified lines9 unmodified lines1011121314141516171833 unmodified lines5253545556565758591 unmodified line616263646667686566676869707113 unmodified lines8586878890919293948990919293949596979899110 unmodified linesExpand all9 unmodified lines postCursors, fetchTimeline, allPosts, timelines, fetchInteractionsUntil 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';33 unmodified lines
const loaderState = new LoaderState(); let scrollContainer = $state<HTMLDivElement>(); let loading = $state(false); let fetchMoreInteractions: boolean | undefined = $state(false); let loadError = $state('');
const loadMore = async () => { if (loading || !client || !did) return;1 unmodified line loading = true; loaderState.status = 'LOADING';
try { await fetchTimeline(did as AtprotoDid, 7, showReplies); // interaction fetching is done lazily so we dont block loading posts fetchMoreInteractions = true; await fetchTimeline(client, did as AtprotoDid, 7, showReplies); // only fetch interactions if logged in (because if not who is the interactor) if (client.user) await fetchInteractionsToTimelineEnd(client, did); loaderState.loaded(); } catch (error) { loadError = `${error}`; loaderState.error();13 unmodified lines // but we do have to handle it to not cause an infinite loop const cursor = did ? postCursors.get(did as AtprotoDid) : undefined; if (!cursor?.end) loadMore(); } if (client && did && fetchMoreInteractions) { // set to false so it doesnt attempt to fetch again while its already fetching fetchMoreInteractions = false; fetchInteractionsUntil(client, did).then(() => (fetchMoreInteractions = undefined)); } }); // we want to load interactions when changing logged in user on timelines // only on timelines that arent logged in users, because those are already // loaded by loadMore $effect(() => { if (client && did && client.user?.did !== did) fetchInteractionsToTimelineEnd(client, did); });</script>
{#snippet replyPost(post: ThreadPost, reverse: boolean = reverseChronological)} <span110 unmodified linesExpand all26 unmodified lines272829303132333435365 unmodified lines26 unmodified lines
export const likeSource: BacklinksSource = 'app.bsky.feed.like:subject.uri';export const repostSource: BacklinksSource = 'app.bsky.feed.repost:subject.uri';export const replySource: BacklinksSource = 'app.bsky.feed.post:reply.parent.uri';export const replyRootSource: BacklinksSource = 'app.bsky.feed.post:reply.root.uri';export const blockSource: BacklinksSource = 'app.bsky.graph.block:subject';
export const timestampFromCursor = (cursor: string | undefined) => { if (!cursor) return undefined; try {5 unmodified lines12345672394561213789101112131415161718192021222324252627282930313233101 unmodified linesExpand all1351361371381391401411421431411421431441441451461471471481491501512 unmodified lines15415515615715815915815916016116216372 unmodified lines2362372382392402412422432402412422432442452462512522472482492502512522552532542552592602612622562572582592602612622632642652642662672682692702712722702732742752732742762772762782792802812822832842852802812862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203 unmodified lines3243253263272952962972982993003013023033043053283293303312 unmodified lines33433533633731631731831932032132233833934034134234334434534634734835 unmodified lines3843853863873883663893903913923703713723933943953753763963973983994003793803814014024034044053844064074084094104114124134144154164174184193864204214224233894244254264273934284294304314324334344354364374384394404414424434444454464476 unmodified lines45445545645741045845946046146246346446546646746841946947047147247347447547647747847948048148242748348448548648748848949049159 unmodified linesimport { writable } from 'svelte/store';import { AtpClient, newPublicClient, type NotificationsStream, type NotificationsStreamEvent} from './at/client';import { AtpClient, type NotificationsStream, type NotificationsStreamEvent } from './at/client';import { SvelteMap, SvelteDate, SvelteSet } from 'svelte/reactivity';import type { Did, Handle, InferOutput, Nsid, RecordKey, ResourceUri } from '@atcute/lexicons';import type { Did, Handle, Nsid, RecordKey, ResourceUri } from '@atcute/lexicons';import { fetchPosts, hydratePosts, type PostWithUri } from './at/fetch';import { parseCanonicalResourceUri, type AtprotoDid } from '@atcute/lexicons/syntax';import { AppBskyActorProfile, AppBskyFeedPost, type AppBskyGraphFollow } from '@atcute/bluesky';import type { ComAtprotoRepoListRecords } from '@atcute/atproto';import { AppBskyActorProfile, AppBskyFeedPost, AppBskyGraphBlock, type AppBskyGraphFollow} from '@atcute/bluesky';import type { JetstreamSubscription, JetstreamEvent } from '@atcute/jetstream';import { expect, ok } from './result';import type { Backlink, BacklinksSource } from './at/constellation';import { now as tidNow } from '@atcute/tid';import type { Records } from '@atcute/lexicons/ambient';import { blockSource, extractDidFromUri, likeSource, replyRootSource, replySource, repostSource, timestampFromCursor, toCanonicalUri} from '$lib';import { Router } from './router.svelte';import type { Account } from './accounts';
export const notificationStream = writable<NotificationsStream | null>(null);export const jetstream = writable<JetstreamSubscription | null>(null);
101 unmodified linesExpand all SvelteMap<BacklinksSource, string | undefined>>();
export const fetchLinksUntil = async ( subject: Did, client: AtpClient, backlinkSource: BacklinksSource, timestamp: number = -1) => { const did = client.user?.did; if (!did) return;
let cursorMap = backlinksCursors.get(did); let cursorMap = backlinksCursors.get(subject); if (!cursorMap) { cursorMap = new SvelteMap<BacklinksSource, string | undefined>(); backlinksCursors.set(did, cursorMap); backlinksCursors.set(subject, cursorMap); }
const [_collection, source] = backlinkSource.split(':'); const collection = _collection as keyof Records;2 unmodified lines // if already fetched we dont need to fetch again const cursorTimestamp = timestampFromCursor(cursor); if (cursorTimestamp && cursorTimestamp <= timestamp) return;
console.log(`${did}: fetchLinksUntil`, backlinkSource, cursor, timestamp); const result = await client.listRecordsUntil(collection, cursor, timestamp); console.log(`${subject}: fetchLinksUntil`, backlinkSource, cursor, timestamp); const result = await client.listRecordsUntil(subject, collection, cursor, timestamp);
if (!result.ok) { console.error('failed to fetch links until', result.error); return;72 unmodified linesexport const pulsingPostId = writable<string | null>(null);
export const viewClient = new AtpClient();export const clients = new SvelteMap<Did, AtpClient>();export const getClient = async (did: Did): Promise<AtpClient> => { if (!clients.has(did)) clients.set(did, await newPublicClient(did)); return clients.get(did)!;};
export const follows = new SvelteMap<Did, SvelteMap<ResourceUri, AppBskyGraphFollow.Main>>();
export const addFollows = ( did: Did, followMap: Iterable<[ResourceUri, AppBskyGraphFollow.Main]>) => { if (!follows.has(did)) { follows.set(did, new SvelteMap(followMap)); let map = follows.get(did)!; if (!map) { map = new SvelteMap(followMap); follows.set(did, map); return; } const map = follows.get(did)!; for (const [uri, record] of followMap) map.set(uri, record);};
export const fetchFollows = async (did: AtprotoDid) => { const client = await getClient(did); const res = await client.listRecordsUntil('app.bsky.graph.follow'); if (!res.ok) return;export const fetchFollows = async ( account: Account): Promise<IteratorObject<AppBskyGraphFollow.Main>> => { const client = clients.get(account.did)!; const res = await client.listRecordsUntil(account.did, 'app.bsky.graph.follow'); if (!res.ok) { console.error("can't fetch follows:", res.error); return [].values(); } addFollows( did, account.did, res.value.records.map((follow) => [follow.uri, follow.value as AppBskyGraphFollow.Main]) ); return res.value.records.values().map((follow) => follow.value as AppBskyGraphFollow.Main);};
// this fetches up to three days of posts and interactions for using in following listexport const fetchForInteractions = async (did: AtprotoDid) => {export const fetchForInteractions = async (client: AtpClient, subject: Did) => { const threeDaysAgo = (Date.now() - 3 * 24 * 60 * 60 * 1000) * 1000;
const client = await getClient(did); const res = await client.listRecordsUntil('app.bsky.feed.post', undefined, threeDaysAgo); const res = await client.listRecordsUntil(subject, 'app.bsky.feed.post', undefined, threeDaysAgo); if (!res.ok) return; addPostsRaw(did, res.value); const postsWithUri = res.value.records.map( (post) => ({ cid: post.cid, uri: post.uri, record: post.value as AppBskyFeedPost.Main }) as PostWithUri ); addPosts(postsWithUri);
const cursorTimestamp = timestampFromCursor(res.value.cursor) ?? -1; const timestamp = Math.min(cursorTimestamp, threeDaysAgo); console.log(`${did}: fetchForInteractions`, res.value.cursor, timestamp); await Promise.all([repostSource].map((s) => fetchLinksUntil(client, s, timestamp))); console.log(`${subject}: fetchForInteractions`, res.value.cursor, timestamp); await Promise.all([repostSource].map((s) => fetchLinksUntil(subject, client, s, timestamp)));};
// if did is in set, we have fetched blocks for them already (against logged in users)export const blockFlags = new SvelteMap<Did, SvelteSet<Did>>();
export const fetchBlocked = async (client: AtpClient, subject: Did, blocker: Did) => { const subjectUri = `at://${subject}` as ResourceUri; const res = await client.getBacklinks(subjectUri, blockSource, [blocker], 1); if (!res.ok) return; if (res.value.total > 0) addBacklinks(subjectUri, blockSource, res.value.records);};
export const fetchBlocks = async (account: Account) => { const client = clients.get(account.did)!; const res = await client.listRecordsUntil(account.did, 'app.bsky.graph.block'); if (!res.ok) return; for (const block of res.value.records) { const record = block.value as AppBskyGraphBlock.Main; const parsedUri = expect(parseCanonicalResourceUri(block.uri)); addBacklinks(`at://${record.subject}`, blockSource, [ { did: parsedUri.repo, collection: parsedUri.collection, rkey: parsedUri.rkey } ]); }};
export const allPosts = new SvelteMap<Did, SvelteMap<ResourceUri, PostWithUri>>();// did -> post uris that are replies to that didexport const replyIndex = new SvelteMap<Did, SvelteSet<ResourceUri>>();
3 unmodified lines const cached = getPost(did, rkey); return cached ? ok(cached) : undefined;};
export const addPostsRaw = ( did: AtprotoDid, newPosts: InferOutput<ComAtprotoRepoListRecords.mainSchema['output']['schema']>) => { const postsWithUri = newPosts.records.map( (post) => ({ cid: post.cid, uri: post.uri, record: post.value as AppBskyFeedPost.Main }) as PostWithUri ); addPosts(postsWithUri);};
export const addPosts = (newPosts: Iterable<PostWithUri>) => { for (const post of newPosts) { const parsedUri = expect(parseCanonicalResourceUri(post.uri)); let posts = allPosts.get(parsedUri.repo);2 unmodified lines allPosts.set(parsedUri.repo, posts); } posts.set(post.uri, post); if (post.record.reply) { addBacklinks(post.record.reply.parent.uri, replySource, [ { did: parsedUri.repo, collection: parsedUri.collection, rkey: parsedUri.rkey } ]); const link = { did: parsedUri.repo, collection: parsedUri.collection, rkey: parsedUri.rkey }; addBacklinks(post.record.reply.parent.uri, replySource, [link]); addBacklinks(post.record.reply.root.uri, replyRootSource, [link]);
// update reply index const parentDid = extractDidFromUri(post.record.reply.parent.uri); if (parentDid) {35 unmodified lines }};
export const fetchTimeline = async ( client: AtpClient, did: AtprotoDid, subject: AtprotoDid, limit: number = 6, withBacklinks: boolean = true) => { const targetClient = await getClient(did);
const cursor = postCursors.get(did); const cursor = postCursors.get(subject); if (cursor && cursor.end) return;
const accPosts = await fetchPosts(targetClient, cursor?.value, limit, withBacklinks); if (!accPosts.ok) throw `cant fetch posts ${did}: ${accPosts.error}`; const accPosts = await fetchPosts(subject, client, cursor?.value, limit, withBacklinks); if (!accPosts.ok) throw `cant fetch posts ${subject}: ${accPosts.error}`;
// if the cursor is undefined, we've reached the end of the timeline const newCursor = { value: accPosts.value.cursor, end: !accPosts.value.cursor }; postCursors.set(did, { value: accPosts.value.cursor, end: !accPosts.value.cursor }); const hydrated = await hydratePosts(targetClient, did, accPosts.value.posts, hydrateCacheFn); if (!hydrated.ok) throw `cant hydrate posts ${did}: ${hydrated.error}`; postCursors.set(subject, newCursor); const hydrated = await hydratePosts(client, subject, accPosts.value.posts, hydrateCacheFn); if (!hydrated.ok) throw `cant hydrate posts ${subject}: ${hydrated.error}`;
addPosts(hydrated.value.values()); addTimeline(did, hydrated.value.keys()); addTimeline(subject, hydrated.value.keys());
// we only need to check blocks if the user is the subject (ie. logged in) if (client.user?.did === subject) { // check if any of the post authors block the user // eslint-disable-next-line svelte/prefer-svelte-reactivity let distinctDids = new Set(hydrated.value.keys().map((uri) => extractDidFromUri(uri)!)); distinctDids.delete(subject); // dont need to check if user blocks themselves const alreadyFetched = blockFlags.get(subject); if (alreadyFetched) distinctDids = distinctDids.difference(alreadyFetched); if (distinctDids.size > 0) await Promise.all(distinctDids.values().map((did) => fetchBlocked(client, subject, did))); }
console.log(`${did}: fetchTimeline`, accPosts.value.cursor); console.log(`${subject}: fetchTimeline`, accPosts.value.cursor); return newCursor;};
export const fetchInteractionsUntil = async (client: AtpClient, did: Did) => {export const fetchInteractionsToTimelineEnd = async (client: AtpClient, did: Did) => { const cursor = postCursors.get(did); if (!cursor) return; const timestamp = timestampFromCursor(cursor.value); await Promise.all([likeSource, repostSource].map((s) => fetchLinksUntil(client, s, timestamp))); await Promise.all( [likeSource, repostSource].map((s) => fetchLinksUntil(did, client, s, timestamp)) );};
export const fetchInitial = async (account: Account) => { const client = clients.get(account.did)!; await Promise.all([ fetchBlocks(account), fetchForInteractions(client, account.did), fetchFollows(account).then((follows) => Promise.all(follows.map((follow) => fetchForInteractions(client, follow.subject)) ?? []) ) ]);};
export const handleJetstreamEvent = async (event: JetstreamEvent) => { if (event.kind !== 'commit') return;
const { did, commit } = event;6 unmodified lines uri, cid: commit.cid } ]; const client = await getClient(did); 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}`); return; } addPosts(hydrated.value.values()); addTimeline(did, hydrated.value.keys()); } else if (commit.operation === 'delete') { const post = allPosts.get(did)?.get(uri); if (post) { allPosts.get(did)?.delete(uri); allPosts.get(did)?.delete(uri); // remove from timeline timelines.get(did)?.delete(uri); // remove reply from index const subjectDid = extractDidFromUri(post.record.reply?.parent.uri ?? ''); if (subjectDid) replyIndex.get(subjectDid)?.delete(uri); } } }};
const handlePostNotification = async (event: NotificationsStreamEvent & { type: 'message' }) => { const parsedSubjectUri = expect(parseCanonicalResourceUri(event.data.link.subject)); const did = parsedSubjectUri.repo as AtprotoDid; const client = await getClient(did); const client = clients.get(did); if (!client) { console.error(`${did}: cant handle post notification, client not found !?`); return; } const subjectPost = await client.getRecord( AppBskyFeedPost.mainSchema, did, parsedSubjectUri.rkey59 unmodified lines6 unmodified lines78910111112131419 unmodified lines3435363739383940414245 unmodified lines8889909193949592939495991001019697981041051061071089910010110211111211311411511611711810310410510610710878 unmodified lines1871881891901911921931941951961972112121981992002012022032042052062142072082092102117 unmodified lines2192202212222232242252262273 unmodified lines2312322332342412352362372382398 unmodified lines2482492502512582522532542552562572582592602672682692702712722732742752762772782792612812822832622632642652662672682692702712722922732742942752762772782792803 unmodified lines28428528628730630730830928828929029129297 unmodified lines39039139239341541641741841942042142242342442539439539639739839940040140240340440542740640740842943043143243343443543643743843940941041141241341444244344444544644744844941541641741841942042142242330 unmodified lines45445545645748845845946046146246346418 unmodified lines6 unmodified linesimport { Client as AtcuteClient, simpleFetchHandler } from '@atcute/client';import { safeParse, type Blob as AtpBlob, type Handle, type InferOutput } from '@atcute/lexicons';import { isDid, parseCanonicalResourceUri, parseResourceUri, type ActorIdentifier, type AtprotoDid, type Cid,19 unmodified linesimport { WebSocket } from '@soffinal/websocket';import type { Notification } from './stardust';import type { OAuthUserAgent } from '@atcute/oauth-browser-client';import { timestampFromCursor, toCanonicalUri, toResourceUri } from '$lib';import { constellationUrl, slingshotUrl, spacedustUrl } from '.';import { constellationUrl, httpToDidWeb, slingshotUrl, spacedustUrl } from '.';
export type RecordOutput<Output> = { uri: ResourceUri; cid: Cid | undefined; record: Output };
const cacheWithHandles = rawCache.define(45 unmodified lines xhr.open('POST', url);
if (onProgress && xhr.upload) { xhr.upload.onprogress = (event: ProgressEvent) => { if (event.lengthComputable) { onProgress(event.loaded, event.total); } if (event.lengthComputable) onProgress(event.loaded, event.total); }; }
Object.keys(headers).forEach((key) => { xhr.setRequestHeader(key, headers[key]); }); Object.keys(headers).forEach((key) => xhr.setRequestHeader(key, headers[key]));
xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { resolve(ok(JSON.parse(xhr.responseText))); } else { resolve(err(JSON.parse(xhr.responseText))); } if (xhr.status >= 200 && xhr.status < 300) resolve(ok(JSON.parse(xhr.responseText))); else resolve(err(JSON.parse(xhr.responseText))); };
xhr.onerror = () => { resolve(err({ error: 'xhr_error', message: 'network error' })); };
xhr.onabort = () => { resolve(err({ error: 'xhr_error', message: 'upload aborted' })); };
xhr.onerror = () => resolve(err({ error: 'xhr_error', message: 'network error' })); xhr.onabort = () => resolve(err({ error: 'xhr_error', message: 'upload aborted' })); xhr.send(body); });};
78 unmodified lines return map(await this.getRecord(AppBskyActorProfile.mainSchema, repo, 'self'), (d) => d.record); }
async listRecords<Collection extends keyof Records>( ident: ActorIdentifier, collection: Collection, cursor?: string, limit: number = 100 ): Promise< Result<InferXRPCBodyOutput<(typeof ComAtprotoRepoListRecords.mainSchema)['output']>, string> > { if (!this.atcute || !this.user) return err('not authenticated'); const res = await this.atcute.get('com.atproto.repo.listRecords', { if (!this.atcute) return err('not authenticated'); const docRes = await resolveDidDoc(ident); if (!docRes.ok) return docRes; const atp = this.user?.did === docRes.value.did ? this.atcute : new AtcuteClient({ handler: simpleFetchHandler({ service: docRes.value.pds }) }); const res = await atp.get('com.atproto.repo.listRecords', { params: { repo: this.user.did, repo: docRes.value.did, collection, cursor, limit, reverse: false7 unmodified lines return ok(res.data); }
async listRecordsUntil<Collection extends keyof Records>( ident: ActorIdentifier, collection: Collection, cursor?: string, timestamp: number = -1 ): Promise<ReturnType<typeof this.listRecords>> {3 unmodified lines };
let end = false; while (!end) { const res = await this.listRecords(collection, data.cursor); const res = await this.listRecords(ident, collection, data.cursor); if (!res.ok) return res; data.cursor = res.value.cursor; data.records.push(...res.value.records); end = data.records.length === 0 || !data.cursor;8 unmodified lines } else if (cursorTimestamp <= timestamp) { end = true; } else { console.info( `${this.user?.did}: continuing to fetch ${collection}, on ${cursorTimestamp} until ${timestamp}` `${ident}: continuing to fetch ${collection}, on ${cursorTimestamp} until ${timestamp}` ); } } }
return ok(data); }
async getBacklinksUri( uri: ResourceUri, source: BacklinksSource ): Promise<Result<Backlinks, string>> { const parsedResourceUri = expect(parseCanonicalResourceUri(uri)); return await this.getBacklinks( parsedResourceUri.repo, parsedResourceUri.collection, parsedResourceUri.rkey, source ); }
async getBacklinks( repo: ActorIdentifier, collection: Nsid, rkey: RecordKey, subject: ResourceUri, source: BacklinksSource, filterBy?: Did[], limit?: number ): Promise<Result<Backlinks, string>> { const { repo, collection, rkey } = expect(parseResourceUri(subject)); const did = await resolveHandle(repo); if (!did.ok) return err(`cant resolve handle: ${did.error}`);
const timeout = new Promise<null>((resolve) => setTimeout(() => resolve(null), 2000)); const query = fetchMicrocosm(constellationUrl, BacklinksQuery, { subject: toCanonicalUri({ did: did.value, collection, rkey }), subject: collection ? toCanonicalUri({ did: did.value, collection, rkey: rkey! }) : did.value, source, limit: limit || 100 limit: limit || 100, did: filterBy });
const results = await Promise.race([query, timeout]); if (!results) return err('cant fetch backlinks: timeout');3 unmodified lines
async getServiceAuth(lxm: keyof XRPCProcedures, exp: number): Promise<Result<string, string>> { if (!this.atcute || !this.user) return err('not authenticated'); const serviceAuthUrl = new URL(`${this.user.pds}xrpc/com.atproto.server.getServiceAuth`); serviceAuthUrl.searchParams.append( 'aud', this.user.pds.replace('https://', 'did:web:').slice(0, -1) ); serviceAuthUrl.searchParams.append('aud', httpToDidWeb(this.user.pds)); serviceAuthUrl.searchParams.append('lxm', 'com.atproto.repo.uploadBlob'); serviceAuthUrl.searchParams.append('exp', exp.toString()); // 30 minutes
const serviceAuthResponse = await this.atcute.handler(97 unmodified lines return ok(videoBlobRef); }}
export const newPublicClient = async (ident: ActorIdentifier): Promise<AtpClient> => { const atp = new AtpClient(); const didDoc = await resolveDidDoc(ident); if (!didDoc.ok) { console.error('failed to resolve did doc', didDoc.error); return atp; } atp.atcute = new AtcuteClient({ handler: simpleFetchHandler({ service: didDoc.value.pds }) }); atp.user = didDoc.value; return atp;};// export const newPublicClient = async (ident: ActorIdentifier) => {// const atp = new AtpClient();// const didDoc = await resolveDidDoc(ident);// if (!didDoc.ok) {// console.error('failed to resolve did doc', didDoc.error);// return atp;// }// atp.atcute = new AtcuteClient({ handler: simpleFetchHandler({ service: didDoc.value.pds }) });// atp.user = didDoc.value;// return atp;// };
// Wrappers that use the cacheexport const resolveHandle = (identifier: ActorIdentifier) => { if (isDid(identifier)) return Promise.resolve(ok(identifier as AtprotoDid));
export const resolveHandle = async ( identifier: ActorIdentifier): Promise<Result<AtprotoDid, string>> => { if (isDid(identifier)) return ok(identifier as AtprotoDid);
try { const did = await cache.resolveHandle(identifier); return ok(did); } catch (e) { return err(String(e)); } return cache .resolveHandle(identifier) .then((did) => ok(did)) .catch((e) => err(String(e)));};
export const resolveDidDoc = async (ident: ActorIdentifier): Promise<Result<MiniDoc, string>> => { try { const doc = await cache.resolveDidDoc(ident); return ok(doc); } catch (e) { return err(String(e)); }};export const resolveDidDoc = (ident: ActorIdentifier) => cache .resolveDidDoc(ident) .then((doc) => ok(doc)) .catch((e) => err(String(e)));
type NotificationsStreamEncoder = WebSocket.Encoder<undefined, Notification>;export type NotificationsStream = WebSocket<NotificationsStreamEncoder>;export type NotificationsStreamEvent = WebSocket.Event<NotificationsStreamEncoder>;30 unmodified lines init?: RequestInit): Promise<Result<Output, string>> => { if (!schema.output || schema.output.type === 'blob') return err('schema must be blob'); api.pathname = `/xrpc/${schema.nsid}`; api.search = params ? `?${new URLSearchParams(params)}` : ''; api.search = params ? `?${new URLSearchParams(Object.entries(params).flatMap(([k, v]) => (v === undefined ? [] : [[k, String(v)]])))}` : ''; try { const body = await fetchJson(api, init); if (!body.ok) return err(body.error); const parsed = safeParse(schema.output.schema, body.value);18 unmodified lines7 unmodified lines89101112121314151611 unmodified lines7 unmodified lines rkey: v.recordKeyString()});export const BacklinksQuery = v.query('blue.microcosm.links.getBacklinks', { params: v.object({ subject: v.resourceUriString(), subject: v.string(), source: v.string(), did: v.optional(v.array(v.didString())), limit: v.optional(v.integer()) }),11 unmodified lines15 unmodified lines1617181920212223242525262728293010 unmodified lines4142434444454647484970 unmodified lines12012112212312312412512612712820 unmodified lines15 unmodified lines replies?: Backlinks;};
export const fetchPosts = async ( subject: Did, client: AtpClient, cursor?: string, limit?: number, withBacklinks: boolean = true): Promise<Result<{ posts: PostWithBacklinks[]; cursor?: string }, string>> => { const recordsList = await client.listRecords('app.bsky.feed.post', cursor, limit); const recordsList = await client.listRecords(subject, 'app.bsky.feed.post', cursor, limit); if (!recordsList.ok) return err(`can't retrieve posts: ${recordsList.error}`); cursor = recordsList.value.cursor; const records = recordsList.value.records;
10 unmodified lines
try { const allBacklinks = await Promise.all( records.map(async (r): Promise<PostWithBacklinks> => { const result = await client.getBacklinksUri(r.uri, replySource); const result = await client.getBacklinks(r.uri, replySource); if (!result.ok) throw `cant fetch replies: ${result.error}`; const replies = result.value; return { uri: r.uri,70 unmodified lines const { repo: postRepo } = expect(parseCanonicalResourceUri(post.uri)); if (repo === postRepo) return;
// get chains that are the same author until we exhaust them const backlinks = await client.getBacklinksUri(post.uri, replySource); const backlinks = await client.getBacklinks(post.uri, replySource); if (!backlinks.ok) return;
const promises = []; for (const reply of backlinks.value.records) {20 unmodified lines123456789import { settings } from '$lib/settings';import type { Did } from '@atcute/lexicons';import { get } from 'svelte/store';
export const slingshotUrl: URL = new URL(get(settings).endpoints.slingshot);export const spacedustUrl: URL = new URL(get(settings).endpoints.spacedust);export const constellationUrl: URL = new URL(get(settings).endpoints.constellation);
export const httpToDidWeb = (url: string): Did => `did:web:${new URL(url).hostname}`;