A fork of pds-dash for selfhosted.social
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 };