view who was fronting when a record was made
1import { expect } from "@/lib/result";
2import {
3 type Fronter,
4 fronterGetSocialAppHrefs,
5 getFronter,
6 getSpFronters,
7 putFronter,
8 frontersCache,
9 parseSocialAppPostUrl,
10 displayNameCache,
11 deleteFronter,
12 getPkFronters,
13 FronterView,
14 docResolver,
15} from "@/lib/utils";
16import {
17 AppBskyFeedLike,
18 AppBskyFeedPost,
19 AppBskyFeedRepost,
20 AppBskyNotificationListNotifications,
21} from "@atcute/bluesky";
22import { feedViewPostSchema } from "@atcute/bluesky/types/app/feed/defs";
23import { getAtprotoHandle } from "@atcute/identity";
24import { is, parseResourceUri, ResourceUri } from "@atcute/lexicons";
25import {
26 AtprotoDid,
27 Handle,
28 parseCanonicalResourceUri,
29} from "@atcute/lexicons/syntax";
30
31export default defineBackground({
32 persistent: true,
33 main: () => {
34 console.log("setting up background script");
35
36 const cacheFronter = async (uri: ResourceUri, fronter: Fronter) => {
37 const parsedUri = expect(parseResourceUri(uri));
38 await frontersCache.set(
39 `at://${fronter.did}/${parsedUri.collection!}/${parsedUri.rkey!}`,
40 fronter,
41 );
42 await frontersCache.set(
43 `at://${fronter.handle}/${parsedUri.collection!}/${parsedUri.rkey!}`,
44 fronter,
45 );
46 return parsedUri;
47 };
48
49 const setTabFronter = async (recordUri: ResourceUri, fronter: Fronter) => {
50 const tabs = await browser.tabs.query({
51 active: true,
52 currentWindow: true,
53 });
54 const tab = tabs[0];
55 const tabKey: StorageItemKey = `local:tab-${tab.id!}-fronter`;
56 const tabFronter = {
57 recordUri,
58 ...fronter,
59 };
60 await storage.setItem(tabKey, tabFronter);
61 const deleteOld = async (tabId: number) => {
62 if (`local:tab-${tabId}-fronter` !== tabKey) return;
63 await storage.removeItem(tabKey);
64 };
65 browser.tabs.onRemoved.addListener(deleteOld);
66 browser.tabs.onReplaced.addListener(deleteOld);
67 browser.tabs.onUpdated.addListener(deleteOld);
68 };
69
70 const handleDelete = async (
71 data: any,
72 authToken: string | null,
73 sender: globalThis.Browser.runtime.MessageSender,
74 ) => {
75 if (!authToken) return;
76 const deleted = await deleteFronter(
77 data.repo,
78 data.collection,
79 data.rkey,
80 authToken,
81 );
82 if (!deleted.ok) {
83 console.error("failed to delete fronter:", deleted.error);
84 }
85 };
86 const handleWrite = async (
87 items: any[],
88 authToken: string | null,
89 sender: globalThis.Browser.runtime.MessageSender,
90 ) => {
91 if (!authToken) return;
92 const frontersArray = await storage.getItem<string[]>("sync:fronters");
93 let members: Parameters<typeof putFronter>["1"] =
94 frontersArray?.map((n) => ({ name: n, uri: undefined })) ?? [];
95 if (members.length === 0) {
96 members = await getPkFronters();
97 }
98 if (members.length === 0) {
99 members = await getSpFronters();
100 }
101 // dont write if no names is specified or no sp/pk fronters are fetched
102 if (members.length === 0) return;
103 const results: FronterView[] = [];
104 for (const result of items) {
105 const resp = await putFronter(result.uri, members, authToken);
106 if (resp.ok) {
107 const parsedUri = await cacheFronter(result.uri, resp.value);
108 results.push({
109 type:
110 parsedUri.collection === "app.bsky.feed.repost"
111 ? "repost"
112 : parsedUri.collection === "app.bsky.feed.like"
113 ? "like"
114 : "post",
115 rkey: parsedUri.rkey!,
116 ...resp.value,
117 });
118 } else {
119 console.error(`fronter write: ${resp.error}`);
120 }
121 }
122 if (results.length === 0) return;
123 // hijack timeline fronter message because when a write is made it is either on the timeline
124 // or its a reply to a depth === 0 post on a threaded view, which is the same as a timeline post
125 browser.tabs.sendMessage(sender.tab?.id!, {
126 type: "APPLY_FRONTERS",
127 results: Object.fromEntries(
128 results.flatMap((fronter) =>
129 fronterGetSocialAppHrefs(fronter).map((href) => [href, fronter]),
130 ),
131 ),
132 });
133 };
134 const handleNotifications = async (
135 items: any,
136 sender: globalThis.Browser.runtime.MessageSender,
137 ) => {
138 const fetchReply = async (
139 uri: ResourceUri,
140 ): Promise<FronterView | undefined> => {
141 const cachedFronter = await frontersCache.get(uri);
142 const fronter =
143 (cachedFronter ?? null) ||
144 (await getFronter(uri).then((fronter) => {
145 if (!fronter.ok) {
146 frontersCache.set(uri, null);
147 return null;
148 }
149 return fronter.value;
150 }));
151 if (!fronter) return;
152 const parsedUri = await cacheFronter(uri, fronter);
153 return {
154 type: "post",
155 rkey: parsedUri.rkey!,
156 ...fronter,
157 };
158 };
159 const handleNotif = async (
160 item: AppBskyNotificationListNotifications.Notification,
161 ): Promise<FronterView | undefined> => {
162 let postUrl: ResourceUri | null = null;
163 const fronterUrl: ResourceUri = item.uri;
164 if (
165 item.reason === "subscribed-post" ||
166 item.reason === "quote" ||
167 item.reason === "reply"
168 )
169 postUrl = item.uri;
170 if (item.reason === "repost" || item.reason === "repost-via-repost")
171 postUrl = (item.record as AppBskyFeedRepost.Main).subject.uri;
172 if (item.reason === "like" || item.reason === "like-via-repost")
173 postUrl = (item.record as AppBskyFeedLike.Main).subject.uri;
174 if (!postUrl) return;
175 const cachedFronter = await frontersCache.get(fronterUrl);
176 let fronter =
177 (cachedFronter ?? null) ||
178 (await getFronter(fronterUrl).then((fronter) => {
179 if (!fronter.ok) {
180 frontersCache.set(fronterUrl, null);
181 return null;
182 }
183 return fronter.value;
184 }));
185 if (!fronter) return;
186 if (item.reason === "reply")
187 fronter.replyTo = (
188 item.record as AppBskyFeedPost.Main
189 ).reply?.parent.uri;
190 const parsedUri = await cacheFronter(fronterUrl, fronter);
191 const postParsedUri = expect(parseCanonicalResourceUri(postUrl));
192 let handle: Handle | undefined = undefined;
193 try {
194 handle =
195 getAtprotoHandle(
196 await docResolver.resolve(postParsedUri.repo as AtprotoDid),
197 ) ?? undefined;
198 } catch (err) {
199 console.error(`failed to get handle for ${postParsedUri.repo}:`, err);
200 }
201 return {
202 type: "notification",
203 reason: item.reason,
204 rkey: parsedUri.rkey!,
205 subject: {
206 did: postParsedUri.repo as AtprotoDid,
207 rkey: postParsedUri.rkey,
208 handle,
209 },
210 ...fronter,
211 };
212 };
213 const allPromises = [];
214 for (const item of items.notifications ?? []) {
215 if (!is(AppBskyNotificationListNotifications.notificationSchema, item))
216 continue;
217 console.log("Handling notification:", item);
218 allPromises.push(handleNotif(item));
219 if (item.reason === "reply" && item.record) {
220 const parentUri = (item.record as AppBskyFeedPost.Main).reply?.parent
221 .uri;
222 if (parentUri) allPromises.push(fetchReply(parentUri));
223 }
224 }
225 const results = new Map(
226 (await Promise.allSettled(allPromises))
227 .filter((result) => result.status === "fulfilled")
228 .flatMap((result) => result.value ?? [])
229 .flatMap((fronter) =>
230 fronterGetSocialAppHrefs(fronter).map((href) => [href, fronter]),
231 ),
232 );
233 if (results.size === 0) return;
234 browser.tabs.sendMessage(sender.tab?.id!, {
235 type: "APPLY_FRONTERS",
236 results: Object.fromEntries(results),
237 });
238 };
239 const handleTimeline = async (
240 feed: any[],
241 sender: globalThis.Browser.runtime.MessageSender,
242 ) => {
243 const allPromises = feed.flatMap(
244 (item): Promise<FronterView | undefined>[] => {
245 if (!is(feedViewPostSchema, item)) return [];
246 const handleUri = async (
247 uri: ResourceUri,
248 type: "repost" | "post",
249 ) => {
250 const cachedFronter = await frontersCache.get(uri);
251 if (cachedFronter === null) return;
252 const promise = cachedFronter
253 ? Promise.resolve(cachedFronter)
254 : getFronter(uri).then(async (fronter) => {
255 if (!fronter.ok) {
256 await frontersCache.set(uri, null);
257 return;
258 }
259 return fronter.value;
260 });
261 return await promise.then(
262 async (fronter): Promise<FronterView | undefined> => {
263 if (!fronter) return;
264 if (type === "repost") {
265 const parsedPostUri = expect(
266 parseCanonicalResourceUri(item.post.uri),
267 );
268 fronter = {
269 subject: {
270 did: parsedPostUri.repo as AtprotoDid,
271 rkey: parsedPostUri.rkey,
272 handle:
273 item.post.author.handle === "handle.invalid"
274 ? undefined
275 : item.post.author.handle,
276 },
277 ...fronter,
278 };
279 } else if (
280 uri === item.post.uri &&
281 item.reply?.parent.$type === "app.bsky.feed.defs#postView"
282 ) {
283 fronter = {
284 replyTo: item.reply?.parent.uri,
285 ...fronter,
286 };
287 } else if (
288 uri === item.reply?.parent.uri &&
289 item.reply?.parent.$type === "app.bsky.feed.defs#postView"
290 ) {
291 fronter = {
292 replyTo: (item.reply.parent.record as AppBskyFeedPost.Main)
293 .reply?.parent.uri,
294 ...fronter,
295 };
296 }
297 const parsedUri = await cacheFronter(uri, fronter);
298 return {
299 type,
300 rkey: parsedUri.rkey!,
301 ...fronter,
302 };
303 },
304 );
305 };
306 const promises: ReturnType<typeof handleUri>[] = [];
307 promises.push(handleUri(item.post.uri, "post"));
308 if (item.reply?.parent) {
309 promises.push(handleUri(item.reply.parent.uri, "post"));
310 if (item.reply?.parent.$type === "app.bsky.feed.defs#postView") {
311 const grandparentUri = (
312 item.reply.parent.record as AppBskyFeedPost.Main
313 ).reply?.parent.uri;
314 if (grandparentUri)
315 promises.push(handleUri(grandparentUri, "post"));
316 }
317 }
318 if (item.reply?.root) {
319 promises.push(handleUri(item.reply.root.uri, "post"));
320 }
321 if (
322 item.reason &&
323 item.reason.$type === "app.bsky.feed.defs#reasonRepost" &&
324 item.reason.uri
325 ) {
326 promises.push(handleUri(item.reason.uri, "repost"));
327 }
328 return promises;
329 },
330 );
331 const results = new Map(
332 (await Promise.allSettled(allPromises))
333 .filter((result) => result.status === "fulfilled")
334 .flatMap((result) => result.value ?? [])
335 .flatMap((fronter) =>
336 fronterGetSocialAppHrefs(fronter).map((href) => [href, fronter]),
337 ),
338 );
339 if (results.size === 0) return;
340 browser.tabs.sendMessage(sender.tab?.id!, {
341 type: "APPLY_FRONTERS",
342 results: Object.fromEntries(results),
343 });
344 // console.log("sent timeline fronters", results);
345 };
346 const handleThread = async (
347 {
348 data: { body, requestUrl, documentUrl },
349 }: { data: { body: string; requestUrl: string; documentUrl: string } },
350 sender: globalThis.Browser.runtime.MessageSender,
351 ) => {
352 // check if this request was made for fetching replies
353 // if anchor is not the same as current document url, that is the case
354 // which means the depth of the returned posts are invalid to us
355 let isReplyThreadFetch = false;
356 const parsedDocumentUri = parseSocialAppPostUrl(documentUrl);
357 const anchorUri = new URL(requestUrl).searchParams.get("anchor");
358 // console.log(
359 // "parsedDocumentUri",
360 // parsedDocumentUri,
361 // "anchorUri",
362 // anchorUri,
363 // );
364 if (parsedDocumentUri && anchorUri) {
365 const parsedAnchorUri = expect(parseResourceUri(anchorUri));
366 isReplyThreadFetch = parsedDocumentUri.rkey !== parsedAnchorUri.rkey;
367 }
368 // console.log("isReplyThreadFetch", isReplyThreadFetch);
369 const data: any = JSON.parse(body);
370 const promises = (data.thread as any[]).flatMap((item) => {
371 return frontersCache.get(item.uri).then(async (cachedFronter) => {
372 if (cachedFronter === null) return [];
373 const promise = cachedFronter
374 ? Promise.resolve(cachedFronter)
375 : getFronter(item.uri).then(async (fronter) => {
376 if (!fronter.ok) {
377 await frontersCache.set(item.uri, null);
378 return;
379 }
380 return fronter.value;
381 });
382 return promise.then(
383 async (fronter): Promise<FronterView | undefined> => {
384 if (!fronter) return;
385 const parsedUri = await cacheFronter(item.uri, fronter);
386 if (isReplyThreadFetch)
387 return {
388 type: "thread_reply",
389 rkey: parsedUri.rkey!,
390 ...fronter,
391 };
392 if (item.depth === 0) await setTabFronter(item.uri, fronter);
393 const displayName = item.value.post.author.displayName;
394 // cache display name for later use
395 if (fronter.handle)
396 await displayNameCache.set(fronter.handle, displayName);
397 await displayNameCache.set(fronter.did, displayName);
398 return {
399 type: "thread_post",
400 rkey: parsedUri.rkey!,
401 displayName,
402 depth: item.depth,
403 ...fronter,
404 };
405 },
406 );
407 });
408 });
409 const results = new Map(
410 (await Promise.allSettled(promises))
411 .filter((result) => result.status === "fulfilled")
412 .flatMap((result) => result.value ?? [])
413 .flatMap((fronter) =>
414 fronterGetSocialAppHrefs(fronter).map((href) => [href, fronter]),
415 ),
416 );
417 if (results.size === 0) return;
418 browser.tabs.sendMessage(sender.tab?.id!, {
419 type: "APPLY_FRONTERS",
420 results: Object.fromEntries(results),
421 });
422 // console.log("sent thread fronters", results);
423 };
424 const handleInteractions = async (
425 data: any,
426 sender: globalThis.Browser.runtime.MessageSender,
427 collection: string,
428 actors: { did: AtprotoDid; displayName: string }[],
429 ) => {
430 const postUri = data.uri as ResourceUri;
431 const fetchInteractions = async (cursor?: string) => {
432 const resp = await fetch(
433 `https://constellation.microcosm.blue/links?target=${postUri}&collection=${collection}&path=.subject.uri&limit=100${cursor ? `&cursor=${cursor}` : ""}`,
434 );
435 if (!resp.ok) return;
436 const data = await resp.json();
437 return {
438 total: data.total as number,
439 records: data.linking_records.map(
440 (record: any) =>
441 `at://${record.did}/${record.collection}/${record.rkey}` as ResourceUri,
442 ) as ResourceUri[],
443 cursor: data.cursor as string,
444 };
445 };
446 let interactions = await fetchInteractions();
447 if (!interactions) return;
448 let allRecords: (typeof interactions)["records"] = [];
449 while (allRecords.length < interactions.total) {
450 allRecords.push(...interactions.records);
451 if (!interactions.cursor) break;
452 interactions = await fetchInteractions(interactions.cursor);
453 if (!interactions) break;
454 }
455
456 const actorMap = new Map(
457 actors.map((actor) => [actor.did, actor.displayName]),
458 );
459 const allPromises = allRecords.map(
460 async (recordUri): Promise<FronterView | undefined> => {
461 const cachedFronter = await frontersCache.get(recordUri);
462 let fronter =
463 (cachedFronter ?? null) ||
464 (await getFronter(recordUri).then((fronter) => {
465 if (!fronter.ok) {
466 frontersCache.set(recordUri, null);
467 return null;
468 }
469 return fronter.value;
470 }));
471 if (!fronter) return;
472 const parsedUri = await cacheFronter(recordUri, fronter);
473 const displayName =
474 actorMap.get(fronter.did) ??
475 (await displayNameCache.get(fronter.did));
476 if (!displayName) return;
477 return {
478 type:
479 collection === "app.bsky.feed.repost"
480 ? "post_repost_entry"
481 : "post_like_entry",
482 rkey: parsedUri.rkey!,
483 displayName,
484 ...fronter,
485 };
486 },
487 );
488
489 const results = new Map(
490 (await Promise.allSettled(allPromises))
491 .filter((result) => result.status === "fulfilled")
492 .flatMap((result) => result.value ?? [])
493 .flatMap((fronter) =>
494 fronterGetSocialAppHrefs(fronter).map((href) => [href, fronter]),
495 ),
496 );
497 if (results.size === 0) return;
498 browser.tabs.sendMessage(sender.tab?.id!, {
499 type: "APPLY_FRONTERS",
500 results: Object.fromEntries(results),
501 });
502 };
503 const handleReposts = async (
504 data: any,
505 sender: globalThis.Browser.runtime.MessageSender,
506 ) =>
507 handleInteractions(
508 data,
509 sender,
510 "app.bsky.feed.repost",
511 data.repostedBy.map((by: any) => ({
512 did: by.did,
513 displayName: by.displayName,
514 })),
515 );
516 const handleLikes = async (
517 data: any,
518 sender: globalThis.Browser.runtime.MessageSender,
519 ) =>
520 handleInteractions(
521 data,
522 sender,
523 "app.bsky.feed.like",
524 data.likes.map((by: any) => ({
525 did: by.actor.did,
526 displayName: by.actor.displayName,
527 })),
528 );
529
530 browser.runtime.onMessage.addListener(async (message, sender) => {
531 if (message.type !== "RESPONSE_CAPTURED") return;
532 console.log("handling response", message.data);
533 switch (message.data.type as string) {
534 case "delete":
535 await handleDelete(
536 JSON.parse(message.data.body),
537 message.data.authToken,
538 sender,
539 );
540 break;
541 case "write":
542 await handleWrite(
543 JSON.parse(message.data.body).results,
544 message.data.authToken,
545 sender,
546 );
547 break;
548 case "writeOne": {
549 await handleWrite(
550 [JSON.parse(message.data.body)],
551 message.data.authToken,
552 sender,
553 );
554 break;
555 }
556 case "posts":
557 await handleTimeline(
558 (JSON.parse(message.data.body) as any[]).map((post) => ({ post })),
559 sender,
560 );
561 break;
562 case "timeline":
563 await handleTimeline(JSON.parse(message.data.body).feed, sender);
564 break;
565 case "thread":
566 await handleThread(message, sender);
567 break;
568 case "notifications":
569 await handleNotifications(JSON.parse(message.data.body), sender);
570 break;
571 case "reposts":
572 await handleReposts(JSON.parse(message.data.body), sender);
573 break;
574 case "likes":
575 await handleLikes(JSON.parse(message.data.body), sender);
576 break;
577 }
578 });
579 browser.runtime.onMessage.addListener(async (message, sender) => {
580 if (message.type !== "TAB_FRONTER") return;
581 await setTabFronter(message.recordUri, message.fronter);
582 });
583 },
584});