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 const hydratePosts = async (
63 client: AtpClient,
64 repo: Did,
65 data: PostWithBacklinks[],
66 cacheFn: (did: Did, rkey: RecordKey) => Ok<PostWithUri> | undefined
67): Promise<Result<Map<ResourceUri, PostWithUri>, string>> => {
68 let posts: Map<ResourceUri, PostWithUri> = new Map();
69 try {
70 const allPosts = await Promise.all(
71 data.map(async (post) => {
72 const result: PostWithUri[] = [post];
73 if (post.replies) {
74 const replies = await Promise.all(
75 post.replies.records.map(async (r) => {
76 const reply =
77 cacheFn(r.did, r.rkey) ??
78 (await client.getRecord(AppBskyFeedPost.mainSchema, r.did, r.rkey));
79 if (!reply.ok) throw `cant fetch reply: ${reply.error}`;
80 return reply.value;
81 })
82 );
83 result.push(...replies);
84 }
85 return result;
86 })
87 );
88 posts = new Map(allPosts.flat().map((post) => [post.uri, post]));
89 } catch (error) {
90 return err(`cant hydrate immediate replies: ${error}`);
91 }
92
93 const fetchUpwardsChain = async (post: PostWithUri) => {
94 let parent = post.record.reply?.parent;
95 while (parent) {
96 const parentUri = parent.uri as CanonicalResourceUri;
97 // if we already have this parent, then we already fetched this chain / are fetching it
98 if (posts.has(parentUri)) return;
99 const parsedParentUri = expect(parseCanonicalResourceUri(parentUri));
100 const p =
101 cacheFn(parsedParentUri.repo, parsedParentUri.rkey) ??
102 (await client.getRecord(
103 AppBskyFeedPost.mainSchema,
104 parsedParentUri.repo,
105 parsedParentUri.rkey
106 ));
107 if (p.ok) {
108 posts.set(p.value.uri, p.value);
109 parent = p.value.record.reply?.parent;
110 continue;
111 }
112 // TODO: handle deleted parent posts
113 parent = undefined;
114 }
115 };
116 await Promise.all(posts.values().map(fetchUpwardsChain));
117
118 try {
119 const fetchDownwardsChain = async (post: PostWithUri) => {
120 const { repo: postRepo } = expect(parseCanonicalResourceUri(post.uri));
121 if (repo === postRepo) return;
122
123 // get chains that are the same author until we exhaust them
124 const backlinks = await client.getBacklinks(post.uri, replySource);
125 if (!backlinks.ok) return;
126
127 const promises = [];
128 for (const reply of backlinks.value.records) {
129 if (reply.did !== postRepo) continue;
130 // if we already have this reply, then we already fetched this chain / are fetching it
131 if (posts.has(toCanonicalUri(reply))) continue;
132 const record =
133 cacheFn(reply.did, reply.rkey) ??
134 (await client.getRecord(AppBskyFeedPost.mainSchema, reply.did, reply.rkey));
135 if (!record.ok) break; // TODO: this doesnt handle deleted posts in between
136 posts.set(record.value.uri, record.value);
137 promises.push(fetchDownwardsChain(record.value));
138 }
139
140 await Promise.all(promises);
141 };
142 await Promise.all(posts.values().map(fetchDownwardsChain));
143 } catch (error) {
144 return err(`cant fetch post reply chain: ${error}`);
145 }
146
147 return ok(posts);
148};