pds dash for shimaenaga.veryroundbird.house (based off of pds.witchcraft.systems)
1import { simpleFetchHandler, XRPC } from "@atcute/client";
2import "@atcute/bluesky/lexicons";
3import type {
4 AppBskyActorDefs,
5 AppBskyActorProfile,
6 AppBskyFeedPost,
7 At,
8 ComAtprotoRepoListRecords,
9} from "@atcute/client/lexicons";
10import {
11 CompositeDidDocumentResolver,
12 PlcDidDocumentResolver,
13 WebDidDocumentResolver,
14} from "@atcute/identity-resolver";
15import { Config } from "../../config";
16import { Mutex } from "mutex-ts"
17// import { ComAtprotoRepoListRecords.Record } from "@atcute/client/lexicons";
18// import { AppBskyFeedPost } from "@atcute/client/lexicons";
19// import { AppBskyActorDefs } from "@atcute/client/lexicons";
20
21interface AccountMetadata {
22 did: At.Did;
23 displayName: string;
24 handle: string;
25 avatarCid: string | null;
26 currentCursor?: string;
27}
28
29let accountsMetadata: AccountMetadata[] = [];
30
31interface atUriObject {
32 repo: string;
33 collection: string;
34 rkey: string;
35}
36class Post {
37 authorDid: string;
38 authorAvatarCid: string | null;
39 postCid: string;
40 recordName: string;
41 authorHandle: string;
42 displayName: string;
43 text: string;
44 timestamp: number;
45 timenotstamp: string;
46 quotingUri: atUriObject | null;
47 replyingUri: atUriObject | null;
48 imagesCid: string[] | null;
49 videosLinkCid: string | null;
50 gifLink: string | null;
51
52 constructor(
53 record: ComAtprotoRepoListRecords.Record,
54 account: AccountMetadata,
55 ) {
56 this.postCid = record.cid;
57 this.recordName = processAtUri(record.uri).rkey;
58 this.authorDid = account.did;
59 this.authorAvatarCid = account.avatarCid;
60 this.authorHandle = account.handle;
61 this.displayName = account.displayName;
62 const post = record.value as AppBskyFeedPost.Record;
63 this.timenotstamp = post.createdAt;
64 this.text = post.text;
65 this.timestamp = Date.parse(post.createdAt);
66 if (post.reply) {
67 this.replyingUri = processAtUri(post.reply.parent.uri);
68 } else {
69 this.replyingUri = null;
70 }
71 this.quotingUri = null;
72 this.imagesCid = null;
73 this.videosLinkCid = null;
74 this.gifLink = null;
75 switch (post.embed?.$type) {
76 case "app.bsky.embed.images":
77 this.imagesCid = post.embed.images.map(
78 (imageRecord: any) => imageRecord.image.ref.$link,
79 );
80 break;
81 case "app.bsky.embed.video":
82 this.videosLinkCid = post.embed.video.ref.$link;
83 break;
84 case "app.bsky.embed.record":
85 this.quotingUri = processAtUri(post.embed.record.uri);
86 break;
87 case "app.bsky.embed.recordWithMedia":
88 this.quotingUri = processAtUri(post.embed.record.record.uri);
89 switch (post.embed.media.$type) {
90 case "app.bsky.embed.images":
91 this.imagesCid = post.embed.media.images.map(
92 (imageRecord) => imageRecord.image.ref.$link,
93 );
94
95 break;
96 case "app.bsky.embed.video":
97 this.videosLinkCid = post.embed.media.video.ref.$link;
98
99 break;
100 }
101 break;
102 case "app.bsky.embed.external": // assuming that external embeds are gifs for now
103 if (post.embed.external.uri.includes(".gif")) {
104 this.gifLink = post.embed.external.uri;
105 }
106 break;
107 }
108 }
109}
110
111const processAtUri = (aturi: string): atUriObject => {
112 const parts = aturi.split("/");
113 return {
114 repo: parts[2],
115 collection: parts[3],
116 rkey: parts[4],
117 };
118};
119
120const rpc = new XRPC({
121 handler: simpleFetchHandler({
122 service: Config.PDS_URL,
123 }),
124});
125
126const getDidsFromPDS = async (): Promise<At.Did[]> => {
127 const { data } = await rpc.get("com.atproto.sync.listRepos", {
128 params: {},
129 });
130 return data.repos.map((repo: any) => repo.did) as At.Did[];
131};
132const getAccountMetadata = async (
133 did: `did:${string}:${string}`,
134) => {
135 const account: AccountMetadata = {
136 did: did,
137 handle: "", // Guaranteed to be filled out later
138 displayName: "",
139 avatarCid: null,
140 };
141
142 try {
143 const { data } = await rpc.get("com.atproto.repo.getRecord", {
144 params: {
145 repo: did,
146 collection: "app.bsky.actor.profile",
147 rkey: "self",
148 },
149 });
150 const value = data.value as AppBskyActorProfile.Record;
151 account.displayName = value.displayName || "";
152 account.description = value.description || "";
153 if (value.avatar) {
154 account.avatarCid = value.avatar.ref["$link"];
155 }
156 if (value.banner) {
157 account.bannerCid = value.banner.ref["$link"];
158 }
159 } catch (e) {
160 console.warn(`Error fetching profile for ${did}:`, e);
161 }
162
163 try {
164 account.handle = await blueskyHandleFromDid(did);
165 } catch (e) {
166 console.error(`Error fetching handle for ${did}:`, e);
167 return null;
168 }
169
170 return account;
171};
172
173const getAllMetadataFromPds = async (): Promise<AccountMetadata[]> => {
174 const dids = await getDidsFromPDS();
175 const metadata = await Promise.all(
176 dids.map(async (repo: `did:${string}:${string}`) => {
177 return await getAccountMetadata(repo);
178 }),
179 );
180 return metadata.filter((account) => account !== null) as AccountMetadata[];
181};
182
183const identityResolve = async (did: At.Did) => {
184 const resolver = new CompositeDidDocumentResolver({
185 methods: {
186 plc: new PlcDidDocumentResolver(),
187 web: new WebDidDocumentResolver(),
188 },
189 });
190
191 if (did.startsWith("did:plc:") || did.startsWith("did:web:")) {
192 const doc = await resolver.resolve(
193 did as `did:plc:${string}` | `did:web:${string}`,
194 );
195 return doc;
196 } else {
197 throw new Error(`Unsupported DID type: ${did}`);
198 }
199};
200
201const blueskyHandleFromDid = async (did: At.Did) => {
202 const doc = await identityResolve(did);
203 if (doc.alsoKnownAs) {
204 const handleAtUri = doc.alsoKnownAs.find((url) => url.startsWith("at://"));
205 const handle = handleAtUri?.split("/")[2];
206 if (!handle) {
207 return "Handle not found";
208 } else {
209 return handle;
210 }
211 } else {
212 return "Handle not found";
213 }
214};
215
216interface PostsAcc {
217 posts: ComAtprotoRepoListRecords.Record[];
218 account: AccountMetadata;
219}
220const getCutoffDate = (postAccounts: PostsAcc[]) => {
221 const now = Date.now();
222 let cutoffDate: Date | null = null;
223 postAccounts.forEach((postAcc) => {
224 const latestPost = new Date(
225 (postAcc.posts[postAcc.posts.length - 1].value as AppBskyFeedPost.Record)
226 .createdAt,
227 );
228 if (!cutoffDate) {
229 cutoffDate = latestPost;
230 } else {
231 if (latestPost > cutoffDate) {
232 cutoffDate = latestPost;
233 }
234 }
235 });
236 if (cutoffDate) {
237 return cutoffDate;
238 } else {
239 return new Date(now);
240 }
241};
242
243const filterPostsByDate = (posts: PostsAcc[], cutoffDate: Date) => {
244 // filter posts for each account that are older than the cutoff date and save the cursor of the last post included
245 const filteredPosts: PostsAcc[] = posts.map((postAcc) => {
246 const filtered = postAcc.posts.filter((post) => {
247 const postDate = new Date(
248 (post.value as AppBskyFeedPost.Record).createdAt,
249 );
250 return postDate >= cutoffDate;
251 });
252 if (filtered.length > 0) {
253 postAcc.account.currentCursor = processAtUri(filtered[filtered.length - 1].uri).rkey;
254 }
255 return {
256 posts: filtered,
257 account: postAcc.account,
258 };
259 });
260 return filteredPosts;
261};
262
263const postsMutex = new Mutex();
264// nightmare function. However it works so I am not touching it
265const getNextPosts = async () => {
266 const release = await postsMutex.obtain();
267 if (!accountsMetadata.length) {
268 accountsMetadata = await getAllMetadataFromPds();
269 }
270
271 const postsAcc: PostsAcc[] = await Promise.all(
272 accountsMetadata.map(async (account) => {
273 const posts = await fetchPostsForUser(
274 account.did,
275 account.currentCursor || null,
276 );
277 if (posts) {
278 return {
279 posts: posts,
280 account: account,
281 };
282 } else {
283 return {
284 posts: [],
285 account: account,
286 };
287 }
288 }),
289 );
290 const recordsFiltered = postsAcc.filter((postAcc) =>
291 postAcc.posts.length > 0
292 );
293 const cutoffDate = getCutoffDate(recordsFiltered);
294 const recordsCutoff = filterPostsByDate(recordsFiltered, cutoffDate);
295 // update the accountMetadata with the new cursor
296 accountsMetadata = accountsMetadata.map((account) => {
297 const postAcc = recordsCutoff.find(
298 (postAcc) => postAcc.account.did == account.did,
299 );
300 if (postAcc) {
301 account.currentCursor = postAcc.account.currentCursor;
302 }
303 return account;
304 }
305 );
306 // throw the records in a big single array
307 let records = recordsCutoff.flatMap((postAcc) => postAcc.posts);
308 // sort the records by timestamp
309 records = records.sort((a, b) => {
310 const aDate = new Date(
311 (a.value as AppBskyFeedPost.Record).createdAt,
312 ).getTime();
313 const bDate = new Date(
314 (b.value as AppBskyFeedPost.Record).createdAt,
315 ).getTime();
316 return bDate - aDate;
317 });
318 // filter out posts that are in the future
319 if (!Config.SHOW_FUTURE_POSTS) {
320 const now = Date.now();
321 records = records.filter((post) => {
322 const postDate = new Date(
323 (post.value as AppBskyFeedPost.Record).createdAt,
324 ).getTime();
325 return postDate <= now;
326 });
327 }
328
329 const newPosts = records.map((record) => {
330 const account = accountsMetadata.find(
331 (account) => account.did == processAtUri(record.uri).repo,
332 );
333 if (!account) {
334 throw new Error(
335 `Account with DID ${processAtUri(record.uri).repo} not found`,
336 );
337 }
338 return new Post(record, account);
339 });
340 // release the mutex
341 release();
342 return newPosts;
343};
344
345const fetchPostsForUser = async (did: At.Did, cursor: string | null) => {
346 try {
347 const { data } = await rpc.get("com.atproto.repo.listRecords", {
348 params: {
349 repo: did as At.Identifier,
350 collection: "app.bsky.feed.post",
351 limit: Config.MAX_POSTS,
352 cursor: cursor || undefined,
353 },
354 });
355 return data.records as ComAtprotoRepoListRecords.Record[];
356 } catch (e) {
357 console.error(`Error fetching posts for ${did}:`, e);
358 return null;
359 }
360};
361
362export { getAllMetadataFromPds, getNextPosts, Post };
363export type { AccountMetadata };