view who was fronting when a record was made

fix: better caching, dont use wxt:locationchange and listen on popstate instead

ptr.pet 2170a51c 2018e799

verified
Changed files
+179 -59
src
+32 -34
src/entrypoints/background.ts
··· 1 + import { PersistentCache } from "@/lib/cache"; 1 2 import { expect } from "@/lib/result"; 2 3 import { 3 4 type Fronter, ··· 7 8 getSpFronters, 8 9 memberUriString, 9 10 putFronter, 11 + frontersCache, 10 12 } from "@/lib/utils"; 11 13 import { parseResourceUri, ResourceUri } from "@atcute/lexicons"; 12 14 ··· 15 17 main: () => { 16 18 console.log("setting up background script"); 17 19 18 - let fronters = new Map<ResourceUri, Fronter | null>(); 19 - const cacheFronter = (uri: ResourceUri, fronter: Fronter) => { 20 + const cacheFronter = async (uri: ResourceUri, fronter: Fronter) => { 20 21 const parsedUri = expect(parseResourceUri(uri)); 21 - fronters.set( 22 + await frontersCache.set( 22 23 `at://${fronter.did}/${parsedUri.collection!}/${parsedUri.rkey!}`, 23 24 fronter, 24 25 ); 25 - fronters.set( 26 + await frontersCache.set( 26 27 `at://${fronter.handle}/${parsedUri.collection!}/${parsedUri.rkey!}`, 27 28 fronter, 28 29 ); ··· 72 73 for (const result of items) { 73 74 const resp = await putFronter(result.uri, members, authToken); 74 75 if (resp.ok) { 75 - const parsedUri = cacheFronter(result.uri, resp.value); 76 + const parsedUri = await cacheFronter(result.uri, resp.value); 76 77 results.push({ 77 78 rkey: parsedUri.rkey!, 78 79 ...resp.value, ··· 100 101 sender: globalThis.Browser.runtime.MessageSender, 101 102 ) => { 102 103 const handlePost = async (post: any) => { 103 - const cachedFronter = fronters.get(post.uri); 104 + const cachedFronter = await frontersCache.get(post.uri); 104 105 if (cachedFronter === null) return; 105 106 const promise = cachedFronter 106 107 ? Promise.resolve(cachedFronter) 107 108 : getFronter(post.uri).then(async (fronter) => { 108 109 if (!fronter.ok) { 109 - fronters.set(post.uri, null); 110 + await frontersCache.set(post.uri, null); 110 111 return; 111 112 } 112 113 return fronter.value; 113 114 }); 114 - return promise.then((fronter) => { 115 + return promise.then(async (fronter) => { 115 116 if (!fronter) return; 116 - const parsedUri = cacheFronter(post.uri, fronter); 117 + const parsedUri = await cacheFronter(post.uri, fronter); 117 118 return { 118 119 rkey: parsedUri.rkey!, 119 120 ...fronter, ··· 153 154 ) => { 154 155 const data: any = JSON.parse(body); 155 156 const promises = (data.thread as any[]).flatMap((item) => { 156 - const cachedFronter = fronters.get(item.uri); 157 - if (cachedFronter === null) return []; 158 - const promise = cachedFronter 159 - ? Promise.resolve(cachedFronter) 160 - : getFronter(item.uri).then(async (fronter) => { 161 - if (!fronter.ok) { 162 - fronters.set(item.uri, null); 163 - return; 164 - } 165 - return fronter.value; 166 - }); 167 - return promise.then(async (fronter) => { 168 - if (!fronter) return; 169 - const parsedUri = cacheFronter(item.uri, fronter); 170 - if (item.depth === 0) await setTabFronter(item.uri, fronter); 171 - return { 172 - rkey: parsedUri.rkey!, 173 - displayName: item.value.post.author.displayName, 174 - depth: item.depth, 175 - ...fronter, 176 - }; 157 + return frontersCache.get(item.uri).then(async (cachedFronter) => { 158 + if (cachedFronter === null) return []; 159 + const promise = cachedFronter 160 + ? Promise.resolve(cachedFronter) 161 + : getFronter(item.uri).then(async (fronter) => { 162 + if (!fronter.ok) { 163 + await frontersCache.set(item.uri, null); 164 + return; 165 + } 166 + return fronter.value; 167 + }); 168 + return promise.then(async (fronter) => { 169 + if (!fronter) return; 170 + const parsedUri = await cacheFronter(item.uri, fronter); 171 + if (item.depth === 0) await setTabFronter(item.uri, fronter); 172 + return { 173 + rkey: parsedUri.rkey!, 174 + displayName: item.value.post.author.displayName, 175 + depth: item.depth, 176 + ...fronter, 177 + }; 178 + }); 177 179 }); 178 180 }); 179 181 const results = new Map( ··· 224 226 await handleThread(message, sender); 225 227 break; 226 228 } 227 - browser.tabs.sendMessage(sender.tab?.id!, { 228 - type: "CACHED_FRONTERS", 229 - fronters, 230 - }); 231 229 }); 232 230 browser.runtime.onMessage.addListener(async (message, sender) => { 233 231 if (message.type !== "TAB_FRONTER") return;
+13 -2
src/entrypoints/content.ts
··· 1 + import { decodeStorageKey } from "@/lib/cache"; 1 2 import { expect } from "@/lib/result"; 2 3 import { 3 4 Fronter, ··· 120 121 const applyFrontersToPage = (fronters: Map<string, any>) => { 121 122 // console.log("applyFrontersToPage", fronters); 122 123 const match = parseSocialAppPostUrl(document.URL); 124 + // console.log(match, fronters); 125 + for (const el of document.querySelectorAll("[data-fronter]")) { 126 + const previousFronter = el.getAttribute("data-fronter")!; 127 + // remove fronter text 128 + el.textContent = el.textContent.replace(` [f: ${previousFronter}]`, ""); 129 + el.removeAttribute("data-fronter"); 130 + } 131 + if (fronters.size === 0) return; 123 132 for (const el of document.getElementsByTagName("a")) { 124 133 const path = `/${el.href.split("/").slice(3).join("/")}`; 125 134 const fronter = fronters.get(path); ··· 139 148 }; 140 149 let postTabObserver: MutationObserver | null = null; 141 150 window.addEventListener("message", (event) => { 142 - if (event.data.type !== "CACHED_FRONTERS") return; 151 + if (event.data.type !== "APPLY_CACHED_FRONTERS") return; 143 152 const applyFronters = () => { 144 153 const fronters = event.data.fronters as Map<string, Fronter | null>; 145 154 const updated = new Map( 146 - fronters.entries().flatMap(([uri, fronter]) => { 155 + fronters.entries().flatMap(([storageKey, fronter]) => { 147 156 if (!fronter) return []; 157 + const uri = decodeStorageKey(storageKey); 148 158 const rkey = expect(parseResourceUri(uri)).rkey!; 149 159 return fronterGetSocialAppHrefs(fronter, rkey).map((href) => [ 150 160 href, ··· 152 162 ]); 153 163 }), 154 164 ); 165 + // console.log("applying cached fronters"); 155 166 applyFrontersToPage(updated); 156 167 }; 157 168 // check if we are on profile so we can update fronters if the post tab is clicked on
+10 -17
src/entrypoints/isolated.content.ts
··· 1 - import { Fronter, parseSocialAppPostUrl } from "@/lib/utils"; 1 + import { Fronter, frontersCache, parseSocialAppPostUrl } from "@/lib/utils"; 2 2 import { ResourceUri } from "@atcute/lexicons"; 3 3 4 4 export default defineContentScript({ ··· 6 6 runAt: "document_start", 7 7 world: "ISOLATED", 8 8 main: (ctx) => { 9 - let fronters = new Map<ResourceUri, Fronter | null>(); 10 - 11 - const checkFronter = (url: string) => { 9 + const checkFronter = async (url: string) => { 12 10 // match https://*/profile/<actor_identifier>/post/<rkey> regex with named params to extract actor_identifier and rkey 13 11 const match = parseSocialAppPostUrl(url); 14 12 if (!match) return false; 15 13 const recordUri = 16 14 `at://${match.actorIdentifier}/app.bsky.feed.post/${match.rkey}` as ResourceUri; 17 - const fronter = fronters.get(recordUri); 15 + const fronter = await frontersCache.get(recordUri); 18 16 if (!fronter) return false; 19 17 browser.runtime.sendMessage({ 20 18 type: "TAB_FRONTER", ··· 33 31 data, 34 32 }); 35 33 }); 36 - const messageTypes = [ 37 - "TAB_FRONTER", 38 - "THREAD_FRONTER", 39 - "TIMELINE_FRONTER", 40 - "CACHED_FRONTERS", 41 - ]; 34 + const messageTypes = ["TAB_FRONTER", "THREAD_FRONTER", "TIMELINE_FRONTER"]; 42 35 browser.runtime.onMessage.addListener((message) => { 43 36 if (!messageTypes.includes(message.type)) return; 44 - if (message.type === "CACHED_FRONTERS") { 45 - fronters = message.fronters; 46 - } 47 37 window.postMessage(message); 48 38 }); 49 - ctx.addEventListener(window, "wxt:locationchange", (event) => { 50 - window.postMessage({ type: "CACHED_FRONTERS", fronters }); 39 + window.addEventListener("popstate", async (event) => { 40 + window.postMessage({ 41 + type: "APPLY_CACHED_FRONTERS", 42 + fronters: await frontersCache.getAll(), 43 + }); 51 44 // check for tab fronter for the current "post" 52 - checkFronter(event.newUrl.toString()); 45 + await checkFronter(document.location.href); 53 46 }); 54 47 55 48 // setup response "channel"
+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 };
+12 -4
src/lib/utils.ts
··· 25 25 WellKnownHandleResolver, 26 26 } from "@atcute/identity-resolver"; 27 27 import { getAtprotoHandle, getPdsEndpoint } from "@atcute/identity"; 28 + import { PersistentCache } from "./cache"; 28 29 29 30 export type Fronter = { 30 31 members: { ··· 101 102 } 102 103 }; 103 104 104 - let memberCache = new Map<string, any>(); 105 + // Member cache instance 106 + const memberCache = new PersistentCache("member_cache", 24); 107 + 105 108 export const fetchMember = async ( 106 109 memberUri: MemberUri, 107 110 ): Promise<string | undefined> => { 108 111 const s = memberUriString(memberUri); 109 - const cached = memberCache.get(s); 112 + const cached = await memberCache.get(s); 110 113 switch (memberUri.type) { 111 114 case "sp": { 112 115 if (cached) return cached.content.name; ··· 122 125 ); 123 126 if (!resp.ok) return; 124 127 const member = await resp.json(); 125 - memberCache.set(s, member); 128 + await memberCache.set(s, member); 126 129 return member.content.name; 127 130 } 128 131 case "pk": { ··· 132 135 ); 133 136 if (!resp.ok) return; 134 137 const member = await resp.json(); 135 - memberCache.set(s, member); 138 + await memberCache.set(s, member); 136 139 return member.name; 137 140 } 138 141 } ··· 188 191 const handler = simpleFetchHandler({ service: pdsUrl }); 189 192 return new AtpClient({ handler }); 190 193 }; 194 + 195 + export const frontersCache = new PersistentCache<Fronter | null>( 196 + "cachedFronters", 197 + 24, 198 + ); 191 199 192 200 export const getFronter = async <Uri extends ResourceUri>( 193 201 recordUri: Uri,