view who was fronting when a record was made
at main 21 kB view raw
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});