Something went wrong. Try again.
Mirror of https://github.com/kualta/site — web infrastructure
Something went wrong. Try again.
17 kB · 445 lines
Astro
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446---import { formatLastSeenTime, PRESENCE_BANDS, PRESENCE_PARTS, type ActivityFeed } from "@/lib/activity";import MediaViewer from "./MediaViewer.astro";import ActivityRows from "./ActivityRows.astro";
interface Props { feed?: ActivityFeed;}
const { feed } = Astro.props;const lastSeenText = feed?.lastSeenAt ? formatLastSeenTime(feed.lastSeenAt) : null;const presence = PRESENCE_PARTS[feed?.presence ?? "unknown"];const currentBand = PRESENCE_BANDS.find(({ state }) => state === feed?.presence);const presenceDetail = currentBand ? `${currentBand.within} ago` : "not checked for a day";---<MediaViewer /><section class="flex w-full min-w-0 flex-col gap-3" aria-labelledby="activity-presence" data-activity-widget data-server-now={new Date().toISOString()} data-activity-pending={feed ? undefined : "true"} aria-busy={!feed} data-last-seen={feed?.lastSeenAt ?? undefined} data-trusted-until={feed?.trustedUntil ?? undefined} data-fresh-until={feed?.freshUntil ?? undefined}> <div data-activity-skeleton hidden={!!feed} role="status" class="space-y-3"> <span class="sr-only">Loading recent activity</span> <div aria-hidden="true" class="h-6 w-48 rounded bg-secondary motion-safe:animate-pulse dark:bg-dark-secondary" /> {[1, 2, 3].map(() => ( <div aria-hidden="true" class="h-20 w-full rounded-xl bg-secondary motion-safe:animate-pulse dark:bg-dark-secondary" /> ))} </div> <div class="min-w-0" data-activity-content hidden={!feed}> <h2 id="activity-presence" class="text-balance text-base font-bold"> <span class="presence-trigger relative inline-block"> <span class="cursor-help" tabindex="0" aria-describedby="presence-explainer" > <span data-presence-lead>{presence.lead}</span>{" "} <span class="presence-live" data-presence-status>{presence.status}</span> </span> <span id="presence-explainer" role="tooltip" class="presence-tooltip rounded-xl bg-secondary p-3 text-xs font-normal text-secondary-text drop-shadow-sm dark:bg-dark-secondary" > When was kualta last seen <span class="mt-1 block text-text dark:text-dark-text" data-presence-detail> {presenceDetail} </span> </span> </span> </h2> <div class="mt-1 flex flex-wrap items-center gap-x-2 text-xs text-secondary-text"> <time datetime={feed?.lastSeenAt ?? undefined} class="tabular-nums" data-activity-last-seen hidden={!lastSeenText}> {lastSeenText} </time> <span hidden={!feed?.delayed} data-signals-delayed>signals delayed</span> </div> </div> <ol class="flex w-full flex-col gap-3" aria-label="Recent activity, newest first" data-activity-list> <ActivityRows events={feed?.events ?? []} completeSince={feed?.completeSince ?? null} limitedBy={feed?.coverageLimitedBy ?? null} /> </ol></section>
<style> /* the status is the one thing on the page still happening, so it reads as running rather than printed — a dim band drifts through the words, close enough to the text colour that you notice it only if you look */ .presence-live { --presence-ink: var(--text); background-image: linear-gradient( 115deg, var(--presence-ink) 0%, var(--presence-ink) 42%, var(--secondary-text) 50%, var(--presence-ink) 58%, var(--presence-ink) 100% ); background-size: 300% 100%; background-clip: text; -webkit-background-clip: text; color: transparent; animation: presence-sweep 8s linear infinite; }
:global(.dark) .presence-live { --presence-ink: var(--text-dark); }
@keyframes presence-sweep { from { background-position: 150% 0; } to { background-position: -150% 0; } }
/* the sweep is decoration; the words still sit still for anyone who asked for that, and the gradient's own text colour keeps them readable */ @media (prefers-reduced-motion: reduce) { .presence-live { animation: none; background-position: 100% 0; } }
.presence-tooltip { position: absolute; inset-block-start: calc(100% + 0.5rem); inset-inline-start: 0; z-index: 20; width: 16rem; max-width: min(90vw, 16rem); visibility: hidden; opacity: 0; transition: opacity 150ms ease, visibility 150ms; }
.presence-trigger:hover .presence-tooltip, .presence-trigger:focus-within .presence-tooltip { visibility: visible; opacity: 1; }
@media (prefers-reduced-motion: reduce) { .presence-tooltip { transition: none; } }</style>
<script> // rows usually arrive from the rows endpoint, where their own component scripts never run import "@/scripts/bluesky-post-actions"; import "@/scripts/bluesky-post-thread"; import "@/scripts/post-link-menu"; import { uniqueHistoricalRows, mergeNewestRows } from "@/lib/activity/rows"; import { formatLastSeenTime, formatRelativeTime, getPresenceState, isActivityDelayed, createServerClock, PRESENCE_BANDS, PRESENCE_PARTS, } from "@/lib/activity/presence"; import type { PresenceState } from "@/lib/activity/types";
const ROWS_ENDPOINT = "/activity/rows"; const POLL_MS = 60_000; const MAX_POLL_BACKOFF = 8;
const serverClocks = new WeakMap<HTMLElement, (monotonicNow: number) => number>(); function resetServerClock(widget: HTMLElement): void { const clock = createServerClock(widget.dataset.serverNow, performance.now()); if (clock) serverClocks.set(widget, clock); else serverClocks.delete(widget); } function serverNow(widget: HTMLElement): number | undefined { if (!serverClocks.has(widget)) resetServerClock(widget); return serverClocks.get(widget)?.(performance.now()); } let activityClock: number | undefined; let pollTimer: number | undefined; let pollFailures = 0;
interface RowsResponse { rows: HTMLLIElement[]; meta: HTMLElement | null; }
async function fetchRows(query: string): Promise<RowsResponse> { const response = await fetch(`${ROWS_ENDPOINT}${query}`, { headers: { accept: "text/html" }, signal: AbortSignal.timeout(8_000) }); if (!response.ok) throw new Error(`activity rows responded ${response.status}`);
const template = document.createElement("template"); template.innerHTML = await response.text(); return { rows: [...template.content.querySelectorAll("li")], meta: template.content.querySelector<HTMLElement>("[data-activity-meta]"), }; }
function distanceToBottom(): number { return document.documentElement.scrollHeight - window.scrollY - window.innerHeight; }
/** keeps the reader's place when rows are added above what they are looking at */ function preserveScroll(list: HTMLOListElement, mutate: () => void): void { const inFeed = (list.closest("#activity")?.getBoundingClientRect().top ?? 0) <= 0; // Rows below the viewport cannot move what the reader is looking at. const existing = [...list.children] as HTMLElement[]; const anchor = inFeed ? existing.find((row) => { if (!row.dataset.activityRow) return false; const rect = row.getBoundingClientRect(); return rect.top < window.innerHeight && rect.bottom > 0; }) : undefined; const top = anchor?.getBoundingClientRect().top; const id = anchor?.dataset.activityRow; mutate(); if (top === undefined || !id) return; const replacement = ([...list.children] as HTMLElement[]).find((row) => row.dataset.activityRow === id); if (!replacement) return; const shift = replacement.getBoundingClientRect().top - top; if (shift !== 0) window.scrollTo({ top: window.scrollY + shift, behavior: "instant" }); }
function applyMeta(list: HTMLOListElement, meta: HTMLElement | null): void { const widget = list.closest<HTMLElement>("[data-activity-widget]"); if (!widget || !meta) return;
widget.dataset.serverNow = meta.dataset.serverNow ?? ""; resetServerClock(widget); widget.dataset.lastSeen = meta.dataset.lastSeen ?? ""; widget.dataset.trustedUntil = meta.dataset.trustedUntil ?? ""; widget.dataset.freshUntil = meta.dataset.freshUntil ?? ""; // The time under the name lives outside the widget. for (const time of document.querySelectorAll<HTMLTimeElement>("[data-activity-last-seen]")) { time.dateTime = meta.dataset.lastSeen ?? ""; time.hidden = !meta.dataset.lastSeen; }
const delayed = widget.querySelector<HTMLElement>("[data-signals-delayed]"); if (delayed) delayed.hidden = meta.dataset.delayed !== "true"; }
async function loadOlderRows(list: HTMLOListElement): Promise<boolean> { if (list.dataset.activityExhausted === "true") return false;
const loaded = [...list.querySelectorAll<HTMLLIElement>("li[data-activity-cursor]")]; const cursor = list.dataset.activityOlderCursor || loaded[loaded.length - 1]?.dataset.activityCursor; if (!cursor) return false;
const { rows } = await fetchRows(`?before=${encodeURIComponent(cursor)}`); if (!rows.length) { list.dataset.activityExhausted = "true"; return false; }
const nextCursor = rows.filter(row => row.dataset.activityCursor).at(-1)?.dataset.activityCursor; if (nextCursor) list.dataset.activityOlderCursor = nextCursor; const unique = uniqueHistoricalRows(list, rows); list.append(...unique); if (!nextCursor || nextCursor === cursor) { list.dataset.activityExhausted = "true"; return false; } updateActivityClock(); return true; }
/** * Re-syncs the newest rows against the server. A fresh event can join the top * group and change its id, so the overlapping window is replaced wholesale * rather than diffed row by row. */ async function syncNewestRows(list: HTMLOListElement): Promise<void> { const fetched = await fetchRows(""); const { meta } = fetched; const rows = fetched.rows; if (!list.isConnected) return; if (!meta) throw new Error("Activity response is missing metadata"); applyMeta(list, meta); const widget = list.closest<HTMLElement>("[data-activity-widget]"); const initial = widget?.dataset.activityPending === "true"; if (widget) { delete widget.dataset.activityPending; widget.setAttribute("aria-busy", "false"); widget.querySelector<HTMLElement>("[data-activity-skeleton]")?.setAttribute("hidden", ""); widget.querySelector<HTMLElement>("[data-activity-content]")?.removeAttribute("hidden"); } updateActivityClock(); if (initial) { list.replaceChildren(...rows); updateActivityClock(); void fillActivityList(list); return; } if (!rows.length) return;
const existing = [...list.children] as HTMLElement[]; const merged = mergeNewestRows(existing, rows); if (merged !== existing) preserveScroll(list, () => list.replaceChildren(...merged)); if (!existing.some(row => row.dataset.activityRow === rows[rows.length - 1]?.dataset.activityRow)) { list.dataset.activityExhausted = "false"; delete list.dataset.activityOlderCursor; } updateActivityClock(); }
async function fillActivityList(list: HTMLOListElement): Promise<void> { if (list.dataset.activityLoading === "true") return; list.dataset.activityLoading = "true";
try { // keep a screen of feed below the fold so the scroll never hits an end while (distanceToBottom() <= window.innerHeight) { if (!(await loadOlderRows(list))) return; } } catch { // a failed page only means the reader can retry by scrolling again } finally { list.dataset.activityLoading = "false"; } }
function schedulePoll(list: HTMLOListElement): void { window.clearTimeout(pollTimer); const backoff = Math.min(2 ** pollFailures, MAX_POLL_BACKOFF); pollTimer = window.setTimeout(() => void pollActivity(list), POLL_MS * backoff); }
async function pollActivity(list: HTMLOListElement): Promise<void> { if (document.visibilityState !== "visible" || !list.isConnected || list.dataset.activityPolling === "true") return; list.dataset.activityPolling = "true";
try { await syncNewestRows(list); pollFailures = 0; } catch (error) { pollFailures += 1; console.warn("Activity could not refresh; retrying automatically", error); } finally { list.dataset.activityPolling = "false"; if (list.isConnected) schedulePoll(list); } }
/** the tooltip quotes the band the status currently sits in, so it moves with it */ function describePresence(widget: HTMLElement, state: PresenceState): void { const detail = widget.querySelector<HTMLElement>("[data-presence-detail]"); if (!detail) return;
const band = PRESENCE_BANDS.find((candidate) => candidate.state === state); detail.textContent = band ? `${band.within} ago` : "not checked for a day"; }
/** only the status half carries the sweep, so the two halves are set apart */ function writePresence(widget: HTMLElement, state: PresenceState): void { const lead = widget.querySelector<HTMLElement>("[data-presence-lead]"); const status = widget.querySelector<HTMLElement>("[data-presence-status]"); if (lead) lead.textContent = PRESENCE_PARTS[state].lead; if (status) status.textContent = PRESENCE_PARTS[state].status; }
function updateActivityWidget(widget: HTMLElement): void { if (widget.dataset.activityPending === "true") return; const now = serverNow(widget); if (now === undefined) return; const delayed = widget.querySelector<HTMLElement>("[data-signals-delayed]"); if (delayed) delayed.hidden = !isActivityDelayed(widget.dataset.freshUntil, now); const trustedUntil = Date.parse(widget.dataset.trustedUntil ?? "");
if (!Number.isFinite(trustedUntil) || now > trustedUntil) { writePresence(widget, "unknown"); describePresence(widget, "unknown"); if (delayed) delayed.hidden = false; return; }
const lastSeenAt = widget.dataset.lastSeen; if (!lastSeenAt) return;
const state = getPresenceState(lastSeenAt, now); writePresence(widget, state); describePresence(widget, state); }
/** Crossfade over the old words so the status never disappears between values. */ function morphLastSeen(time: HTMLTimeElement, text: string): void { const previous = time.dataset.lastSeenText ?? time.textContent?.trim(); if (previous === text) return; time.dataset.lastSeenText = text; time.textContent = text; if (!previous || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; const old = document.createElement("span"); old.textContent = previous; old.setAttribute("aria-hidden", "true"); Object.assign(old.style, { position: "absolute", inset: "0", whiteSpace: "nowrap" }); const next = document.createElement("span"); next.textContent = text; time.style.position = "relative"; time.replaceChildren(old, next); old.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 350, fill: "forwards" }); const animation = next.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 350 }); animation.onfinish = () => old.remove(); }
function updateActivityClock(): void { const widgets = document.querySelectorAll<HTMLElement>("[data-activity-widget]"); const now = widgets[0] ? serverNow(widgets[0]) ?? Date.now() : Date.now(); for (const widget of widgets) { updateActivityWidget(widget); }
for (const time of document.querySelectorAll<HTMLTimeElement>("[data-activity-relative-time]")) { time.textContent = formatRelativeTime(time.dateTime, now); }
for (const time of document.querySelectorAll<HTMLTimeElement>("[data-activity-last-seen]")) { if (time.dateTime) morphLastSeen(time, formatLastSeenTime(time.dateTime, now)); } }
function initializeActivityList(list: HTMLOListElement): void { if (list.dataset.activityReady !== "true") { list.dataset.activityReady = "true"; window.addEventListener("scroll", () => void fillActivityList(list), { passive: true }); window.addEventListener("resize", () => void fillActivityList(list), { passive: true }); document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible") void pollActivity(list); else window.clearTimeout(pollTimer); }); }
const widget = list.closest<HTMLElement>("[data-activity-widget]"); if (widget?.dataset.activityPending === "true" || widget?.querySelector<HTMLElement>("[data-signals-delayed]")?.hidden === false) { void pollActivity(list); } else { void fillActivityList(list); schedulePoll(list); } }
function initializeActivityWidget(): void { if (activityClock) window.clearInterval(activityClock); window.clearTimeout(pollTimer); updateActivityClock(); activityClock = window.setInterval(updateActivityClock, 60_000);
for (const list of document.querySelectorAll<HTMLOListElement>("[data-activity-list]")) { initializeActivityList(list); } }
document.addEventListener("astro:page-load", initializeActivityWidget); initializeActivityWidget();</script>