appview-less bluesky client
1import {
2 parseCanonicalResourceUri,
3 type CanonicalResourceUri,
4 type Cid,
5 type ResourceUri
6} from '@atcute/lexicons';
7import { type AtpClient } from './client.svelte';
8import { err, expect, ok, type Ok, type Result } from '$lib/result';
9import type { Backlinks } from './constellation';
10import { AppBskyFeedPost } from '@atcute/bluesky';
11import type { Did, RecordKey } from '@atcute/lexicons/syntax';
12import { replySource, toCanonicalUri } from '$lib';
13
14export type PostWithUri = { uri: ResourceUri; cid: Cid | undefined; record: AppBskyFeedPost.Main };
15export type PostWithBacklinks = PostWithUri & {
16 replies?: Backlinks;
17};
18
19export const fetchPosts = async (
20 subject: Did,
21 client: AtpClient,
22 cursor?: string,
23 limit?: number,
24 withBacklinks: boolean = true
25): Promise<Result<{ posts: PostWithBacklinks[]; cursor?: string }, string>> => {
26 const recordsList = await client.listRecords(subject, 'app.bsky.feed.post', cursor, limit);
27 if (!recordsList.ok) return err(`can't retrieve posts: ${recordsList.error}`);
28 cursor = recordsList.value.cursor;
29 const records = recordsList.value.records;
30
31 if (!withBacklinks) {
32 return ok({
33 posts: records.map((r) => ({
34 uri: r.uri,
35 cid: r.cid,
36 record: r.value as AppBskyFeedPost.Main
37 })),
38 cursor
39 });
40 }
41
42 try {
43 const allBacklinks = await Promise.all(
44 records.map(async (r): Promise<PostWithBacklinks> => {
45 const result = await client.getBacklinks(r.uri, replySource);
46 if (!result.ok) throw `cant fetch replies: ${result.error}`;
47 const replies = result.value;
48 return {
49 uri: r.uri,
50 cid: r.cid,
51 record: r.value as AppBskyFeedPost.Main,
52 replies
53 };
54 })
55 );
56 return ok({ posts: allBacklinks, cursor });
57 } catch (error) {
58 return err(`cant fetch posts backlinks: ${error}`);
59 }
60};
61
62export type HydrateOptions = {
63 downwards: 'sameAuthor' | 'none';
64};
65
66export const hydratePosts = async (
67 client: AtpClient,
68 repo: Did,
69 data: PostWithBacklinks[],
70 cacheFn: (did: Did, rkey: RecordKey) => Ok<PostWithUri> | undefined,
71 options?: Partial<HydrateOptions>
72): Promise<Result<Map<ResourceUri, PostWithUri>, string>> => {
73 let posts: Map<ResourceUri, PostWithUri> = new Map();
74 try {
75 const allPosts = await Promise.all(
76 data.map(async (post) => {
77 const result: PostWithUri[] = [post];
78 if (post.replies) {
79 const replies = await Promise.all(
80 post.replies.records.map(async (r) => {
81 const reply =
82 cacheFn(r.did, r.rkey) ??
83 (await client.getRecord(AppBskyFeedPost.mainSchema, r.did, r.rkey));
84 if (!reply.ok) throw `cant fetch reply: ${reply.error}`;
85 return reply.value;
86 })
87 );
88 result.push(...replies);
89 }
90 return result;
91 })
92 );
93 posts = new Map(allPosts.flat().map((post) => [post.uri, post]));
94 } catch (error) {
95 return err(`cant hydrate immediate replies: ${error}`);
96 }
97
98 const fetchUpwardsChain = async (post: PostWithUri) => {
99 let parent = post.record.reply?.parent;
100 while (parent) {
101 const parentUri = parent.uri as CanonicalResourceUri;
102 // if we already have this parent, then we already fetched this chain / are fetching it
103 if (posts.has(parentUri)) return;
104 const parsedParentUri = expect(parseCanonicalResourceUri(parentUri));
105 const p =
106 cacheFn(parsedParentUri.repo, parsedParentUri.rkey) ??
107 (await client.getRecord(
108 AppBskyFeedPost.mainSchema,
109 parsedParentUri.repo,
110 parsedParentUri.rkey
111 ));
112 if (p.ok) {
113 posts.set(p.value.uri, p.value);
114 parent = p.value.record.reply?.parent;
115 continue;
116 }
117 // TODO: handle deleted parent posts
118 parent = undefined;
119 }
120 };
121 await Promise.all(posts.values().map(fetchUpwardsChain));
122
123 if (options?.downwards !== 'none') {
124 try {
125 const fetchDownwardsChain = async (post: PostWithUri) => {
126 const { repo: postRepo } = expect(parseCanonicalResourceUri(post.uri));
127 if (repo === postRepo) return;
128
129 // get chains that are the same author until we exhaust them
130 const backlinks = await client.getBacklinks(post.uri, replySource);
131 if (!backlinks.ok) return;
132
133 const promises = [];
134 for (const reply of backlinks.value.records) {
135 if (reply.did !== postRepo) continue;
136 // if we already have this reply, then we already fetched this chain / are fetching it
137 if (posts.has(toCanonicalUri(reply))) continue;
138 const record =
139 cacheFn(reply.did, reply.rkey) ??
140 (await client.getRecord(AppBskyFeedPost.mainSchema, reply.did, reply.rkey));
141 if (!record.ok) break; // TODO: this doesnt handle deleted posts in between
142 posts.set(record.value.uri, record.value);
143 promises.push(fetchDownwardsChain(record.value));
144 }
145
146 await Promise.all(promises);
147 };
148 await Promise.all(posts.values().map(fetchDownwardsChain));
149 } catch (error) {
150 return err(`cant fetch post reply chain: ${error}`);
151 }
152 }
153
154 return ok(posts);
155};