view who was fronting when a record was made

feat: add sp integration

ptr.pet f60168fc 32e798dd

verified
Changed files
+210 -58
src
+26 -11
src/entrypoints/background.ts
··· 1 1 import { expect } from "@/lib/result"; 2 2 import { 3 - Fronter, 3 + type Fronter, 4 4 fronterGetSocialAppHref, 5 5 getFronter, 6 + getSpFronters, 7 + memberUriString, 6 8 putFronter, 7 9 } from "@/lib/utils"; 8 10 import { parseResourceUri, ResourceUri } from "@atcute/lexicons"; ··· 15 17 let fronters = new Map<ResourceUri, Fronter | null>(); 16 18 const cacheFronter = (uri: ResourceUri, fronter: Fronter) => { 17 19 const parsedUri = expect(parseResourceUri(uri)); 18 - fronters.set(uri, fronter); 19 20 fronters.set( 20 21 `at://${fronter.did}/${parsedUri.collection!}/${parsedUri.rkey!}`, 21 22 fronter, ··· 49 50 }; 50 51 51 52 const handleWrite = async ( 52 - { data: { body, authToken } }: any, 53 + items: any[], 54 + authToken: string | null, 53 55 sender: globalThis.Browser.runtime.MessageSender, 54 56 ) => { 55 57 const fronter = await storage.getItem<string>("sync:fronter"); 56 - if (!fronter) return; 58 + const spFronters = (await getSpFronters()).map((m) => memberUriString(m)); 57 59 if (!authToken) return; 58 - const data: any = JSON.parse(body); 59 - // console.log("will put fronter", fronter, "for records", data.results); 60 60 const results = []; 61 - for (const result of data.results) { 62 - const resp = await putFronter(result.uri, fronter, authToken); 61 + for (const result of items) { 62 + const resp = await putFronter( 63 + { name: fronter ?? "", subject: result.uri, member: spFronters }, 64 + authToken, 65 + ); 63 66 if (resp.ok) { 64 67 const parsedUri = cacheFronter(result.uri, resp.value); 65 68 results.push({ 66 69 rkey: parsedUri.rkey!, 67 70 ...resp.value, 68 71 }); 72 + } else { 73 + console.error(`fronter write: ${resp.error}`); 69 74 } 70 75 } 71 76 browser.tabs.sendMessage(sender.tab?.id!, { ··· 178 183 // console.log("handling response event", message); 179 184 switch (message.data.type as string) { 180 185 case "write": 181 - await handleWrite(message, sender); 186 + await handleWrite( 187 + JSON.parse(message.data.body).results, 188 + message.data.authToken, 189 + sender, 190 + ); 191 + break; 192 + case "writeOne": 193 + await handleWrite( 194 + [JSON.parse(message.data.body)], 195 + message.data.authToken, 196 + sender, 197 + ); 182 198 break; 183 199 case "posts": 184 - const posts = JSON.parse(message.data.body) as any[]; 185 200 await handleTimeline( 186 - posts.map((post) => ({ post })), 201 + (JSON.parse(message.data.body) as any[]).map((post) => ({ post })), 187 202 sender, 188 203 ); 189 204 break;
+20 -9
src/entrypoints/content.ts
··· 38 38 }), 39 39 ); 40 40 }; 41 - 42 - let detail: any; 43 - if (response.url.includes("/xrpc/com.atproto.repo.applyWrites")) { 41 + const getAuthToken = () => { 44 42 let authHeader: string | null = null; 45 43 if (typeof args[0] === "string") { 46 44 if (args[1]?.headers) { ··· 49 47 } else if (args[0] instanceof Request) { 50 48 authHeader = getAuthHeader(args[0].headers); 51 49 } 50 + return authHeader?.split(" ")[1] || null; 51 + }; 52 52 53 + let detail: any = undefined; 54 + if (response.url.includes("/xrpc/com.atproto.repo.applyWrites")) { 53 55 detail = { 54 56 type: "write", 55 57 body, 56 - authToken: authHeader?.split(" ")[1] || null, 58 + authToken: getAuthToken(), 59 + }; 60 + } else if (response.url.includes("/xrpc/com.atproto.repo.createRecord")) { 61 + detail = { 62 + type: "writeOne", 63 + body, 64 + authToken: getAuthToken(), 57 65 }; 58 66 } else if ( 59 67 response.url.includes("/xrpc/app.bsky.feed.getAuthorFeed") || ··· 77 85 body, 78 86 }; 79 87 } 80 - sendEvent(detail); 88 + if (detail) { 89 + sendEvent(detail); 90 + } 81 91 82 92 return response; 83 93 }; ··· 96 106 }); 97 107 respEventSetup.then((name) => (respEventName = name)); 98 108 99 - const applyFronterName = (el: Element, fronterName: string) => { 109 + const applyFronterName = (el: Element, fronterNames: string[]) => { 100 110 if (el.getAttribute("data-fronter")) return; 101 - el.textContent += ` [f: ${fronterName}]`; 102 - el.setAttribute("data-fronter", fronterName); 111 + const s = fronterNames.join(", "); 112 + el.textContent += ` [f: ${s}]`; 113 + el.setAttribute("data-fronter", s); 103 114 }; 104 115 const applyFrontersToPage = (fronters: Map<string, any>) => { 105 116 for (const el of document.getElementsByTagName("a")) { ··· 114 125 : (el.parentElement?.firstElementChild?.firstElementChild 115 126 ?.firstElementChild?.firstElementChild ?? null); 116 127 if (!displayNameElement) continue; 117 - applyFronterName(displayNameElement, fronter.fronterName); 128 + applyFronterName(displayNameElement, fronter.names); 118 129 } 119 130 }; 120 131 window.addEventListener("message", (event) => {
+5 -9
src/entrypoints/isolated.content.ts
··· 30 30 const respEventName = Math.random().toString(36).slice(2); 31 31 window.addEventListener(`${respEventName}-isolated`, async (event) => { 32 32 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 - }); 33 + // console.log("passing response event to bg", event); 34 + await browser.runtime.sendMessage({ 35 + type: "RESPONSE_CAPTURED", 36 + data, 37 + }); 42 38 }); 43 39 const messageTypes = [ 44 40 "TAB_FRONTER",
+34 -18
src/entrypoints/popup/App.svelte
··· 2 2 import { expect } from "@/lib/result"; 3 3 import { getFronter } 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"; 10 6 11 7 let recordAtUri = $state(""); 12 8 let queryResult = $state(""); 13 9 let isQuerying = $state(false); 14 10 let fronterName = $state(""); 11 + let spToken = $state(""); 15 12 16 - const makeOutput = (fronterName: string, handle: Handle | null) => { 17 - return `HANDLE: ${handle ?? "handle.invalid"}\nFRONTER: ${fronterName}`; 13 + const makeOutput = (fronter: any) => { 14 + return `HANDLE: ${fronter.handle ?? "handle.invalid"}<br>FRONTER: ${fronter.fronterName}`; 18 15 }; 19 16 20 17 const queryRecord = async (recordUri: ResourceUri) => { ··· 26 23 try { 27 24 if (!isResourceUri(recordUri)) throw "INVALID_RESOURCE_URI"; 28 25 const result = expect(await getFronter(recordUri)); 29 - queryResult = 30 - makeOutput(result.fronterName, result.handle) || 31 - "NO_FRONTER_FOUND"; 26 + queryResult = makeOutput(result) || "NO_FRONTER_FOUND"; 32 27 } catch (error) { 33 28 queryResult = `ERROR: ${error}`; 34 29 } finally { ··· 39 34 const updateFronter = (event: any) => { 40 35 fronterName = (event.target as HTMLInputElement).value; 41 36 storage.setItem("sync:fronter", fronterName); 37 + }; 38 + 39 + const updateSpToken = (event: any) => { 40 + spToken = (event.target as HTMLInputElement).value; 41 + storage.setItem("sync:sp_token", spToken); 42 42 }; 43 43 44 44 const handleKeyPress = (event: KeyboardEvent) => { ··· 58 58 fronterName = fronter; 59 59 } 60 60 61 + const token = await storage.getItem<string>("sync:sp_token"); 62 + if (token) { 63 + spToken = token; 64 + } 65 + 61 66 const tabs = await browser.tabs.query({ 62 67 active: true, 63 68 currentWindow: true, 64 69 }); 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`); 70 + const tabFronter = await storage.getItem<any>( 71 + `local:tab-${tabs[0].id!}-fronter`, 72 + ); 71 73 if (tabFronter) { 72 - queryResult = makeOutput(tabFronter.fronterName, tabFronter.handle); 74 + queryResult = makeOutput(tabFronter); 73 75 recordAtUri = tabFronter.recordUri; 74 76 } 75 77 }); ··· 142 144 "ERROR:", 143 145 )} 144 146 > 145 - {queryResult} 147 + {@html queryResult} 146 148 </div> 147 149 {:else} 148 150 <div class="placeholder-text"> ··· 170 172 bind:value={fronterName} 171 173 class="config-input" 172 174 class:has-value={fronterName} 175 + /> 176 + </div> 177 + </div> 178 + 179 + <div class="config-row"> 180 + <span class="config-label">SP_TOKEN</span> 181 + <div class="config-input-wrapper"> 182 + <input 183 + type="password" 184 + placeholder="enter_simply_plural_token" 185 + oninput={updateSpToken} 186 + bind:value={spToken} 187 + class="config-input" 188 + class:has-value={spToken} 173 189 /> 174 190 </div> 175 191 </div>
+125 -11
src/lib/utils.ts
··· 7 7 import { 8 8 ActorIdentifier, 9 9 Did, 10 + GenericUri, 10 11 Handle, 11 12 isHandle, 12 13 RecordKey, ··· 26 27 import { getAtprotoHandle, getPdsEndpoint } from "@atcute/identity"; 27 28 28 29 export type Fronter = { 29 - fronterName: string; 30 + memberUris: MemberUri[]; 31 + names: string[]; 30 32 handle: Handle | null; 31 33 did: AtprotoDid; 32 34 }; ··· 47 49 $type: v.literal("systems.gaze.atfronter.fronter"), 48 50 name: v.string(), 49 51 subject: v.resourceUriString(), 52 + member: v.array(v.genericUriString()), // identifier(s) for pk or sp or etc. (maybe member record in the future?) 50 53 }), 51 54 ); 52 55 56 + type MemberUri = 57 + | { type: "at"; recordUri: ResourceUri } 58 + | { type: "pk"; systemId: string; memberId: string } 59 + | { type: "sp"; systemId: string; memberId: string }; 60 + 61 + export const parseMemberId = (memberId: GenericUri): MemberUri => { 62 + const uri = new URL(memberId); 63 + switch (uri.protocol) { 64 + case "pk:": { 65 + const split = uri.pathname.split("/").slice(1); 66 + return { type: "pk", systemId: split[0], memberId: split[1] }; 67 + } 68 + case "sp:": { 69 + const split = uri.pathname.split("/").slice(1); 70 + return { type: "sp", systemId: split[0], memberId: split[1] }; 71 + } 72 + case "at:": { 73 + return { type: "at", recordUri: memberId as ResourceUri }; 74 + } 75 + default: { 76 + throw new Error(`Invalid member ID: ${memberId}`); 77 + } 78 + } 79 + }; 80 + export const memberUriString = (memberUri: MemberUri): GenericUri => { 81 + switch (memberUri.type) { 82 + case "pk": { 83 + return `pk://api.pluralkit.com/${memberUri.systemId}/${memberUri.memberId}`; 84 + } 85 + case "sp": { 86 + return `sp://api.apparyllis.com/${memberUri.systemId}/${memberUri.memberId}`; 87 + } 88 + case "at": { 89 + return memberUri.recordUri; 90 + } 91 + } 92 + }; 93 + 94 + let memberCache = new Map<string, any>(); 95 + export const fetchMember = async ( 96 + memberUri: MemberUri, 97 + ): Promise<string | undefined> => { 98 + switch (memberUri.type) { 99 + case "sp": { 100 + const s = memberUriString(memberUri); 101 + const cached = memberCache.get(s); 102 + if (cached) return cached.content.name; 103 + const token = await storage.getItem<string>("sync:sp_token"); 104 + if (!token) return; 105 + const resp = await fetch( 106 + `https://api.apparyllis.com/v1/member/${memberUri.systemId}/${memberUri.memberId}`, 107 + { 108 + headers: { 109 + authorization: token, 110 + }, 111 + }, 112 + ); 113 + if (!resp.ok) return; 114 + const member = await resp.json(); 115 + memberCache.set(s, member); 116 + return member.content.name; 117 + } 118 + } 119 + }; 120 + 121 + export const getFronterNames = async ( 122 + name: string, 123 + memberUris: MemberUri[], 124 + ) => { 125 + let fronterNames = [name]; 126 + if (memberUris.length > 0) { 127 + fronterNames = ( 128 + await Promise.allSettled(memberUris.map((m) => fetchMember(m))) 129 + ) 130 + .filter((p) => p.status === "fulfilled") 131 + .flatMap((p) => p.value ?? []); 132 + } 133 + return fronterNames; 134 + }; 135 + 53 136 const handleResolver = new CompositeHandleResolver({ 54 137 strategy: "race", 55 138 methods: { ··· 114 197 const maybeTyped = safeParse(fronterSchema, maybeRecord.data.value); 115 198 if (!maybeTyped.ok) return err(maybeTyped.message); 116 199 200 + let memberUris, fronterNames; 201 + try { 202 + memberUris = maybeTyped.value.member.map((m) => parseMemberId(m)); 203 + fronterNames = await getFronterNames(maybeTyped.value.name, memberUris); 204 + } catch (error) { 205 + return err(`error fetching fronter names: ${error}`); 206 + } 207 + 117 208 return ok({ 118 - fronterName: maybeTyped.value.name, 209 + memberUris, 210 + names: fronterNames, 119 211 handle, 120 212 did, 121 213 }); 122 214 }; 123 215 124 - export const putFronter = async <Uri extends ResourceUri>( 125 - recordUri: Uri, 126 - name: string, 216 + export const putFronter = async ( 217 + record: Omit<InferOutput<typeof fronterSchema>, "$type">, 127 218 authToken: string, 128 219 ): Promise<Result<Fronter, string>> => { 129 - const parsedRecordUri = parseResourceUri(recordUri); 220 + const parsedRecordUri = parseResourceUri(record.subject); 130 221 if (!parsedRecordUri.ok) return err(parsedRecordUri.error); 131 222 const { repo, collection, rkey } = parsedRecordUri.value; 132 223 ··· 142 233 repo: did, 143 234 collection: fronterSchema.object.shape.$type.expected, 144 235 rkey: `${collection}_${rkey}`, 145 - record: { 146 - name, 147 - subject: `at://${did}/${collection}/${rkey}`, 148 - }, 236 + record, 149 237 validate: false, 150 238 }, 151 239 headers: { authorization: `Bearer ${authToken}` }, ··· 153 241 if (!maybeRecord.ok) 154 242 return err(maybeRecord.data.message ?? maybeRecord.data.error); 155 243 244 + let memberUris, fronterNames; 245 + try { 246 + memberUris = record.member.map((m) => parseMemberId(m)); 247 + fronterNames = await getFronterNames(record.name, memberUris); 248 + } catch (error) { 249 + return err(`error fetching fronter names: ${error}`); 250 + } 251 + 156 252 return ok({ 157 253 did, 158 254 handle, 159 - fronterName: name, 255 + names: fronterNames, 256 + memberUris, 160 257 }); 161 258 }; 259 + 260 + export const getSpFronters = async (): Promise<MemberUri[]> => { 261 + const spToken = await storage.getItem<string>("sync:sp_token"); 262 + if (!spToken) return []; 263 + const resp = await fetch(`https://api.apparyllis.com/v1/fronters`, { 264 + headers: { 265 + authorization: spToken, 266 + }, 267 + }); 268 + if (!resp.ok) return []; 269 + const spFronters = (await resp.json()) as any[]; 270 + return spFronters.map((fronter) => ({ 271 + type: "sp", 272 + memberId: fronter.content.member, 273 + systemId: fronter.content.uid, 274 + })); 275 + };