diff --git a/docs/unread.md b/docs/unread.md index 68623fcd..a739555b 100644 --- a/docs/unread.md +++ b/docs/unread.md @@ -72,20 +72,34 @@ 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. - -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. +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 unread renders no row marker. DMs, mentions, broadcasts, and +relevant thread replies add one accent dot; non-DM row numerals are omitted and DM +avatars are reserved for promoted offscreen cues. Thread activity reuses that dot: +its hover/focus/click popover groups unread replies by canonical thread root and +opens the existing thread panel, so overlapping priority and thread activity never +produce duplicate dots. Merely revealing the popover does not acknowledge a reply. +A local manual-unread mark strengthens the label without fabricating priority; the +underlying observed count remains available. +Conversation options exposes explicit actions and Unread status/retry. Unknown and +observed-zero both omit unread styling; 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” +buttons reveal the nearest destination in that direction without exposing a count. +The internal directional set is still deduplicated by destination for geometry and +priority: 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. Directional destination counts remain internal and are +not rendered or announced by the control. 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. @@ -174,10 +188,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..49d6726b --- /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(() => {}); + }} + > + + + + + {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 b36a01f7..9b261f4b 100644 --- a/src/bundled/channels/Channels.module.css +++ b/src/bundled/channels/Channels.module.css @@ -82,16 +82,27 @@ 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; + transition: + background-color 160ms ease, + color 160ms ease, + border-color 160ms ease; +} +.sidebar .unreadEdge[data-attention="true"] { + border-color: transparent; + background: var(--primary); + color: var(--on-primary); } .unreadEdge svg { flex-shrink: 0; @@ -362,21 +373,140 @@ border-bottom: 1px solid var(--border); } -.unreadBadge { +.channelLabel:has(~ .unreadState), +.channelLabel:has(~ .threadActivityDot) { + color: var(--text); + font-weight: var(--type-weight-medium); +} +.unreadState { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: calc(-0.5 * var(--space-half)); + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} +.priorityDot, +.threadActivityDot { + display: block; margin-inline-start: auto; - min-width: 1.25rem; + flex-shrink: 0; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--primary); +} +.threadActivityDot { + cursor: help; +} +.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: var(--radius-control); - padding: 0 var(--space-1h); + 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); +} +.activityStale { + margin: 0; + padding: var(--space-1h) var(--space-3); + color: var(--text-muted); 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); +} +.activityList { + max-height: 360px; + overflow-y: auto; +} +.activityItem { + display: grid; + grid-template-columns: 30px minmax(0, 1fr); + align-items: start; + width: 100%; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + 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); - text-align: center; } -.unreadBadge[data-attention="true"] { - color: inherit; - outline: 1px solid currentColor; +.activityAvatar { + width: 30px; + height: 30px; + border-radius: var(--radius-row); + font-size: var(--text-caption); + line-height: var(--text-caption--line-height); + letter-spacing: var(--text-caption--letter-spacing); +} +.activityItemBody { + display: grid; + min-width: 0; + gap: var(--space-half); +} +.activityItemHeading { + display: flex; + min-width: 0; + align-items: baseline; + justify-content: space-between; + gap: var(--space-3); + font-size: var(--text-body-sm); + line-height: var(--text-body-sm--line-height); + letter-spacing: var(--text-body-sm--letter-spacing); +} +.activityItemHeading strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.activityItemHeading > span.activityTimestamp, +.activityItemMeta { + color: var(--text-muted); + font-size: var(--text-caption); + line-height: var(--text-caption--line-height); + letter-spacing: var(--text-caption--letter-spacing); +} +.activityItemPreview { + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + white-space: normal; + font-size: var(--text-body-sm); + line-height: var(--text-body-sm--line-height); + letter-spacing: var(--text-body-sm--letter-spacing); +} +@media (prefers-reduced-motion: reduce) { + .activityPopover { + transition: opacity 120ms ease-out; + } + .activityPopover[data-starting-style], + .activityPopover[data-ending-style] { + transform: none; + } } .channelLaunchers { diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx index 97a6c53b..ba4c9730 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,41 @@ 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..5e4093f4 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={`Unread ${edge}`} + data-attention={edges[edge].some(({ attention }) => attention)} onClick={() => reveal(edge)} >