import 'actor-typeahead' import { parseAtUri } from '../lib/atproto' import { AUTH_ERROR_KEY } from '../lib/authflow' import { invalidate, invalidateAccount } from '../lib/cache' import { isDevChannel } from '../lib/channel' import { BOARD_URL, type BoardSpace, DISCUSSION_COLLECTION, type SpaceTag, buildDiscussion, discussionUrl, fetchSpace, } from '../lib/feedback' import { refreshStoredSession, restoreAgent, signOut } from '../lib/oauth' import { isScopeError } from '../lib/scope' import { SESSION_EXPIRED_KEY, getStoredSession } from '../lib/session-store' import { DEFAULT_READER_ID, READERS, type Reader, docUrl, migrateReaderId, pubSiteUrl, pubUrl, } from '../lib/readers' import { shareOnBlueskyUrl } from '../lib/share' import { type StatusMessage, statusMessagesFor } from '../lib/status' import { welcomeUrl } from '../lib/welcome' import { CARDS, type CardHost } from './cards' import { LoadTracker } from './cards/loading' import { $, TONE_ICONS, failedIconUrls } from './dom' import { send } from './send' import { wireTypeaheadSubmit } from '../lib/typeahead' import type { PageState, PubInfo, SessionInfo } from '../lib/types' const SUB_COLLECTION = 'site.standard.graph.subscription' /** * Which card loads are still running, and which have run long enough to be * worth a placeholder. Redraws by itself when one crosses that line, since * nothing else is happening at that moment (src/popup/cards/loading.ts). */ const loads = new LoadTracker(() => render()) /** * What the cards can ask for. Methods rather than values: a card holds this * for the life of the popup and the answers change under it. */ const host: CardHost = { state: () => state, session: () => session, rerender: () => render(), loading: (id) => loads.busy(id), } /** Start every card's load, and watch it, in the one place they are started. */ function loadCards(refresh: boolean) { // Every card fetches on its own, in parallel, and redraws when it lands. for (const card of CARDS) loads.track(card.id, card.load?.(host, refresh)) // Redrawn here rather than left to the first load that finishes, so the // card says it is busy from the moment it is (see render). render() } let state: PageState | undefined let session: SessionInfo | undefined let tabId: number | undefined let httpPage = false let requestFailed = false let sessionExpired = false let defaultReader = DEFAULT_READER_ID let signinNudge = false /** Subscribe is armed for its second click (blocked publisher; see renderSubscribe). */ let subscribeArmed = false /** The feedback board, read once per popup open (see loadBoard). */ let board: BoardSpace | undefined /** * What a write owes the cache: this one changed the subscription collection, * so the list read from it is no longer what the PDS holds. Nothing else the * account has is affected — re-reading 1500 follows because somebody * subscribed to a blog would be a strange thing to do (src/lib/cache.ts). */ async function invalidateSubsCache() { if (session) await invalidate({ scope: 'own', subject: session.did, name: 'subs' }) } /** * Drop everything cached from this account's own repo. For signing out, where * the next account's answers must not be the previous one's. */ async function invalidateAccountCaches() { if (session) await invalidateAccount(session.did) } async function init() { const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }) tabId = tab?.id httpPage = !!tab?.url && /^https?:\/\//.test(tab.url) const stored = await chrome.storage.local.get('defaultReader') // Migrated on read, not rewritten: a choice stored under the old id keeps // working, and it is rewritten the next time the user picks a reader. const chosen = typeof stored.defaultReader === 'string' ? migrateReaderId(stored.defaultReader) : undefined if (chosen && READERS.some((r) => r.id === chosen)) { defaultReader = chosen } else { console.debug(`substandard: no stored reader choice, defaulting to ${DEFAULT_READER_ID}`) } // The board's own page, linkable before (and whether or not) its record // loads; the link text becomes the space's real name once it does. The host // it is hosted on stays outside the link, in the markup, so the sentence // names userinput.app either way — the board is called substandard.blog, // which on its own reads as if the post lands on this extension's own site. $('feedback-board').href = BOARD_URL for (const card of CARDS) card.mount?.(host) session = await getStoredSession() renderAccount() void refreshAccount() void probeSessionExpiry() await showAuthError() await showSessionExpired() await loadState(false) } /** * The stored did/handle mirror can outlive the OAuth session it mirrors. * Restore the real session off the open path so an expired sign-in surfaces * as a status pill instead of a dead Subscribe button. Skipped while * offline: restore also fails on network errors, and that must not read as * "signed out". */ async function probeSessionExpiry() { if (!session || navigator.onLine === false) return const agent = await restoreAgent() if (agent || !session) return console.debug('[substandard] stored session no longer restorable; treating as expired') sessionExpired = true session = undefined renderAccount() render() } /** Handle and avatar can change remotely; refresh them off the open path. */ async function refreshAccount() { if (!session) return const updated = await refreshStoredSession() if (updated) { session = updated renderAccount() } } /** Show (once) a sign-in failure that happened after the last popup closed. */ async function showAuthError() { const stored = await chrome.storage.session.get(AUTH_ERROR_KEY) const authError = stored[AUTH_ERROR_KEY] as string | undefined if (!authError) return openSigninPanel() $('signin-note').textContent = `Sign-in failed: ${authError}` await chrome.storage.session.remove(AUTH_ERROR_KEY) } /** Show (once) that the previous session ended on its own, not by sign-out. */ async function showSessionExpired() { if (session) return const stored = await chrome.storage.local.get(SESSION_EXPIRED_KEY) if (!stored[SESSION_EXPIRED_KEY]) return openSigninPanel() $('signin-note').textContent = 'Your session expired — sign in again.' await chrome.storage.local.remove(SESSION_EXPIRED_KEY) } async function loadState(refresh: boolean) { if (tabId === undefined) { $('status').textContent = 'No active tab.' return } if (refresh) $('status').textContent = 'Re-checking this page…' requestFailed = false // A new answer about the page disarms a pending confirm: the thing the // second click was going to agree to may no longer be what is on screen. subscribeArmed = false try { state = await send({ type: 'get-state', tabId, refresh }) } catch (err) { console.debug('[substandard] get-state failed', err) state = undefined requestFailed = true } render() loadCards(refresh) } function renderAccount() { $('account-btn').hidden = !session if (!session) { $('account-menu').hidden = true $('signin-open').hidden = false return } // Keep the panel as it is: a profile refresh landing mid-switch must not // close the form under the user. setSigninPanel(!$('signin-panel').hidden) $('account-handle').textContent = `@${session.handle ?? session.did}` const img = $('account-avatar') const url = session.avatarUrl if (url) { img.onerror = () => { console.debug('[substandard] account avatar failed to load', url) img.hidden = true } if (img.src !== url) img.src = url img.hidden = false } else { img.hidden = true } } /** * The header Sign in button is the panel's only button: it expands into the * handle form, and once open it submits it. Everything that hides the panel * must go through here so the button's state stays truthful. While signed in * the button appears only with the panel (the switch-account flow), so the * form always has its submit button. */ function setSigninPanel(open: boolean) { const opening = open && $('signin-panel').hidden $('signin-panel').hidden = !open $('signin-open').hidden = !!session && !open $('signin-open').setAttribute('aria-expanded', String(open)) if (opening) $('handle-input').focus() } function openSigninPanel() { setSigninPanel(true) } function render() { const pub = state?.pub renderStatus() const pubEl = $('pub') if (!pub) { pubEl.hidden = true return } pubEl.hidden = false renderPubIcon(pub) renderPubName(pub) $('pub-desc').textContent = pub.record.description ?? '' $('pub-desc').hidden = !pub.record.description const docLine = $('doc-line') if (state?.doc) { docLine.textContent = `Article: ${state.doc.record.title}` docLine.hidden = false } else { docLine.hidden = true } for (const card of CARDS) card.render(host) // One "still working" for the whole card, rather than one per placeholder: // the placeholders are hidden from assistive technology, which gets the // card's own state instead of a row of empty elements. if (loads.anyPending()) pubEl.setAttribute('aria-busy', 'true') else pubEl.removeAttribute('aria-busy') renderSubscribe() renderOpen() renderShare() } /** * Every applicable status as its own pill, most severe first (a page can be * offline, unverified, and signed out all at once). Hidden when nominal. */ function renderStatus() { const messages = statusMessagesFor({ state, httpPage, signedIn: !!session, requestFailed, sessionExpired, signinNudge, }) const el = $('status') el.hidden = messages.length === 0 el.replaceChildren(...messages.map(pillFor)) // "Checking this page…" is the worker not having answered yet, which is the // same kind of not-finished the publication card reports while its lookups // are out — said here in the same way, on the region that is showing it. if (messages.some((msg) => msg.pending)) el.setAttribute('aria-busy', 'true') else el.removeAttribute('aria-busy') } function pillFor(msg: StatusMessage): HTMLElement { const pill = document.createElement('div') pill.className = `pill ${msg.tone}` if (msg.tone !== 'info') { const icon = document.createElement('span') icon.className = 'pill-icon' icon.innerHTML = TONE_ICONS[msg.tone] pill.append(icon) } const body = document.createElement('span') const at = msg.link ? msg.text.indexOf(msg.link.text) : -1 if (!msg.link || at === -1) { body.textContent = msg.text } else { const link = document.createElement('a') link.href = msg.link.href link.target = '_blank' link.rel = 'noopener noreferrer' link.textContent = msg.link.text body.append(msg.text.slice(0, at), link, msg.text.slice(at + msg.link.text.length)) } pill.append(body) return pill } function renderPubIcon(pub: PubInfo) { const img = $('pub-icon') const fallback = $('pub-icon-fallback') // Neutral monogram from the record's own name — never a broken-image glyph. fallback.textContent = [...pub.record.name.trim()][0]?.toUpperCase() ?? '·' const url = pub.iconUrl const showFallback = () => { img.hidden = true fallback.hidden = false } if (!url || failedIconUrls.has(url)) { showFallback() return } img.onerror = () => { console.debug('[substandard] pub icon failed to load', url) failedIconUrls.add(url) showFallback() } if (img.src !== url) img.src = url img.hidden = false fallback.hidden = true } /** * Publication name, linked to the pub's own site. Usually redundant (the * popup mostly opens on that site), but from a reader or any other page it * is the one way back to the pub itself. */ function renderPubName(pub: PubInfo) { const el = $('pub-name') const url = pubSiteUrl(pub) if (!url) { console.debug('[substandard] pub record url not linkable', pub.record.url) el.textContent = pub.record.name return } const link = document.createElement('a') link.href = url link.target = '_blank' link.rel = 'noopener noreferrer' link.textContent = pub.record.name el.replaceChildren(link) } function renderSubscribe() { const btn = $('subscribe') const rkey = state?.subscriptionRkey if (!session) { // aria-disabled, not disabled: the button stays clickable so the click // can answer with the sign-in pill instead of doing nothing. btn.textContent = 'Subscribe' btn.disabled = false btn.setAttribute('aria-disabled', 'true') btn.title = 'Sign in to subscribe' btn.classList.add('primary') btn.classList.remove('subscribed') return } btn.removeAttribute('aria-disabled') btn.title = '' btn.classList.remove('confirm') if (rkey) { btn.textContent = '✓ Subscribed' btn.title = 'Unsubscribe' btn.classList.remove('primary') btn.classList.add('subscribed') } else if (subscribeArmed) { // Second click of a subscribe to a blocked account. Red and reworded, so // the click that goes through is never the one muscle memory expects. btn.textContent = 'Subscribe anyway?' btn.title = 'Subscribe to a publication you have blocked' btn.classList.remove('primary', 'subscribed') btn.classList.add('confirm') } else { btn.textContent = 'Subscribe' btn.classList.add('primary') btn.classList.remove('subscribed') } } function readerTarget(readerId: string): string | null { const pub = state?.pub if (!pub) return null if (!state?.doc) return pubUrl(readerId, pub) const target = docUrl(readerId, pub, state.doc, state.url) if (!target) { console.debug( `[substandard] ${readerId} has no article view (or it would just link back to this page); linking the publication instead`, ) } return target ?? pubUrl(readerId, pub) } function readerIcon(reader: Reader): HTMLImageElement { const img = document.createElement('img') img.className = 'reader-icon' img.src = reader.icon img.alt = '' // Vendored files, so a load failure is a packaging bug: log it, drop the img. img.onerror = () => { console.debug('[substandard] vendored reader icon missing', reader.icon) img.remove() } return img } /** The record a reader would be opening: the article if there is one. */ function targetCollection(): string | undefined { const uri = state?.doc?.uri ?? state?.pub?.uri return uri ? (parseAtUri(uri)?.collection ?? undefined) : undefined } function renderOpen() { const reader = READERS.find((r) => r.id === defaultReader) ?? READERS[0]! const collection = targetCollection() const btn = $('open-default') btn.replaceChildren(readerIcon(reader), `Open in ${reader.name}`) btn.title = reader.describe(collection) btn.disabled = !readerTarget(reader.id) const menu = $('reader-menu') menu.innerHTML = '' for (const r of READERS) { const target = readerTarget(r.id) const item = document.createElement('button') item.className = 'item' item.disabled = !target // The catalog's own words for what this reader does with this record. item.title = r.describe(collection) const label = document.createElement('span') label.textContent = r.name const star = document.createElement('span') star.className = 'star' + (r.id === defaultReader ? ' active' : '') star.textContent = r.id === defaultReader ? '★ default' : '☆' star.title = 'Set as default reader' star.addEventListener('click', async (e) => { e.stopPropagation() defaultReader = r.id await chrome.storage.local.set({ defaultReader: r.id }) renderOpen() }) item.append(readerIcon(r), label, star) item.addEventListener('click', () => target && openUrl(target)) menu.append(item) } } /** * Hands the article to Bluesky's composer; nothing is posted from here, and * the button works signed out because the intent link needs no session * (src/lib/share.ts). */ function renderShare() { const pub = state?.pub const url = pub ? shareOnBlueskyUrl(pub, state?.doc, state?.url) : null const btn = $('share') btn.disabled = !url btn.dataset.url = url ?? '' } function openUrl(url: string) { chrome.tabs.create({ url }) window.close() } // --- feedback --- /** * The report form, opened from the footer. Independent of everything above it: * feedback works on a page with no publication, and on no page at all. */ function setFeedbackPanel(open: boolean) { $('feedback').hidden = !open $('feedback-open').setAttribute('aria-expanded', String(open)) if (!open) return $('feedback-title').focus() // Said before anything is typed rather than after: the write needs a session. $('feedback-note').textContent = session ? '' : 'Sign in to send feedback.' void loadBoard() } /** * The board record: fetched on first open, not on every popup open — the popup * is opened constantly and most opens never touch feedback. One read per popup * lifetime is also fresh enough for the strongRef, which is seconds old by the * time anything is sent. */ async function loadBoard() { if (board) return try { board = await fetchSpace() } catch (err) { console.debug('[substandard] feedback board could not be read', err) $('feedback-note').textContent = 'The feedback board could not be read. Try again later.' return } console.debug('[substandard] feedback board loaded', board.uri, board.cid) $('feedback-board').textContent = board.name renderFeedbackTags(board.tags) } /** Categories as the board itself lists them, so this repo holds no copy. */ function renderFeedbackTags(tags: SpaceTag[]) { const row = $('feedback-tags') row.replaceChildren( ...tags.map((tag) => { const box = document.createElement('input') box.type = 'checkbox' box.value = tag.value const label = document.createElement('label') label.className = 'feedback-tag' // The label wraps the box, so the word is part of the hit target. label.append(box, tag.label) return label }), ) row.hidden = tags.length === 0 } function tickedTags(): string[] { return [...$('feedback-tags').querySelectorAll('input:checked')].map( (box) => box.value, ) } /** Empties the form and points at the posted report on the board. */ function feedbackPosted(did: string, uri: string) { $('feedback-title').value = '' $('feedback-body').value = '' for (const box of $('feedback-tags').querySelectorAll('input')) { box.checked = false } const note = $('feedback-note') note.textContent = 'Sent. ' const rkey = parseAtUri(uri)?.rkey if (!rkey) { console.debug('[substandard] posted feedback has an unexpected uri', uri) return } const link = document.createElement('a') link.href = discussionUrl(did, rkey) link.target = '_blank' link.rel = 'noopener noreferrer' link.textContent = 'View it on the board.' note.append(link) } // --- events --- $('open-default').addEventListener('click', () => { const target = readerTarget(defaultReader) if (target) openUrl(target) }) $('open-more').addEventListener('click', (e) => { e.stopPropagation() $('reader-menu').hidden = !$('reader-menu').hidden }) document.addEventListener('click', () => { $('reader-menu').hidden = true $('account-menu').hidden = true setDebugMenu(false) setSigninPanel(false) }) $('account-btn').addEventListener('click', (e) => { e.stopPropagation() $('account-menu').hidden = !$('account-menu').hidden }) $('signin-open').addEventListener('click', (e) => { e.stopPropagation() if ($('signin-panel').hidden) openSigninPanel() else $('signin-form').requestSubmit() }) // Keep clicks inside the form (input focus, submit) from reaching the // document-level close-all handler. $('signin-panel').addEventListener('click', (e) => e.stopPropagation()) $('subscribe').addEventListener('click', async () => { if (!session) { console.debug('[substandard] subscribe clicked while signed out; showing the sign-in pill') signinNudge = true renderStatus() return } const pub = state?.pub if (!pub || tabId === undefined) return const rkey = state?.subscriptionRkey // Subscribing to an account you have blocked takes two clicks: the first // only arms the button. Unsubscribing is never gated — undoing it is the // safe direction. if (!rkey && state?.blocked && !subscribeArmed) { console.debug('[substandard] subscribe to a blocked publisher; arming the confirm', pub.did) subscribeArmed = true renderSubscribe() return } const btn = $('subscribe') btn.disabled = true $('action-note').textContent = rkey ? 'Unsubscribing…' : 'Subscribing…' try { const agent = await restoreAgent() if (!agent) { sessionExpired = true session = undefined renderAccount() render() throw new Error('Session expired, please sign in again') } if (rkey) { await agent.com.atproto.repo.deleteRecord({ repo: session.did, collection: SUB_COLLECTION, rkey, }) } else { await agent.com.atproto.repo.createRecord({ repo: session.did, collection: SUB_COLLECTION, record: { $type: SUB_COLLECTION, publication: pub.uri, createdAt: new Date().toISOString(), }, }) } await invalidateSubsCache() state = await send({ type: 'get-state', tabId, refresh: true }) $('action-note').textContent = '' } catch (err) { $('action-note').textContent = `Failed: ${err instanceof Error ? err.message : err}` } // Disarmed whether the write landed or failed: a confirm answers one click, // and a retry after a failure deserves to be asked again. subscribeArmed = false renderSubscribe() $('subscribe').disabled = false }) $('signin-form').addEventListener('submit', async (e) => { e.preventDefault() const handle = $('handle-input').value.trim().replace(/^@/, '') if (!handle) return const note = $('signin-note') note.textContent = 'Waiting for consent…' try { // The worker runs the flow (consent window + offscreen OAuth host); this // popup dies as soon as the consent window takes focus, and the result // lands in storage for the next popup open. await send({ type: 'signin', handle }) window.close() } catch (err) { note.textContent = `Sign-in failed: ${err instanceof Error ? err.message : err}` } }) // The host element makes clicking a suggested account sign in as it. const handleInput = $('handle-input') wireTypeaheadSubmit(handleInput, $('signin-form'), handleInput.closest('actor-typeahead') ?? undefined) // Just a shortcut into the sign-in form: the current login stays usable, and // the worker revokes it only once the replacement sign-in completes // (src/signin.ts). Closing the popup or the consent window changes nothing. $('switch-account').addEventListener('click', (e) => { e.stopPropagation() $('account-menu').hidden = true openSigninPanel() }) $('signout').addEventListener('click', async () => { await invalidateAccountCaches() await signOut() session = undefined // Everything a card remembered was the old account's answer. for (const card of CARDS) card.reset?.() renderAccount() render() loadCards(false) }) $('share').addEventListener('click', () => { const url = $('share').dataset.url if (url) openUrl(url) }) $('refresh').addEventListener('click', () => loadState(true)) // No stopPropagation, unlike the sign-in panel: the document-level handler // closes the menus and leaves this section alone, which is what a click here // should do anyway. $('feedback-open').addEventListener('click', () => setFeedbackPanel($('feedback').hidden)) $('feedback-cancel').addEventListener('click', () => setFeedbackPanel(false)) // --- debug menu (dev channel only) --- // // Where dev-only tools go, all of them reaching something a released build // deliberately makes unreachable: // // - the welcome page, which Chrome otherwise shows exactly once per install // (src/background.ts) and which is therefore impossible to look at again // without reinstalling the extension; // - a re-scan, which the header's Refresh button also does but which lives // inside the publication section, so it is hidden on exactly the pages a // re-scan is most wanted for: the ones detection found nothing on; // - dropping every cached read, which nothing else does at all. Refresh // bypasses the caches on the path it walks; this empties them, so the // next read of anything — on any tab, from a cold worker — runs the miss // path a user with a warm cache would never see. function setDebugMenu(open: boolean) { $('debug-menu').hidden = !open $('debug-open').setAttribute('aria-expanded', String(open)) // A result belongs to the action that produced it, not to the next opening. if (open) debugNote('') } /** What the last debug action did. Cleared whenever the menu opens. */ function debugNote(text: string) { $('debug-note').textContent = text $('debug-note').hidden = !text } if (isDevChannel()) $('debug-area').hidden = false $('debug-open').addEventListener('click', (e) => { e.stopPropagation() setDebugMenu($('debug-menu').hidden) }) $('debug-welcome').addEventListener('click', () => openUrl(welcomeUrl())) // Both of the below stop the click reaching the document-level close-all // handler: the menu is where the answer is written, so it has to still be // open when the answer arrives. $('debug-rescan').addEventListener('click', async (e) => { e.stopPropagation() debugNote('Re-scanning…') await loadState(true) debugNote(requestFailed ? 'Re-scan failed; see the console.' : 'Re-scanned.') }) // Only drops. Re-scanning from here would refill on the way out, and watching // a cold read happen is the reason to empty the caches in the first place — // so which read runs next is left to whoever asked. $('debug-drop-cache').addEventListener('click', async (e) => { e.stopPropagation() debugNote('Dropping…') try { const { dropped } = await send<{ dropped: number }>({ type: 'drop-caches' }) debugNote(`Dropped ${dropped} ${dropped === 1 ? 'entry' : 'entries'}.`) } catch (err) { console.debug('[substandard] drop-caches failed', err) debugNote(err instanceof Error ? err.message : String(err)) } }) $('feedback-form').addEventListener('submit', async (e) => { e.preventDefault() const note = $('feedback-note') if (!session) { note.textContent = 'Sign in to send feedback.' return } if (!board) { note.textContent = 'The feedback board could not be read. Try again later.' return } // Validated before the session is restored: a too-long title should not cost // a token refresh, and its message is the same either way. let record: Record try { record = buildDiscussion(board, { title: $('feedback-title').value, body: $('feedback-body').value, tags: tickedTags(), }) } catch (err) { note.textContent = err instanceof Error ? err.message : String(err) return } const did = session.did const send = $('feedback-send') send.disabled = true note.textContent = 'Sending…' try { const agent = await restoreAgent() if (!agent) { sessionExpired = true session = undefined renderAccount() render() throw new Error('Session expired, please sign in again') } const { data } = await agent.com.atproto.repo.createRecord({ repo: did, collection: DISCUSSION_COLLECTION, record, }) console.debug('[substandard] feedback posted', data.uri) feedbackPosted(did, data.uri) } catch (err) { console.debug('[substandard] feedback post failed', err) // A session granted before this extension asked for the feedback scope is // refused by the PDS, not by anything here — see isScopeError. note.textContent = isScopeError(err) ? 'This sign-in cannot post feedback. Sign in again to allow it.' : `Failed: ${err instanceof Error ? err.message : err}` } send.disabled = false }) // The running build, read from the manifest rather than baked in at build // time, so it is the version Chrome actually installed and can be read back // in a bug report. Dev-channel builds carry the commit in version_name // ("1.4.0+abc1234"); store builds have no version_name and show the version. const manifest = chrome.runtime.getManifest() $('version').textContent = `v${manifest.version_name ?? manifest.version}` init()