From a130c14d16a34a1983ea00cfe283c24a107e3cfe Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sun, 5 Jul 2026 14:32:48 -0400 Subject: [PATCH] feat(viewer): show recent revision indicator --- .changeset/fresh-dots-dance.md | 5 +++++ viewer/src/Card.tsx | 29 +++++++++++++++++++++++++ viewer/src/styles.css | 39 ++++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+) create mode 100644 .changeset/fresh-dots-dance.md diff --git a/.changeset/fresh-dots-dance.md b/.changeset/fresh-dots-dance.md new file mode 100644 index 0000000..628764a --- /dev/null +++ b/.changeset/fresh-dots-dance.md @@ -0,0 +1,5 @@ +--- +"sideshow": patch +--- + +Show a pulsing sidecar dot next to the version pill when a post revision was updated recently. diff --git a/viewer/src/Card.tsx b/viewer/src/Card.tsx index 8224719..9cbf7de 100644 --- a/viewer/src/Card.tsx +++ b/viewer/src/Card.tsx @@ -71,6 +71,7 @@ export function frameForSource(source: unknown): { id: string; iframe: HTMLIFram // one line, max generous enough for a long diff/markdown without runaway growth. const MIN_FRAME_H = 24; const MAX_FRAME_H = 4000; +const RECENT_UPDATE_MS = 2 * 60 * 1000; export function applyFrameHeight(iframe: HTMLIFrameElement, reportedHeight: unknown): void { iframe.style.height = Math.min(Math.max(Number(reportedHeight), MIN_FRAME_H), MAX_FRAME_H) + "px"; } @@ -185,6 +186,7 @@ export function Card(props: { post: Post; standalone?: boolean }) { const [annotating, setAnnotating] = createSignal(false); const [anchorDraft, setAnchorDraft] = createSignal(null); const [fullscreenSurface, setFullscreenSurface] = createSignal(null); + const [recentUpdateNow, setRecentUpdateNow] = createSignal(Date.now()); let stopPoll: (() => void) | undefined; const surfaceTitle = (surfaceIndex: number) => @@ -200,6 +202,15 @@ export function Card(props: { post: Post; standalone?: boolean }) { const anchoredComments = (surfaceIndex: number) => comments().filter((c) => c.postId === props.post.id && c.anchor?.surfaceIndex === surfaceIndex); + const isRecentRevision = () => { + const updatedAt = new Date(props.post.updatedAt).getTime(); + return ( + props.post.version > 1 && + Number.isFinite(updatedAt) && + recentUpdateNow() - updatedAt < RECENT_UPDATE_MS + ); + }; + const closeFullscreen = () => { const opener = fullscreenOpener; fullscreenOpener = undefined; @@ -241,6 +252,20 @@ export function Card(props: { post: Post; standalone?: boolean }) { createEffect(scrollIfTarget); onCleanup(() => stopPoll?.()); + createEffect(() => { + const version = props.post.version; + const updatedAt = new Date(props.post.updatedAt).getTime(); + if (version <= 1 || !Number.isFinite(updatedAt)) return; + + const now = Date.now(); + setRecentUpdateNow(now); + const remaining = RECENT_UPDATE_MS - (now - updatedAt); + if (remaining <= 0) return; + + const timer = setTimeout(() => setRecentUpdateNow(Date.now()), remaining + 50); + onCleanup(() => clearTimeout(timer)); + }); + createEffect(() => { if (!fullscreenSurface()) return; queueMicrotask(() => fullscreenCloseButton?.focus()); @@ -350,6 +375,10 @@ export function Card(props: { post: Post; standalone?: boolean }) { )} + + + updated recently + {relTime(props.post.updatedAt)} diff --git a/viewer/src/styles.css b/viewer/src/styles.css index 4f7f365..aeadcf5 100644 --- a/viewer/src/styles.css +++ b/viewer/src/styles.css @@ -514,6 +514,12 @@ main { font-weight: 500; font-size: 14px; } +.card-head .vslot { + display: inline-flex; + align-items: center; + gap: 5px; + white-space: nowrap; +} .vbadge { font-size: 11px; color: var(--muted); @@ -529,6 +535,36 @@ select.vbadge { cursor: pointer; appearance: none; } +.card-head .update-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--accent); + box-shadow: 0 0 0 3px var(--accent-bg); + animation: update-dot-pulse 1.6s ease-out infinite; +} +.card-head .update-status { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} +@keyframes update-dot-pulse { + 0%, + 100% { + opacity: 1; + transform: scale(1); + } + 60% { + opacity: 0.48; + transform: scale(0.82); + } +} .card-meta { font-size: 12px; color: var(--faint); @@ -1860,6 +1896,9 @@ iframe { color: var(--accent); } @media (prefers-reduced-motion: reduce) { + .card-head .update-dot { + animation: none; + } .topbar .menu, aside, #scrim,