view who was fronting when a record was made

Compare changes

Choose any two refs to compare.

+5
README.md
··· 2 2 3 3 web extension to show which member(s) of a system was fronting when an ATProto record was created. it also implements showing fronter names on posts and other places, currently only for social-app (bsky.app & forks). 4 4 5 + #### installing 6 + 7 + - for firefox, download [here](https://dev.gaze.systems/x/at-fronter_firefox.xpi) 8 + - for chrome, download [here](https://dev.gaze.systems/x/at-fronter_chrome.crx) 9 + 5 10 #### building 6 11 7 12 install dependencies with `pnpm i` and run `pnpm build` for chrome and `pnpm build:firefox` for firefox.
+1
env.d.ts
··· 1 1 /// <reference types="@atcute/atproto" /> 2 + /// <reference types="@atcute/bluesky" />
+2 -1
package.json
··· 2 2 "name": "at-fronter", 3 3 "description": "view who was fronting when a record was made", 4 4 "private": true, 5 - "version": "0.0.1", 5 + "version": "0.0.8", 6 6 "type": "module", 7 7 "scripts": { 8 8 "dev": "wxt", ··· 25 25 }, 26 26 "dependencies": { 27 27 "@atcute/atproto": "^3.1.1", 28 + "@atcute/bluesky": "^3.2.2", 28 29 "@atcute/client": "^4.0.3", 29 30 "@atcute/identity": "^1.0.3", 30 31 "@atcute/identity-resolver": "^1.1.3",
+11
pnpm-lock.yaml
··· 11 11 '@atcute/atproto': 12 12 specifier: ^3.1.1 13 13 version: 3.1.3 14 + '@atcute/bluesky': 15 + specifier: ^3.2.2 16 + version: 3.2.2 14 17 '@atcute/client': 15 18 specifier: ^4.0.3 16 19 version: 4.0.3 ··· 66 69 67 70 '@atcute/atproto@3.1.3': 68 71 resolution: {integrity: sha512-+5u0l+8E7h6wZO7MM1HLXIPoUEbdwRtr28ZRTgsURp+Md9gkoBj9e5iMx/xM8F2Exfyb65J5RchW/WlF2mw/RQ==} 72 + 73 + '@atcute/bluesky@3.2.2': 74 + resolution: {integrity: sha512-L8RrMNeRLGvSHMq2KDIAGXrpuNGA87YOXpXHY1yhmovVCjQ5n55FrR6JoQaxhprdXdKKQiefxNwQQQybDrfgFQ==} 69 75 70 76 '@atcute/client@4.0.3': 71 77 resolution: {integrity: sha512-RIOZWFVLca/HiPAAUDqQPOdOreCxTbL5cb+WUf5yqQOKIu5yEAP3eksinmlLmgIrlr5qVOE7brazUUzaskFCfw==} ··· 1935 1941 1936 1942 '@atcute/atproto@3.1.3': 1937 1943 dependencies: 1944 + '@atcute/lexicons': 1.1.1 1945 + 1946 + '@atcute/bluesky@3.2.2': 1947 + dependencies: 1948 + '@atcute/atproto': 3.1.3 1938 1949 '@atcute/lexicons': 1.1.1 1939 1950 1940 1951 '@atcute/client@4.0.3':
+366 -80
src/entrypoints/background.ts
··· 1 - import { PersistentCache } from "@/lib/cache"; 2 1 import { expect } from "@/lib/result"; 3 2 import { 4 3 type Fronter, 5 4 fronterGetSocialAppHrefs, 6 - fronterGetSocialAppHref, 7 5 getFronter, 8 6 getSpFronters, 9 - memberUriString, 10 7 putFronter, 11 8 frontersCache, 12 9 parseSocialAppPostUrl, 13 10 displayNameCache, 14 11 deleteFronter, 12 + getPkFronters, 13 + FronterView, 14 + docResolver, 15 15 } from "@/lib/utils"; 16 16 import { 17 + AppBskyFeedLike, 18 + AppBskyFeedPost, 19 + AppBskyFeedRepost, 20 + AppBskyNotificationListNotifications, 21 + } from "@atcute/bluesky"; 22 + import { feedViewPostSchema } from "@atcute/bluesky/types/app/feed/defs"; 23 + import { getAtprotoHandle } from "@atcute/identity"; 24 + import { is, parseResourceUri, ResourceUri } from "@atcute/lexicons"; 25 + import { 26 + AtprotoDid, 27 + Handle, 17 28 parseCanonicalResourceUri, 18 - parseResourceUri, 19 - ResourceUri, 20 - } from "@atcute/lexicons"; 29 + } from "@atcute/lexicons/syntax"; 21 30 22 31 export default defineBackground({ 23 32 persistent: true, ··· 81 90 ) => { 82 91 if (!authToken) return; 83 92 const frontersArray = await storage.getItem<string[]>("sync:fronters"); 84 - let members: Parameters<typeof putFronter>["1"] = frontersArray ?? []; 93 + let members: Parameters<typeof putFronter>["1"] = 94 + frontersArray?.map((n) => ({ name: n, uri: undefined })) ?? []; 85 95 if (members.length === 0) { 86 - const pkFronters = await storage.getItem<string[]>("sync:pk-fronter"); 87 - if (pkFronters) { 88 - members = pkFronters.map((id) => ({ type: "pk", memberId: id })); 89 - } else { 90 - members = await getSpFronters(); 91 - } 96 + members = await getPkFronters(); 97 + } 98 + if (members.length === 0) { 99 + members = await getSpFronters(); 92 100 } 93 101 // dont write if no names is specified or no sp/pk fronters are fetched 94 102 if (members.length === 0) return; 95 - const results = []; 103 + const results: FronterView[] = []; 96 104 for (const result of items) { 97 105 const resp = await putFronter(result.uri, members, authToken); 98 106 if (resp.ok) { 99 107 const parsedUri = await cacheFronter(result.uri, resp.value); 100 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", 101 115 rkey: parsedUri.rkey!, 102 116 ...resp.value, 103 117 }); ··· 109 123 // hijack timeline fronter message because when a write is made it is either on the timeline 110 124 // or its a reply to a depth === 0 post on a threaded view, which is the same as a timeline post 111 125 browser.tabs.sendMessage(sender.tab?.id!, { 112 - type: "TIMELINE_FRONTER", 113 - results: new Map( 126 + type: "APPLY_FRONTERS", 127 + results: Object.fromEntries( 114 128 results.flatMap((fronter) => 115 - fronterGetSocialAppHrefs(fronter, fronter.rkey).map((href) => [ 116 - href, 117 - fronter, 118 - ]), 129 + fronterGetSocialAppHrefs(fronter).map((href) => [href, fronter]), 119 130 ), 120 131 ), 121 132 }); 122 133 }; 123 - const handleTimeline = async ( 124 - feed: any[], 134 + const handleNotifications = async ( 135 + items: any, 125 136 sender: globalThis.Browser.runtime.MessageSender, 126 137 ) => { 127 - const handlePost = async (post: any) => { 128 - const cachedFronter = await frontersCache.get(post.uri); 129 - if (cachedFronter === null) return; 130 - const promise = cachedFronter 131 - ? Promise.resolve(cachedFronter) 132 - : getFronter(post.uri).then(async (fronter) => { 133 - if (!fronter.ok) { 134 - await frontersCache.set(post.uri, null); 135 - return; 136 - } 137 - return fronter.value; 138 - }); 139 - return promise.then(async (fronter) => { 140 - if (!fronter) return; 141 - const parsedUri = await cacheFronter(post.uri, fronter); 142 - return { 143 - rkey: parsedUri.rkey!, 144 - ...fronter, 145 - }; 146 - }); 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 + }; 147 158 }; 148 - const allPromises = feed.flatMap((item) => { 149 - const promises = [handlePost(item.post)]; 150 - if (item.reply?.parent) { 151 - promises.push(handlePost(item.reply.parent)); 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); 152 200 } 153 - if (item.reply?.root) { 154 - promises.push(handlePost(item.reply.root)); 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)); 155 223 } 156 - return promises; 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), 157 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 + ); 158 331 const results = new Map( 159 332 (await Promise.allSettled(allPromises)) 160 333 .filter((result) => result.status === "fulfilled") 161 334 .flatMap((result) => result.value ?? []) 162 335 .flatMap((fronter) => 163 - fronterGetSocialAppHrefs(fronter, fronter.rkey).map((href) => [ 164 - href, 165 - fronter, 166 - ]), 336 + fronterGetSocialAppHrefs(fronter).map((href) => [href, fronter]), 167 337 ), 168 338 ); 169 339 if (results.size === 0) return; 170 340 browser.tabs.sendMessage(sender.tab?.id!, { 171 - type: "TIMELINE_FRONTER", 172 - results, 341 + type: "APPLY_FRONTERS", 342 + results: Object.fromEntries(results), 173 343 }); 174 344 // console.log("sent timeline fronters", results); 175 345 }; ··· 181 351 ) => { 182 352 // check if this request was made for fetching replies 183 353 // if anchor is not the same as current document url, that is the case 184 - // which means the depth of the returned posts are invalid to us, in the case of THREAD_FRONTER 185 - // if so we will use TIMELINE_FRONTER to send it back to content script 354 + // which means the depth of the returned posts are invalid to us 186 355 let isReplyThreadFetch = false; 187 356 const parsedDocumentUri = parseSocialAppPostUrl(documentUrl); 188 357 const anchorUri = new URL(requestUrl).searchParams.get("anchor"); ··· 210 379 } 211 380 return fronter.value; 212 381 }); 213 - return promise.then(async (fronter): Promise<any> => { 214 - if (!fronter) return; 215 - const parsedUri = await cacheFronter(item.uri, fronter); 216 - if (isReplyThreadFetch) 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); 217 398 return { 399 + type: "thread_post", 218 400 rkey: parsedUri.rkey!, 401 + displayName, 402 + depth: item.depth, 219 403 ...fronter, 220 404 }; 221 - if (item.depth === 0) await setTabFronter(item.uri, fronter); 222 - const displayName = item.value.post.author.displayName; 223 - // cache display name for later use 224 - if (fronter.handle) 225 - await displayNameCache.set(fronter.handle, displayName); 226 - await displayNameCache.set(fronter.did, displayName); 227 - return { 228 - rkey: parsedUri.rkey!, 229 - displayName, 230 - depth: item.depth, 231 - ...fronter, 232 - }; 233 - }); 405 + }, 406 + ); 234 407 }); 235 408 }); 236 409 const results = new Map( ··· 238 411 .filter((result) => result.status === "fulfilled") 239 412 .flatMap((result) => result.value ?? []) 240 413 .flatMap((fronter) => 241 - fronterGetSocialAppHrefs(fronter, fronter.rkey, fronter.depth).map( 242 - (href) => [href, fronter], 243 - ), 414 + fronterGetSocialAppHrefs(fronter).map((href) => [href, fronter]), 244 415 ), 245 416 ); 246 417 if (results.size === 0) return; 247 418 browser.tabs.sendMessage(sender.tab?.id!, { 248 - type: isReplyThreadFetch ? "TIMELINE_FRONTER" : "THREAD_FRONTER", 249 - results, 419 + type: "APPLY_FRONTERS", 420 + results: Object.fromEntries(results), 250 421 }); 251 422 // console.log("sent thread fronters", results); 252 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 + ); 253 529 254 530 browser.runtime.onMessage.addListener(async (message, sender) => { 255 531 if (message.type !== "RESPONSE_CAPTURED") return; ··· 269 545 sender, 270 546 ); 271 547 break; 272 - case "writeOne": 548 + case "writeOne": { 273 549 await handleWrite( 274 550 [JSON.parse(message.data.body)], 275 551 message.data.authToken, 276 552 sender, 277 553 ); 278 554 break; 555 + } 279 556 case "posts": 280 557 await handleTimeline( 281 558 (JSON.parse(message.data.body) as any[]).map((post) => ({ post })), ··· 287 564 break; 288 565 case "thread": 289 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); 290 576 break; 291 577 } 292 578 });
+171 -34
src/entrypoints/content.ts
··· 1 - import { decodeStorageKey } from "@/lib/cache"; 2 1 import { expect } from "@/lib/result"; 3 - import { 4 - Fronter, 5 - fronterGetSocialAppHref, 6 - fronterGetSocialAppHrefs, 7 - parseSocialAppPostUrl, 8 - } from "@/lib/utils"; 9 - import { parseResourceUri, ResourceUri } from "@atcute/lexicons"; 2 + import { FronterView, parseSocialAppPostUrl } from "@/lib/utils"; 3 + import { parseResourceUri } from "@atcute/lexicons"; 10 4 11 5 const getAuthHeader = (headers: any): string | null => { 12 6 if (headers instanceof Headers) { ··· 124 118 type: "posts", 125 119 body, 126 120 }; 121 + } else if ( 122 + response.url.includes("/xrpc/app.bsky.notification.listNotifications") 123 + ) { 124 + detail = { 125 + type: "notifications", 126 + body, 127 + }; 128 + } else if (response.url.includes("/xrpc/app.bsky.feed.getLikes")) { 129 + detail = { 130 + type: "likes", 131 + body, 132 + }; 133 + } else if (response.url.includes("/xrpc/app.bsky.feed.getRepostedBy")) { 134 + detail = { 135 + type: "reposts", 136 + body, 137 + }; 127 138 } 128 139 if (detail) { 129 140 sendEvent(detail); ··· 146 157 }); 147 158 respEventSetup.then((name) => (respEventName = name)); 148 159 149 - const applyFronterName = (el: Element, fronters: Fronter["members"]) => { 150 - if (el.hasAttribute("data-fronter")) return; 160 + const applyFronterName = ( 161 + el: Element, 162 + fronters: FronterView["members"], 163 + ) => { 164 + if (el.hasAttribute("data-fronter")) return false; 151 165 const s = fronters.map((f) => f.name).join(", "); 152 166 el.textContent += ` [f: ${s}]`; 153 167 el.setAttribute("data-fronter", s); 168 + return true; 154 169 }; 155 170 const applyFrontersToPage = ( 156 - fronters: Map<string, any>, 171 + fronters: Map<string, FronterView | null>, 157 172 pageChange: boolean, 158 173 ) => { 159 174 // console.log("applyFrontersToPage", fronters); ··· 164 179 ); 165 180 for (const el of document.querySelectorAll("[data-fronter]")) { 166 181 const previousFronter = el.getAttribute("data-fronter")!; 167 - // remove fronter text 168 - el.textContent = el.textContent.replace( 169 - ` [f: ${previousFronter}]`, 170 - "", 171 - ); 182 + if (previousFronter !== "__set__") { 183 + // remove fronter text 184 + el.textContent = el.textContent.replace( 185 + ` [f: ${previousFronter}]`, 186 + "", 187 + ); 188 + } 172 189 el.removeAttribute("data-fronter"); 173 190 } 174 191 } 175 192 console.log("applyFrontersToPage", match, fronters); 176 193 if (fronters.size === 0) return; 194 + const applyFronterToElement = (el: Element, fronter: FronterView) => { 195 + let displayNameElement: Element | null = null; 196 + if (fronter.type === "repost") { 197 + displayNameElement = 198 + el.parentElement?.parentElement?.parentElement?.parentElement 199 + ?.parentElement?.firstElementChild?.nextElementSibling 200 + ?.firstElementChild?.nextElementSibling?.firstElementChild 201 + ?.firstElementChild?.nextElementSibling?.firstElementChild 202 + ?.firstElementChild?.firstElementChild?.firstElementChild ?? null; 203 + const actorIdentifier = displayNameElement?.parentElement 204 + ?.getAttribute("href") 205 + ?.split("/")[2]; 206 + if ( 207 + fronter.did !== actorIdentifier && 208 + fronter.handle !== actorIdentifier 209 + ) { 210 + return; 211 + } 212 + // sanity check 213 + if (displayNameElement?.tagName !== "SPAN") { 214 + console.log( 215 + `invalid display element tag ${displayNameElement?.tagName}, expected span:`, 216 + displayNameElement, 217 + ); 218 + return; 219 + } 220 + } else if ( 221 + fronter.type === "post" || 222 + fronter.type === "thread_reply" || 223 + fronter.type === "thread_post" || 224 + (fronter.type === "notification" && 225 + (fronter.reason === "reply" || fronter.reason === "quote")) 226 + ) { 227 + if (fronter.type === "thread_post" && fronter.depth === 0) { 228 + if (match && match.rkey !== fronter.rkey) return; 229 + if (el.ariaLabel !== fronter.displayName) return; 230 + displayNameElement = 231 + el.firstElementChild?.firstElementChild?.firstElementChild 232 + ?.firstElementChild?.firstElementChild ?? null; 233 + // sanity check 234 + if (displayNameElement?.tagName !== "DIV") { 235 + console.log( 236 + `invalid display element tag ${displayNameElement?.tagName}, expected a:`, 237 + displayNameElement, 238 + ); 239 + return; 240 + } 241 + } else { 242 + displayNameElement = 243 + el.parentElement?.firstElementChild?.firstElementChild 244 + ?.firstElementChild?.firstElementChild ?? null; 245 + // sanity check 246 + if (displayNameElement?.tagName !== "A") { 247 + console.log( 248 + `invalid display element tag ${displayNameElement?.tagName}, expected a:`, 249 + displayNameElement, 250 + ); 251 + return; 252 + } 253 + if (fronter.type === "post" && fronter.replyTo) { 254 + const parsedReplyUri = expect(parseResourceUri(fronter.replyTo)); 255 + const replyFronter = fronters.get( 256 + `/profile/${parsedReplyUri.repo}/post/${parsedReplyUri.rkey}`, 257 + ); 258 + if (replyFronter && replyFronter.members?.length > 0) { 259 + const replyDisplayNameElement = 260 + el.parentElement?.parentElement?.parentElement 261 + ?.firstElementChild?.nextElementSibling?.firstElementChild 262 + ?.nextElementSibling?.firstElementChild?.firstElementChild 263 + ?.firstElementChild?.firstElementChild ?? null; 264 + if (replyDisplayNameElement) { 265 + applyFronterName( 266 + replyDisplayNameElement, 267 + replyFronter.members, 268 + ); 269 + } 270 + } 271 + } 272 + } 273 + } else if (fronter.type === "notification") { 274 + const multiOne = 275 + el.firstElementChild?.nextElementSibling?.nextElementSibling 276 + ?.firstElementChild?.firstElementChild?.nextElementSibling 277 + ?.nextElementSibling?.firstElementChild?.firstElementChild 278 + ?.firstElementChild ?? null; 279 + const singleOne = 280 + el.firstElementChild?.nextElementSibling?.nextElementSibling 281 + ?.firstElementChild?.nextElementSibling?.nextElementSibling 282 + ?.firstElementChild?.firstElementChild?.firstElementChild ?? null; 283 + displayNameElement = multiOne ?? singleOne ?? null; 284 + if (displayNameElement?.tagName !== "A") { 285 + console.log( 286 + `invalid display element tag ${displayNameElement?.tagName}, expected a:`, 287 + displayNameElement, 288 + ); 289 + return; 290 + } 291 + const profileHref = displayNameElement?.getAttribute("href"); 292 + if (profileHref) { 293 + const actorIdentifier = profileHref.split("/").slice(2)[0]; 294 + const isUser = 295 + fronter.handle !== actorIdentifier && 296 + fronter.did !== actorIdentifier; 297 + if (isUser) displayNameElement = null; 298 + } else displayNameElement = null; 299 + } else if ( 300 + fronter.type === "post_repost_entry" || 301 + fronter.type === "post_like_entry" 302 + ) { 303 + // HACK: evil ass way to do this 304 + if (el.ariaLabel !== `View ${fronter.displayName}'s profile`) return; 305 + displayNameElement = 306 + el.firstElementChild?.firstElementChild?.firstElementChild 307 + ?.nextElementSibling?.firstElementChild?.firstElementChild ?? 308 + null; 309 + if (displayNameElement?.tagName !== "DIV") { 310 + console.log( 311 + `invalid display element tag ${displayNameElement?.tagName}, expected div:`, 312 + displayNameElement, 313 + ); 314 + return; 315 + } 316 + } 317 + if (!displayNameElement) return; 318 + return applyFronterName(displayNameElement, fronter.members); 319 + }; 177 320 for (const el of document.getElementsByTagName("a")) { 321 + if (el.getAttribute("data-fronter")) continue; 178 322 const path = `/${el.href.split("/").slice(3).join("/")}`; 179 - const fronter = fronters.get(path); 180 - if (!fronter || fronter.members?.length === 0) continue; 181 - if (el.hasAttribute("data-fronter")) continue; 182 - const isFocusedPost = fronter.depth === 0; 183 - if (isFocusedPost) if (match && match.rkey !== fronter.rkey) continue; 184 - if (isFocusedPost) if (el.ariaLabel !== fronter.displayName) continue; 185 - const displayNameElement = isFocusedPost 186 - ? (el.firstElementChild?.firstElementChild?.firstElementChild 187 - ?.firstElementChild?.firstElementChild ?? null) 188 - : (el.parentElement?.firstElementChild?.firstElementChild 189 - ?.firstElementChild?.firstElementChild ?? null); 190 - if (!displayNameElement) continue; 191 - applyFronterName(displayNameElement, fronter.members); 323 + const elFronters = [fronters.get(path), fronters.get(`${path}#repost`)]; 324 + for (const fronter of elFronters) { 325 + if (!fronter || fronter.members?.length === 0) continue; 326 + if (applyFronterToElement(el, fronter)) { 327 + el.setAttribute("data-fronter", "__set__"); 328 + } 329 + } 192 330 } 193 331 }; 194 332 let postTabObserver: MutationObserver | null = null; ··· 196 334 if (event.data.type !== "APPLY_CACHED_FRONTERS") return; 197 335 const applyFronters = () => { 198 336 console.log("applying cached fronters", event.data.fronters); 199 - applyFrontersToPage(event.data.fronters, true); 337 + applyFrontersToPage(new Map(Object.entries(event.data.fronters)), true); 200 338 }; 201 339 // check if we are on profile so we can update fronters if the post tab is clicked on 202 340 const postTabElement = document.querySelector( ··· 213 351 applyFronters(); 214 352 }); 215 353 window.addEventListener("message", (event) => { 216 - if (!["TIMELINE_FRONTER", "THREAD_FRONTER"].includes(event.data.type)) 217 - return; 218 - console.log(`received ${event.data.type} fronters`, event.data.results); 219 - applyFrontersToPage(event.data.results, false); 354 + if (event.data.type !== "APPLY_FRONTERS") return; 355 + console.log(`received new fronters`, event.data.results); 356 + applyFrontersToPage(new Map(Object.entries(event.data.results)), false); 220 357 }); 221 358 }, 222 359 });
+42 -21
src/entrypoints/isolated.content.ts
··· 3 3 import { 4 4 displayNameCache, 5 5 Fronter, 6 + fronterGetSocialAppHref, 6 7 fronterGetSocialAppHrefs, 7 8 frontersCache, 9 + FronterView, 8 10 parseSocialAppPostUrl, 9 11 } from "@/lib/utils"; 10 12 import { parseResourceUri, ResourceUri } from "@atcute/lexicons"; ··· 39 41 data, 40 42 }); 41 43 }); 42 - const messageTypes = ["TAB_FRONTER", "THREAD_FRONTER", "TIMELINE_FRONTER"]; 44 + const bgMessageTypes = ["APPLY_FRONTERS"]; 43 45 browser.runtime.onMessage.addListener((message) => { 44 - if (!messageTypes.includes(message.type)) return; 46 + if (!bgMessageTypes.includes(message.type)) return; 45 47 window.postMessage(message); 46 48 }); 47 49 const updateOnUrlChange = async () => { 48 50 const fronters = await frontersCache.getAll(); 49 - const updated = new Map<string, any>( 50 - fronters.entries().flatMap(([storageKey, fronter]) => { 51 - if (!fronter) return []; 52 - const uri = decodeStorageKey(storageKey); 53 - const rkey = expect(parseResourceUri(uri)).rkey!; 54 - return fronterGetSocialAppHrefs(fronter, rkey).map((href) => [ 55 - href, 56 - fronter, 57 - ]); 58 - }), 59 - ); 51 + const updated = new Map<string, FronterView | null>(); 52 + for (const [storageKey, fronter] of fronters.entries()) { 53 + const uri = decodeStorageKey(storageKey); 54 + const parsedUri = expect(parseResourceUri(uri)); 55 + if (!fronter) { 56 + updated.set( 57 + fronterGetSocialAppHref(parsedUri.repo, parsedUri.rkey!), 58 + null, 59 + ); 60 + continue; 61 + } 62 + const view: FronterView = { 63 + type: 64 + parsedUri.collection === "app.bsky.feed.repost" ? "repost" : "post", 65 + rkey: parsedUri.rkey!, 66 + ...fronter, 67 + }; 68 + for (const href of fronterGetSocialAppHrefs(view)) { 69 + updated.set(href, view); 70 + } 71 + } 60 72 // add entry for current page 61 73 const match = parseSocialAppPostUrl(document.location.href); 62 74 if (match && !updated.has(`/profile/${match.actorIdentifier}`)) { 63 75 const maybeFronter = updated.get( 64 76 `/profile/${match.actorIdentifier}/post/${match.rkey}`, 65 77 ); 66 - if (maybeFronter) 67 - updated.set(`/profile/${match.actorIdentifier}`, { 68 - depth: 0, 69 - displayName: await displayNameCache.get(match.actorIdentifier), 70 - rkey: match.rkey, 71 - ...maybeFronter, 72 - }); 78 + if (maybeFronter) { 79 + const displayName = await displayNameCache.get(match.actorIdentifier); 80 + if (displayName) { 81 + const view: FronterView = { 82 + ...maybeFronter, 83 + type: "thread_post", 84 + depth: 0, 85 + displayName, 86 + rkey: match.rkey, 87 + }; 88 + updated.set(`/profile/${maybeFronter.did}`, view); 89 + if (maybeFronter.handle) { 90 + updated.set(`/profile/${maybeFronter.handle}`, view); 91 + } 92 + } 93 + } 73 94 } 74 95 window.postMessage({ 75 96 type: "APPLY_CACHED_FRONTERS", 76 - fronters: updated, 97 + fronters: Object.fromEntries(updated), 77 98 }); 78 99 // check for tab fronter for the current "post" 79 100 await checkFronter(document.location.href);
+26 -16
src/entrypoints/popup/App.svelte
··· 13 13 let queryError = $state(""); 14 14 let isQuerying = $state(false); 15 15 let fronters = $state<string[]>([]); 16 - let pkFronters = $state<string[]>([]); 16 + let pkSystemId = $state<string>(""); 17 17 let spToken = $state(""); 18 18 let isFromCurrentTab = $state(false); 19 19 ··· 51 51 storage.setItem("sync:fronters", newFronters); 52 52 }; 53 53 54 - const updatePkFronters = (newPkFronters: string[]) => { 55 - pkFronters = newPkFronters; 56 - storage.setItem("sync:pk-fronter", newPkFronters); 54 + const updatePkSystem = (event: any) => { 55 + pkSystemId = (event.target as HTMLInputElement).value; 56 + storage.setItem("sync:pk-system", pkSystemId); 57 57 }; 58 58 59 59 const updateSpToken = (event: any) => { ··· 80 80 fronters = frontersArray; 81 81 } 82 82 83 - const pkFrontersArray = 84 - await storage.getItem<string[]>("sync:pk-fronter"); 85 - if (pkFrontersArray && Array.isArray(pkFrontersArray)) { 86 - pkFronters = pkFrontersArray; 83 + const pkSystem = await storage.getItem<string>("sync:pk-system"); 84 + if (pkSystem) { 85 + pkSystemId = pkSystem; 87 86 } 88 87 89 88 const token = await storage.getItem<string>("sync:sp_token"); ··· 232 231 </span> 233 232 </div> 234 233 </div> 235 - <FronterList 236 - bind:fronters={pkFronters} 237 - onUpdate={updatePkFronters} 238 - label="PK FRONTERS" 239 - placeholder="enter_member_ids" 240 - note="PluralKit member IDs, overrides SP fronters" 241 - fetchNames={true} 242 - /> 234 + <div class="config-card"> 235 + <div class="config-row"> 236 + <span class="config-label">PK SYSTEM</span> 237 + <input 238 + type="password" 239 + placeholder="enter_pk_system_id" 240 + oninput={updatePkSystem} 241 + bind:value={pkSystemId} 242 + class="config-input" 243 + class:has-value={pkSystemId} 244 + /> 245 + </div> 246 + <div class="config-note"> 247 + <span class="note-text"> 248 + when set, pulls fronters from PluralKit (fronters 249 + must be public) 250 + </span> 251 + </div> 252 + </div> 243 253 <FronterList 244 254 bind:fronters 245 255 onUpdate={updateFronters}
+110 -23
src/lib/utils.ts
··· 27 27 } from "@atcute/identity-resolver"; 28 28 import { getAtprotoHandle, getPdsEndpoint } from "@atcute/identity"; 29 29 import { PersistentCache } from "./cache"; 30 + import { AppBskyNotificationListNotifications } from "@atcute/bluesky"; 31 + 32 + export type Subject = { 33 + handle?: Handle; 34 + did: AtprotoDid; 35 + rkey: RecordKey; 36 + }; 30 37 31 38 export type Fronter = { 32 39 members: { ··· 35 42 }[]; 36 43 handle: Handle | null; 37 44 did: AtprotoDid; 45 + subject?: Subject; 46 + replyTo?: ResourceUri; 38 47 }; 39 48 49 + export type FronterView = Fronter & { rkey: RecordKey } & ( 50 + | { 51 + type: "thread_reply"; 52 + } 53 + | { 54 + type: "thread_post"; 55 + displayName: string; 56 + depth: number; 57 + } 58 + | { 59 + type: "post"; 60 + } 61 + | { 62 + type: "like"; 63 + } 64 + | { 65 + type: "repost"; 66 + } 67 + | { 68 + type: "notification"; 69 + reason: InferOutput<AppBskyNotificationListNotifications.notificationSchema>["reason"]; 70 + } 71 + | { 72 + type: "post_repost_entry"; 73 + displayName: string; 74 + } 75 + | { 76 + type: "post_like_entry"; 77 + displayName: string; 78 + } 79 + ); 80 + export type FronterType = FronterView["type"]; 81 + 40 82 export const fronterSchema = v.record( 41 83 v.string(), 42 84 v.object({ ··· 54 96 55 97 export type MemberUri = 56 98 | { type: "at"; recordUri: ResourceUri } 57 - | { type: "pk"; memberId: string } 99 + | { type: "pk"; systemId: string; memberId: string } 58 100 | { type: "sp"; systemId: string; memberId: string }; 59 101 60 102 export const parseMemberId = (memberId: GenericUri): MemberUri => { ··· 62 104 switch (uri.protocol) { 63 105 case "pk:": { 64 106 const split = uri.pathname.split("/").slice(1); 65 - return { type: "pk", memberId: split[0] }; 107 + return { type: "pk", systemId: split[0], memberId: split[1] }; 66 108 } 67 109 case "sp:": { 68 110 const split = uri.pathname.split("/").slice(1); ··· 142 184 } 143 185 }; 144 186 145 - export const getFronterNames = async (members: (string | MemberUri)[]) => { 187 + export const getFronterNames = async ( 188 + members: { name?: string; uri?: MemberUri }[], 189 + ) => { 146 190 const promises = await Promise.allSettled( 147 191 members.map(async (m): Promise<Fronter["members"][0] | null> => { 148 - if (typeof m === "string") 149 - return Promise.resolve({ uri: undefined, name: m }); 150 - const name = await fetchMember(m); 151 - return name ? { uri: m, name } : null; 192 + if (!m.uri) return Promise.resolve({ uri: undefined, name: m.name! }); 193 + if (m.name) return Promise.resolve({ uri: m.uri, name: m.name }); 194 + const name = await fetchMember(m.uri); 195 + return name ? { uri: m.uri, name } : null; 152 196 }), 153 197 ); 154 198 return promises ··· 156 200 .flatMap((p) => p.value ?? []); 157 201 }; 158 202 159 - const handleResolver = new CompositeHandleResolver({ 203 + export const handleResolver = new CompositeHandleResolver({ 160 204 strategy: "race", 161 205 methods: { 162 206 dns: new DohJsonHandleResolver({ ··· 165 209 http: new WellKnownHandleResolver(), 166 210 }, 167 211 }); 168 - const docResolver = new CompositeDidDocumentResolver({ 212 + export const docResolver = new CompositeDidDocumentResolver({ 169 213 methods: { 170 214 plc: new PlcDidDocumentResolver(), 171 215 web: new WebDidDocumentResolver(), ··· 244 288 245 289 export const putFronter = async ( 246 290 subject: FronterSchema["subject"], 247 - members: (string | MemberUri)[], 291 + members: { name?: string; uri?: MemberUri }[], 248 292 authToken: string, 249 293 ): Promise<Result<Fronter, string>> => { 250 294 const parsedRecordUri = parseResourceUri(subject); ··· 315 359 return ok(true); 316 360 }; 317 361 318 - export const getSpFronters = async (): Promise<MemberUri[]> => { 362 + export const getSpFronters = async (): Promise< 363 + Parameters<typeof putFronter>["1"] 364 + > => { 319 365 const spToken = await storage.getItem<string>("sync:sp_token"); 320 366 if (!spToken) return []; 321 367 const resp = await fetch(`https://api.apparyllis.com/v1/fronters`, { ··· 326 372 if (!resp.ok) return []; 327 373 const spFronters = (await resp.json()) as any[]; 328 374 return spFronters.map((fronter) => ({ 329 - type: "sp", 330 - memberId: fronter.content.member, 331 - systemId: fronter.content.uid, 375 + name: undefined, 376 + uri: { 377 + type: "sp", 378 + memberId: fronter.content.member, 379 + systemId: fronter.content.uid, 380 + }, 332 381 })); 333 382 }; 334 383 335 - export const fronterGetSocialAppHrefs = ( 336 - fronter: Fronter, 337 - rkey: RecordKey, 338 - depth?: number, 339 - ) => { 384 + export const getPkFronters = async (): Promise< 385 + Parameters<typeof putFronter>["1"] 386 + > => { 387 + const pkSystemId = await storage.getItem<string>("sync:pk-system"); 388 + if (!pkSystemId) return []; 389 + const resp = await fetch( 390 + `https://api.pluralkit.me/v2/systems/${pkSystemId}/fronters`, 391 + ); 392 + if (!resp.ok) return []; 393 + const pkFronters = await resp.json(); 394 + return (pkFronters.members as any[]).map((member) => ({ 395 + name: member.display_name ?? member.name, 396 + uri: { 397 + type: "pk", 398 + memberId: member.id, 399 + systemId: member.system, 400 + }, 401 + })); 402 + }; 403 + 404 + export const fronterGetSocialAppHrefs = (view: FronterView) => { 405 + if (view.type === "repost" && view.subject) { 406 + const subject = view.subject; 407 + const handle = subject?.handle; 408 + return [ 409 + handle ? [`${fronterGetSocialAppHref(handle, subject.rkey)}#repost`] : [], 410 + `${fronterGetSocialAppHref(subject.did, subject.rkey)}#repost`, 411 + ].flat(); 412 + } else if (view.type === "notification" && view.subject) { 413 + const subject = view.subject; 414 + const handle = subject?.handle; 415 + return [ 416 + handle ? [`${fronterGetSocialAppHref(handle, subject.rkey)}`] : [], 417 + `${fronterGetSocialAppHref(subject.did, subject.rkey)}`, 418 + ].flat(); 419 + } else if ( 420 + view.type === "post_repost_entry" || 421 + view.type === "post_like_entry" 422 + ) { 423 + return [ 424 + view.handle ? [`/profile/${view.handle}`] : [], 425 + `/profile/${view.did}`, 426 + ].flat(); 427 + } 428 + const depth = view.type === "thread_post" ? view.depth : undefined; 340 429 return [ 341 - fronter.handle 342 - ? [fronterGetSocialAppHref(fronter.handle, rkey, depth)] 343 - : [], 344 - fronterGetSocialAppHref(fronter.did, rkey, depth), 430 + view.handle ? [fronterGetSocialAppHref(view.handle, view.rkey, depth)] : [], 431 + fronterGetSocialAppHref(view.did, view.rkey, depth), 345 432 ].flat(); 346 433 }; 347 434
+1 -1
wxt.config.ts
··· 19 19 ], 20 20 host_permissions: ["<all_urls>"], 21 21 content_security_policy: { 22 - extension_pages: "script-src 'self' 'unsafe-eval'; object-src 'self'", 22 + extension_pages: "script-src 'self'; object-src 'self'", 23 23 }, 24 24 }, 25 25 });