view who was fronting when a record was made

Compare changes

Choose any two refs to compare.

+1
.gitignore
··· 14 14 .wxt 15 15 .wxt-runner 16 16 web-ext.config.ts 17 + web-ext-artifacts 17 18 18 19 # Editor directories and files 19 20 .vscode/*
+19
LICENSE
··· 1 + Copyright (c) 2025 dusk 2 + 3 + Permission is hereby granted, free of charge, to any person obtaining a 4 + copy of this software and associated documentation files (the "Software"), 5 + to deal in the Software without restriction, including without limitation 6 + the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 + and/or sell copies of the Software, and to permit persons to whom the 8 + Software is furnished to do so, subject to the following conditions: 9 + 10 + The above copyright notice and this permission notice shall be included 11 + in all copies or substantial portions of the Software. 12 + 13 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 14 + OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 19 + IN THE SOFTWARE.
+12
README.md
··· 1 + ## at fronter 2 + 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 + 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 + 10 + #### building 11 + 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.0", 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':
+369
src/components/FronterList.svelte
··· 1 + <script lang="ts"> 2 + import { fetchMember, type MemberUri } from "@/lib/utils"; 3 + 4 + interface Props { 5 + fronters: string[]; 6 + onUpdate: (fronters: string[]) => void; 7 + label?: string; 8 + placeholder?: string; 9 + note?: string; 10 + fetchNames?: boolean; // If true, treat as PK member IDs and fetch names 11 + } 12 + 13 + let { 14 + fronters = $bindable([]), 15 + onUpdate, 16 + label = "FRONTERS", 17 + placeholder = "enter_identifier", 18 + note = "list of identifiers", 19 + fetchNames = false, 20 + }: Props = $props(); 21 + 22 + let inputValue = $state(""); 23 + let inputElement: HTMLInputElement; 24 + let memberNames = $state<Map<string, string | null>>(new Map()); 25 + let memberErrors = $state<Map<string, string>>(new Map()); 26 + 27 + const fetchMemberName = async (memberId: string) => { 28 + try { 29 + const memberUri: MemberUri = { type: "pk", memberId }; 30 + const name = await fetchMember(memberUri); 31 + if (name) { 32 + memberNames.set(memberId, name); 33 + memberErrors.delete(memberId); 34 + } else { 35 + memberNames.set(memberId, null); 36 + memberErrors.set(memberId, "Member not found"); 37 + } 38 + } catch (error) { 39 + memberNames.set(memberId, null); 40 + memberErrors.set(memberId, `Error: ${error}`); 41 + } 42 + // Trigger reactivity 43 + memberNames = new Map(memberNames); 44 + memberErrors = new Map(memberErrors); 45 + }; 46 + 47 + const addFronter = (name: string) => { 48 + const trimmedName = name.trim(); 49 + if (!trimmedName || fronters.includes(trimmedName)) return; 50 + 51 + const updatedFronters = [...fronters, trimmedName]; 52 + fronters = updatedFronters; 53 + onUpdate(updatedFronters); 54 + inputValue = ""; 55 + 56 + // Fetch the member name if this is a PK fronter 57 + if (fetchNames) { 58 + fetchMemberName(trimmedName); 59 + } 60 + }; 61 + 62 + const removeFronter = (index: number) => { 63 + const identifier = fronters[index]; 64 + const updatedFronters = fronters.filter((_, i) => i !== index); 65 + fronters = updatedFronters; 66 + onUpdate(updatedFronters); 67 + 68 + // Clean up the member name cache if this is a PK fronter 69 + if (fetchNames) { 70 + memberNames.delete(identifier); 71 + memberErrors.delete(identifier); 72 + memberNames = new Map(memberNames); 73 + memberErrors = new Map(memberErrors); 74 + } 75 + 76 + inputElement?.focus(); 77 + }; 78 + 79 + const handleKeyPress = (event: KeyboardEvent) => { 80 + if (event.key === "Enter" || event.key === "," || event.key === " ") { 81 + event.preventDefault(); 82 + addFronter(inputValue); 83 + } else if ( 84 + event.key === "Backspace" && 85 + inputValue === "" && 86 + fronters.length > 0 87 + ) { 88 + // Remove last tag when backspacing on empty input 89 + removeFronter(fronters.length - 1); 90 + } 91 + }; 92 + 93 + const handleInput = (event: Event) => { 94 + const target = event.target as HTMLInputElement; 95 + const value = target.value; 96 + 97 + // Check for comma or space at the end 98 + if (value.endsWith(",") || value.endsWith(" ")) { 99 + addFronter(value.slice(0, -1)); 100 + } else { 101 + inputValue = value; 102 + } 103 + }; 104 + 105 + const focusInput = () => { 106 + inputElement?.focus(); 107 + }; 108 + 109 + // Load existing member names on mount (only for PK fronters) 110 + $effect(() => { 111 + if (fetchNames) { 112 + fronters.forEach((identifier) => { 113 + if ( 114 + !memberNames.has(identifier) && 115 + !memberErrors.has(identifier) 116 + ) { 117 + fetchMemberName(identifier); 118 + } 119 + }); 120 + } 121 + }); 122 + 123 + // Helper function to get display text for a fronter 124 + const getDisplayText = (identifier: string) => { 125 + if (!fetchNames) return identifier; 126 + return memberNames.get(identifier) || identifier; 127 + }; 128 + 129 + // Helper function to check if we should show error/loading state 130 + const getStatusInfo = (identifier: string) => { 131 + if (!fetchNames) return null; 132 + 133 + if (memberErrors.has(identifier)) { 134 + return { type: "error", text: memberErrors.get(identifier) }; 135 + } 136 + if (memberNames.get(identifier) === undefined) { 137 + return { type: "loading", text: "loading..." }; 138 + } 139 + return null; 140 + }; 141 + </script> 142 + 143 + <div class="config-card"> 144 + <div class="config-row"> 145 + <span class="config-label">{label}</span> 146 + <div 147 + class="tag-input-container" 148 + onclick={focusInput} 149 + onkeydown={(e) => e.key === "Enter" && focusInput()} 150 + role="textbox" 151 + tabindex="0" 152 + > 153 + <div class="tag-input-wrapper"> 154 + {#each fronters as identifier, index} 155 + <div class="fronter-tag"> 156 + <div class="tag-content"> 157 + <span class="tag-text"> 158 + {getDisplayText(identifier)} 159 + </span> 160 + {#if getStatusInfo(identifier)} 161 + {@const status = getStatusInfo(identifier)} 162 + {#if status} 163 + <span class="tag-{status.type}" 164 + >{status.text}</span 165 + > 166 + {/if} 167 + {/if} 168 + </div> 169 + <button 170 + onclick={() => removeFronter(index)} 171 + class="tag-remove" 172 + title="Remove fronter" 173 + > 174 + ร— 175 + </button> 176 + </div> 177 + {/each} 178 + <input 179 + bind:this={inputElement} 180 + type="text" 181 + placeholder={fronters.length === 0 ? placeholder : ""} 182 + value={inputValue} 183 + oninput={handleInput} 184 + onkeydown={handleKeyPress} 185 + class="tag-input" 186 + /> 187 + </div> 188 + </div> 189 + </div> 190 + 191 + <div class="config-note"> 192 + <span class="note-text">{note}</span> 193 + </div> 194 + </div> 195 + 196 + <style> 197 + .config-card { 198 + background: #0d0d0d; 199 + border: 1px solid #2a2a2a; 200 + border-left: 3px solid #444444; 201 + padding: 10px; 202 + display: flex; 203 + flex-direction: column; 204 + gap: 6px; 205 + transition: border-left-color 0.2s ease; 206 + } 207 + 208 + .config-card:hover { 209 + border-left-color: #555555; 210 + } 211 + 212 + .config-row { 213 + display: flex; 214 + align-items: center; 215 + gap: 12px; 216 + margin-bottom: 0; 217 + } 218 + 219 + .config-label { 220 + font-size: 12px; 221 + color: #cccccc; 222 + letter-spacing: 1px; 223 + font-weight: 700; 224 + white-space: nowrap; 225 + min-width: 90px; 226 + } 227 + 228 + .tag-input-container { 229 + flex: 1; 230 + background: #181818; 231 + border: 1px solid #333333; 232 + transition: border-color 0.2s ease; 233 + cursor: text; 234 + min-height: 42px; 235 + display: flex; 236 + align-items: center; 237 + } 238 + 239 + .tag-input-container:focus-within { 240 + border-color: #666666; 241 + } 242 + 243 + .tag-input-container:focus-within:has(.fronter-tag) { 244 + border-bottom-color: #00ff41; 245 + } 246 + 247 + .tag-input-wrapper { 248 + display: flex; 249 + flex-wrap: wrap; 250 + align-items: center; 251 + gap: 6px; 252 + padding: 8px 12px; 253 + width: 100%; 254 + min-height: 26px; 255 + } 256 + 257 + .fronter-tag { 258 + display: flex; 259 + align-items: center; 260 + background: #2a2a2a; 261 + border: 1px solid #444444; 262 + border-radius: 3px; 263 + padding: 4px 6px; 264 + gap: 6px; 265 + font-size: 11px; 266 + color: #ffffff; 267 + font-weight: 600; 268 + line-height: 1; 269 + transition: all 0.15s ease; 270 + animation: tagAppear 0.2s ease-out; 271 + } 272 + 273 + .fronter-tag:hover { 274 + background: #333333; 275 + border-color: #555555; 276 + } 277 + 278 + .tag-content { 279 + display: flex; 280 + flex-direction: column; 281 + gap: 2px; 282 + } 283 + 284 + .tag-text { 285 + white-space: nowrap; 286 + letter-spacing: 0.5px; 287 + } 288 + 289 + .tag-error { 290 + font-size: 9px; 291 + color: #ff6666; 292 + font-weight: 500; 293 + letter-spacing: 0.3px; 294 + } 295 + 296 + .tag-loading { 297 + font-size: 9px; 298 + color: #888888; 299 + font-weight: 500; 300 + letter-spacing: 0.3px; 301 + font-style: italic; 302 + } 303 + 304 + .tag-remove { 305 + background: none; 306 + border: none; 307 + color: #888888; 308 + font-size: 14px; 309 + font-weight: 700; 310 + cursor: pointer; 311 + padding: 0; 312 + line-height: 1; 313 + transition: color 0.15s ease; 314 + display: flex; 315 + align-items: center; 316 + justify-content: center; 317 + width: 14px; 318 + height: 14px; 319 + margin-left: 2px; 320 + } 321 + 322 + .tag-remove:hover { 323 + color: #ff4444; 324 + } 325 + 326 + .tag-input { 327 + background: transparent; 328 + border: none; 329 + outline: none; 330 + color: #ffffff; 331 + font-family: inherit; 332 + font-size: 12px; 333 + font-weight: 500; 334 + flex: 1; 335 + min-width: 120px; 336 + height: 26px; 337 + } 338 + 339 + .tag-input::placeholder { 340 + color: #777777; 341 + font-size: 12px; 342 + } 343 + 344 + .config-note { 345 + padding: 0; 346 + background: transparent; 347 + border: none; 348 + margin: 0; 349 + } 350 + 351 + .note-text { 352 + font-size: 11px; 353 + color: #bbbbbb; 354 + line-height: 1.3; 355 + font-weight: 500; 356 + letter-spacing: 0.5px; 357 + } 358 + 359 + @keyframes tagAppear { 360 + 0% { 361 + opacity: 0; 362 + transform: scale(0.8); 363 + } 364 + 100% { 365 + opacity: 1; 366 + transform: scale(1); 367 + } 368 + } 369 + </style>
+467 -90
src/entrypoints/background.ts
··· 1 1 import { expect } from "@/lib/result"; 2 2 import { 3 - Fronter, 4 - fronterGetSocialAppHref, 3 + type Fronter, 4 + fronterGetSocialAppHrefs, 5 5 getFronter, 6 + getSpFronters, 6 7 putFronter, 8 + frontersCache, 9 + parseSocialAppPostUrl, 10 + displayNameCache, 11 + deleteFronter, 12 + getPkFronters, 13 + FronterView, 14 + docResolver, 7 15 } from "@/lib/utils"; 8 - import { parseResourceUri, ResourceUri } from "@atcute/lexicons"; 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, 28 + parseCanonicalResourceUri, 29 + } from "@atcute/lexicons/syntax"; 9 30 10 31 export default defineBackground({ 11 32 persistent: true, 12 33 main: () => { 13 34 console.log("setting up background script"); 14 35 15 - let fronters = new Map<ResourceUri, Fronter | null>(); 16 - const cacheFronter = (uri: ResourceUri, fronter: Fronter) => { 36 + const cacheFronter = async (uri: ResourceUri, fronter: Fronter) => { 17 37 const parsedUri = expect(parseResourceUri(uri)); 18 - fronters.set(uri, fronter); 19 - fronters.set( 38 + await frontersCache.set( 20 39 `at://${fronter.did}/${parsedUri.collection!}/${parsedUri.rkey!}`, 21 40 fronter, 22 41 ); 23 - fronters.set( 42 + await frontersCache.set( 24 43 `at://${fronter.handle}/${parsedUri.collection!}/${parsedUri.rkey!}`, 25 44 fronter, 26 45 ); ··· 48 67 browser.tabs.onUpdated.addListener(deleteOld); 49 68 }; 50 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 + }; 51 86 const handleWrite = async ( 52 - { data: { body, authToken } }: any, 87 + items: any[], 88 + authToken: string | null, 53 89 sender: globalThis.Browser.runtime.MessageSender, 54 90 ) => { 55 - const fronter = await storage.getItem<string>("sync:fronter"); 56 - if (!fronter) return; 57 91 if (!authToken) return; 58 - const data: any = JSON.parse(body); 59 - // console.log("will put fronter", fronter, "for records", data.results); 60 - const results = []; 61 - for (const result of data.results) { 62 - const resp = await putFronter(result.uri, fronter, authToken); 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); 63 106 if (resp.ok) { 64 - const parsedUri = cacheFronter(result.uri, resp.value); 107 + const parsedUri = await cacheFronter(result.uri, resp.value); 65 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", 66 115 rkey: parsedUri.rkey!, 67 116 ...resp.value, 68 117 }); 118 + } else { 119 + console.error(`fronter write: ${resp.error}`); 69 120 } 70 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 71 125 browser.tabs.sendMessage(sender.tab?.id!, { 72 - type: "TIMELINE_FRONTER", 73 - results: new Map( 74 - results.map((fronter) => [ 75 - fronterGetSocialAppHref(fronter, fronter.rkey), 76 - fronter, 77 - ]), 126 + type: "APPLY_FRONTERS", 127 + results: Object.fromEntries( 128 + results.flatMap((fronter) => 129 + fronterGetSocialAppHrefs(fronter).map((href) => [href, fronter]), 130 + ), 78 131 ), 79 132 }); 80 133 }; 81 - const handleTimeline = async ( 82 - feed: any[], 134 + const handleNotifications = async ( 135 + items: any, 83 136 sender: globalThis.Browser.runtime.MessageSender, 84 137 ) => { 85 - const handlePost = async (post: any) => { 86 - const cachedFronter = fronters.get(post.uri); 87 - if (cachedFronter === null) return; 88 - const promise = cachedFronter 89 - ? Promise.resolve(cachedFronter) 90 - : getFronter(post.uri).then(async (fronter) => { 91 - if (!fronter.ok) { 92 - fronters.set(post.uri, null); 93 - return; 94 - } 95 - return fronter.value; 96 - }); 97 - return promise.then((fronter) => { 98 - if (!fronter) return; 99 - const parsedUri = cacheFronter(post.uri, fronter); 100 - return { 101 - rkey: parsedUri.rkey!, 102 - ...fronter, 103 - }; 104 - }); 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 + }; 105 158 }; 106 - const allPromises = feed.flatMap((item) => { 107 - const promises = [handlePost(item.post)]; 108 - if (item.reply?.parent) { 109 - 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); 110 200 } 111 - if (item.reply?.root) { 112 - 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)); 113 223 } 114 - 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), 115 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 + ); 116 331 const results = new Map( 117 332 (await Promise.allSettled(allPromises)) 118 333 .filter((result) => result.status === "fulfilled") 119 334 .flatMap((result) => result.value ?? []) 120 - .map((fronter) => [ 121 - fronterGetSocialAppHref(fronter, fronter.rkey), 122 - fronter, 123 - ]), 335 + .flatMap((fronter) => 336 + fronterGetSocialAppHrefs(fronter).map((href) => [href, fronter]), 337 + ), 124 338 ); 339 + if (results.size === 0) return; 125 340 browser.tabs.sendMessage(sender.tab?.id!, { 126 - type: "TIMELINE_FRONTER", 127 - results, 341 + type: "APPLY_FRONTERS", 342 + results: Object.fromEntries(results), 128 343 }); 129 344 // console.log("sent timeline fronters", results); 130 345 }; 131 346 const handleThread = async ( 132 - { data: { body } }: any, 347 + { 348 + data: { body, requestUrl, documentUrl }, 349 + }: { data: { body: string; requestUrl: string; documentUrl: string } }, 133 350 sender: globalThis.Browser.runtime.MessageSender, 134 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); 135 369 const data: any = JSON.parse(body); 136 370 const promises = (data.thread as any[]).flatMap((item) => { 137 - const cachedFronter = fronters.get(item.uri); 138 - if (cachedFronter === null) return []; 139 - const promise = cachedFronter 140 - ? Promise.resolve(cachedFronter) 141 - : getFronter(item.uri).then(async (fronter) => { 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) => { 142 465 if (!fronter.ok) { 143 - fronters.set(item.uri, null); 144 - return; 466 + frontersCache.set(recordUri, null); 467 + return null; 145 468 } 146 469 return fronter.value; 147 - }); 148 - return promise.then(async (fronter) => { 470 + })); 149 471 if (!fronter) return; 150 - const parsedUri = cacheFronter(item.uri, fronter); 151 - if (item.depth === 0) await setTabFronter(item.uri, fronter); 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; 152 477 return { 478 + type: 479 + collection === "app.bsky.feed.repost" 480 + ? "post_repost_entry" 481 + : "post_like_entry", 153 482 rkey: parsedUri.rkey!, 154 - displayName: item.value.post.author.displayName, 155 - depth: item.depth, 483 + displayName, 156 484 ...fronter, 157 485 }; 158 - }); 159 - }); 486 + }, 487 + ); 488 + 160 489 const results = new Map( 161 - (await Promise.allSettled(promises)) 490 + (await Promise.allSettled(allPromises)) 162 491 .filter((result) => result.status === "fulfilled") 163 492 .flatMap((result) => result.value ?? []) 164 - .map((fronter) => [ 165 - fronterGetSocialAppHref(fronter, fronter.rkey, fronter.depth), 166 - fronter, 167 - ]), 493 + .flatMap((fronter) => 494 + fronterGetSocialAppHrefs(fronter).map((href) => [href, fronter]), 495 + ), 168 496 ); 497 + if (results.size === 0) return; 169 498 browser.tabs.sendMessage(sender.tab?.id!, { 170 - type: "THREAD_FRONTER", 171 - results, 499 + type: "APPLY_FRONTERS", 500 + results: Object.fromEntries(results), 172 501 }); 173 - // console.log("sent thread fronters", results); 174 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 + ); 175 529 176 530 browser.runtime.onMessage.addListener(async (message, sender) => { 177 531 if (message.type !== "RESPONSE_CAPTURED") return; 178 - // console.log("handling response event", message); 532 + console.log("handling response", message.data); 179 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; 180 541 case "write": 181 - await handleWrite(message, sender); 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 + ); 182 554 break; 555 + } 183 556 case "posts": 184 - const posts = JSON.parse(message.data.body) as any[]; 185 557 await handleTimeline( 186 - posts.map((post) => ({ post })), 558 + (JSON.parse(message.data.body) as any[]).map((post) => ({ post })), 187 559 sender, 188 560 ); 189 561 break; ··· 193 565 case "thread": 194 566 await handleThread(message, sender); 195 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; 196 577 } 197 - browser.tabs.sendMessage(sender.tab?.id!, { 198 - type: "CACHED_FRONTERS", 199 - fronters, 200 - }); 201 578 }); 202 579 browser.runtime.onMessage.addListener(async (message, sender) => { 203 580 if (message.type !== "TAB_FRONTER") return;
+256 -35
src/entrypoints/content.ts
··· 1 1 import { expect } from "@/lib/result"; 2 - import { Fronter, fronterGetSocialAppHref } from "@/lib/utils"; 3 - import { parseResourceUri, ResourceUri } from "@atcute/lexicons"; 2 + import { FronterView, parseSocialAppPostUrl } from "@/lib/utils"; 3 + import { parseResourceUri } from "@atcute/lexicons"; 4 4 5 5 const getAuthHeader = (headers: any): string | null => { 6 6 if (headers instanceof Headers) { ··· 22 22 const overriddenFetch = async ( 23 23 ...args: [input: RequestInfo | URL, init?: RequestInit] 24 24 ) => { 25 + const getRequestBody = async () => { 26 + if (args[0] instanceof Request) { 27 + if (args[0].bodyUsed) return null; 28 + try { 29 + const clone = args[0].clone(); 30 + return await clone.text(); 31 + } catch { 32 + return null; 33 + } 34 + } else if (args[1]?.body) { 35 + return typeof args[1].body === "string" 36 + ? args[1].body 37 + : JSON.stringify(args[1].body); 38 + } 39 + return null; 40 + }; 41 + const requestBody = await getRequestBody(); 25 42 const response = await originalFetch.apply(this, args); 26 43 27 44 if (respEventName === null) return response; ··· 38 55 }), 39 56 ); 40 57 }; 41 - 42 - let detail: any; 43 - if (response.url.includes("/xrpc/com.atproto.repo.applyWrites")) { 58 + const getAuthToken = () => { 44 59 let authHeader: string | null = null; 45 60 if (typeof args[0] === "string") { 46 61 if (args[1]?.headers) { ··· 49 64 } else if (args[0] instanceof Request) { 50 65 authHeader = getAuthHeader(args[0].headers); 51 66 } 67 + return authHeader?.split(" ")[1] || null; 68 + }; 69 + const getRequestUrl = () => { 70 + let url: string | null = null; 71 + if (args[0] instanceof Request) { 72 + url = args[0].url; 73 + } else { 74 + url = args[0].toString(); 75 + } 76 + return decodeURI(url); 77 + }; 52 78 79 + let detail: any = undefined; 80 + if (response.url.includes("/xrpc/com.atproto.repo.applyWrites")) { 53 81 detail = { 54 82 type: "write", 55 83 body, 56 - authToken: authHeader?.split(" ")[1] || null, 84 + authToken: getAuthToken(), 85 + }; 86 + } else if (response.url.includes("/xrpc/com.atproto.repo.deleteRecord")) { 87 + detail = { 88 + type: "delete", 89 + body: requestBody, 90 + authToken: getAuthToken(), 91 + }; 92 + } else if (response.url.includes("/xrpc/com.atproto.repo.createRecord")) { 93 + detail = { 94 + type: "writeOne", 95 + body, 96 + authToken: getAuthToken(), 57 97 }; 58 98 } else if ( 59 99 response.url.includes("/xrpc/app.bsky.feed.getAuthorFeed") || ··· 70 110 detail = { 71 111 type: "thread", 72 112 body, 113 + requestUrl: getRequestUrl(), 114 + documentUrl: document.location.href, 73 115 }; 74 116 } else if (response.url.includes("/xrpc/app.bsky.feed.getPosts")) { 75 117 detail = { 76 118 type: "posts", 77 119 body, 78 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 + }; 79 138 } 80 - sendEvent(detail); 139 + if (detail) { 140 + sendEvent(detail); 141 + } 81 142 82 143 return response; 83 144 }; ··· 96 157 }); 97 158 respEventSetup.then((name) => (respEventName = name)); 98 159 99 - const applyFronterName = (el: Element, fronterName: string) => { 100 - if (el.getAttribute("data-fronter")) return; 101 - el.textContent += ` [f: ${fronterName}]`; 102 - el.setAttribute("data-fronter", fronterName); 160 + const applyFronterName = ( 161 + el: Element, 162 + fronters: FronterView["members"], 163 + ) => { 164 + if (el.hasAttribute("data-fronter")) return false; 165 + const s = fronters.map((f) => f.name).join(", "); 166 + el.textContent += ` [f: ${s}]`; 167 + el.setAttribute("data-fronter", s); 168 + return true; 103 169 }; 104 - const applyFrontersToPage = (fronters: Map<string, any>) => { 170 + const applyFrontersToPage = ( 171 + fronters: Map<string, FronterView | null>, 172 + pageChange: boolean, 173 + ) => { 174 + // console.log("applyFrontersToPage", fronters); 175 + const match = parseSocialAppPostUrl(document.URL); 176 + if (pageChange) { 177 + console.log( 178 + "page change so clearing all elements with data-fronter attribute", 179 + ); 180 + for (const el of document.querySelectorAll("[data-fronter]")) { 181 + const previousFronter = el.getAttribute("data-fronter")!; 182 + if (previousFronter !== "__set__") { 183 + // remove fronter text 184 + el.textContent = el.textContent.replace( 185 + ` [f: ${previousFronter}]`, 186 + "", 187 + ); 188 + } 189 + el.removeAttribute("data-fronter"); 190 + } 191 + } 192 + console.log("applyFrontersToPage", match, fronters); 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 + }; 105 320 for (const el of document.getElementsByTagName("a")) { 321 + if (el.getAttribute("data-fronter")) continue; 106 322 const path = `/${el.href.split("/").slice(3).join("/")}`; 107 - const fronter = fronters.get(path); 108 - if (!fronter) continue; 109 - const isFocusedPost = fronter.depth === 0; 110 - if (isFocusedPost && el.ariaLabel !== fronter.displayName) continue; 111 - const displayNameElement = isFocusedPost 112 - ? (el.firstElementChild?.firstElementChild?.firstElementChild 113 - ?.firstElementChild?.firstElementChild ?? null) 114 - : (el.parentElement?.firstElementChild?.firstElementChild 115 - ?.firstElementChild?.firstElementChild ?? null); 116 - if (!displayNameElement) continue; 117 - applyFronterName(displayNameElement, fronter.fronterName); 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 + } 118 330 } 119 331 }; 332 + let postTabObserver: MutationObserver | null = null; 120 333 window.addEventListener("message", (event) => { 121 - if (event.data.type !== "CACHED_FRONTERS") return; 122 - const fronters = event.data.fronters as Map<string, Fronter | null>; 123 - const updated = new Map( 124 - fronters.entries().flatMap(([uri, fronter]) => { 125 - if (!fronter) return []; 126 - const rkey = expect(parseResourceUri(uri)).rkey!; 127 - return [[fronterGetSocialAppHref(fronter, rkey), fronter]]; 128 - }), 334 + if (event.data.type !== "APPLY_CACHED_FRONTERS") return; 335 + const applyFronters = () => { 336 + console.log("applying cached fronters", event.data.fronters); 337 + applyFrontersToPage(new Map(Object.entries(event.data.fronters)), true); 338 + }; 339 + // check if we are on profile so we can update fronters if the post tab is clicked on 340 + const postTabElement = document.querySelector( 341 + '[data-testid="profilePager-Posts"]', 129 342 ); 130 - applyFrontersToPage(updated); 343 + if (postTabElement) { 344 + postTabObserver = new MutationObserver(applyFronters); 345 + postTabObserver.observe(postTabElement, { attributes: true }); 346 + } else if (postTabObserver) { 347 + postTabObserver.disconnect(); 348 + postTabObserver = null; 349 + } 350 + // update fronters on page 351 + applyFronters(); 131 352 }); 132 353 window.addEventListener("message", (event) => { 133 - if (!["TIMELINE_FRONTER", "THREAD_FRONTER"].includes(event.data.type)) 134 - return; 135 - applyFrontersToPage(event.data.results as Map<string, any>); 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); 136 357 }); 137 358 }, 138 359 });
+76 -47
src/entrypoints/isolated.content.ts
··· 1 - import { Fronter } from "@/lib/utils"; 2 - import { ResourceUri } from "@atcute/lexicons"; 1 + import { decodeStorageKey } from "@/lib/cache"; 2 + import { expect } from "@/lib/result"; 3 + import { 4 + displayNameCache, 5 + Fronter, 6 + fronterGetSocialAppHref, 7 + fronterGetSocialAppHrefs, 8 + frontersCache, 9 + FronterView, 10 + parseSocialAppPostUrl, 11 + } from "@/lib/utils"; 12 + import { parseResourceUri, ResourceUri } from "@atcute/lexicons"; 3 13 4 14 export default defineContentScript({ 5 15 matches: ["<all_urls>"], 6 16 runAt: "document_start", 7 17 world: "ISOLATED", 8 18 main: (ctx) => { 9 - let fronters = new Map<ResourceUri, Fronter | null>(); 10 - 11 - const checkFronter = (url: string) => { 19 + const checkFronter = async (url: string) => { 12 20 // match https://*/profile/<actor_identifier>/post/<rkey> regex with named params to extract actor_identifier and rkey 13 - const match = url.match( 14 - /https:\/\/[^/]+\/profile\/([^/]+)\/post\/([^/]+)/, 15 - ); 21 + const match = parseSocialAppPostUrl(url); 16 22 if (!match) return false; 17 - const [website, actorIdentifier, rkey] = match; 18 23 const recordUri = 19 - `at://${actorIdentifier}/app.bsky.feed.post/${rkey}` as ResourceUri; 20 - const fronter = fronters.get(recordUri); 24 + `at://${match.actorIdentifier}/app.bsky.feed.post/${match.rkey}` as ResourceUri; 25 + const fronter = await frontersCache.get(recordUri); 21 26 if (!fronter) return false; 22 27 browser.runtime.sendMessage({ 23 28 type: "TAB_FRONTER", ··· 30 35 const respEventName = Math.random().toString(36).slice(2); 31 36 window.addEventListener(`${respEventName}-isolated`, async (event) => { 32 37 const data = (event as any).detail; 33 - // console.log("passing response event to bg", data); 34 - await browser.runtime 35 - .sendMessage({ 36 - type: "RESPONSE_CAPTURED", 37 - data, 38 - }) 39 - .catch(() => { 40 - console.log("background script not ready"); 41 - }); 38 + // console.log("passing response event to bg", event); 39 + await browser.runtime.sendMessage({ 40 + type: "RESPONSE_CAPTURED", 41 + data, 42 + }); 42 43 }); 43 - const messageTypes = [ 44 - "TAB_FRONTER", 45 - "THREAD_FRONTER", 46 - "TIMELINE_FRONTER", 47 - "CACHED_FRONTERS", 48 - ]; 44 + const bgMessageTypes = ["APPLY_FRONTERS"]; 49 45 browser.runtime.onMessage.addListener((message) => { 50 - if (!messageTypes.includes(message.type)) return; 51 - if (message.type === "CACHED_FRONTERS") { 52 - fronters = message.fronters; 53 - } 46 + if (!bgMessageTypes.includes(message.type)) return; 54 47 window.postMessage(message); 55 48 }); 56 - let postTabObserver: MutationObserver | null = null; 57 - ctx.addEventListener(window, "wxt:locationchange", (event) => { 58 - window.postMessage({ type: "CACHED_FRONTERS", fronters }); 59 - // check if we are on profile so we can update fronters if the post tab is clicked on 60 - const postTabElement = document.querySelector( 61 - '[data-testid="profilePager-Posts"]', 62 - ); 63 - if (postTabElement) { 64 - postTabObserver = new MutationObserver(() => { 65 - window.postMessage({ type: "CACHED_FRONTERS", fronters }); 66 - }); 67 - postTabObserver.observe(postTabElement, { attributes: true }); 68 - } else if (postTabObserver) { 69 - postTabObserver.disconnect(); 70 - postTabObserver = null; 49 + const updateOnUrlChange = async () => { 50 + const fronters = await frontersCache.getAll(); 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 + } 72 + // add entry for current page 73 + const match = parseSocialAppPostUrl(document.location.href); 74 + if (match && !updated.has(`/profile/${match.actorIdentifier}`)) { 75 + const maybeFronter = updated.get( 76 + `/profile/${match.actorIdentifier}/post/${match.rkey}`, 77 + ); 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 + } 71 94 } 95 + window.postMessage({ 96 + type: "APPLY_CACHED_FRONTERS", 97 + fronters: Object.fromEntries(updated), 98 + }); 72 99 // check for tab fronter for the current "post" 73 - checkFronter(event.newUrl.toString()); 74 - }); 100 + await checkFronter(document.location.href); 101 + }; 102 + window.addEventListener("popstate", updateOnUrlChange); 103 + ctx.addEventListener(window, "wxt:locationchange", updateOnUrlChange); 75 104 76 105 // setup response "channel" 77 106 document.dispatchEvent(
+279 -130
src/entrypoints/popup/App.svelte
··· 1 1 <script lang="ts"> 2 2 import { expect } from "@/lib/result"; 3 - import { getFronter } from "@/lib/utils"; 3 + import { getFronter, getMemberPublicUri } from "@/lib/utils"; 4 4 import { isResourceUri } from "@atcute/lexicons"; 5 - import type { 6 - AtprotoDid, 7 - Handle, 8 - ResourceUri, 9 - } from "@atcute/lexicons/syntax"; 5 + import type { ResourceUri } from "@atcute/lexicons/syntax"; 6 + import FronterList from "@/components/FronterList.svelte"; 10 7 11 8 let recordAtUri = $state(""); 12 - let queryResult = $state(""); 9 + let queryResult = $state<{ 10 + handle: string; 11 + fronters: { name: string; uri?: string }[]; 12 + } | null>(null); 13 + let queryError = $state(""); 13 14 let isQuerying = $state(false); 14 - let fronterName = $state(""); 15 + let fronters = $state<string[]>([]); 16 + let pkSystemId = $state<string>(""); 17 + let spToken = $state(""); 18 + let isFromCurrentTab = $state(false); 15 19 16 - const makeOutput = (fronterName: string, handle: Handle | null) => { 17 - return `HANDLE: ${handle ?? "handle.invalid"}\nFRONTER: ${fronterName}`; 20 + const makeOutput = (record: any) => { 21 + const fronters = record.members.map((f: any) => ({ 22 + name: f.name, 23 + uri: f.uri ? getMemberPublicUri(f.uri) : undefined, 24 + })); 25 + return { 26 + handle: record.handle ?? `handle.invalid (${record.did})`, 27 + fronters, 28 + }; 18 29 }; 19 30 20 31 const queryRecord = async (recordUri: ResourceUri) => { 21 32 if (!recordAtUri.trim()) return; 22 33 23 34 isQuerying = true; 24 - queryResult = ""; 35 + queryResult = null; 25 36 26 37 try { 27 38 if (!isResourceUri(recordUri)) throw "INVALID_RESOURCE_URI"; 28 39 const result = expect(await getFronter(recordUri)); 29 - queryResult = 30 - makeOutput(result.fronterName, result.handle) || 31 - "NO_FRONTER_FOUND"; 40 + queryResult = makeOutput(result); 32 41 } catch (error) { 33 - queryResult = `ERROR: ${error}`; 42 + queryResult = null; 43 + queryError = `ERROR: ${error}`; 34 44 } finally { 35 45 isQuerying = false; 36 46 } 37 47 }; 38 48 39 - const updateFronter = (event: any) => { 40 - fronterName = (event.target as HTMLInputElement).value; 41 - storage.setItem("sync:fronter", fronterName); 49 + const updateFronters = (newFronters: string[]) => { 50 + fronters = newFronters; 51 + storage.setItem("sync:fronters", newFronters); 52 + }; 53 + 54 + const updatePkSystem = (event: any) => { 55 + pkSystemId = (event.target as HTMLInputElement).value; 56 + storage.setItem("sync:pk-system", pkSystemId); 57 + }; 58 + 59 + const updateSpToken = (event: any) => { 60 + spToken = (event.target as HTMLInputElement).value; 61 + storage.setItem("sync:sp_token", spToken); 42 62 }; 43 63 44 64 const handleKeyPress = (event: KeyboardEvent) => { ··· 48 68 }; 49 69 50 70 const clearResult = () => { 51 - queryResult = ""; 71 + queryResult = null; 72 + queryError = ""; 52 73 recordAtUri = ""; 74 + isFromCurrentTab = false; 53 75 }; 54 76 55 77 onMount(async () => { 56 - const fronter = await storage.getItem<string>("sync:fronter"); 57 - if (fronter) { 58 - fronterName = fronter; 78 + const frontersArray = await storage.getItem<string[]>("sync:fronters"); 79 + if (frontersArray && Array.isArray(frontersArray)) { 80 + fronters = frontersArray; 81 + } 82 + 83 + const pkSystem = await storage.getItem<string>("sync:pk-system"); 84 + if (pkSystem) { 85 + pkSystemId = pkSystem; 86 + } 87 + 88 + const token = await storage.getItem<string>("sync:sp_token"); 89 + if (token) { 90 + spToken = token; 59 91 } 60 92 61 93 const tabs = await browser.tabs.query({ 62 94 active: true, 63 95 currentWindow: true, 64 96 }); 65 - const tabFronter = await storage.getItem<{ 66 - fronterName: string; 67 - recordUri: ResourceUri; 68 - handle: Handle | null; 69 - did: AtprotoDid; 70 - }>(`local:tab-${tabs[0].id!}-fronter`); 97 + const tabFronter = await storage.getItem<any>( 98 + `local:tab-${tabs[0].id!}-fronter`, 99 + ); 71 100 if (tabFronter) { 72 - queryResult = makeOutput(tabFronter.fronterName, tabFronter.handle); 101 + queryResult = makeOutput(tabFronter); 73 102 recordAtUri = tabFronter.recordUri; 103 + isFromCurrentTab = true; 74 104 } 75 105 }); 76 106 </script> 77 107 78 108 <main> 79 109 <div class="container"> 80 - <header class="header"> 81 - <div class="title">AT_FRONTER</div> 82 - </header> 83 - 84 110 <div class="content"> 85 111 <section class="query-panel"> 86 112 <div class="panel-header"> ··· 104 130 class="exec-button" 105 131 disabled={isQuerying || !recordAtUri.trim()} 106 132 > 107 - <span class="button-text" 108 - >{isQuerying ? "EXECUTING" : "EXEC"}</span 109 - > 133 + <span class="button-text">EXEC</span> 110 134 <div class="button-accent"></div> 111 135 </button> 112 136 </div> ··· 114 138 115 139 <div class="output-container"> 116 140 <div class="output-header"> 117 - <span>OUTPUT</span> 141 + <div class="output-header-left"> 142 + <span>OUTPUT</span> 143 + {#if isFromCurrentTab} 144 + <div class="tab-indicator"> 145 + <span class="tab-indicator-text" 146 + >FROM_CURRENT_TAB</span 147 + > 148 + <div class="tab-indicator-accent"></div> 149 + </div> 150 + {/if} 151 + </div> 118 152 <div class="clear-button-container"> 119 - {#if queryResult && !isQuerying} 153 + {#if (queryResult || queryError) && !isQuerying} 120 154 <button 121 155 class="clear-button" 122 156 onclick={clearResult} ··· 135 169 > 136 170 <div class="loading-bar"></div> 137 171 </div> 172 + {:else if queryError} 173 + <div class="result-text error"> 174 + {queryError} 175 + </div> 138 176 {:else if queryResult} 139 - <div 140 - class="result-text" 141 - class:error={queryResult.startsWith( 142 - "ERROR:", 143 - )} 144 - > 145 - {queryResult} 177 + <div class="result-text"> 178 + <div>HANDLE: {queryResult.handle}</div> 179 + <div> 180 + FRONTER(S): 181 + {#each queryResult.fronters as fronter, i} 182 + {#if fronter.uri} 183 + <a 184 + href={fronter.uri} 185 + class="fronter-link" 186 + >{fronter.name}</a 187 + > 188 + {:else} 189 + {fronter.name + 190 + (i < 191 + queryResult.fronters 192 + .length - 193 + 1 194 + ? ", " 195 + : "")} 196 + {/if} 197 + {/each} 198 + </div> 146 199 </div> 147 200 {:else} 148 201 <div class="placeholder-text"> ··· 159 212 <span class="panel-title">CONFIGURATION</span> 160 213 <div class="panel-accent"></div> 161 214 </div> 162 - 163 - <div class="config-row"> 164 - <span class="config-label">FRONTER_NAME</span> 165 - <div class="config-input-wrapper"> 215 + <div class="config-card"> 216 + <div class="config-row"> 217 + <span class="config-label">SP TOKEN</span> 166 218 <input 167 - type="text" 168 - placeholder="enter_identifier" 169 - oninput={updateFronter} 170 - bind:value={fronterName} 219 + type="password" 220 + placeholder="enter_simply_plural_token" 221 + oninput={updateSpToken} 222 + bind:value={spToken} 171 223 class="config-input" 172 - class:has-value={fronterName} 224 + class:has-value={spToken} 173 225 /> 174 226 </div> 227 + <div class="config-note"> 228 + <span class="note-text"> 229 + when set, pulls fronters from Simply Plural (token 230 + only requires read permissions) 231 + </span> 232 + </div> 175 233 </div> 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> 253 + <FronterList 254 + bind:fronters 255 + onUpdate={updateFronters} 256 + label="FRONTERS" 257 + placeholder="enter_fronter_names" 258 + note="just names, overrides SP & PK fronters" 259 + /> 176 260 </section> 177 261 </div> 178 262 179 263 <footer class="footer"> 180 - <span 181 - >SOURCE ON <a 182 - href="https://tangled.sh/did:plc:dfl62fgb7wtjj3fcbb72naae/at-fronter" 183 - >TANGLED</a 184 - ></span 264 + <span class="title">AT_FRONTER</span> 265 + <span class="footer-separator">โ€ข</span> 266 + <span class="footer-source">SOURCE ON </span> 267 + <a 268 + href="https://tangled.sh/did:plc:dfl62fgb7wtjj3fcbb72naae/at-fronter" 269 + class="footer-link">TANGLED</a 185 270 > 186 271 </footer> 187 272 </div> ··· 209 294 background: linear-gradient(180deg, #000000 0%, #0a0a0a 100%); 210 295 } 211 296 212 - .header { 213 - display: flex; 214 - align-items: center; 215 - justify-content: center; 216 - padding: 20px 20px; 217 - background: #000000; 218 - border-bottom: 1px solid #333333; 219 - position: relative; 220 - } 221 - 222 - .header::after { 223 - content: ""; 224 - position: absolute; 225 - bottom: 0; 226 - left: 0; 227 - width: 100%; 228 - height: 1px; 229 - background: linear-gradient(90deg, transparent, #555555, transparent); 230 - } 231 - 232 297 .title { 233 - font-size: 18px; 234 - font-weight: 800; 235 - letter-spacing: 3px; 236 - color: #ffffff; 298 + font-size: 10px; 299 + font-weight: 700; 300 + letter-spacing: 2px; 301 + color: #999999; 302 + line-height: 1; 303 + vertical-align: baseline; 237 304 } 238 305 239 306 .content { 240 307 flex: 1; 241 308 display: flex; 242 309 flex-direction: column; 243 - gap: 24px; 244 - padding: 24px 20px; 310 + gap: 20px; 311 + padding: 18px 16px; 245 312 overflow-y: auto; 246 313 } 247 314 ··· 254 321 .config-panel { 255 322 display: flex; 256 323 flex-direction: column; 257 - gap: 16px; 324 + gap: 12px; 325 + } 326 + 327 + .config-card { 328 + background: #0d0d0d; 329 + border: 1px solid #2a2a2a; 330 + border-left: 3px solid #444444; 331 + padding: 10px; 332 + display: flex; 333 + flex-direction: column; 334 + gap: 6px; 335 + transition: border-left-color 0.2s ease; 336 + } 337 + 338 + .config-card:hover { 339 + border-left-color: #555555; 340 + } 341 + 342 + .config-note { 343 + padding: 0; 344 + background: transparent; 345 + border: none; 346 + margin: 0; 347 + } 348 + 349 + .note-text { 350 + font-size: 11px; 351 + color: #bbbbbb; 352 + line-height: 1.3; 353 + font-weight: 500; 354 + letter-spacing: 0.5px; 258 355 } 259 356 260 357 .panel-header { ··· 294 391 295 392 .record-input { 296 393 flex: 1; 297 - padding: 16px 18px; 394 + padding: 12px 14px; 298 395 background: transparent; 299 396 border: none; 300 397 outline: none; ··· 315 412 316 413 .exec-button { 317 414 position: relative; 318 - padding: 16px 28px; 415 + padding: 8px 10px; 319 416 background: #2a2a2a; 320 417 border: none; 321 418 border-left: 1px solid #444444; ··· 380 477 min-height: 32px; 381 478 } 382 479 480 + .output-header-left { 481 + display: flex; 482 + align-items: center; 483 + gap: 12px; 484 + } 485 + 486 + .tab-indicator { 487 + display: flex; 488 + align-items: center; 489 + gap: 6px; 490 + padding: 4px 8px; 491 + background: #1a1a1a; 492 + border: 1px solid #333333; 493 + position: relative; 494 + overflow: hidden; 495 + } 496 + 497 + .tab-indicator-text { 498 + font-size: 9px; 499 + color: #00ff41; 500 + font-weight: 700; 501 + letter-spacing: 1px; 502 + position: relative; 503 + z-index: 1; 504 + } 505 + 506 + .tab-indicator-accent { 507 + position: absolute; 508 + left: 0; 509 + bottom: 0; 510 + width: 100%; 511 + height: 1px; 512 + background: #00ff41; 513 + animation: pulse 2s ease-in-out infinite; 514 + } 515 + 383 516 .clear-button-container { 384 517 width: 60px; 385 518 display: flex; ··· 419 552 } 420 553 421 554 .output-content { 422 - padding: 18px; 555 + padding: 14px; 423 556 height: 100%; 424 557 display: flex; 425 558 align-items: center; ··· 469 602 color: #ff4444; 470 603 } 471 604 605 + .fronter-link { 606 + color: #00ff41; 607 + text-decoration: none; 608 + font-weight: 700; 609 + transition: all 0.2s ease; 610 + position: relative; 611 + border-bottom: 1px solid transparent; 612 + } 613 + 614 + .fronter-link:hover { 615 + color: #33ff66; 616 + border-bottom-color: #00ff41; 617 + } 618 + 619 + .fronter-link:active { 620 + color: #ffffff; 621 + } 622 + 472 623 .placeholder-text { 473 624 color: #888888; 474 625 font-size: 12px; ··· 479 630 480 631 .config-row { 481 632 display: flex; 482 - flex-direction: column; 483 - gap: 8px; 633 + align-items: center; 634 + gap: 12px; 635 + margin-bottom: 0; 484 636 } 485 637 486 638 .config-label { 487 - font-size: 11px; 488 - color: #aaaaaa; 489 - letter-spacing: 1.5px; 639 + font-size: 12px; 640 + color: #cccccc; 641 + letter-spacing: 1px; 490 642 font-weight: 700; 491 - } 492 - 493 - .config-input-wrapper { 494 - display: flex; 495 - align-items: center; 643 + white-space: nowrap; 644 + min-width: 90px; 496 645 } 497 646 498 647 .config-input { 499 648 flex: 1; 500 - padding: 14px 18px; 649 + padding: 10px 12px; 501 650 background: #181818; 502 651 border: 1px solid #333333; 503 652 color: #ffffff; 504 653 font-family: inherit; 505 - font-size: 13px; 654 + font-size: 12px; 506 655 font-weight: 500; 507 656 transition: all 0.2s ease; 508 657 position: relative; ··· 524 673 525 674 .footer { 526 675 display: flex; 527 - align-items: center; 676 + align-items: baseline; 528 677 justify-content: center; 529 - padding: 16px 20px; 678 + gap: 8px; 679 + padding: 12px 16px; 530 680 background: #000000; 531 - border-top: 1px solid #333333; 532 - font-size: 10px; 533 - color: #888888; 534 - font-weight: 600; 535 - letter-spacing: 1px; 681 + border-top: 1px solid #222222; 682 + font-size: 9px; 683 + color: #666666; 684 + font-weight: 500; 685 + letter-spacing: 0.5px; 686 + line-height: 1; 536 687 position: relative; 537 688 } 538 689 ··· 543 694 left: 0; 544 695 width: 100%; 545 696 height: 1px; 546 - background: linear-gradient(90deg, transparent, #555555, transparent); 697 + background: linear-gradient(90deg, transparent, #333333, transparent); 547 698 } 548 699 549 - .footer a { 550 - color: #aaaaaa; 700 + .footer-separator { 701 + color: #444444; 702 + font-weight: 400; 703 + line-height: 1; 704 + vertical-align: baseline; 705 + } 706 + 707 + .footer-source { 708 + color: #777777; 709 + line-height: 1; 710 + vertical-align: baseline; 711 + } 712 + 713 + .footer-link { 714 + color: #999999; 551 715 text-decoration: none; 552 716 font-weight: 700; 553 717 transition: color 0.2s ease; 718 + line-height: 1; 719 + vertical-align: baseline; 554 720 } 555 721 556 - .footer a:hover { 557 - color: #ffffff; 722 + .footer-link:hover { 723 + color: #cccccc; 558 724 } 559 725 560 726 /* Animations */ ··· 575 741 100% { 576 742 left: 100%; 577 743 } 578 - } 579 - 580 - /* Scrollbar */ 581 - .content::-webkit-scrollbar { 582 - width: 2px; 583 - } 584 - 585 - .content::-webkit-scrollbar-track { 586 - background: #000000; 587 - } 588 - 589 - .content::-webkit-scrollbar-thumb { 590 - background: #333333; 591 - } 592 - 593 - .content::-webkit-scrollbar-thumb:hover { 594 - background: #555555; 595 744 } 596 745 </style>
+68 -3
src/entrypoints/popup/app.css
··· 87 87 color: #ffffff; 88 88 } 89 89 90 + /* Cross-browser scrollbar styling */ 91 + 92 + /* Standard scrollbar properties (Firefox, Chrome 121+, Edge 121+) */ 93 + * { 94 + scrollbar-width: thin; 95 + scrollbar-color: #333333 #0a0a0a; 96 + } 97 + 98 + /* Content areas get even thinner scrollbars */ 99 + .content, 100 + .output-content, 101 + textarea, 102 + input { 103 + scrollbar-width: thin; 104 + scrollbar-color: #2a2a2a #000000; 105 + } 106 + 107 + /* Webkit scrollbar styling for older browsers and better customization */ 90 108 /* Global scrollbar styling */ 91 109 ::-webkit-scrollbar { 92 - width: 2px; 93 - height: 2px; 110 + width: 8px; 111 + height: 8px; 94 112 } 95 113 96 114 ::-webkit-scrollbar-track { 97 - background: #000000; 115 + background: #0a0a0a; 116 + border-radius: 0; 98 117 } 99 118 100 119 ::-webkit-scrollbar-thumb { 101 120 background: #333333; 121 + border-radius: 0; 102 122 border: none; 123 + transition: background 0.2s ease; 103 124 } 104 125 105 126 ::-webkit-scrollbar-thumb:hover { 106 127 background: #555555; 107 128 } 108 129 130 + ::-webkit-scrollbar-thumb:active { 131 + background: #666666; 132 + } 133 + 109 134 ::-webkit-scrollbar-corner { 135 + background: #0a0a0a; 136 + } 137 + 138 + /* Scrollbar for specific containers */ 139 + .content::-webkit-scrollbar, 140 + .output-content::-webkit-scrollbar, 141 + textarea::-webkit-scrollbar, 142 + input::-webkit-scrollbar { 143 + width: 6px; 144 + height: 6px; 145 + } 146 + 147 + .content::-webkit-scrollbar-track, 148 + .output-content::-webkit-scrollbar-track, 149 + textarea::-webkit-scrollbar-track, 150 + input::-webkit-scrollbar-track { 110 151 background: #000000; 152 + } 153 + 154 + .content::-webkit-scrollbar-thumb, 155 + .output-content::-webkit-scrollbar-thumb, 156 + textarea::-webkit-scrollbar-thumb, 157 + input::-webkit-scrollbar-thumb { 158 + background: #2a2a2a; 159 + border-radius: 0; 160 + border: none; 161 + transition: background 0.15s ease; 162 + } 163 + 164 + .content::-webkit-scrollbar-thumb:hover, 165 + .output-content::-webkit-scrollbar-thumb:hover, 166 + textarea::-webkit-scrollbar-thumb:hover, 167 + input::-webkit-scrollbar-thumb:hover { 168 + background: #444444; 169 + } 170 + 171 + .content::-webkit-scrollbar-thumb:active, 172 + .output-content::-webkit-scrollbar-thumb:active, 173 + textarea::-webkit-scrollbar-thumb:active, 174 + input::-webkit-scrollbar-thumb:active { 175 + background: #555555; 111 176 } 112 177 113 178 /* Animations */
+110
src/lib/cache.ts
··· 1 + interface CachedItem<T> { 2 + data: T; 3 + timestamp: number; 4 + } 5 + 6 + export const decodeStorageKey = (storageKey: string) => 7 + atob(storageKey.split("_")[1]); 8 + 9 + export class PersistentCache<T = any> { 10 + private readonly keyPrefix: string; 11 + private readonly expiryHours: number; 12 + private readonly keysSetKey: `local:${string}`; 13 + 14 + constructor(keyPrefix: string, expiryHours: number) { 15 + this.keyPrefix = keyPrefix; 16 + this.expiryHours = expiryHours; 17 + this.keysSetKey = `local:${keyPrefix}_keys`; 18 + } 19 + 20 + private getCacheKey(key: string): `local:${string}` { 21 + const safeKey = btoa(key); 22 + return `local:${this.keyPrefix}_${safeKey}`; 23 + } 24 + 25 + private async getStoredKeys(): Promise<Set<string>> { 26 + const keys = await storage.getItem<string[]>(this.keysSetKey); 27 + return new Set(keys || []); 28 + } 29 + 30 + private async addKeyToSet(key: string): Promise<void> { 31 + const keys = await this.getStoredKeys(); 32 + keys.add(key); 33 + await storage.setItem(this.keysSetKey, Array.from(keys)); 34 + } 35 + 36 + private async removeKeyFromSet(...key: string[]): Promise<void> { 37 + const keys = await this.getStoredKeys(); 38 + for (const k of key) keys.delete(k); 39 + await storage.setItem(this.keysSetKey, Array.from(keys)); 40 + } 41 + 42 + async get(key: string): Promise<T | undefined> { 43 + const cacheKey = this.getCacheKey(key); 44 + const cached = await storage.getItem<CachedItem<T>>(cacheKey); 45 + 46 + if (!cached) return undefined; 47 + 48 + const now = Date.now(); 49 + const expiryTime = cached.timestamp + this.expiryHours * 60 * 60 * 1000; 50 + 51 + if (this.expiryHours > 0 && now > expiryTime) { 52 + await storage.removeItem(cacheKey); 53 + return undefined; 54 + } 55 + 56 + return cached.data; 57 + } 58 + 59 + async set(key: string, value: T): Promise<void> { 60 + const cacheKey = this.getCacheKey(key); 61 + const cachedItem: CachedItem<T> = { 62 + data: value, 63 + timestamp: Date.now(), 64 + }; 65 + await storage.setItem(cacheKey, cachedItem); 66 + await this.addKeyToSet(key); 67 + } 68 + 69 + async remove(key: string): Promise<void> { 70 + const cacheKey = this.getCacheKey(key); 71 + await storage.removeItem(cacheKey); 72 + await this.removeKeyFromSet(key); 73 + } 74 + 75 + async getAll(): Promise<Map<string, T>> { 76 + const keys = await this.getStoredKeys(); 77 + 78 + if (keys.size === 0) { 79 + return new Map(); 80 + } 81 + 82 + const cacheKeys = Array.from(keys).map((key) => this.getCacheKey(key)); 83 + const items = await storage.getItems(cacheKeys); 84 + 85 + const result = new Map<string, T>(); 86 + const now = Date.now(); 87 + const keysToRemove: string[] = []; 88 + 89 + for (const { key, value } of items) { 90 + const expiryTime = value.timestamp + this.expiryHours * 60 * 60 * 1000; 91 + 92 + if (this.expiryHours > 0 && now > expiryTime) { 93 + keysToRemove.push(key); 94 + } else { 95 + result.set(key, value.data); 96 + } 97 + } 98 + 99 + // Clean up expired or missing items 100 + if (keysToRemove.length > 0) { 101 + const expiredCacheKeys = keysToRemove.map((key) => this.getCacheKey(key)); 102 + await Promise.all([ 103 + storage.removeItems(expiredCacheKeys), 104 + this.removeKeyFromSet(...keysToRemove), 105 + ]); 106 + } 107 + 108 + return result; 109 + } 110 + }
+2 -2
src/lib/result.ts
··· 16 16 }; 17 17 export const expect = <T, E>( 18 18 v: Result<T, E>, 19 - msg: string = "expected result to not be error", 19 + msg: string = "expected result to not be error:", 20 20 ) => { 21 21 if (v.ok) { 22 22 return v.value; 23 23 } 24 - throw msg; 24 + throw `${msg} ${v.error}`; 25 25 };
+314 -22
src/lib/utils.ts
··· 7 7 import { 8 8 ActorIdentifier, 9 9 Did, 10 + GenericUri, 10 11 Handle, 11 12 isHandle, 13 + Nsid, 12 14 RecordKey, 13 15 type AtprotoDid, 14 16 type ResourceUri, ··· 24 26 WellKnownHandleResolver, 25 27 } from "@atcute/identity-resolver"; 26 28 import { getAtprotoHandle, getPdsEndpoint } from "@atcute/identity"; 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 + }; 27 37 28 38 export type Fronter = { 29 - fronterName: string; 39 + members: { 40 + uri?: MemberUri; 41 + name: string; 42 + }[]; 30 43 handle: Handle | null; 31 44 did: AtprotoDid; 45 + subject?: Subject; 46 + replyTo?: ResourceUri; 32 47 }; 33 48 34 - export const fronterGetSocialAppHref = ( 35 - fronter: Fronter, 36 - rkey: RecordKey, 37 - depth?: number, 38 - ) => { 39 - return depth === 0 40 - ? `/profile/${fronter.handle ?? fronter.did}` 41 - : `/profile/${fronter.handle ?? fronter.did}/post/${rkey}`; 42 - }; 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"]; 43 81 44 - const fronterSchema = v.record( 82 + export const fronterSchema = v.record( 45 83 v.string(), 46 84 v.object({ 47 85 $type: v.literal("systems.gaze.atfronter.fronter"), 48 - name: v.string(), 49 86 subject: v.resourceUriString(), 87 + members: v.array( 88 + v.object({ 89 + name: v.string(), 90 + uri: v.optional(v.genericUriString()), // identifier(s) for pk or sp or etc. (maybe member record in the future?) 91 + }), 92 + ), 50 93 }), 51 94 ); 95 + export type FronterSchema = InferOutput<typeof fronterSchema>; 52 96 53 - const handleResolver = new CompositeHandleResolver({ 97 + export type MemberUri = 98 + | { type: "at"; recordUri: ResourceUri } 99 + | { type: "pk"; systemId: string; memberId: string } 100 + | { type: "sp"; systemId: string; memberId: string }; 101 + 102 + export const parseMemberId = (memberId: GenericUri): MemberUri => { 103 + const uri = new URL(memberId); 104 + switch (uri.protocol) { 105 + case "pk:": { 106 + const split = uri.pathname.split("/").slice(1); 107 + return { type: "pk", systemId: split[0], memberId: split[1] }; 108 + } 109 + case "sp:": { 110 + const split = uri.pathname.split("/").slice(1); 111 + return { type: "sp", systemId: split[0], memberId: split[1] }; 112 + } 113 + case "at:": { 114 + return { type: "at", recordUri: memberId as ResourceUri }; 115 + } 116 + default: { 117 + throw new Error(`Invalid member ID: ${memberId}`); 118 + } 119 + } 120 + }; 121 + export const memberUriString = (memberUri: MemberUri): GenericUri => { 122 + switch (memberUri.type) { 123 + case "pk": { 124 + return `pk://api.pluralkit.me/${memberUri.memberId}`; 125 + } 126 + case "sp": { 127 + return `sp://api.apparyllis.com/${memberUri.systemId}/${memberUri.memberId}`; 128 + } 129 + case "at": { 130 + return memberUri.recordUri; 131 + } 132 + } 133 + }; 134 + export const getMemberPublicUri = (memberUri: MemberUri) => { 135 + switch (memberUri.type) { 136 + case "pk": { 137 + return `https://dash.pluralkit.me/profile/m/${memberUri.memberId}`; 138 + } 139 + case "sp": { 140 + return null; 141 + } 142 + case "at": { 143 + return `https://pdsls.dev/${memberUri.recordUri}`; 144 + } 145 + } 146 + }; 147 + 148 + // Member cache instance 149 + const memberCache = new PersistentCache("member_cache", 24); 150 + 151 + export const fetchMember = async ( 152 + memberUri: MemberUri, 153 + ): Promise<string | undefined> => { 154 + const s = memberUriString(memberUri); 155 + const cached = await memberCache.get(s); 156 + switch (memberUri.type) { 157 + case "sp": { 158 + if (cached) return cached.content.name; 159 + const token = await storage.getItem<string>("sync:sp_token"); 160 + if (!token) return; 161 + const resp = await fetch( 162 + `https://api.apparyllis.com/v1/member/${memberUri.systemId}/${memberUri.memberId}`, 163 + { 164 + headers: { 165 + authorization: token, 166 + }, 167 + }, 168 + ); 169 + if (!resp.ok) return; 170 + const member = await resp.json(); 171 + await memberCache.set(s, member); 172 + return member.content.name; 173 + } 174 + case "pk": { 175 + if (cached) return cached.name; 176 + const resp = await fetch( 177 + `https://api.pluralkit.me/v2/members/${memberUri.memberId}`, 178 + ); 179 + if (!resp.ok) return; 180 + const member = await resp.json(); 181 + await memberCache.set(s, member); 182 + return member.name; 183 + } 184 + } 185 + }; 186 + 187 + export const getFronterNames = async ( 188 + members: { name?: string; uri?: MemberUri }[], 189 + ) => { 190 + const promises = await Promise.allSettled( 191 + members.map(async (m): Promise<Fronter["members"][0] | 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; 196 + }), 197 + ); 198 + return promises 199 + .filter((p) => p.status === "fulfilled") 200 + .flatMap((p) => p.value ?? []); 201 + }; 202 + 203 + export const handleResolver = new CompositeHandleResolver({ 54 204 strategy: "race", 55 205 methods: { 56 206 dns: new DohJsonHandleResolver({ ··· 59 209 http: new WellKnownHandleResolver(), 60 210 }, 61 211 }); 62 - const docResolver = new CompositeDidDocumentResolver({ 212 + export const docResolver = new CompositeDidDocumentResolver({ 63 213 methods: { 64 214 plc: new PlcDidDocumentResolver(), 65 215 web: new WebDidDocumentResolver(), ··· 87 237 return new AtpClient({ handler }); 88 238 }; 89 239 240 + export const frontersCache = new PersistentCache<Fronter | null>( 241 + "cachedFronters", 242 + 24, 243 + ); 244 + 90 245 export const getFronter = async <Uri extends ResourceUri>( 91 246 recordUri: Uri, 92 247 ): Promise<Result<Fronter, string>> => { ··· 114 269 const maybeTyped = safeParse(fronterSchema, maybeRecord.data.value); 115 270 if (!maybeTyped.ok) return err(maybeTyped.message); 116 271 272 + let members: Fronter["members"]; 273 + try { 274 + members = maybeTyped.value.members.map((m) => ({ 275 + name: m.name, 276 + uri: m.uri ? parseMemberId(m.uri) : undefined, 277 + })); 278 + } catch (error) { 279 + return err(`error fetching fronter names: ${error}`); 280 + } 281 + 117 282 return ok({ 118 - fronterName: maybeTyped.value.name, 283 + members, 119 284 handle, 120 285 did, 121 286 }); 122 287 }; 123 288 124 - export const putFronter = async <Uri extends ResourceUri>( 125 - recordUri: Uri, 126 - name: string, 289 + export const putFronter = async ( 290 + subject: FronterSchema["subject"], 291 + members: { name?: string; uri?: MemberUri }[], 127 292 authToken: string, 128 293 ): Promise<Result<Fronter, string>> => { 129 - const parsedRecordUri = parseResourceUri(recordUri); 294 + const parsedRecordUri = parseResourceUri(subject); 130 295 if (!parsedRecordUri.ok) return err(parsedRecordUri.error); 131 296 const { repo, collection, rkey } = parsedRecordUri.value; 132 297 ··· 135 300 136 301 // make client 137 302 const atpClient = await getAtpClient(did); 303 + 304 + let filteredMembers: Fronter["members"]; 305 + try { 306 + filteredMembers = await getFronterNames(members); 307 + } catch (error) { 308 + return err(`error fetching fronter names: ${error}`); 309 + } 138 310 139 311 // put 140 312 let maybeRecord = await atpClient.post("com.atproto.repo.putRecord", { ··· 143 315 collection: fronterSchema.object.shape.$type.expected, 144 316 rkey: `${collection}_${rkey}`, 145 317 record: { 146 - name, 147 - subject: `at://${did}/${collection}/${rkey}`, 318 + subject, 319 + members: filteredMembers.map((member) => ({ 320 + name: member.name, 321 + uri: member.uri ? memberUriString(member.uri) : undefined, 322 + })), 148 323 }, 149 324 validate: false, 150 325 }, ··· 156 331 return ok({ 157 332 did, 158 333 handle, 159 - fronterName: name, 334 + members: filteredMembers, 160 335 }); 161 336 }; 337 + 338 + export const deleteFronter = async ( 339 + did: AtprotoDid, 340 + collection: Nsid, 341 + rkey: RecordKey, 342 + authToken: string, 343 + ): Promise<Result<boolean, string>> => { 344 + // make client 345 + const atpClient = await getAtpClient(did); 346 + 347 + // delete 348 + let maybeRecord = await atpClient.post("com.atproto.repo.deleteRecord", { 349 + input: { 350 + repo: did, 351 + collection: fronterSchema.object.shape.$type.expected, 352 + rkey: `${collection}_${rkey}`, 353 + }, 354 + headers: { authorization: `Bearer ${authToken}` }, 355 + }); 356 + if (!maybeRecord.ok) 357 + return err(maybeRecord.data.message ?? maybeRecord.data.error); 358 + 359 + return ok(true); 360 + }; 361 + 362 + export const getSpFronters = async (): Promise< 363 + Parameters<typeof putFronter>["1"] 364 + > => { 365 + const spToken = await storage.getItem<string>("sync:sp_token"); 366 + if (!spToken) return []; 367 + const resp = await fetch(`https://api.apparyllis.com/v1/fronters`, { 368 + headers: { 369 + authorization: spToken, 370 + }, 371 + }); 372 + if (!resp.ok) return []; 373 + const spFronters = (await resp.json()) as any[]; 374 + return spFronters.map((fronter) => ({ 375 + name: undefined, 376 + uri: { 377 + type: "sp", 378 + memberId: fronter.content.member, 379 + systemId: fronter.content.uid, 380 + }, 381 + })); 382 + }; 383 + 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; 429 + return [ 430 + view.handle ? [fronterGetSocialAppHref(view.handle, view.rkey, depth)] : [], 431 + fronterGetSocialAppHref(view.did, view.rkey, depth), 432 + ].flat(); 433 + }; 434 + 435 + export const fronterGetSocialAppHref = ( 436 + repo: string, 437 + rkey: RecordKey, 438 + depth?: number, 439 + ) => { 440 + return depth === 0 ? `/profile/${repo}` : `/profile/${repo}/post/${rkey}`; 441 + }; 442 + 443 + export const parseSocialAppPostUrl = (url: string) => { 444 + const match = url.match(/https:\/\/[^/]+\/profile\/([^/]+)\/post\/([^/]+)/); 445 + if (!match) return; 446 + const [website, actorIdentifier, rkey] = match; 447 + return { actorIdentifier, rkey }; 448 + }; 449 + 450 + export const displayNameCache = new PersistentCache<string>( 451 + "displayNameCache", 452 + 1, 453 + );
+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 });