import {
parseCanonicalResourceUri,
type CanonicalResourceUri,
type Cid,
type ResourceUri
} from '@atcute/lexicons';
import { type AtpClient } from './client.svelte';
import { err, expect, ok, type Ok, type Result } from '$lib/result';
import type { Backlinks } from './constellation';
import { AppBskyFeedPost } from '@atcute/bluesky';
import type { Did, RecordKey } from '@atcute/lexicons/syntax';
import { replySource, toCanonicalUri } from '$lib';
export type PostWithUri = { uri: ResourceUri; cid: Cid | undefined; record: AppBskyFeedPost.Main };
export type PostWithBacklinks = PostWithUri & {
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(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;
if (!withBacklinks) {
return ok({
posts: records.map((r) => ({
uri: r.uri,
cid: r.cid,
record: r.value as AppBskyFeedPost.Main
})),
cursor
});
}
try {
const allBacklinks = await Promise.all(
records.map(async (r): Promise<PostWithBacklinks> => {
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,
cid: r.cid,
record: r.value as AppBskyFeedPost.Main,
replies
};
})
);
return ok({ posts: allBacklinks, cursor });
} catch (error) {
return err(`cant fetch posts backlinks: ${error}`);
}
};
export type HydrateOptions = {
downwards: 'sameAuthor' | 'none';
};
export const hydratePosts = async (
client: AtpClient,
repo: Did,
data: PostWithBacklinks[],
cacheFn: (did: Did, rkey: RecordKey) => Ok<PostWithUri> | undefined,
options?: Partial<HydrateOptions>
): Promise<Result<Map<ResourceUri, PostWithUri>, string>> => {
let posts: Map<ResourceUri, PostWithUri> = new Map();
try {
const allPosts = await Promise.all(
data.map(async (post) => {
const result: PostWithUri[] = [post];
if (post.replies) {
const replies = await Promise.all(
post.replies.records.map(async (r) => {
const reply =
cacheFn(r.did, r.rkey) ??
(await client.getRecord(AppBskyFeedPost.mainSchema, r.did, r.rkey));
if (!reply.ok) throw `cant fetch reply: ${reply.error}`;
return reply.value;
})
);
result.push(...replies);
}
return result;
})
);
posts = new Map(allPosts.flat().map((post) => [post.uri, post]));
} catch (error) {
return err(`cant hydrate immediate replies: ${error}`);
}
const fetchUpwardsChain = async (post: PostWithUri) => {
let parent = post.record.reply?.parent;
while (parent) {
const parentUri = parent.uri as CanonicalResourceUri;
// if we already have this parent, then we already fetched this chain / are fetching it
if (posts.has(parentUri)) return;
const parsedParentUri = expect(parseCanonicalResourceUri(parentUri));
const p =
cacheFn(parsedParentUri.repo, parsedParentUri.rkey) ??
(await client.getRecord(
AppBskyFeedPost.mainSchema,
parsedParentUri.repo,
parsedParentUri.rkey
));
if (p.ok) {
posts.set(p.value.uri, p.value);
parent = p.value.record.reply?.parent;
continue;
}
// TODO: handle deleted parent posts
parent = undefined;
}
};
await Promise.all(posts.values().map(fetchUpwardsChain));
if (options?.downwards !== 'none') {
try {
const fetchDownwardsChain = async (post: PostWithUri) => {
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.getBacklinks(post.uri, replySource);
if (!backlinks.ok) return;
const promises = [];
for (const reply of backlinks.value.records) {
if (reply.did !== postRepo) continue;
// if we already have this reply, then we already fetched this chain / are fetching it
if (posts.has(toCanonicalUri(reply))) continue;
const record =
cacheFn(reply.did, reply.rkey) ??
(await client.getRecord(AppBskyFeedPost.mainSchema, reply.did, reply.rkey));
if (!record.ok) break; // TODO: this doesnt handle deleted posts in between
posts.set(record.value.uri, record.value);
promises.push(fetchDownwardsChain(record.value));
}
await Promise.all(promises);
};
await Promise.all(posts.values().map(fetchDownwardsChain));
} catch (error) {
return err(`cant fetch post reply chain: ${error}`);
}
}
return ok(posts);
};