Something went wrong. Try again.
Browser extension: detect and subscribe to standard.site publications on ATProto
Something went wrong. Try again.
29 kB · 846 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847import '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 | undefinedlet session: SessionInfo | undefinedlet tabId: number | undefinedlet httpPage = falselet requestFailed = falselet sessionExpired = falselet defaultReader = DEFAULT_READER_IDlet 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. $<HTMLAnchorElement>('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<PageState | undefined>({ 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 = $<HTMLImageElement>('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) $<HTMLInputElement>('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 = $<HTMLImageElement>('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 = $<HTMLButtonElement>('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 = $<HTMLButtonElement>('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 = $<HTMLButtonElement>('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 $<HTMLInputElement>('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<HTMLInputElement>('input:checked')].map( (box) => box.value, )}
/** Empties the form and points at the posted report on the board. */function feedbackPosted(did: string, uri: string) { $<HTMLInputElement>('feedback-title').value = '' $<HTMLTextAreaElement>('feedback-body').value = '' for (const box of $('feedback-tags').querySelectorAll<HTMLInputElement>('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 $<HTMLFormElement>('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 = $<HTMLButtonElement>('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<PageState>({ 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() $<HTMLButtonElement>('subscribe').disabled = false})
$('signin-form').addEventListener('submit', async (e) => { e.preventDefault() const handle = $<HTMLInputElement>('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 = $<HTMLInputElement>('handle-input')wireTypeaheadSubmit(handleInput, $<HTMLFormElement>('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 = $<HTMLButtonElement>('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<string, unknown> try { record = buildDiscussion(board, { title: $<HTMLInputElement>('feedback-title').value, body: $<HTMLTextAreaElement>('feedback-body').value, tags: tickedTags(), }) } catch (err) { note.textContent = err instanceof Error ? err.message : String(err) return }
const did = session.did const send = $<HTMLButtonElement>('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()