diff --git a/extension/entrypoints/popup/App.tsx b/extension/entrypoints/popup/App.tsx index ee3092f..6885049 100644 --- a/extension/entrypoints/popup/App.tsx +++ b/extension/entrypoints/popup/App.tsx @@ -2,8 +2,9 @@ import { useEffect, useMemo, useState } from 'react'; import { browser } from '#imports'; import { MousePointer2, Telescope } from 'lucide-react'; import type { ReverseMatch } from '@aturi/reverseParsers'; -import type { WaypointData, WaypointType } from '@aturi/waypoints.data'; +import type { WaypointActivity, WaypointData, WaypointType } from '@aturi/waypoints.data'; import { matchSupportedUrl, parseAtUri, SUPPORTED_HOSTS } from '@aturi/reverseParsers'; +import { waypointActivity } from '@aturi/waypoints.data'; import { matchCustomUrl } from '../../lib/template'; import { categorizedForType, @@ -29,6 +30,7 @@ import { resolveHandleToDid } from '../../lib/handleResolver'; import { describeWaypoint } from '../../lib/describe'; import { getWaypointHomePageUrl, homePageSubtitle } from '../../lib/homePage'; import { WaypointIcon } from '../../lib/Icons'; +import { cachedRepoCollections, scanRepoCollections } from '../../lib/repoScan'; import InspectView from './InspectView'; type PopupMode = 'waypoints' | 'inspect'; @@ -456,14 +458,52 @@ function Ready({ match, prefs, pendingId, copiedId, onOpen, onCopy }: ReadyProps const { parsed, source } = match; const type: WaypointType = parsed.type === 'unknown' ? 'profile' : parsed.type; + // describeRepo scan against the target DID. Result feeds the per-waypoint + // 'present' | 'absent' | 'unknown' badge and re-orders recommendations so + // confirmed-active waypoints win the top slot. Gated on the prefs toggle + // and on the target having a DID we can resolve. + const [repoCollections, setRepoCollections] = useState | null>(null); + + useEffect(() => { + if (!prefs.pdsRecordScan) { + setRepoCollections(null); + return undefined; + } + let cancelled = false; + + async function run() { + // We need a DID to scan. Profiles often arrive with just the handle; + // resolve to DID up-front so the scan can proceed. + let did = parsed.did; + if (!did && parsed.handle) { + const resolved = await resolveHandleToDid(parsed.handle); + if (resolved) did = resolved; + } + if (!did) return; + + // Show whatever's cached immediately, then live-refresh in the + // background so the badges don't flicker on a warm cache. + const cached = await cachedRepoCollections(did); + if (!cancelled && cached) setRepoCollections(cached); + + const live = await scanRepoCollections(did); + if (!cancelled && live) setRepoCollections(live); + } + + void run(); + return () => { + cancelled = true; + }; + }, [prefs.pdsRecordScan, parsed.did, parsed.handle]); + const categorized = useMemo( () => categorizedForType(prefs, type), [prefs, type] ); const recommended = useMemo( - () => recommendedForType(prefs, type, parsed.collection), - [prefs, type, parsed.collection] + () => recommendedForType(prefs, type, parsed.collection, repoCollections), + [prefs, type, parsed.collection, repoCollections] ); const recents = useMemo(() => { @@ -554,6 +594,7 @@ function Ready({ match, prefs, pendingId, copiedId, onOpen, onCopy }: ReadyProps type={type} pending={pendingId === w.id} copied={copiedId === w.id} + activity={waypointActivity(w, repoCollections)} onClick={onOpen} onCopy={onCopy} /> @@ -579,6 +620,7 @@ function Ready({ match, prefs, pendingId, copiedId, onOpen, onCopy }: ReadyProps type={type} pending={pendingId === w.id} copied={copiedId === w.id} + activity={waypointActivity(w, repoCollections)} onClick={onOpen} onCopy={onCopy} /> @@ -757,6 +799,7 @@ function WaypointButton({ type, pending, copied, + activity, onClick, onCopy, }: { @@ -765,22 +808,37 @@ function WaypointButton({ type: WaypointType; pending: boolean; copied: boolean; + /** + * 'absent' dims the row and replaces the description with a "no records + * found" caption. 'present' / 'unknown' render as normal. Set to + * 'unknown' (or omitted) when the PDS-scan toggle is off so the badge + * stays out of the way. + */ + activity?: WaypointActivity; onClick: (w: WaypointData) => void; onCopy: (w: WaypointData) => void; }) { + const isAbsent = activity === 'absent'; return ( -
+
diff --git a/extension/entrypoints/popup/popup.css b/extension/entrypoints/popup/popup.css index f9edef7..fc31eb0 100644 --- a/extension/entrypoints/popup/popup.css +++ b/extension/entrypoints/popup/popup.css @@ -252,6 +252,23 @@ body { transform: none; } +/* PDS-scan came back without a matching collection in the target repo — + the row stays clickable (we don't know that the user wouldn't want to + open the app anyway) but reads quietly. The caption switches to + "No records found on this repo" so the dimming is explained inline. */ +.popup-waypoint.is-absent { + opacity: 0.5; +} + +.popup-waypoint.is-absent:hover { + opacity: 0.85; +} + +.popup-waypoint.is-absent .popup-waypoint-desc { + font-style: italic; + color: var(--text-tertiary); +} + .popup-waypoint-main { flex: 1; min-width: 0; diff --git a/extension/lib/catalog.ts b/extension/lib/catalog.ts index 7ddb0dd..acb44d4 100644 --- a/extension/lib/catalog.ts +++ b/extension/lib/catalog.ts @@ -2,6 +2,7 @@ import { WAYPOINT_DESTINATIONS_DATA, WAYPOINT_ORDER, getRecommendedWaypointsData, + waypointActivity, type WaypointData, type WaypointType, type WaypointCategoryData, @@ -208,17 +209,39 @@ export function categorizedForType( export function recommendedForType( prefs: Prefs, type: WaypointType, - collection?: string + collection?: string, + /** + * Set of NSIDs known to exist on the target repo (from describeRepo). + * When provided, confirmed-present waypoints sort to the front of the + * recommendation list — they win the "Recommended" slot over candidates + * with no matching records. + */ + repoCollections?: ReadonlySet | null, ): { waypoints: WaypointData[]; label: string } { const { waypoints, label } = getRecommendedWaypointsData(type, collection); const visibleIds = new Set(visibleWaypoints(prefs).map(w => w.id)); const orderIdx = buildOrderIndex(effectiveWaypointOrder(prefs)); + const activeSet = repoCollections ?? null; const filtered = waypoints .filter(w => visibleIds.has(w.id)) - .sort((a, b) => (orderIdx.get(a.id) ?? 0) - (orderIdx.get(b.id) ?? 0)); + .sort((a, b) => { + // Confirmed-present waypoints sort to the front; absent waypoints sort + // to the back; unknown rides the catalog order. Within each bucket we + // preserve the user's chosen order so a manual reordering still wins. + const aRank = activityRank(waypointActivity(a, activeSet)); + const bRank = activityRank(waypointActivity(b, activeSet)); + if (aRank !== bRank) return aRank - bRank; + return (orderIdx.get(a.id) ?? 0) - (orderIdx.get(b.id) ?? 0); + }); return { waypoints: filtered, label }; } +function activityRank(status: 'present' | 'absent' | 'unknown'): number { + if (status === 'present') return 0; + if (status === 'unknown') return 1; + return 2; // 'absent' +} + /** * Returns true if `waypoint` actually targets the given collection/rkey rather * than silently falling back to its profile/home URL. When there is no diff --git a/extension/lib/repoScan.ts b/extension/lib/repoScan.ts new file mode 100644 index 0000000..803288e --- /dev/null +++ b/extension/lib/repoScan.ts @@ -0,0 +1,124 @@ +/** + * PDS record scan. Given a target DID, resolve its PDS, call describeRepo, + * and return the set of collection NSIDs the repo holds. Used by the popup + * to dim waypoints whose `expectedCollections` aren't present and to boost + * confirmed-active waypoints in smart recommendations. + * + * Results are cached in chrome.storage.local keyed by DID with a 1h TTL. + * Each scan adds at most two network calls — a didDoc fetch (PLC directory + * or did:web .well-known) and the describeRepo XRPC — which only fires on + * a cache miss. + */ + +import { browser } from '#imports'; +import { fetchDidDocument, extractPdsEndpoint } from '@aturi/didResolver'; +import { describeRepo } from '@aturi/atproto/pdsClient'; + +const CACHE_KEY = 'aturi.repoScan.v1'; +const TTL_MS = 60 * 60 * 1000; // 1 hour + +type CacheEntry = { + collections: string[]; + fetchedAt: number; +}; + +type Cache = Record; + +// In-memory mirror so multiple popup opens within the same service-worker +// lifetime don't re-read chrome.storage.local. +let memoryCache: Cache | null = null; + +async function loadCache(): Promise { + if (memoryCache) return memoryCache; + try { + const items = await browser.storage.local.get(CACHE_KEY); + const raw = (items as Record)[CACHE_KEY]; + memoryCache = raw && typeof raw === 'object' ? (raw as Cache) : {}; + } catch { + memoryCache = {}; + } + return memoryCache; +} + +async function persistCache(cache: Cache): Promise { + memoryCache = cache; + try { + await browser.storage.local.set({ [CACHE_KEY]: cache }); + } catch { + // Storage failures are non-fatal — the in-memory copy still helps. + } +} + +function isFresh(entry: CacheEntry | undefined): boolean { + if (!entry) return false; + return Date.now() - entry.fetchedAt < TTL_MS; +} + +/** + * Resolve a DID to its PDS endpoint via the didDoc. Returns null when the + * DID can't be resolved (offline, retired DID, malformed didDoc, etc.). + */ +async function pdsForDid(did: string): Promise { + const didDoc = await fetchDidDocument(did); + if (!didDoc) return null; + const endpoint = extractPdsEndpoint(didDoc); + return endpoint ? endpoint.replace(/\/$/, '') : null; +} + +/** + * Scan the target repo and return the set of NSIDs the user has records in. + * Returns null when the scan fails (network error, no PDS, etc.) so callers + * can keep the "unknown" UI state rather than misclassifying every waypoint + * as 'absent'. + */ +export async function scanRepoCollections( + did: string, +): Promise | null> { + if (!did.startsWith('did:')) return null; + const cache = await loadCache(); + const cached = cache[did]; + if (isFresh(cached)) { + return new Set(cached.collections); + } + + const pds = await pdsForDid(did); + if (!pds) return null; + + try { + const desc = await describeRepo(pds, did); + const collections = Array.isArray(desc.collections) ? desc.collections : []; + const next: Cache = { ...cache, [did]: { collections, fetchedAt: Date.now() } }; + // Trim the cache so it doesn't grow unbounded — keep the 50 most + // recently fetched DIDs. + const trimmed = pruneCache(next); + await persistCache(trimmed); + return new Set(collections); + } catch (err) { + console.warn('[aturi:repoScan] describeRepo failed', { did, err }); + return null; + } +} + +const CACHE_MAX = 50; + +function pruneCache(cache: Cache): Cache { + const entries = Object.entries(cache); + if (entries.length <= CACHE_MAX) return cache; + entries.sort((a, b) => b[1].fetchedAt - a[1].fetchedAt); + return Object.fromEntries(entries.slice(0, CACHE_MAX)); +} + +/** + * Read whatever's cached for `did` without triggering a fetch. Returns null + * when there's no entry — used by the popup's initial render to short-circuit + * waypoint classification before the live scan completes. + */ +export async function cachedRepoCollections( + did: string, +): Promise | null> { + if (!did.startsWith('did:')) return null; + const cache = await loadCache(); + const cached = cache[did]; + if (!isFresh(cached)) return null; + return new Set(cached.collections); +}