BlueSky & more on desktop lazurite.stormlightlabs.org/
tauri rust typescript bluesky appview atproto solid
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: clear lexicon favicon cache

+173 -5
+1
.gitignore
··· 1 1 node_modules 2 2 dist 3 3 .sandbox/ 4 + .fallow/
+5
src-tauri/src/commands/explorer.rs
··· 46 46 ) -> Result<HashMap<String, Option<String>>, AppError> { 47 47 explorer::get_lexicon_favicons(collections, &app).await 48 48 } 49 + 50 + #[tauri::command] 51 + pub async fn clear_lexicon_favicon_cache(app: AppHandle) -> Result<(), AppError> { 52 + explorer::clear_lexicon_favicon_cache(&app) 53 + }
+28 -3
src-tauri/src/explorer.rs
··· 32 32 const PDS_REPO_LIST_LIMIT: i64 = 100; 33 33 const QUERY_LABELS_LIMIT: i64 = 100; 34 34 const FAVICON_FETCH_TIMEOUT: Duration = Duration::from_secs(2); 35 - const LEXICON_FAVICON_HOST_OVERRIDES: &[(&str, &str)] = &[("sh.tangled.", "tangled.org")]; 35 + const LEXICON_FAVICON_HOST_OVERRIDES: &[(&str, &str)] = &[("sh.tangled.", "tangled.org"), ("chat.bsky.", "bsky.app")]; 36 36 37 37 type ExplorerClient = Agent<UnauthenticatedSession<JacquardResolver>>; 38 38 ··· 278 278 } 279 279 280 280 Ok(icons) 281 + } 282 + 283 + pub fn clear_lexicon_favicon_cache(app: &AppHandle) -> Result<()> { 284 + let cache_dir = resolve_favicon_cache_dir(app)?; 285 + clear_favicon_cache_dir(&cache_dir) 281 286 } 282 287 283 288 pub async fn emit_explorer_navigation(app: &AppHandle, raw: &str) -> Result<()> { ··· 564 569 cache_dir.push("explorer"); 565 570 cache_dir.push("favicons"); 566 571 Ok(cache_dir) 572 + } 573 + 574 + fn clear_favicon_cache_dir(cache_dir: &std::path::Path) -> Result<()> { 575 + if !cache_dir.exists() { 576 + return Ok(()); 577 + } 578 + 579 + std::fs::remove_dir_all(cache_dir)?; 580 + Ok(()) 567 581 } 568 582 569 583 async fn resolve_lexicon_favicon_data_url( ··· 944 958 #[cfg(test)] 945 959 mod tests { 946 960 use super::{ 947 - build_resolved_at_uri, canonical_at_uri, detect_favicon_mime, detect_input_kind, extract_favicon_urls, 948 - extract_html_attribute, lexicon_favicon_hosts, normalize_handle, normalize_pds_url, 961 + build_resolved_at_uri, canonical_at_uri, clear_favicon_cache_dir, detect_favicon_mime, detect_input_kind, 962 + extract_favicon_urls, extract_html_attribute, lexicon_favicon_hosts, normalize_handle, normalize_pds_url, 949 963 read_cached_favicon_data_url, rel_indicates_favicon, repo_car_filename, repo_metadata_from_did_doc, 950 964 resolve_html_base_url, resolve_lexicon_favicon_data_url, sanitize_did_for_filename, write_cached_favicon, 951 965 CachedFavicon, ExplorerInputKind, ExplorerTargetKind, ··· 1178 1192 .expect("client should build"); 1179 1193 1180 1194 assert!(super::fetch_host_favicon(&client, "127.0.0.1:9").await.is_none()); 1195 + } 1196 + 1197 + #[test] 1198 + fn clears_favicon_cache_directory_contents() { 1199 + let cache_dir = create_temp_cache_dir(); 1200 + fs::write(cache_dir.join("icon.bin"), [1_u8, 2_u8, 3_u8]).expect("test cache file should be written"); 1201 + fs::write(cache_dir.join("icon.mime"), "image/png").expect("test cache mime should be written"); 1202 + 1203 + clear_favicon_cache_dir(&cache_dir).expect("cache directory should clear"); 1204 + 1205 + assert!(!cache_dir.exists()); 1181 1206 } 1182 1207 1183 1208 #[test]
+1
src-tauri/src/lib.rs
··· 115 115 cmd::explorer::export_repo_car, 116 116 cmd::explorer::query_labels, 117 117 cmd::explorer::get_lexicon_favicons, 118 + cmd::explorer::clear_lexicon_favicon_cache, 118 119 cmd::search::search_posts_network, 119 120 cmd::search::search_posts, 120 121 cmd::search::search_actors,
+36
src/components/explorer/ExplorerPanel.test.tsx
··· 5 5 const describeRepoMock = vi.hoisted(() => vi.fn()); 6 6 const describeServerMock = vi.hoisted(() => vi.fn()); 7 7 const exportRepoCarMock = vi.hoisted(() => vi.fn()); 8 + const clearLexiconFaviconCacheMock = vi.hoisted(() => vi.fn()); 8 9 const getLexiconFaviconsMock = vi.hoisted(() => vi.fn()); 9 10 const getRecordMock = vi.hoisted(() => vi.fn()); 10 11 const getRecordBacklinksMock = vi.hoisted(() => vi.fn()); ··· 20 21 describeRepo: describeRepoMock, 21 22 describeServer: describeServerMock, 22 23 exportRepoCar: exportRepoCarMock, 24 + clearLexiconFaviconCache: clearLexiconFaviconCacheMock, 23 25 getLexiconFavicons: getLexiconFaviconsMock, 24 26 getRecord: getRecordMock, 25 27 listRecords: listRecordsMock, ··· 41 43 describeRepoMock.mockReset(); 42 44 describeServerMock.mockReset(); 43 45 exportRepoCarMock.mockReset(); 46 + clearLexiconFaviconCacheMock.mockReset(); 44 47 getLexiconFaviconsMock.mockReset(); 45 48 getRecordMock.mockReset(); 46 49 getRecordBacklinksMock.mockReset(); ··· 51 54 listenMock.mockReset(); 52 55 53 56 exportRepoCarMock.mockResolvedValue({ did: "did:plc:alice", path: "/tmp/alice.car", bytesWritten: 64 }); 57 + clearLexiconFaviconCacheMock.mockResolvedValue(undefined); 54 58 getLexiconFaviconsMock.mockResolvedValue({}); 55 59 getProfileMock.mockResolvedValue({ 56 60 status: "available", ··· 277 281 fireEvent.click(screen.getByRole("button", { name: /app\.bsky\.feed\.post/u })); 278 282 279 283 expect(await screen.findAllByAltText("app.bsky.feed.post favicon")).not.toHaveLength(0); 284 + }); 285 + 286 + it("clears and rehydrates the explorer icon cache for the current repo view", async () => { 287 + resolveInputMock.mockResolvedValue({ 288 + input: "@alice.test", 289 + inputKind: "handle", 290 + targetKind: "repo", 291 + normalizedInput: "did:plc:alice", 292 + uri: "at://did:plc:alice", 293 + did: "did:plc:alice", 294 + handle: "alice.test", 295 + pdsUrl: "https://pds.example.com", 296 + collection: null, 297 + rkey: null, 298 + }); 299 + describeRepoMock.mockResolvedValue({ collections: ["sh.tangled.feed.star"] }); 300 + getLexiconFaviconsMock.mockResolvedValue({ "sh.tangled.feed.star": null }); 301 + 302 + renderPanel(); 303 + 304 + const input = screen.getByPlaceholderText(/at:\/\/did:\.\.\. or @handle or https:\/\/pds/u); 305 + fireEvent.input(input, { target: { value: "@alice.test" } }); 306 + fireEvent.submit(input.closest("form")!); 307 + 308 + expect(await screen.findByRole("button", { name: /sh\.tangled\.feed\.star/u })).toBeInTheDocument(); 309 + await waitFor(() => expect(getLexiconFaviconsMock).toHaveBeenCalledWith(["sh.tangled.feed.star"])); 310 + 311 + fireEvent.click(screen.getByRole("button", { name: /clear icon cache/i })); 312 + 313 + await waitFor(() => expect(clearLexiconFaviconCacheMock).toHaveBeenCalledOnce()); 314 + await waitFor(() => expect(getLexiconFaviconsMock).toHaveBeenCalledTimes(2)); 315 + expect(await screen.findByText("Cleared explorer icon cache.")).toBeInTheDocument(); 280 316 }); 281 317 });
+51 -2
src/components/explorer/ExplorerPanel.tsx
··· 1 1 import { 2 + clearLexiconFaviconCache, 2 3 describeRepo, 3 4 describeServer, 4 5 exportRepoCar, ··· 74 75 75 76 export function ExplorerPanel() { 76 77 const explorer = createExplorerState(); 78 + const [clearingIconCache, setClearingIconCache] = createSignal(false); 77 79 const [statusMessage, setStatusMessage] = createSignal<{ kind: "error" | "success"; text: string } | null>(null); 78 80 let resolveRequestId = 0; 79 81 ··· 100 102 })); 101 103 } 102 104 103 - async function hydrateLexiconIcons(collections: string[]) { 105 + async function hydrateLexiconIcons(collections: string[], options?: { force?: boolean }) { 104 106 const pendingCollections = [...new Set(collections)].filter((collection) => collection.trim().length > 0).filter(( 105 107 collection, 106 - ) => !hasCachedLexiconIcon(explorer.state.lexiconIcons, collection)); 108 + ) => options?.force || !hasCachedLexiconIcon(explorer.state.lexiconIcons, collection)); 107 109 108 110 if (pendingCollections.length === 0) { 109 111 return; ··· 117 119 keyValues: { collections: pendingCollections.join(","), error: String(error) }, 118 120 }); 119 121 } 122 + } 123 + 124 + function currentLexiconCollections(): string[] { 125 + const current = explorer.state.current; 126 + if (!current) { 127 + return []; 128 + } 129 + 130 + if (current.repoData) { 131 + return current.repoData.collections.map((collection) => collection.nsid); 132 + } 133 + 134 + if (current.collectionData) { 135 + return [current.collectionData.collection]; 136 + } 137 + 138 + if (current.resolved?.collection) { 139 + return [current.resolved.collection]; 140 + } 141 + 142 + return []; 120 143 } 121 144 122 145 async function handleResolveInput(input: string) { ··· 344 367 } 345 368 } 346 369 370 + async function handleClearIconCache() { 371 + if (clearingIconCache()) { 372 + return; 373 + } 374 + 375 + setClearingIconCache(true); 376 + setStatusMessage(null); 377 + 378 + try { 379 + await clearLexiconFaviconCache(); 380 + explorer.resetLexiconIcons(); 381 + setStatusMessage({ kind: "success", text: "Cleared explorer icon cache." }); 382 + 383 + const collections = currentLexiconCollections(); 384 + if (collections.length > 0) { 385 + await hydrateLexiconIcons(collections, { force: true }); 386 + } 387 + } catch (error) { 388 + setStatusMessage({ kind: "error", text: String(error) }); 389 + } finally { 390 + setClearingIconCache(false); 391 + } 392 + } 393 + 347 394 function handleRepoClick(did: string) { 348 395 void handleResolveInput(`at://${did}`); 349 396 } ··· 419 466 canGoBack={canGoBack()} 420 467 canGoForward={canGoForward()} 421 468 canExport={canExport()} 469 + clearingIconCache={clearingIconCache()} 422 470 onInput={explorer.setInputValue} 423 471 onSubmit={handleResolveInput} 424 472 onBack={handleBack} 425 473 onForward={handleForward} 474 + onClearIconCache={handleClearIconCache} 426 475 onExport={handleExport} /> 427 476 428 477 <Show when={breadcrumb().length > 0}>
+31
src/components/explorer/ExplorerUrlBar.test.tsx
··· 22 22 canGoBack={false} 23 23 canGoForward={false} 24 24 canExport={false} 25 + clearingIconCache={false} 25 26 onInput={onInput} 26 27 onSubmit={onSubmit} 27 28 onBack={() => {}} 28 29 onForward={() => {}} 30 + onClearIconCache={() => {}} 29 31 onExport={() => {}} /> 30 32 )); 31 33 ··· 44 46 canGoBack={false} 45 47 canGoForward={false} 46 48 canExport={false} 49 + clearingIconCache={false} 47 50 onInput={onInput} 48 51 onSubmit={onSubmit} 49 52 onBack={() => {}} 50 53 onForward={() => {}} 54 + onClearIconCache={() => {}} 51 55 onExport={() => {}} /> 52 56 )); 53 57 ··· 65 69 canGoBack={false} 66 70 canGoForward={false} 67 71 canExport={false} 72 + clearingIconCache={false} 68 73 onInput={onInput} 69 74 onSubmit={onSubmit} 70 75 onBack={() => {}} 71 76 onForward={() => {}} 77 + onClearIconCache={() => {}} 72 78 onExport={() => {}} /> 73 79 )); 74 80 ··· 90 96 canGoBack={false} 91 97 canGoForward={false} 92 98 canExport={false} 99 + clearingIconCache={false} 93 100 onInput={onInput} 94 101 onSubmit={onSubmit} 95 102 onBack={() => {}} 96 103 onForward={() => {}} 104 + onClearIconCache={() => {}} 97 105 onExport={() => {}} /> 98 106 )); 99 107 ··· 103 111 fireEvent.click(await screen.findByRole("option", { name: /alice\.test/u })); 104 112 105 113 expect(onSubmit).toHaveBeenCalledTimes(2); 114 + }); 115 + 116 + it("renders a clear icon cache control", () => { 117 + const onClearIconCache = vi.fn(); 118 + 119 + render(() => ( 120 + <ExplorerUrlBar 121 + value="" 122 + canGoBack={false} 123 + canGoForward={false} 124 + canExport={false} 125 + clearingIconCache={false} 126 + onInput={() => {}} 127 + onSubmit={() => {}} 128 + onBack={() => {}} 129 + onForward={() => {}} 130 + onClearIconCache={onClearIconCache} 131 + onExport={() => {}} /> 132 + )); 133 + 134 + fireEvent.click(screen.getByRole("button", { name: /clear icon cache/i })); 135 + 136 + expect(onClearIconCache).toHaveBeenCalledOnce(); 106 137 }); 107 138 });
+11
src/components/explorer/ExplorerUrlBar.tsx
··· 8 8 canGoBack: boolean; 9 9 canGoForward: boolean; 10 10 canExport: boolean; 11 + clearingIconCache: boolean; 11 12 onInput: (value: string) => void; 12 13 onSubmit: (value: string) => void; 13 14 onBack: () => void; 14 15 onForward: () => void; 16 + onClearIconCache: () => void; 15 17 onExport: () => void; 16 18 }; 17 19 ··· 157 159 aria-label="Reload" 158 160 title="Reload"> 159 161 <Icon kind="refresh" /> 162 + </button> 163 + 164 + <button 165 + onClick={() => props.onClearIconCache()} 166 + disabled={props.clearingIconCache} 167 + class="p-2 rounded-lg text-on-surface-variant hover:text-on-surface hover:bg-white/5 transition-all disabled:cursor-not-allowed disabled:opacity-30" 168 + aria-label="Clear icon cache" 169 + title="Clear icon cache"> 170 + <Icon iconClass={props.clearingIconCache ? "i-ri-loader-4-line" : "i-ri-delete-bin-6-line"} /> 160 171 </button> 161 172 162 173 <button
+5
src/components/explorer/explorer-state.ts
··· 73 73 setState("lexiconIcons", (current) => ({ ...current, ...icons })); 74 74 } 75 75 76 + function resetLexiconIcons() { 77 + setState("lexiconIcons", {}); 78 + } 79 + 76 80 function getBreadcrumb(): Array<{ label: string; level: ExplorerTargetKind; active: boolean }> { 77 81 const current = state.current; 78 82 if (!current || !current.resolved) return []; ··· 121 125 canGoBack, 122 126 canGoForward, 123 127 mergeLexiconIcons, 128 + resetLexiconIcons, 124 129 getBreadcrumb, 125 130 }; 126 131 }
+4
src/lib/api/explorer.ts
··· 32 32 export async function getLexiconFavicons(collections: string[]): Promise<Record<string, string | null>> { 33 33 return invoke("get_lexicon_favicons", { collections }); 34 34 } 35 + 36 + export async function clearLexiconFaviconCache(): Promise<void> { 37 + return invoke("clear_lexicon_favicon_cache"); 38 + }