A fork of pds-dash for selfhosted.social
at main 431 lines 12 kB view raw
1import { simpleFetchHandler, XRPC } from '@atcute/client'; 2import '@atcute/bluesky/lexicons'; 3import type { 4 AppBskyActorProfile, 5 AppBskyEmbedImages, 6 AppBskyFeedPost, 7 At, 8 ComAtprotoRepoListRecords, 9} from '@atcute/client/lexicons'; 10import { Config } from '$lib/config'; 11import { Mutex } from 'mutex-ts'; 12import moment from 'moment'; 13import { RichText } from '@atproto/api'; 14import type {Repo} from '@atproto/api/dist/client/types/com/atproto/sync/listRepos'; 15 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 gifLink: string | null; 50 hideMedia: boolean; 51 richText: RichText; 52 53 constructor( 54 record: ComAtprotoRepoListRecords.Record, 55 account: AccountMetadata, 56 richText: RichText, 57 ) { 58 const post = record.value as AppBskyFeedPost.Record; 59 const hideLabels = ['!hide', '!no-promote', '!warn', '!no-unauthenticated', 60 'dmca-violation', 'doxxing', 'porn', 'sexual', 'nudity', 61 'nsfl', 'gore']; 62 63 if (post.labels?.values?.length > 0) { 64 65 const labels = post.labels.values.map(label => label.val); 66 this.hideMedia = hideLabels.some(label => labels.includes(label)); 67 } else { 68 this.hideMedia = false; 69 } 70 71 this.richText = richText; 72 this.postCid = record.cid; 73 this.recordName = processAtUri(record.uri).rkey; 74 this.authorDid = account.did; 75 this.authorAvatarCid = account.avatarCid; 76 this.authorHandle = account.handle; 77 this.displayName = account.displayName; 78 this.timenotstamp = post.createdAt; 79 this.text = post.text; 80 this.timestamp = Date.parse(post.createdAt); 81 if (post.reply) { 82 this.replyingUri = processAtUri(post.reply.parent.uri); 83 } else { 84 this.replyingUri = null; 85 } 86 this.quotingUri = 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: AppBskyEmbedImages.Image) => 94 imageRecord.image.ref.$link, 95 ); 96 break; 97 case 'app.bsky.embed.video': 98 this.videosLinkCid = post.embed.video.ref.$link; 99 break; 100 case 'app.bsky.embed.record': 101 this.quotingUri = processAtUri(post.embed.record.uri); 102 break; 103 case 'app.bsky.embed.recordWithMedia': 104 this.quotingUri = processAtUri(post.embed.record.record.uri); 105 switch (post.embed.media.$type) { 106 case 'app.bsky.embed.images': 107 this.imagesCid = post.embed.media.images.map( 108 (imageRecord) => imageRecord.image.ref.$link, 109 ); 110 111 break; 112 case 'app.bsky.embed.video': 113 this.videosLinkCid = post.embed.media.video.ref.$link; 114 115 break; 116 } 117 break; 118 case 'app.bsky.embed.external': // assuming that external embeds are gifs for now 119 if (post.embed.external.uri.includes('.gif')) { 120 this.gifLink = post.embed.external.uri; 121 } 122 break; 123 } 124 } 125} 126 127const processAtUri = (aturi: string): atUriObject => { 128 const parts = aturi.split('/'); 129 return { 130 repo: parts[2], 131 collection: parts[3], 132 rkey: parts[4], 133 }; 134}; 135 136const rpc = new XRPC({ 137 handler: simpleFetchHandler({ 138 service: Config.PDS_URL, 139 }), 140}); 141 142const getAllMetadataFromPds = async (): Promise<AccountMetadata[]> => { 143 try { 144 if (accountsMetadata.length > 0) { 145 return accountsMetadata; 146 } 147 const response = await fetch('/api/accounts'); 148 if (!response.ok) { 149 throw new Error(`Failed to fetch metadata: ${response.statusText}`); 150 } 151 const metadata = await response.json(); 152 // Populate the module-level accountsMetadata to prevent duplicate fetches 153 accountsMetadata = metadata; 154 return metadata; 155 } catch (error) { 156 console.error('Error fetching all accounts:', error); 157 return []; 158 } 159}; 160 161const blueskyHandleFromDid = async (did: At.Did) => { 162 try { 163 const localStorageKey = `did-handle:${did}`; 164 const cachedResult = cacheGet<string>(localStorageKey); 165 if (cachedResult) { 166 return cachedResult; 167 } 168 169 const response = await fetch(`/api/handle/${did}`); 170 if (!response.ok) { 171 throw new Error(`Failed to fetch handle: ${response.statusText}`); 172 } 173 const data = await response.json(); 174 cacheSet<string>(localStorageKey, data.handle); 175 return data.handle; 176 } catch (error) { 177 console.error(`Error fetching handle for ${did}:`, error); 178 return 'Handle not found'; 179 } 180}; 181 182interface PostsAcc { 183 posts: ComAtprotoRepoListRecords.Record[]; 184 account: AccountMetadata; 185} 186const getCutoffDate = (postAccounts: PostsAcc[]) => { 187 const now = Date.now(); 188 let cutoffDate: Date | null = null; 189 postAccounts.forEach((postAcc) => { 190 const latestPost = new Date( 191 (postAcc.posts[postAcc.posts.length - 1].value as AppBskyFeedPost.Record) 192 .createdAt, 193 ); 194 if (!cutoffDate) { 195 cutoffDate = latestPost; 196 } else { 197 if (latestPost > cutoffDate) { 198 cutoffDate = latestPost; 199 } 200 } 201 }); 202 if (cutoffDate) { 203 return cutoffDate; 204 } else { 205 return new Date(now); 206 } 207}; 208 209const filterPostsByDate = (posts: PostsAcc[], cutoffDate: Date) => { 210 // filter posts for each account that are older than the cutoff date and save the cursor of the last post included 211 const filteredPosts: PostsAcc[] = posts.map((postAcc) => { 212 const filtered = postAcc.posts.filter((post) => { 213 const postDate = new Date( 214 (post.value as AppBskyFeedPost.Record).createdAt, 215 ); 216 return postDate >= cutoffDate; 217 }); 218 if (filtered.length > 0) { 219 postAcc.account.currentCursor = 220 processAtUri(filtered[filtered.length - 1].uri).rkey; 221 } 222 return { 223 posts: filtered, 224 account: postAcc.account, 225 }; 226 }); 227 return filteredPosts; 228}; 229 230const postsMutex = new Mutex(); 231// nightmare function. However it works so I am not touching it 232const getNextPosts = async () => { 233 const release = await postsMutex.obtain(); 234 if (!accountsMetadata.length) { 235 accountsMetadata = await getAllMetadataFromPds(); 236 } 237 238 const postsAcc: PostsAcc[] = await Promise.all( 239 accountsMetadata.map(async (account) => { 240 const posts = await fetchPostsForUser( 241 account.did, 242 account.currentCursor || null, 243 ); 244 if (posts) { 245 return { 246 posts: posts, 247 account: account, 248 }; 249 } else { 250 return { 251 posts: [], 252 account: account, 253 }; 254 } 255 }), 256 ); 257 const recordsFiltered = postsAcc.filter((postAcc) => 258 postAcc.posts.length > 0 259 ); 260 const cutoffDate = getCutoffDate(recordsFiltered); 261 const recordsCutoff = filterPostsByDate(recordsFiltered, cutoffDate); 262 // update the accountMetadata with the new cursor 263 accountsMetadata = accountsMetadata.map((account) => { 264 const postAcc = recordsCutoff.find( 265 (postAcc) => postAcc.account.did == account.did, 266 ); 267 if (postAcc) { 268 account.currentCursor = postAcc.account.currentCursor; 269 } 270 return account; 271 }); 272 // throw the records in a big single array 273 let records = recordsCutoff.flatMap((postAcc) => postAcc.posts); 274 // sort the records by timestamp 275 records = records.sort((a, b) => { 276 const aDate = new Date( 277 (a.value as AppBskyFeedPost.Record).createdAt, 278 ).getTime(); 279 const bDate = new Date( 280 (b.value as AppBskyFeedPost.Record).createdAt, 281 ).getTime(); 282 return bDate - aDate; 283 }); 284 // filter out posts that are in the future 285 if (!Config.SHOW_FUTURE_POSTS) { 286 const now = Date.now(); 287 records = records.filter((post) => { 288 const postDate = new Date( 289 (post.value as AppBskyFeedPost.Record).createdAt, 290 ).getTime(); 291 return postDate <= now; 292 }); 293 } 294 295 const newPosts = records.map((record) => { 296 const account = accountsMetadata.find( 297 (account) => account.did == processAtUri(record.uri).repo, 298 ); 299 if (!account) { 300 throw new Error( 301 `Account with DID ${processAtUri(record.uri).repo} not found`, 302 ); 303 } 304 const post = record.value as AppBskyFeedPost.Record; 305 const richText = new RichText({ text: post.text, facets: post.facets }); 306 307 return new Post(record, account, richText); 308 }); 309 // release the mutex 310 release(); 311 return newPosts; 312}; 313 314const fetchPostsForUser = async (did: At.Did, cursor: string | null) => { 315 try { 316 const { data } = await rpc.get('com.atproto.repo.listRecords', { 317 params: { 318 repo: did as At.Identifier, 319 collection: 'app.bsky.feed.post', 320 limit: Config.MAX_POSTS, 321 cursor: cursor || undefined, 322 }, 323 }); 324 return data.records as ComAtprotoRepoListRecords.Record[]; 325 } catch (e) { 326 console.error(`Error fetching posts for ${did}:`, e); 327 return null; 328 } 329}; 330 331type artists = { 332 artistName: string; 333}; 334 335type dietTeal = { 336 artists: artists[]; 337 trackName: string; 338 playedTime: number; 339}; 340 341const getTealNowListeningTo = async (did: At.Did) => { 342 const { data } = await rpc.get('com.atproto.repo.listRecords', { 343 params: { 344 repo: did as At.Identifier, 345 collection: 'fm.teal.alpha.feed.play', 346 limit: 1, 347 }, 348 }); 349 if (data.records.length > 0) { 350 const record = data.records[0] as ComAtprotoRepoListRecords.Record; 351 const value = record.value as dietTeal; 352 const artists = value.artists.map((artist) => artist.artistName).join(', '); 353 const timeStamp = 354 moment(value.playedTime).isBefore(moment().subtract(1, 'month')) 355 ? moment(value.playedTime).format('MMM D, YYYY') 356 : moment(value.playedTime).fromNow(); 357 return `Listening to ${value.trackName} by ${artists} ${timeStamp}`; 358 } 359 return null; 360}; 361 362type statusSphere = { 363 status: string; 364}; 365 366const getStatusSphere = async (did: At.Did) => { 367 const { data } = await rpc.get('com.atproto.repo.listRecords', { 368 params: { 369 repo: did as At.Identifier, 370 collection: 'xyz.statusphere.status', 371 limit: 1, 372 }, 373 }); 374 if (data.records.length > 0) { 375 const record = data.records[0].value as statusSphere; 376 return record.status; 377 } 378 return null; 379}; 380 381type CacheEntry<T> = { 382 data: T; 383 expire_timestamp: number; 384}; 385 386// In-memory cache using Map (will be replaced with SQLite later) 387const inMemoryCache = new Map<string, CacheEntry<unknown>>(); 388 389const cacheSet = <T>(key: string, value: T) => { 390 try { 391 const day = 60 * 60 * 24 * 1000; 392 const cacheData: CacheEntry<T> = { 393 data: value, 394 expire_timestamp: Date.now() + day, 395 }; 396 inMemoryCache.set(key, cacheData); 397 } catch (e) { 398 console.error('Error caching data:', e); 399 // Clear the cache if something goes wrong 400 inMemoryCache.clear(); 401 } 402}; 403 404const cacheGet = <T>(key: string): T | null => { 405 try { 406 const cachedData = inMemoryCache.get(key); 407 if (cachedData) { 408 const parsedData = cachedData as CacheEntry<T>; 409 if (parsedData.expire_timestamp > Date.now()) { 410 return parsedData.data; 411 } else { 412 inMemoryCache.delete(key); 413 } 414 } 415 //Return null if empty or expired 416 return null; 417 } catch (e) { 418 console.error('Error fetching data from cache:', e); 419 return null; 420 } 421}; 422 423export { 424 blueskyHandleFromDid, 425 getAllMetadataFromPds, 426 getNextPosts, 427 getStatusSphere, 428 getTealNowListeningTo, 429 Post, 430}; 431export type { AccountMetadata };