appview-less bluesky client
24
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 155 lines 4.9 kB view raw
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};