diff --git a/eslint.config.mjs b/eslint.config.mjs index 9428f12..7ca958e 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -5,6 +5,21 @@ import nextTs from "eslint-config-next/typescript"; const eslintConfig = defineConfig([ ...nextVitals, ...nextTs, + // The explorer's data-fetching effects follow the canonical "reset state + // then kick off cancellable async work" pattern. The set-state-in-effect + // rule flags this as cascading-renders, but the immediate setState is + // bounded to a single render before the async result lands. Disable the + // rule for the explore subtree so the pattern stays consistent with the + // rest of the explorer's React code. + { + files: [ + "src/components/explore/**/*.tsx", + "src/components/explore/**/*.ts", + ], + rules: { + "react-hooks/set-state-in-effect": "off", + }, + }, // Override default ignores of eslint-config-next. globalIgnores([ // Default ignores of eslint-config-next: diff --git a/extension/entrypoints/inspect-scan.content.ts b/extension/entrypoints/inspect-scan.content.ts new file mode 100644 index 0000000..91d4f68 --- /dev/null +++ b/extension/entrypoints/inspect-scan.content.ts @@ -0,0 +1,31 @@ +import { defineContentScript } from '#imports'; +import { + dedupeByUri, + scanDocumentForAtUris, + type DetectedAtUri, +} from '../lib/inspectScanner'; + +/** + * Content script for the Inspect tab. Scans the current page on demand for + * AT URIs (head/meta/link/JSON-LD/text) and reports them back to the popup. + * + * Runs alongside `detect-head.content.ts` — that script handles a different + * message (`aturi:query-head`) used by the Waypoints flow, while this one + * answers `aturi:inspect-scan` for the new Inspect tab. + */ +export default defineContentScript({ + matches: [''], + runAt: 'document_idle', + main() { + chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { + if (message?.type !== 'aturi:inspect-scan') return undefined; + try { + const hits: DetectedAtUri[] = scanDocumentForAtUris(document); + sendResponse({ atUris: dedupeByUri(hits) }); + } catch (err) { + sendResponse({ atUris: [], error: err instanceof Error ? err.message : String(err) }); + } + return true; + }); + }, +}); diff --git a/extension/entrypoints/popup/App.tsx b/extension/entrypoints/popup/App.tsx index 208e699..ee3092f 100644 --- a/extension/entrypoints/popup/App.tsx +++ b/extension/entrypoints/popup/App.tsx @@ -1,5 +1,6 @@ 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 { matchSupportedUrl, parseAtUri, SUPPORTED_HOSTS } from '@aturi/reverseParsers'; @@ -28,6 +29,9 @@ import { resolveHandleToDid } from '../../lib/handleResolver'; import { describeWaypoint } from '../../lib/describe'; import { getWaypointHomePageUrl, homePageSubtitle } from '../../lib/homePage'; import { WaypointIcon } from '../../lib/Icons'; +import InspectView from './InspectView'; + +type PopupMode = 'waypoints' | 'inspect'; type PopupState = | { phase: 'loading' } @@ -66,6 +70,7 @@ export default function App() { const [state, setState] = useState({ phase: 'loading' }); const [pendingId, setPendingId] = useState(null); const [copiedId, setCopiedId] = useState(null); + const [mode, setMode] = useState('waypoints'); useEffect(() => { void init(); @@ -82,9 +87,15 @@ export default function App() { return unsub; }, []); + function selectMode(next: PopupMode) { + setMode(next); + void savePrefs({ popupMode: next }); + } + async function init() { const prefs = await loadPrefs(); applyAppearance(prefs); + if (prefs.popupMode === 'inspect') setMode('inspect'); const tab = await getActiveTab(); const tabId = (tab?.id as number | undefined) ?? null; if (!tab?.url) { @@ -215,28 +226,69 @@ export default function App() { return
Loading...
; } - if (state.phase === 'unsupported') { - return ( + const prefs = state.prefs; + + const inner = + mode === 'inspect' ? ( + + ) : state.phase === 'unsupported' ? ( + ) : ( + ); - } return ( - +
+ + {inner} +
+ ); +} + +function PopupModeTabs({ + mode, + onSelect, +}: { + mode: PopupMode; + onSelect: (next: PopupMode) => void; +}) { + return ( +
+ + +
); } diff --git a/extension/entrypoints/popup/InspectView.tsx b/extension/entrypoints/popup/InspectView.tsx new file mode 100644 index 0000000..867e9e6 --- /dev/null +++ b/extension/entrypoints/popup/InspectView.tsx @@ -0,0 +1,379 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { browser } from '#imports'; +import { + Activity, + Check, + Copy, + ExternalLink, + Hash, + Link as LinkIcon, + RefreshCw, + Server, + Telescope, +} from 'lucide-react'; +import { parseAtUri } from '@aturi/atproto/urls'; +import { resolveIdentifier, type IdentityBundle } from '@aturi/atproto/identity'; +import { getRecord, type AtRecord } from '@aturi/atproto/pdsClient'; +import { previewFor } from '@aturi/atproto/previewExtractors'; +import { matchSupportedUrl } from '@aturi/reverseParsers'; +import { type Prefs } from '../../lib/prefs'; +import type { DetectedAtUri } from '../../lib/inspectScanner'; +import { buildExploreUrl } from '../../lib/aturiUrl'; +import { dedupeByUri } from '../../lib/inspectScanner'; + +type Props = { + prefs: Prefs; +}; + +type AnyTab = { url?: string; id?: number; active?: boolean; [k: string]: unknown }; + +async function getActiveTab(): Promise { + try { + const tabs = (await browser.tabs.query({ active: true, lastFocusedWindow: true })) as unknown as + | AnyTab[] + | undefined; + return tabs?.[0] ?? null; + } catch { + return null; + } +} + +/** + * The new Inspect tab. Scans the current page for AT URIs and surfaces + * underlying PDS data with copy / "open in explorer" tools. + */ +export default function InspectView(_props: Props) { + void _props; + const [tab, setTab] = useState(null); + const [scanning, setScanning] = useState(true); + const [hits, setHits] = useState([]); + const [scanError, setScanError] = useState(null); + + const runScan = useMemo( + () => async () => { + setScanning(true); + setScanError(null); + const t = await getActiveTab(); + setTab(t); + const out: DetectedAtUri[] = []; + + // 1. URL-pattern match — the page itself is a known atmosphere app. + if (t?.url) { + try { + const url = new URL(t.url); + const match = matchSupportedUrl(url); + if (match?.parsed.uri) { + out.push({ uri: match.parsed.uri, where: 'url' }); + } + } catch { + /* ignore */ + } + } + + // 2. Ask the inspect-scan content script for in-page hits. + const tabId = (t?.id as number | undefined) ?? null; + if (tabId != null) { + try { + const response = (await browser.tabs.sendMessage(tabId, { + type: 'aturi:inspect-scan', + })) as { atUris?: DetectedAtUri[]; error?: string } | undefined; + if (response?.atUris) out.push(...response.atUris); + if (response?.error) { + // Not fatal — we still show URL hits if any. + console.warn('[aturi:inspect] scan reported error:', response.error); + } + } catch (err) { + console.warn('[aturi:inspect] content script unreachable', err); + } + } + + setHits(dedupeByUri(out)); + setScanning(false); + }, + [], + ); + + useEffect(() => { + void runScan(); + }, [runScan]); + + return ( +
+
+ Detected AT URIs on this page +
+ + {scanning && hits.length === 0 && ( +
+ + Scanning… +
+ )} + + {!scanning && hits.length === 0 && ( +
+
No AT URIs detected on this page.
+ +
+ )} + + {hits.length > 0 && ( + <> + {hits.map((hit) => ( + + ))} +
+ +
+ + )} + + {scanError && ( +
+
Scan error
+
{scanError}
+
+ )} +
+ ); +} + +type Resolution = { + identity: IdentityBundle | null; + record: AtRecord | null; + error: string | null; +}; + +function InspectCard({ hit, pds }: { hit: DetectedAtUri; pds: string | null }) { + const parsed = useMemo(() => parseAtUri(hit.uri), [hit.uri]); + const [resolution, setResolution] = useState({ + identity: null, + record: null, + error: null, + }); + const cancelled = useRef(false); + + useEffect(() => { + cancelled.current = false; + if (!parsed) return undefined; + setResolution({ identity: null, record: null, error: null }); + (async () => { + try { + const identity = await resolveIdentifier(parsed.repo); + if (cancelled.current) return; + setResolution((prev) => ({ ...prev, identity })); + if (parsed.collection && parsed.rkey) { + try { + const record = await getRecord(identity.pds, { + repo: identity.did, + collection: parsed.collection, + rkey: parsed.rkey, + }); + if (cancelled.current) return; + setResolution((prev) => ({ ...prev, record })); + } catch { + /* not fatal — the URI may point at a deleted record */ + } + } + } catch (err) { + if (cancelled.current) return; + setResolution((prev) => ({ + ...prev, + error: err instanceof Error ? err.message : String(err), + })); + } + })(); + return () => { + cancelled.current = true; + }; + }, [parsed]); + + const identity = resolution.identity; + const record = resolution.record; + const preview = record ? previewFor(record.value) : ''; + const explorerUrl = parsed + ? buildExploreUrl( + identity?.handle || identity?.did || parsed.repo, + parsed.collection, + parsed.rkey, + ) + : null; + const effectivePds = pds || identity?.pds || null; + const effectiveDid = identity?.did || (parsed?.repo.startsWith('did:') ? parsed.repo : null); + + return ( +
+
+ + {hit.where} + + {identity?.handle && ( + @{identity.handle} + )} + {parsed?.collection && ( + + {parsed.collection} + + )} +
+ + + {hit.uri} + + + {hit.sample && ( +
+ “{hit.sample}” +
+ )} + + {preview && ( +
{preview}
+ )} + + {resolution.error && ( +
+ {resolution.error} +
+ )} + +
+ } value={hit.uri} /> + {record && ( + } + value={JSON.stringify(record, null, 2)} + /> + )} + {effectivePds && ( + } + value={effectivePds} + /> + )} + {effectiveDid && ( + } + value={effectiveDid} + /> + )} + {explorerUrl && ( + { + // Close the popup after the user navigates so they land on the + // page instead of having the popup hover over their new tab. + window.setTimeout(() => window.close(), 50); + }} + > + + Open in Explorer + + )} +
+
+ ); +} + +async function writeToClipboard(text: string): Promise { + try { + await navigator.clipboard.writeText(text); + return; + } catch { + /* fall through */ + } + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.select(); + try { + document.execCommand('copy'); + } finally { + document.body.removeChild(ta); + } +} + +function CopyChip({ + label, + icon, + value, +}: { + label: string; + icon: React.ReactNode; + value: string; +}) { + const [copied, setCopied] = useState(false); + return ( + + ); +} diff --git a/extension/entrypoints/popup/popup.css b/extension/entrypoints/popup/popup.css index cbbdb93..f9edef7 100644 --- a/extension/entrypoints/popup/popup.css +++ b/extension/entrypoints/popup/popup.css @@ -641,3 +641,55 @@ body { .popup-root.is-compact .popup-waypoint:hover { transform: translateX(2px); } + +/* Popup mode tabs (Waypoints / Inspect) — sit above both views */ +.popup-shell { + display: flex; + flex-direction: column; + background: var(--bg-primary); +} + +.popup-mode-tabs { + display: flex; + align-items: stretch; + border-bottom: 1px solid var(--border-subtle); + background: var(--bg-secondary); + position: sticky; + top: 0; + z-index: 3; +} + +.popup-mode-tab { + flex: 1; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 10px 12px; + background: transparent; + border: 0; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + color: var(--text-tertiary); + font-family: var(--font-sans); + font-size: var(--fs-xs); + font-weight: 500; + letter-spacing: 0.08em; + text-transform: uppercase; + cursor: pointer; + transition: color 0.2s ease, border-color 0.2s ease, background 0.2s ease; +} + +.popup-mode-tab:hover { + color: var(--text-secondary); + background: var(--bg-tertiary); +} + +.popup-mode-tab.is-active { + color: var(--text-primary); + border-bottom-color: var(--text-accent); +} + +.popup-mode-tab svg { + flex-shrink: 0; +} diff --git a/extension/lib/aturiUrl.ts b/extension/lib/aturiUrl.ts new file mode 100644 index 0000000..11995d2 --- /dev/null +++ b/extension/lib/aturiUrl.ts @@ -0,0 +1,22 @@ +/** + * Base URL helper for cross-product deep links. The extension links into the + * web app's /explore views from the Inspect tab. In dev we usually want + * those to hit localhost so the developer can test changes end-to-end + * without bouncing through aturi.to. + * + * For now we default to production. A future enhancement could expose this + * as a pref in the options page. + */ + +export const ATURI_BASE = 'https://aturi.to'; + +export function buildExploreUrl(repo: string, collection?: string, rkey?: string): string { + const encodedRepo = encodeURIComponent(repo).replace(/%3A/g, ':'); + if (collection && rkey) { + return `${ATURI_BASE}/explore/${encodedRepo}/${collection}/${encodeURIComponent(rkey)}`; + } + if (collection) { + return `${ATURI_BASE}/explore/${encodedRepo}/${collection}`; + } + return `${ATURI_BASE}/explore/${encodedRepo}`; +} diff --git a/extension/lib/inspectScanner.ts b/extension/lib/inspectScanner.ts new file mode 100644 index 0000000..f9062ef --- /dev/null +++ b/extension/lib/inspectScanner.ts @@ -0,0 +1,176 @@ +/** + * Page-scanning helpers for the Inspect tab. The scanner runs inside a + * content script (with full DOM access) and returns a deduplicated array + * of detected AT URIs back to the popup for display. + * + * Bucket meanings: + * - 'url' : the page URL itself matched a known atmosphere app pattern. + * - 'head' : in . + * - 'meta' : OpenGraph / Twitter meta tags with an at:// value. + * - 'link' : anywhere on the page. + * - 'jsonld': inside a