From cb0edf813254655100a45626755651c0910f7222 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Fri, 11 Sep 2026 08:39:35 -1000 Subject: [PATCH 01/19] fix(channels): clarify unread attention hierarchy Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz> Signed-off-by: Taylor Ho --- docs/unread.md | 13 ++++++++----- src/bundled/channels/Channels.module.css | 23 ++++++++++++++++++----- src/bundled/channels/ChannelsPage.tsx | 2 +- src/bundled/channels/UnreadBadge.tsx | 5 +++-- tests/browser/sidebar-unread.spec.mjs | 14 ++++++++++++++ 5 files changed, 44 insertions(+), 13 deletions(-) diff --git a/docs/unread.md b/docs/unread.md index 68623fcd..dbff8291 100644 --- a/docs/unread.md +++ b/docs/unread.md @@ -72,11 +72,14 @@ could hide unseen siblings. Oversized rows that never fit fully are not auto-rea and retries pending publication. `ReadMutationResult.durability === "saved"` means the local transaction committed, not that the relay accepted it. -The sidebar displays observed badges with accessible non-exact wording. A local -manual-unread mark replaces the count with a dot and local-only label; the -underlying observed count and attention styling remain available. Conversation -options exposes explicit actions and Unread status/retry. Unknown and observed-zero both omit a badge; the API preserves -the distinction. There is no notification, feed, or exact-count service here. +The sidebar separates ordinary unread from directed attention. Any unread state +strengthens the channel label and keeps a small quiet dot for reveal geometry; +DMs, mentions and participating-thread replies receive the stronger count badge. +A local manual-unread mark replaces any displayed count with a dot and a local-only +label; the underlying observed count and attention styling remain available. +Conversation options exposes explicit actions and Unread status/retry. Unknown and +observed-zero both omit a badge; the API preserves the distinction. There is no +notification, feed, or exact-count service here. When unread rows are outside the sidebar's scroll viewport, floating “Unread above/below” buttons reveal the nearest one in that direction. They measure the diff --git a/src/bundled/channels/Channels.module.css b/src/bundled/channels/Channels.module.css index b36a01f7..fc4dc81f 100644 --- a/src/bundled/channels/Channels.module.css +++ b/src/bundled/channels/Channels.module.css @@ -362,8 +362,24 @@ border-bottom: 1px solid var(--border); } +.channelLabel:has(+ .unreadBadge) { + color: var(--text); + font-weight: 650; +} .unreadBadge { margin-inline-start: auto; + flex-shrink: 0; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--text-muted); + font-size: 0; +} +.unreadBadge[data-attention="true"] { + display: grid; + place-items: center; + width: auto; + height: auto; min-width: 1.25rem; border-radius: var(--radius-control); padding: 0 var(--space-1h); @@ -371,13 +387,10 @@ line-height: var(--text-caption--line-height); letter-spacing: var(--text-caption--letter-spacing); font-weight: var(--type-weight-medium); - background: var(--surface-hover); + background: var(--primary); + color: var(--on-primary); text-align: center; } -.unreadBadge[data-attention="true"] { - color: inherit; - outline: 1px solid currentColor; -} .channelLaunchers { display: flex; diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx index 97a6c53b..e0d95c74 100644 --- a/src/bundled/channels/ChannelsPage.tsx +++ b/src/bundled/channels/ChannelsPage.tsx @@ -527,7 +527,7 @@ function ChannelWorkspace({ onClick={() => select(channel.id)} > - {channel.name} + {channel.name} ); diff --git a/src/bundled/channels/UnreadBadge.tsx b/src/bundled/channels/UnreadBadge.tsx index 79c0f276..2a485214 100644 --- a/src/bundled/channels/UnreadBadge.tsx +++ b/src/bundled/channels/UnreadBadge.tsx @@ -25,6 +25,7 @@ export function UnreadBadge({ const count = snapshot.observedCount; const manual = snapshot.manual !== "none"; if (!manual && !count) return null; + const attention = (snapshot.attentionCount ?? 0) > 0; const label = manual ? `Marked unread${snapshot.manual === "local-only" ? " on this device only" : ""}` : `${count} observed unread messages${snapshot.freshness === "stale" ? "; may be out of date" : ""}. Not an exact total.`; @@ -35,9 +36,9 @@ export function UnreadBadge({ role="img" aria-label={label} title={label} - data-attention={!!snapshot.attentionCount} + data-attention={attention} > - {manual ? "•" : (count ?? 0) > 99 ? "99+" : count} + {attention ? (manual ? "•" : (count ?? 0) > 99 ? "99+" : count) : null} ); } diff --git a/tests/browser/sidebar-unread.spec.mjs b/tests/browser/sidebar-unread.spec.mjs index e53cc60f..0e001cc8 100644 --- a/tests/browser/sidebar-unread.spec.mjs +++ b/tests/browser/sidebar-unread.spec.mjs @@ -111,6 +111,20 @@ test("edge pills follow scroll and reveal the nearest unread without selection o } finally { app.relay.releaseEose("alpha"); } + const ordinary = row(page, "alpha").getByRole("img", { + name: /observed unread messages/, + }); + const directed = row(page, "dm-090").getByRole("img"); + await expect(ordinary).toHaveAttribute("data-attention", "false"); + await expect(ordinary).toHaveText(""); + await expect(ordinary).toHaveCSS("width", "6px"); + await expect(row(page, "alpha").locator("span").first()).toHaveCSS( + "font-weight", + "650", + ); + await expect(directed).toHaveAttribute("data-attention", "true"); + await expect(directed).toHaveText("1"); + await expect(directed).toHaveCSS("color", "rgb(255, 255, 255)"); await expect(row(page, "dm-090").getByRole("img")).toHaveCount(1); await expect(cue(page, "below")).toBeVisible(); await expect(cue(page, "above")).toHaveCount(0); From c7d8efbc67f1fe67382a1791750d21b8abf5475b Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Fri, 11 Sep 2026 10:29:39 -1000 Subject: [PATCH 02/19] feat(channels): surface actionable unread destinations Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz> Signed-off-by: Taylor Ho --- docs/unread.md | 42 +++-- .../channels/ChannelActivityPopover.tsx | 153 +++++++++++++++++ src/bundled/channels/Channels.module.css | 144 +++++++++++++++- src/bundled/channels/ChannelsPage.tsx | 89 ++++++++-- src/bundled/channels/SidebarUnread.tsx | 50 ++++-- src/bundled/channels/UnreadBadge.tsx | 101 ++++++++++-- .../relay/unread-invalidation.test.ts | 25 ++- src/features/relay/unread.test.ts | 133 +++++++++++++++ src/features/relay/unread.ts | 156 +++++++++++++++++- src/plugins/author.ts | 2 + tests/browser/sidebar-unread.spec.mjs | 55 ++++-- tests/browser/thread-unread.spec.mjs | 34 +++- 12 files changed, 900 insertions(+), 84 deletions(-) create mode 100644 src/bundled/channels/ChannelActivityPopover.tsx diff --git a/docs/unread.md b/docs/unread.md index dbff8291..e1cc65fb 100644 --- a/docs/unread.md +++ b/docs/unread.md @@ -72,23 +72,30 @@ could hide unseen siblings. Oversized rows that never fit fully are not auto-rea and retries pending publication. `ReadMutationResult.durability === "saved"` means the local transaction committed, not that the relay accepted it. -The sidebar separates ordinary unread from directed attention. Any unread state -strengthens the channel label and keeps a small quiet dot for reveal geometry; -DMs, mentions and participating-thread replies receive the stronger count badge. -A local manual-unread mark replaces any displayed count with a dot and a local-only -label; the underlying observed count and attention styling remain available. +The sidebar separates ordinary unread from directed attention. Any unread state, +including activity that exists only in a relevant thread, strengthens the channel +label. Ordinary channel unread uses a quiet dot; DMs keep the stronger count badge +and participant avatar. Non-DM thread activity uses a distinct dot whose +hover/focus/click popover groups unread replies by canonical thread root and opens +the existing thread panel; merely revealing the popover does not acknowledge a +reply. A local manual-unread mark replaces any displayed count with a dot and a +local-only label; the underlying observed count remains available. Conversation options exposes explicit actions and Unread status/retry. Unknown and observed-zero both omit a badge; the API preserves the distinction. There is no notification, feed, or exact-count service here. -When unread rows are outside the sidebar's scroll viewport, floating “Unread -above/below” buttons reveal the nearest one in that direction. They measure the -existing rendered badges—no extra unread subscriptions or relay reads just to -show the pills. Search-filtered rows do not participate. Collapsed sections use -the summary's position and expand when revealed. A partly visible row is not -outside the fold. Activation scrolls and focuses the row, retaining its ordinary -focus preparation; it does not select the channel or acknowledge any messages. -The pills use presence, not a potentially misleading aggregate message total. +When unread rows are outside the sidebar's scroll viewport, floating “N unread” +buttons count distinct destinations in that direction and reveal the nearest one. +Ordinary destinations use a quiet treatment; any DM, mention, broadcast, or +relevant thread destination promotes the same composition to primary. Thread-only +rows participate, and DMs remain promoted even when their only evidence is thread +activity. The controls measure existing rendered badges/dots—no extra unread +subscriptions or relay reads just to show them. Search-filtered rows do not +participate. Collapsed sections use the summary's position and expand when revealed. +A partly visible row is not outside the fold. Activation scrolls and focuses the +row, retaining its ordinary focus preparation; it does not select the channel or +acknowledge any messages. The count is destinations, not a potentially misleading +aggregate message total. Thread buttons keep the summary's total reply count and add a dot when the shared thread selector has observed unread replies or explicit thread-unread intent. @@ -177,10 +184,11 @@ durable account-owned intent survives without exposing revoked context projectio - `MessageRow.test.tsx`, `tests/browser/thread-unread.spec.mjs`: thread selector presentation, unchanged summary counts, hover/keyboard-focus treatment, independent thread reading, own/peer live arrivals and reload through the production broker. -- `tests/browser/sidebar-unread.spec.mjs`: above/below geometry, no layout shift, - resize/search/collapse, keyboard continuation, manual intent, evidence refresh, - session retargeting, and no reading/selection from reveal. Focus retains existing - channel preparation; merely showing the indicators does not fetch channels. +- `tests/browser/sidebar-unread.spec.mjs`: above/below destination counts and + priority, activity-only rows, no layout shift, resize/search/collapse, keyboard + continuation, manual intent, evidence refresh, session retargeting, and no + reading/selection from reveal. Focus retains existing channel preparation; + merely showing the indicators does not fetch channels. - `tests/browser/unread.spec.mjs`: production build/React/session/IndexedDB/broker, observed sidebar → focused dwell → encrypted publication/readback, reload, cancellation and explicit local-unread clearing with network content held. diff --git a/src/bundled/channels/ChannelActivityPopover.tsx b/src/bundled/channels/ChannelActivityPopover.tsx new file mode 100644 index 00000000..f519c4b1 --- /dev/null +++ b/src/bundled/channels/ChannelActivityPopover.tsx @@ -0,0 +1,153 @@ +import { Popover } from "@base-ui/react/popover"; +import { + useCallback, + useMemo, + useState, + useSyncExternalStore, + type ReactElement, +} from "react"; +import { selectProfiles } from "../../features/relay/profile-selection"; +import type { RelaySession } from "../../features/relay/session"; +import type { ThreadActivityItem } from "../../features/relay/unread"; +import { Avatar } from "../../shared/Avatar"; +import styles from "./Channels.module.css"; + +const elapsed = (createdAt: number) => { + const seconds = Math.max(0, Math.floor(Date.now() / 1000) - createdAt); + if (seconds < 60) return "now"; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m`; + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`; + return `${Math.floor(seconds / 86400)}d`; +}; + +function ActivityRow({ + item, + session, + onOpen, +}: { + item: ThreadActivityItem; + session: RelaySession; + onOpen(item: ThreadActivityItem): void; +}) { + const selection = useMemo( + () => selectProfiles(session.profiles, [item.authorId]), + [session.profiles, item.authorId], + ); + const profiles = useSyncExternalStore( + selection.subscribe, + selection.snapshot, + selection.snapshot, + ); + const profile = profiles.get(item.authorId); + const name = profile?.name ?? "Someone"; + return ( + + ); +} + +export function ChannelActivityPopover({ + session, + channelId, + channelName, + trigger, + onOpenThread, +}: { + session: RelaySession; + channelId: string; + channelName: string; + trigger: ReactElement; + onOpenThread(item: ThreadActivityItem): void; +}) { + const subscribe = useCallback( + (listener: () => void) => + session.unread.subscribeActivity(channelId, listener), + [session, channelId], + ); + const get = useCallback( + () => session.unread.activity(channelId), + [session, channelId], + ); + const snapshot = useSyncExternalStore(subscribe, get, get); + const items = snapshot.items ?? []; + const [open, setOpen] = useState(false); + if (!items.length) return trigger; + const stale = snapshot.freshness === "stale"; + return ( + { + setOpen(next); + if (next) + void session.profiles + .ensure( + [...new Set(items.map(({ authorId }) => authorId))], + "background", + ) + .catch(() => {}); + }} + > + + + + + + Activity in {channelName} + + {stale && ( +

May be out of date

+ )} + {open && ( +
+ {items.map((item) => ( + { + setOpen(false); + onOpenThread(selected); + }} + /> + ))} +
+ )} +
+
+
+
+ ); +} diff --git a/src/bundled/channels/Channels.module.css b/src/bundled/channels/Channels.module.css index fc4dc81f..80cf6112 100644 --- a/src/bundled/channels/Channels.module.css +++ b/src/bundled/channels/Channels.module.css @@ -82,17 +82,24 @@ gap: var(--space-1); width: max-content; max-width: calc(100% - 8px); + min-height: 28px; padding: var(--space-1h) var(--space-2); - border: 1px solid transparent; + border: 1px solid var(--border); border-radius: var(--radius-pill); - background: var(--primary); - color: var(--on-primary); + background: var(--surface); + color: var(--text-muted); box-shadow: var(--elevation-card); font-size: var(--text-caption); line-height: var(--text-caption--line-height); letter-spacing: var(--text-caption--letter-spacing); + font-weight: var(--type-weight-medium); cursor: pointer; } +.sidebar .unreadEdge[data-attention="true"] { + border-color: transparent; + background: var(--primary); + color: var(--on-primary); +} .unreadEdge svg { flex-shrink: 0; } @@ -362,10 +369,18 @@ border-bottom: 1px solid var(--border); } -.channelLabel:has(+ .unreadBadge) { +.channelLabel:has(~ .unreadBadge), +.channelLabel:has(~ .threadActivityDot) { color: var(--text); font-weight: 650; } +.unreadAvatar { + margin-inline-start: auto; + width: 18px; + height: 18px; + border-radius: 6px; + font-size: 8px; +} .unreadBadge { margin-inline-start: auto; flex-shrink: 0; @@ -375,6 +390,9 @@ background: var(--text-muted); font-size: 0; } +.unreadAvatar + .unreadBadge { + margin-inline-start: 0; +} .unreadBadge[data-attention="true"] { display: grid; place-items: center; @@ -391,6 +409,124 @@ color: var(--on-primary); text-align: center; } +.threadActivityDot { + margin-inline-start: auto; + flex-shrink: 0; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--primary); +} +.unreadAvatar ~ .threadActivityDot { + margin-inline-start: 0; +} +.threadActivityDot[data-with-unread="true"] { + margin-inline-start: 0; +} +.activityPopover { + z-index: 20; + width: min(360px, calc(100vw - 24px)); + max-height: min(420px, calc(100vh - 24px)); + overflow: hidden; + border: 1px solid var(--border); + border-radius: 14px; + background: var(--surface-elevated); + box-shadow: var(--elevation-popover); + transform-origin: var(--transform-origin); + transition: + opacity 120ms ease-out, + transform 120ms ease-out; +} +.activityPopover[data-starting-style], +.activityPopover[data-ending-style] { + opacity: 0; + transform: scale(0.98); +} +.activityHeading { + margin: 0; + padding: 10px 12px; + border-bottom: 1px solid var(--border); + font-size: calc(14px * var(--buzz-text-scale, 1)); + line-height: 20px; + font-weight: 600; +} +.activityStale { + margin: 0; + padding: 6px 12px; + color: var(--text-muted); + font-size: calc(12px * var(--buzz-text-scale, 1)); +} +.activityList { + max-height: 360px; + overflow-y: auto; +} +.activityItem { + display: grid; + grid-template-columns: 30px minmax(0, 1fr); + align-items: start; + width: 100%; + gap: 9px; + padding: 10px 12px; + border: 0; + border-radius: 0; + background: transparent; + color: var(--text); + line-height: 1.4; + text-align: left; +} +.activityItem + .activityItem { + border-top: 1px solid var(--border); +} +.activityItem:hover, +.activityItem:focus-visible { + background: var(--surface-hover); +} +.activityAvatar { + width: 30px; + height: 30px; + border-radius: 10px; + font-size: 11px; +} +.activityItemBody { + display: grid; + min-width: 0; + gap: 2px; +} +.activityItemHeading { + display: flex; + min-width: 0; + align-items: baseline; + justify-content: space-between; + gap: 12px; + font-size: calc(14px * var(--buzz-text-scale, 1)); +} +.activityItemHeading strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.activityItemHeading > span.activityTimestamp, +.activityItemMeta { + color: var(--text-muted); + font-size: calc(12px * var(--buzz-text-scale, 1)); +} +.activityItemPreview { + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + white-space: normal; + font-size: calc(14px * var(--buzz-text-scale, 1)); +} +@media (prefers-reduced-motion: reduce) { + .activityPopover { + transition: opacity 120ms ease-out; + } + .activityPopover[data-starting-style], + .activityPopover[data-ending-style] { + transform: none; + } +} .channelLaunchers { display: flex; diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx index e0d95c74..b11700f4 100644 --- a/src/bundled/channels/ChannelsPage.tsx +++ b/src/bundled/channels/ChannelsPage.tsx @@ -6,6 +6,7 @@ import { isBuzzLink, } from "../../features/navigation/buzz-links"; import { UnreadBadge, UnreadOptions } from "./UnreadBadge"; +import { ChannelActivityPopover } from "./ChannelActivityPopover"; import { SidebarUnread } from "./SidebarUnread"; import type { ConversationExtensions } from "../../features/conversation/contracts"; import { @@ -156,8 +157,10 @@ function ChannelWorkspace({ const [selected, setSelected] = useState(() => readView(scope, "selected-channel", undefined), ); - const select = useCallback( + const navigate = useCallback( (id: string) => { + setSelected(id); + writeView(scope, "selected-channel", id); if (navigator && viewer) { void navigator.open({ version: 1, @@ -169,8 +172,6 @@ function ChannelWorkspace({ }, }); } - setSelected(id); - writeView(scope, "selected-channel", id); }, [navigator, viewer, scope], ); @@ -178,6 +179,13 @@ function ChannelWorkspace({ channelId: string; messageId: string; }>(); + const select = useCallback( + (id: string) => { + navigate(id); + setThread(undefined); + }, + [navigate], + ); const threadTrigger = useRef(null); const [sent, setSent] = useState<{ channelId: string; id: string }>(); const sidebar = useSidebarView( @@ -347,6 +355,33 @@ function ChannelWorkspace({ }, [currentId, navigator, viewer, scope, open], ); + const openActivityThread = useCallback( + (channelId: string, rootId: string) => { + threadTrigger.current = + sidebar.list.current?.querySelector( + `[data-channel-id="${CSS.escape(channelId)}"]`, + ) ?? null; + if (navigator && viewer) { + setThread(undefined); + void navigator.open({ + version: 1, + kind: "conversation", + channelId, + messageId: rootId, + threadRootId: rootId, + scope: { + viewer, + communityOrigin: scope.slice(0, -(viewer.length + 1)), + }, + }); + } else { + navigate(channelId); + setThread({ channelId, messageId: rootId }); + } + open(undefined); + }, + [navigate, navigator, viewer, scope, sidebar.list, open], + ); const closeThread = () => { if (showingThread?.navigation && current) select(current.id); setThread(undefined); @@ -512,24 +547,42 @@ function ChannelWorkspace({ : MessageCircle : Hash; return ( - } - onFocus={() => queries.channels.prepare?.(channel.id)} - onClick={() => select(channel.id)} - > - - {channel.name} - - + /> ); })} diff --git a/src/bundled/channels/SidebarUnread.tsx b/src/bundled/channels/SidebarUnread.tsx index c8c2011e..3c355f6c 100644 --- a/src/bundled/channels/SidebarUnread.tsx +++ b/src/bundled/channels/SidebarUnread.tsx @@ -8,25 +8,36 @@ import { } from "react"; import styles from "./Channels.module.css"; -type Edges = { above: HTMLButtonElement[]; below: HTMLButtonElement[] }; +type EdgeTarget = { row: HTMLButtonElement; attention: boolean }; +type Edges = { above: EdgeTarget[]; below: EdgeTarget[] }; -/** Geometry over the existing badges, not another unread store or subscription. */ +/** Geometry over rendered unread destinations, not another unread store. */ function unreadEdges(list: HTMLElement): Edges { const edges: Edges = { above: [], below: [] }; const viewport = list.getBoundingClientRect(); if (!list.clientHeight || !viewport.width) return edges; - for (const badge of list.querySelectorAll("[data-channel-unread]")) { - const row = badge.closest("button"); - if (!row) continue; + const rows = new Set(); + for (const marker of list.querySelectorAll( + "[data-channel-unread], [data-channel-activity]", + )) { + const row = marker.closest("button"); + if (row) rows.add(row); + } + for (const row of rows) { // A collapsed section represents its hidden rows at the summary. Clicking // an edge cue expands that section before revealing the actual channel. const closed = row.closest("details:not([open])"); const anchor = closed?.querySelector("summary") ?? row; const rect = anchor.getBoundingClientRect(); if (!rect.height || !rect.width) continue; - if (rect.bottom <= viewport.top) edges.above.push(row); + const attention = + row.getAttribute("data-channel-type") === "dm" || + row.querySelector('[data-priority="true"]') !== null || + row.querySelector("[data-channel-activity]") !== null; + const target = { row, attention }; + if (rect.bottom <= viewport.top) edges.above.push(target); else if (rect.top >= viewport.top + list.clientHeight) - edges.below.push(row); + edges.below.push(target); } return edges; } @@ -54,7 +65,11 @@ export function SidebarUnread({ (["above", "below"] as const).every( (edge) => previous[edge].length === next[edge].length && - previous[edge].every((row, i) => row === next[edge][i]), + previous[edge].every( + (target, i) => + target.row === next[edge][i]?.row && + target.attention === next[edge][i]?.attention, + ), ) ? previous : next, @@ -71,7 +86,13 @@ export function SidebarUnread({ subtree: true, childList: true, attributes: true, - attributeFilter: ["open", "data-channel-unread"], + attributeFilter: [ + "open", + "data-channel-unread", + "data-channel-activity", + "data-channel-type", + "data-priority", + ], }); viewport.addEventListener("scroll", schedule, { passive: true }); schedule(); @@ -89,16 +110,17 @@ export function SidebarUnread({ const targets = unreadEdges(viewport)[edge]; const target = edge === "above" ? targets.at(-1) : targets[0]; if (!target) return; - const section = target.closest("details"); + const { row } = target; + const section = row.closest("details"); if (section) section.open = true; - const rect = target.getBoundingClientRect(); + const rect = row.getBoundingClientRect(); viewport.scrollTop += rect.top - viewport.getBoundingClientRect().top - (viewport.clientHeight - rect.height) / 2; // Continue keyboard navigation at the revealed row, not the start of the // roster. Its existing focus preparation still applies; focus is not selection. - target.focus({ preventScroll: true }); + row.focus({ preventScroll: true }); }; return (
@@ -121,10 +143,12 @@ export function SidebarUnread({ className={styles.unreadEdge} data-edge={edge} title={`Reveal the nearest unread channel ${edge} without opening it`} + aria-label={`${edges[edge].length} unread ${edge}`} + data-attention={edges[edge].some(({ attention }) => attention)} onClick={() => reveal(edge)} >