From e3816a1fcc16cd8fa1e87958a4ad2d1b57bbbdd2 Mon Sep 17 00:00:00 2001 From: dusk Date: Fri, 17 Oct 2025 04:07:41 +0000 Subject: [PATCH] feat: implement like a bunch of shit --- deno.lock | 16 ++++++++++++++++ package.json | 2 ++ src/app.css | 14 ++++++++++++++ src/components/AccountSelector.svelte | 295 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/BskyPost.svelte | 121 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------ src/components/PostComposer.svelte | 60 +++++++++++++++++++++++++++++++++++++++++------------------- src/lib/accounts.ts | 41 +++++++++++++++++++++++++++++++++++++++++ src/lib/cache.ts | 71 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/lib/theme.svelte.ts | 13 +++++++++++++ src/routes/+layout.svelte | 8 +++++++- src/routes/+page.svelte | 134 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----- src/lib/at/client.ts | 123 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------------- src/lib/at/constellation.ts | 27 +++++++++++++++++++++++++++ src/lib/at/fetch.ts | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/lib/at/slingshot.ts | 17 +++++++++++++++++ 15 file(s) changed, 931 insertion(s)(+), 70 deletion(s)(-) diff --git a/deno.lock b/deno.lock --- a/deno.lock +++ b/deno.lock @@ -14,10 +14,12 @@ "npm:@tailwindcss/forms@~0.5.10": "0.5.10_tailwindcss@4.1.14", "npm:@tailwindcss/vite@^4.1.13": "4.1.14_vite@7.1.10__@types+node@24.8.0__picomatch@4.0.3_@types+node@24.8.0", "npm:@types/node@24": "24.8.0", + "npm:@wora/cache-persist@^2.2.1": "2.2.1", "npm:eslint-config-prettier@^10.1.8": "10.1.8_eslint@9.37.0", "npm:eslint-plugin-svelte@^3.12.4": "3.12.4_eslint@9.37.0_svelte@5.40.1__acorn@8.15.0_postcss@8.5.6", "npm:eslint@^9.36.0": "9.37.0", "npm:globals@^16.4.0": "16.4.0", + "npm:hash-wasm@^4.12.0": "4.12.0", "npm:lru-cache@^11.2.2": "11.2.2", "npm:prettier-plugin-svelte@^3.4.0": "3.4.0_prettier@3.6.2_svelte@5.40.1__acorn@8.15.0", "npm:prettier-plugin-tailwindcss@~0.6.14": "0.6.14_prettier@3.6.2_prettier-plugin-svelte@3.4.0__prettier@3.6.2__svelte@5.40.1___acorn@8.15.0_svelte@5.40.1__acorn@8.15.0", @@ -725,6 +727,12 @@ "eslint-visitor-keys@4.2.1" ] }, + "@wora/cache-persist@2.2.1": { + "integrity": "sha512-X9MHiML5F8z3mabnl6J8hAwjn9g6Sria6+wUGwo97UDLMOWpZtJ+Jp/DQ7GjI1JirVXMQUDXBftVDgpvjhpNcw==", + "dependencies": [ + "idb" + ] + }, "acorn-jsx@5.3.2_acorn@8.15.0": { "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dependencies": [ @@ -1101,6 +1109,12 @@ }, "has-flag@4.0.0": { "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" + }, + "hash-wasm@4.12.0": { + "integrity": "sha512-+/2B2rYLb48I/evdOIhP+K/DD2ca2fgBjp6O+GBEnCDk2e4rpeXIK8GvIyRPjTezgmWn9gmKwkQjjx6BtqDHVQ==" + }, + "idb@4.0.5": { + "integrity": "sha512-P+Fk9HT2h1DhXoE1YNK183SY+CRh2GHNh28de94sGwhe0bUA75JJeVJWt3SenE5p0BXK7maflIq29dl6UZHrFw==" }, "ignore@5.3.2": { "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==" @@ -1707,10 +1721,12 @@ "npm:@tailwindcss/forms@~0.5.10", "npm:@tailwindcss/vite@^4.1.13", "npm:@types/node@24", + "npm:@wora/cache-persist@^2.2.1", "npm:eslint-config-prettier@^10.1.8", "npm:eslint-plugin-svelte@^3.12.4", "npm:eslint@^9.36.0", "npm:globals@^16.4.0", + "npm:hash-wasm@^4.12.0", "npm:lru-cache@^11.2.2", "npm:prettier-plugin-svelte@^3.4.0", "npm:prettier-plugin-tailwindcss@~0.6.14", diff --git a/package.json b/package.json --- a/package.json +++ b/package.json @@ -19,6 +19,8 @@ "@atcute/client": "^4.0.5", "@atcute/identity": "^1.1.1", "@atcute/lexicons": "^1.2.2", + "@wora/cache-persist": "^2.2.1", + "hash-wasm": "^4.12.0", "lru-cache": "^11.2.2" }, "devDependencies": { diff --git a/src/app.css b/src/app.css --- a/src/app.css +++ b/src/app.css @@ -1,2 +1,16 @@ @import 'tailwindcss'; @plugin '@tailwindcss/forms'; + +.grain:before { + content: ''; + background-color: transparent; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 600 600'%3E%3Cfilter id='a'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23a)'/%3E%3C/svg%3E"); + background-repeat: repeat; + background-size: 364px; + opacity: 0.05; + top: 0; + left: 0; + position: absolute; + width: 100%; + height: 100%; +} diff --git a/src/components/AccountSelector.svelte b/src/components/AccountSelector.svelte new file mode 100644 --- /dev/null +++ b/src/components/AccountSelector.svelte @@ -0,0 +1,295 @@ + + + + +
+ + + {#if isDropdownOpen} + + +
e.stopPropagation()} + > + {#if accounts.length > 0} +
+ {#each accounts as account (account.did)} + {@const color = generateColorForDid(account.did)} + + {/each} +
+
+ {/if} + +
+ {/if} +
+ +{#if isLoginModalOpen} +
+ + +
e.stopPropagation()} + role="dialog" + > +
+
+

add account

+
+
+
+
+
+ + +
+ +
+
+ + +
+ +
+ + +
+ + {#if loginError} +
+

{loginError}

+
+ {/if} + +
+ + +
+
+
+
+{/if} diff --git a/src/components/BskyPost.svelte b/src/components/BskyPost.svelte --- a/src/components/BskyPost.svelte +++ b/src/components/BskyPost.svelte @@ -2,43 +2,140 @@ import type { AtpClient } from '$lib/at/client'; import { AppBskyFeedPost } from '@atcute/bluesky'; import type { ActorIdentifier, RecordKey } from '@atcute/lexicons'; + import { theme } from '$lib/theme.svelte'; + import { map, ok } from '$lib/result'; + import type { Backlinks } from '$lib/at/constellation'; + import { generateColorForDid } from '$lib/accounts'; interface Props { client: AtpClient; identifier: ActorIdentifier; rkey: RecordKey; + replyBacklinks?: Backlinks; + record?: AppBskyFeedPost.Main; } - const { client, identifier, rkey }: Props = $props(); + const { client, identifier, rkey, record, replyBacklinks }: Props = $props(); - const post = client.getRecord(AppBskyFeedPost.mainSchema, identifier, rkey); + const color = generateColorForDid(identifier) ?? theme.accent2; + + let handle = $state(identifier); + client + .resolveDidDoc(identifier) + .then((res) => map(res, (data) => data.handle)) + .then((res) => { + if (res.ok) handle = res.value; + }); + const post = record + ? Promise.resolve(ok(record)) + : client.getRecord(AppBskyFeedPost.mainSchema, identifier, rkey); + const replies = replyBacklinks + ? Promise.resolve(ok(replyBacklinks)) + : client.getBacklinks( + identifier, + 'app.bsky.feed.post', + rkey, + 'app.bsky.feed.post:reply.parent.uri' + ); const getEmbedText = (embedType: string) => { switch (embedType) { case 'app.bsky.embed.external': - return 'contains external link'; + return '๐Ÿ”— has external link'; case 'app.bsky.embed.record': - return 'quotes post'; + return '๐Ÿ’ฌ has quote'; case 'app.bsky.embed.images': - return 'contains images'; + return '๐Ÿ–ผ๏ธ has images'; case 'app.bsky.embed.video': - return 'contains video'; + return '๐ŸŽฅ has video'; case 'app.bsky.embed.recordWithMedia': - return 'quotes post with media'; + return '๐Ÿ“Ž has quote with media'; default: - return 'contains unknown embed'; + return 'โ“ has unknown embed'; } + }; + + const getRelativeTime = (date: Date) => { + const now = new Date(); + const diff = now.getTime() - date.getTime(); + const seconds = Math.floor(diff / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + const months = Math.floor(days / 30); + const years = Math.floor(months / 12); + + if (years > 0) return `${years} year${years > 1 ? 's' : ''} ago`; + if (months > 0) return `${months} month${months > 1 ? 's' : ''} ago`; + if (days > 0) return `${days} day${days > 1 ? 's' : ''} ago`; + if (hours > 0) return `${hours} hour${hours > 1 ? 's' : ''} ago`; + if (minutes > 0) return `${minutes} minute${minutes > 1 ? 's' : ''} ago`; + return `${seconds} second${seconds > 1 ? 's' : ''} ago`; }; {#await post} - loading post... +
+
+

loading post...

+
{:then post} {#if post.ok} {@const record = post.value} - {identifier} - [{record.embed ? getEmbedText(record.embed.$type) : null}] - {record.text} +
+
+ + @{handle} + + ยท + {#await replies} + โ€ฆ replies + {:then replies} + {#if replies.ok} + {@const repliesValue = replies.value} + + {#if repliesValue.total > 0} + {repliesValue.total} + {repliesValue.total > 1 ? 'replies' : 'reply'} + {:else} + no replies + {/if} + + {:else} + {replies.error} + {/if} + {/await} + ยท + {getRelativeTime(new Date(record.createdAt))} +
+

+ {record.text} + {#if record.embed} + + {getEmbedText(record.embed.$type)} + + {/if} +

+
{:else} - error fetching post: {post.error} +
+

error: {post.error}

+
{/if} {/await} diff --git a/src/components/PostComposer.svelte b/src/components/PostComposer.svelte --- a/src/components/PostComposer.svelte +++ b/src/components/PostComposer.svelte @@ -4,6 +4,7 @@ import type { ComAtprotoRepoCreateRecord } from '@atcute/atproto'; import type { AppBskyFeedPost } from '@atcute/bluesky'; import type { InferOutput } from '@atcute/lexicons'; + import { theme } from '$lib/theme.svelte'; interface Props { client: AtpClient; @@ -45,24 +46,45 @@ let info = $state(''); -
- {#if info.length > 0} - {info} - {/if} -
- - +
+
+ {#if info.length > 0} +
+ {info} +
+ {:else} +
+ + +
+ {/if}
diff --git a/src/lib/accounts.ts b/src/lib/accounts.ts new file mode 100644 --- /dev/null +++ b/src/lib/accounts.ts @@ -0,0 +1,41 @@ +import type { Did, Handle } from '@atcute/lexicons'; +import { writable } from 'svelte/store'; +import { createXXHash3, type IHasher } from 'hash-wasm'; + +export type Account = { + did: Did; + handle: Handle; + password: string; +}; + +let _accounts: Account[] = []; +export const accounts = (() => { + const raw = localStorage.getItem('accounts'); + _accounts = raw ? JSON.parse(raw) : []; + const store = writable(_accounts); + store.subscribe((accounts) => { + _accounts = accounts; + localStorage.setItem('accounts', JSON.stringify(accounts)); + }); + return store; +})(); + +export const addAccount = (account: Account): void => { + accounts.update((accounts) => [...accounts, account]); +}; + +// fucked up and evil (i hate promises :3) +const _initHasher = () => { + createXXHash3(90001, 8008135).then((s) => (hasher = s)); + return null; +}; +let hasher: IHasher | null = _initHasher(); + +export const generateColorForDid = (did: string): string | null => { + const h = hasher!; + h.init(); + h.update(did); + const hex = h.digest(); + const color = hex.slice(-6); + return `#${color}`; +}; diff --git a/src/lib/cache.ts b/src/lib/cache.ts new file mode 100644 --- /dev/null +++ b/src/lib/cache.ts @@ -0,0 +1,71 @@ +import { Cache, type CacheOptions } from '@wora/cache-persist'; +import { LRUCache } from 'lru-cache'; + +export interface PersistedLRUOptions { + prefix?: string; + max: number; + ttl?: number; + persistOptions?: CacheOptions; +} + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export class PersistedLRU { + private memory: LRUCache; + private storage: Cache; // from wora/cache-persist + + private prefix = ''; // or derive from options + + constructor(opts: PersistedLRUOptions) { + this.memory = new LRUCache({ + max: opts.max, + ttl: opts.ttl + }); + this.storage = new Cache(opts.persistOptions); + this.prefix = opts.prefix ? `${opts.prefix}%` : ''; + + this.init(); + } + + async init(): Promise { + await this.storage.restore(); + + const state = this.storage.getState(); + for (const [key, val] of Object.entries(state)) { + try { + console.log('restoring', key); + const k = this.unprefix(key) as unknown as K; + const v = val as V; + this.memory.set(k, v); + } catch (err) { + console.warn('skipping invalid persisted entry', key, err); + } + } + } + + get(key: K): V | undefined { + return this.memory.get(key); + } + set(key: K, value: V): void { + this.memory.set(key, value); + this.storage.set(this.prefixed(key), value); + this.storage.flush(); // TODO: uh evil and fucked up (this whole file is evil honestly) + } + has(key: K): boolean { + return this.memory.has(key); + } + delete(key: K): void { + this.memory.delete(key); + this.storage.delete(this.prefixed(key)); + } + clear(): void { + this.memory.clear(); + this.storage.purge(); // clears stored state + } + + private prefixed(key: K): string { + return this.prefix + key; + } + private unprefix(prefixed: string): string { + return prefixed.slice(this.prefix.length); + } +} diff --git a/src/lib/theme.svelte.ts b/src/lib/theme.svelte.ts new file mode 100644 --- /dev/null +++ b/src/lib/theme.svelte.ts @@ -0,0 +1,13 @@ +export const theme = $state({ + bg: '#0f172a', // slate-900 - deep blue-grey background + fg: '#f8fafc', // slate-50 - crisp white foreground + accent: '#ec4899', // pink-500 - vibrant pink accent + accent2: '#8b5cf6' // violet-500 - purple secondary accent +}); + +export const setTheme = (bg: string, fg: string, accent: string, accent2: string) => { + theme.bg = bg; + theme.fg = fg; + theme.accent = accent; + theme.accent2 = accent2; +}; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,5 +1,6 @@ -
- -
- +
+
+

nucleus

+
+
+
+
+
+ +
+
+ + + {#if selectedClient} +
+ +
+ {:else} +
+

+ select or add an account to post +

+
+ {/if} +
+ +
+ +
+ {#each sortedTimeline as [postUri, data] (postUri)} + {@const parsedUri = expect(parseCanonicalResourceUri(postUri))} + + {/each} +
+
diff --git a/src/lib/at/client.ts b/src/lib/at/client.ts --- a/src/lib/at/client.ts +++ b/src/lib/at/client.ts @@ -1,8 +1,20 @@ -import { err, map, ok, type Result } from '$lib/result'; -import { ComAtprotoIdentityResolveIdentity, ComAtprotoRepoGetRecord } from '@atcute/atproto'; +import { err, expect, map, ok, type Result } from '$lib/result'; +import { + ComAtprotoIdentityResolveHandle, + ComAtprotoRepoGetRecord, + ComAtprotoRepoListRecords +} from '@atcute/atproto'; import { Client as AtcuteClient, CredentialManager } from '@atcute/client'; import { safeParse, type Handle, type InferOutput } from '@atcute/lexicons'; -import type { ActorIdentifier, AtprotoDid, Nsid, RecordKey } from '@atcute/lexicons/syntax'; +import { + isHandle, + parseCanonicalResourceUri, + type ActorIdentifier, + type AtprotoDid, + type CanonicalResourceUri, + type Nsid, + type RecordKey +} from '@atcute/lexicons/syntax'; import type { InferXRPCBodyOutput, ObjectSchema, @@ -11,39 +23,29 @@ XRPCQueryMetadata } from '@atcute/lexicons/validations'; import * as v from '@atcute/lexicons/validations'; -import { LRUCache } from 'lru-cache'; - -export const MiniDocQuery = v.query('com.bad-example.identity.resolveMiniDoc', { - params: v.object({ - identifier: v.actorIdentifierString() - }), - output: { - type: 'lex', - schema: v.object({ - did: v.didString(), - handle: v.handleString(), - pds: v.genericUriString(), - signing_key: v.string() - }) - } -}); -export type MiniDoc = InferOutput; +import { MiniDocQuery, type MiniDoc } from './slingshot'; +import { BacklinksQuery, type Backlinks, type BacklinksSource } from './constellation'; +import type { Records } from '@atcute/lexicons/ambient'; +import { PersistedLRU } from '$lib/cache'; const cacheTtl = 1000 * 60 * 60 * 24; -const handleCache = new LRUCache({ +const handleCache = new PersistedLRU({ max: 1000, - ttl: cacheTtl + ttl: cacheTtl, + prefix: 'handle' }); -const didDocCache = new LRUCache({ +const didDocCache = new PersistedLRU({ max: 1000, - ttl: cacheTtl + ttl: cacheTtl, + prefix: 'didDoc' }); -const recordCache = new LRUCache< +const recordCache = new PersistedLRU< string, InferOutput >({ max: 5000, - ttl: cacheTtl + ttl: cacheTtl, + prefix: 'record' }); export class AtpClient { @@ -52,6 +54,7 @@ private slingshotUrl: URL = new URL('https://slingshot.microcosm.blue'); private spacedustUrl: URL = new URL('https://spacedust.microcosm.blue'); + private constellationUrl: URL = new URL('https://constellation.microcosm.blue'); async login(handle: Handle, password: string): Promise> { const didDoc = await this.resolveDidDoc(handle); @@ -101,15 +104,36 @@ return ok(parsed.value as Output); } + async listRecords( + collection: Collection, + repo: ActorIdentifier, + cursor?: string, + limit?: number + ): Promise< + Result, string> + > { + if (!this.atcute) return err('not authenticated'); + const res = await this.atcute.get('com.atproto.repo.listRecords', { + params: { + repo, + collection, + cursor, + limit + } + }); + if (!res.ok) return err(`${res.data.error}: ${res.data.message ?? 'no details'}`); + return ok(res.data); + } + async resolveHandle(handle: Handle): Promise> { const cached = handleCache.get(handle); if (cached) return ok(cached); const res = await fetchMicrocosm( this.slingshotUrl, - ComAtprotoIdentityResolveIdentity.mainSchema, + ComAtprotoIdentityResolveHandle.mainSchema, { - handle: handle + handle } ); @@ -136,24 +160,57 @@ return result; } + + async getBacklinksUri( + uri: CanonicalResourceUri, + source: BacklinksSource + ): Promise> { + const parsedResourceUri = expect(parseCanonicalResourceUri(uri)); + return await this.getBacklinks( + parsedResourceUri.repo, + parsedResourceUri.collection, + parsedResourceUri.rkey, + source + ); + } + + async getBacklinks( + repo: ActorIdentifier, + collection: Nsid, + rkey: RecordKey, + source: BacklinksSource + ): Promise> { + let did = repo; + if (isHandle(did)) { + const resolvedDid = await this.resolveHandle(did); + if (!resolvedDid.ok) { + return err(`failed to resolve handle: ${resolvedDid.error}`); + } + did = resolvedDid.value; + } + return await fetchMicrocosm(this.constellationUrl, BacklinksQuery, { + subject: `at://${did}/${collection}/${rkey}`, + source, + limit: 100 + }); + } } const fetchMicrocosm = async < Schema extends XRPCQueryMetadata, + Input extends Schema['params'] extends ObjectSchema ? InferOutput : undefined, Output extends InferXRPCBodyOutput >( api: URL, schema: Schema, - params?: URLSearchParams | Record, + params: Input, init?: RequestInit ): Promise> => { if (!schema.output || schema.output.type === 'blob') return err('schema must be blob'); - if (params && !(params instanceof URLSearchParams)) params = new URLSearchParams(params); - if (params?.size === 0) params = undefined; try { api.pathname = `/xrpc/${schema.nsid}`; - api.search = params ? `?${params}` : ''; - // console.info(`fetching:`, api.href); + api.search = params ? `?${new URLSearchParams(params)}` : ''; + console.info(`fetching:`, api.href); const response = await fetch(api, init); const body = await response.json(); if (response.status === 400) return err(`${body.error}: ${body.message}`); diff --git a/src/lib/at/constellation.ts b/src/lib/at/constellation.ts new file mode 100644 --- /dev/null +++ b/src/lib/at/constellation.ts @@ -0,0 +1,27 @@ +import type { Nsid } from '@atcute/lexicons'; +import * as v from '@atcute/lexicons/validations'; + +export type BacklinksSource = `${Nsid}:${string}`; +export const BacklinkSchema = v.object({ + did: v.didString(), + collection: v.nsidString(), + rkey: v.recordKeyString() +}); +export const BacklinksQuery = v.query('blue.microcosm.links.getBacklinks', { + params: v.object({ + subject: v.resourceUriString(), + source: v.string(), + did: v.optional(v.array(v.didString())), + limit: v.optional(v.integer()) + }), + output: { + type: 'lex', + schema: v.object({ + total: v.integer(), + records: v.array(BacklinkSchema), + cursor: v.nullable(v.string()) + }) + } +}); +export type Backlink = v.InferOutput; +export type Backlinks = v.InferOutput; diff --git a/src/lib/at/fetch.ts b/src/lib/at/fetch.ts --- a/src/lib/at/fetch.ts +++ b/src/lib/at/fetch.ts @@ -0,0 +1,59 @@ +import type { ActorIdentifier, CanonicalResourceUri } from '@atcute/lexicons'; +import type { AtpClient } from './client'; +import { err, map, ok, type Result } from '$lib/result'; +import type { Backlinks } from './constellation'; +import { AppBskyFeedPost } from '@atcute/bluesky'; + +export type PostWithBacklinks = { + post: AppBskyFeedPost.Main; + replies: Backlinks | string; +}; +export type PostsWithReplyBacklinks = Map; + +export const fetchPostsWithReplyBacklinks = async ( + client: AtpClient, + repo: ActorIdentifier, + cursor?: string, + limit?: number +): Promise> => { + const recordsList = await client.listRecords('app.bsky.feed.post', repo, cursor, limit); + if (!recordsList.ok) return err(`can't retrieve posts: ${recordsList.error}`); + cursor = recordsList.value.cursor; + const records = recordsList.value.records; + + const allBacklinks = await Promise.all( + records.map((r) => + client + .getBacklinksUri(r.uri as CanonicalResourceUri, 'app.bsky.feed.post:reply.parent.uri') + .then((res) => ({ + key: r.uri as CanonicalResourceUri, + value: { + post: r.value as AppBskyFeedPost.Main, + replies: res.ok ? res.value : res.error + } + })) + ) + ); + + return ok({ posts: new Map(allBacklinks.map((b) => [b.key, b.value])), cursor }); +}; + +export const fetchReplies = async (client: AtpClient, data: PostsWithReplyBacklinks) => { + const allReplies = await Promise.all( + Array.from(data.values()).map(async (d) => { + if (typeof d.replies === 'string') return []; + const replies = await Promise.all( + d.replies.records.map((r) => + client + .getRecord(AppBskyFeedPost.mainSchema, r.did, r.rkey) + .then((res) => + map(res, (d) => ({ uri: `at://${r.did}/app.bsky.feed.post/${r.rkey}`, record: d })) + ) + ) + ); + return replies; + }) + ); + + return allReplies.flat(); +}; diff --git a/src/lib/at/slingshot.ts b/src/lib/at/slingshot.ts new file mode 100644 --- /dev/null +++ b/src/lib/at/slingshot.ts @@ -0,0 +1,17 @@ +import * as v from '@atcute/lexicons/validations'; + +export const MiniDocQuery = v.query('com.bad-example.identity.resolveMiniDoc', { + params: v.object({ + identifier: v.actorIdentifierString() + }), + output: { + type: 'lex', + schema: v.object({ + did: v.didString(), + handle: v.handleString(), + pds: v.genericUriString(), + signing_key: v.string() + }) + } +}); +export type MiniDoc = v.InferOutput; -- tangled.sh