Something went wrong. Try again.
Browser extension: detect and subscribe to standard.site publications on ATProto
Something went wrong. Try again.
5.0 kB · 133 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134// When a card is allowed to say that it is working.//// Every card fetches off the render path and redraws when its answer lands// (see card.ts), so on a slow connection the popup used to sit there dropping// rows in one at a time. A placeholder in the shape of the row that is coming// says the same thing without the jank — but only if it is rarer than the// answer itself.//// Two rules, and the first one is what makes the second bearable://// - Nothing appears for the first `SHOW_AFTER_MS`. Most loads here are a// read of the extension's own cache, which lands in single-digit// milliseconds, and a placeholder that flickers past on every popup open// is worse than the pop-in it replaces.// - A placeholder stands where its content will, at roughly the height that// content will take. There is no minimum time it stays up: it is the same// shape as what replaces it, so an answer landing a moment after it// appeared fills the boxes in rather than moving anything.//// The tracker below is the whole mechanism. `popup.ts` hands it every `load`// it starts, cards ask it (through `CardHost.loading`) whether to draw a// placeholder, and it redraws the popup at the one moment nothing else will:// the instant a slow load crosses the delay.
/** * How long a load may run before it is worth drawing anything about it. Long * enough that a cache hit never shows a placeholder, short enough that a real * network round trip always does. */export const SHOW_AFTER_MS = 150
/** One card's `load`, from the moment it started to the moment it settled. */export interface Load { startedAt: number /** Set when the promise settles, however it settled. */ endedAt?: number}
/** Whether the placeholder for this load is on screen at `now`. */export function showsPlaceholder(load: Load, now: number): boolean { if (load.endedAt !== undefined) return false return now - load.startedAt >= SHOW_AFTER_MS}
/** * When this load next changes what is on screen, or undefined when nothing * more will happen on a timer. Only ever the moment it crosses the delay: the * end of a load is an event, not a deadline, and arrives on its own. */export function nextChange(load: Load, now: number): number | undefined { if (load.endedAt !== undefined) return undefined const shownAt = load.startedAt + SHOW_AFTER_MS return now < shownAt ? shownAt : undefined}
/** * The loads this popup has started, by card id. * * It redraws by itself twice per slow load: once when the placeholder is due, * and once when a load that was showing one ends. The second one matters even * though a card that got an answer redraws on its own — a load can end with * nothing to draw (no publication, a failed lookup), and then nothing else * would take the placeholder back down. */export class LoadTracker { private loads = new Map<string, Load>() private timer: ReturnType<typeof setTimeout> | undefined private now: () => number
constructor( private redraw: () => void, now: () => number = Date.now, ) { this.now = now }
/** Watch one card's load. A card with no `load` is never busy. */ track(id: string, work: Promise<unknown> | undefined): void { if (!work) return const load: Load = { startedAt: this.now() } this.loads.set(id, load) this.schedule() void Promise.resolve(work) .catch(() => {}) .then(() => { const showing = showsPlaceholder(load, this.now()) load.endedAt = this.now() this.schedule() if (showing) this.redraw() }) }
/** Whether this card should be drawing a placeholder right now. */ busy(id: string): boolean { const load = this.loads.get(id) return !!load && showsPlaceholder(load, this.now()) }
/** Whether anything at all is still being waited on, placeholder or not. */ anyPending(): boolean { return [...this.loads.values()].some((load) => load.endedAt === undefined) }
/** One timer for every load: the earliest moment any of them changes. */ private schedule(): void { const now = this.now() const due = [...this.loads.values()] .map((load) => nextChange(load, now)) .filter((at): at is number => at !== undefined) clearTimeout(this.timer) this.timer = undefined if (due.length === 0) return this.timer = setTimeout( () => { this.timer = undefined this.redraw() this.schedule() }, Math.max(0, Math.min(...due) - now), ) }}
/** * Put an element into (or out of) its placeholder state: the class the * stylesheet draws grey boxes from, and hidden from assistive technology, * which has `aria-busy` on the card instead of a row of empty elements. */export function placeholder(el: HTMLElement, on: boolean): void { el.classList.toggle('loading', on) if (on) el.setAttribute('aria-hidden', 'true') else el.removeAttribute('aria-hidden')}