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