A frontend for your PDS
at main 465 lines 13 kB view raw
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 };