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"
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {#if 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...
+
{: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}
+
{/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 @@
-
-
-
-
+
+
+
+
+
+
+
+ {#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;