A frontend for your PDS
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 deactivated?: boolean;
28 hiddenFromHomepage: boolean;
29}
30
31let accountsMetadata: AccountMetadata[] = [];
32let accountFetchIndex = 0;
33let initialFetchDone = false;
34
35interface atUriObject {
36 repo: string;
37 collection: string;
38 rkey: string;
39 handle?: string;
40}
41class Post {
42 authorDid: string;
43 authorAvatarCid: string | null;
44 postCid: string;
45 recordName: string;
46 authorHandle: string;
47 displayName: string;
48 text: string;
49 timestamp: number;
50 timenotstamp: string;
51 quotingUri: atUriObject | null;
52 replyingUri: atUriObject | null;
53 quotingHandle: string | null;
54 replyingHandle: string | null;
55 imagesCid: string[] | null;
56 videosLinkCid: string | null;
57 gifLink: string | null;
58 facets: any[] | null;
59 imageAlts: string[] | null;
60 externalLink: { uri: string; title: string; description?: string; thumb?: string } | null;
61
62 constructor(
63 record: ComAtprotoRepoListRecords.Record,
64 account: AccountMetadata,
65 ) {
66 this.postCid = record.cid;
67 this.recordName = processAtUri(record.uri).rkey;
68 this.authorDid = account.did;
69 this.authorAvatarCid = account.avatarCid;
70 this.authorHandle = account.handle;
71 this.displayName = account.displayName;
72 const post = record.value as AppBskyFeedPost.Record;
73 this.timenotstamp = post.createdAt;
74 this.text = post.text;
75 this.timestamp = Date.parse(post.createdAt);
76 this.facets = post.facets || null;
77 this.imageAlts = null;
78 this.externalLink = null;
79 if (post.reply) {
80 this.replyingUri = processAtUri(post.reply.parent.uri);
81 } else {
82 this.replyingUri = null;
83 }
84 this.quotingUri = null;
85 this.quotingHandle = null;
86 this.replyingHandle = null;
87 this.imagesCid = null;
88 this.videosLinkCid = null;
89 this.gifLink = null;
90 switch (post.embed?.$type) {
91 case "app.bsky.embed.images":
92 this.imagesCid = post.embed.images.map(
93 (imageRecord: any) => imageRecord.image.ref.$link,
94 );
95 this.imageAlts = post.embed.images.map(
96 (imageRecord: any) => imageRecord.alt || '',
97 );
98 break;
99 case "app.bsky.embed.video":
100 this.videosLinkCid = post.embed.video.ref.$link;
101 break;
102 case "app.bsky.embed.record":
103 this.quotingUri = processAtUri(post.embed.record.uri);
104 break;
105 case "app.bsky.embed.recordWithMedia":
106 this.quotingUri = processAtUri(post.embed.record.record.uri);
107 switch (post.embed.media.$type) {
108 case "app.bsky.embed.images":
109 this.imagesCid = post.embed.media.images.map(
110 (imageRecord) => imageRecord.image.ref.$link,
111 );
112 this.imageAlts = post.embed.media.images.map(
113 (imageRecord) => imageRecord.alt || '',
114 );
115
116 break;
117 case "app.bsky.embed.video":
118 this.videosLinkCid = post.embed.media.video.ref.$link;
119
120 break;
121 }
122 break;
123 case "app.bsky.embed.external":
124 // Check if it's a GIF
125 if (post.embed.external.uri.includes(".gif")) {
126 this.gifLink = post.embed.external.uri;
127 } else {
128 // It's a regular external link
129 this.externalLink = {
130 uri: post.embed.external.uri,
131 title: post.embed.external.title || post.embed.external.uri,
132 description: post.embed.external.description,
133 thumb: post.embed.external.thumb?.ref?.$link || undefined,
134 };
135 }
136 break;
137 }
138 }
139}
140
141const processAtUri = (aturi: string): atUriObject => {
142 const parts = aturi.split("/");
143 return {
144 repo: parts[2],
145 collection: parts[3],
146 rkey: parts[4],
147 };
148};
149
150const rpc = new XRPC({
151 handler: simpleFetchHandler({
152 service: Config.PDS_URL,
153 }),
154});
155
156const getDidsFromPDS = async (): Promise<At.Did[]> => {
157 const { data } = await rpc.get("com.atproto.sync.listRepos", {
158 params: {},
159 });
160
161 return data.repos
162 .filter((repo: any) => repo.active !== false)
163 .map((repo: any) => repo.did) as At.Did[];
164};
165
166const getAccountMetadata = async (
167 did: `did:${string}:${string}`,
168) => {
169 const account: AccountMetadata = {
170 did: did,
171 handle: "", // Guaranteed to be filled out later
172 displayName: "",
173 avatarCid: null,
174 deactivated: false,
175 hiddenFromHomepage: false,
176 };
177
178 try {
179 const { data } = await rpc.get("com.atproto.repo.getRecord", {
180 params: {
181 repo: did,
182 collection: "app.bsky.actor.profile",
183 rkey: "self",
184 },
185 });
186 const value = data.value as AppBskyActorProfile.Record;
187 account.displayName = value.displayName || "";
188 if (value.avatar) {
189 account.avatarCid = value.avatar.ref["$link"];
190 }
191 } catch (e) {
192 console.warn(`Error fetching profile for ${did}:`, e);
193 }
194
195 try {
196 const { data } = await rpc.get("com.atproto.repo.getRecord", {
197 params: {
198 repo: did,
199 collection: "social.tophhie.profile",
200 rkey: "self",
201 },
202 });
203 const value = (data.value as any).pdsPreferences.showOnHomepage;
204 account.hiddenFromHomepage = value === false;
205 } catch {
206 // Ignore errors, as this record may not exist
207 }
208
209 try {
210 account.handle = await blueskyHandleFromDid(did);
211 } catch (e) {
212 console.error(`Error fetching handle for ${did}:`, e);
213 return null;
214 }
215
216 return account;
217};
218
219const getAllMetadataFromPds = async (): Promise<AccountMetadata[]> => {
220 const dids = await getDidsFromPDS();
221 const metadata = await Promise.all(
222 dids.map(async (repo: `did:${string}:${string}`) => {
223 return await getAccountMetadata(repo);
224 }),
225 );
226 return metadata.filter((account) => account !== null) as AccountMetadata[];
227};
228
229const identityResolve = async (did: At.Did) => {
230 const resolver = new CompositeDidDocumentResolver({
231 methods: {
232 plc: new PlcDidDocumentResolver(),
233 web: new WebDidDocumentResolver(),
234 },
235 });
236
237 if (did.startsWith("did:plc:") || did.startsWith("did:web:")) {
238 const doc = await resolver.resolve(
239 did as `did:plc:${string}` | `did:web:${string}`,
240 );
241 return doc;
242 } else {
243 throw new Error(`Unsupported DID type: ${did}`);
244 }
245};
246
247const blueskyHandleFromDid = async (did: At.Did) => {
248 const doc = await identityResolve(did);
249 if (doc.alsoKnownAs) {
250 const handleAtUri = doc.alsoKnownAs.find((url) => url.startsWith("at://"));
251 const handle = handleAtUri?.split("/")[2];
252 if (!handle) {
253 return "Handle not found";
254 } else {
255 return handle;
256 }
257 } else {
258 return "Handle not found";
259 }
260};
261
262interface PostsAcc {
263 posts: ComAtprotoRepoListRecords.Record[];
264 account: AccountMetadata;
265}
266const getCutoffDate = (postAccounts: PostsAcc[]) => {
267 const now = Date.now();
268 let cutoffDate: Date | null = null;
269 postAccounts.forEach((postAcc) => {
270 const latestPost = new Date(
271 (postAcc.posts[postAcc.posts.length - 1].value as AppBskyFeedPost.Record)
272 .createdAt,
273 );
274 if (!cutoffDate) {
275 cutoffDate = latestPost;
276 } else {
277 if (latestPost > cutoffDate) {
278 cutoffDate = latestPost;
279 }
280 }
281 });
282 if (cutoffDate) {
283 return cutoffDate;
284 } else {
285 return new Date(now);
286 }
287};
288
289const filterPostsByDate = (posts: PostsAcc[], cutoffDate: Date) => {
290 // filter posts for each account that are older than the cutoff date and save the cursor of the last post included
291 const filteredPosts: PostsAcc[] = posts.map((postAcc) => {
292 const filtered = postAcc.posts.filter((post) => {
293 const postDate = new Date(
294 (post.value as AppBskyFeedPost.Record).createdAt,
295 );
296 return postDate >= cutoffDate;
297 });
298 if (filtered.length > 0) {
299 postAcc.account.currentCursor = processAtUri(filtered[filtered.length - 1].uri).rkey;
300 }
301 return {
302 posts: filtered,
303 account: postAcc.account,
304 };
305 });
306 return filteredPosts;
307};
308
309// Cache for DID to handle resolution
310const didToHandleCache = new Map<string, string>();
311
312const resolveDidToHandle = async (did: string): Promise<string> => {
313 // Check cache first
314 if (didToHandleCache.has(did)) {
315 return didToHandleCache.get(did)!;
316 }
317
318 try {
319 const handle = await blueskyHandleFromDid(did as At.Did);
320 didToHandleCache.set(did, handle);
321 return handle;
322 } catch (e) {
323 console.warn(`Failed to resolve handle for ${did}:`, e);
324 return did; // Fallback to DID if resolution fails
325 }
326};
327
328const postsMutex = new Mutex();
329
330const getNextPosts = async (): Promise<Post[]> => {
331 const release = await postsMutex.obtain();
332 try {
333 // Ensure metadata is loaded
334 if (!accountsMetadata.length) {
335 accountsMetadata = await getAllMetadataFromPds();
336 }
337 if (!accountsMetadata.length) {
338 return [];
339 }
340 // Fetch posts for a subset of accounts (batch) to reduce per-call load
341 const accountsPerBatch = Config.ACCOUNTS_PER_BATCH || 10;
342 let accountsToFetch: AccountMetadata[];
343
344 // On the first fetch, query all accounts but with a small per-account limit
345 if (!initialFetchDone) {
346 accountsToFetch = accountsMetadata.slice();
347 initialFetchDone = true;
348 } else {
349 const start = accountFetchIndex;
350 const end = start + accountsPerBatch;
351 if (end <= accountsMetadata.length) {
352 accountsToFetch = accountsMetadata.slice(start, end);
353 } else {
354 accountsToFetch = accountsMetadata.slice(start).concat(
355 accountsMetadata.slice(0, end % accountsMetadata.length),
356 );
357 }
358
359 // Advance the rotating index for the next invocation
360 accountFetchIndex = (accountFetchIndex + accountsToFetch.length) % accountsMetadata.length;
361 }
362
363 const postsAcc: PostsAcc[] = await Promise.all(
364 accountsToFetch.map(async (account) => {
365 if (account.hiddenFromHomepage) {
366 return {
367 posts: [],
368 account,
369 };
370 }
371 const result = await fetchPostsForUser(
372 account.did,
373 account.currentCursor || null,
374 Config.POSTS_BATCH_SIZE,
375 );
376
377 const records = result?.records ?? [];
378
379 // Always update cursor, even if no posts
380 if (result?.cursor) {
381 account.currentCursor = result.cursor;
382 }
383
384 return {
385 posts: records,
386 account,
387 };
388 }),
389 );
390
391 // Flatten posts
392 let records = postsAcc.flatMap((p) => p.posts);
393
394 // Sort by timestamp (newest first)
395 records.sort((a, b) => {
396 const aDate = new Date((a.value as AppBskyFeedPost.Record).createdAt).getTime();
397 const bDate = new Date((b.value as AppBskyFeedPost.Record).createdAt).getTime();
398 return bDate - aDate;
399 });
400
401 // Filter out future posts if needed
402 if (!Config.SHOW_FUTURE_POSTS) {
403 const now = Date.now();
404 records = records.filter((post) => {
405 const postDate = new Date((post.value as AppBskyFeedPost.Record).createdAt).getTime();
406 return postDate <= now;
407 });
408 }
409
410 // Map to Post objects
411 const newPosts = records.map((record) => {
412 const account = accountsMetadata.find(
413 (acc) => acc.did === processAtUri(record.uri).repo
414 );
415 if (!account) {
416 throw new Error(`Account with DID ${processAtUri(record.uri).repo} not found`);
417 }
418 return new Post(record, account);
419 });
420
421 // Resolve handles for replies and quotes
422 await Promise.all(
423 newPosts.map(async (post) => {
424 if (post.replyingUri) {
425 post.replyingHandle = await resolveDidToHandle(post.replyingUri.repo);
426 }
427 if (post.quotingUri) {
428 post.quotingHandle = await resolveDidToHandle(post.quotingUri.repo);
429 }
430 })
431 );
432
433 console.log(`Fetched ${newPosts.length} posts`);
434 return newPosts;
435 } finally {
436 release();
437 }
438};
439
440const fetchPostsForUser = async (did: At.Did, cursor: string | null, limit: number = Config.POSTS_BATCH_SIZE) => {
441 try {
442 const { data } = await rpc.get("com.atproto.repo.listRecords", {
443 params: {
444 repo: did as At.Identifier,
445 collection: "app.bsky.feed.post",
446 limit: limit,
447 cursor: cursor || undefined,
448 },
449 });
450
451 return {
452 records: data.records as ComAtprotoRepoListRecords.Record[],
453 cursor: data.cursor ?? null,
454 };
455 } catch (e) {
456 console.error(`Error fetching posts for ${did}:`, e);
457 return {
458 records: [],
459 cursor: null,
460 };
461 }
462};
463
464export { getAllMetadataFromPds, getNextPosts, Post, fetchPostsForUser };
465export type { AccountMetadata };