Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
cb0edf8
fix(channels): clarify unread attention hierarchy
tellaho Sep 11, 2026
c7d8efb
feat(channels): surface actionable unread destinations
tellaho Sep 11, 2026
f95a1f4
fix(channels): simplify unread navigation hierarchy
tellaho Sep 11, 2026
1a29af5
fix(channels): transition unread priority states
tellaho Sep 11, 2026
0f2fba4
fix(channels): remove activity popover header
tellaho Sep 11, 2026
1a1c1ed
test(channels): assert passive activity before opening
tellaho Sep 11, 2026
87c56cb
fix(channels): preserve selected conversation
tellaho Sep 12, 2026
e2975e8
test(channels): preserve manual unread disclosure
tellaho Sep 12, 2026
037c443
fix(channels): distinguish activity threads
tellaho Sep 12, 2026
ab6799a
fix(channels): present current activity previews
tellaho Sep 12, 2026
a17ecd7
test(channels): tolerate profile hydration in activity labels
tellaho Sep 12, 2026
2e1db87
fix(channels): retire deleted activity edits
tellaho Sep 13, 2026
47d5431
fix(channels): preserve notification attention policy
tellaho Sep 14, 2026
af2ec00
fix(channels): admit repair-retained activity evidence
tellaho Sep 15, 2026
b24ab6e
fix(channels): preserve broadcast sidebar priority
tellaho Sep 15, 2026
44a774b
fix(channels): align activity with design foundations
tellaho Sep 15, 2026
6d7dd59
test(channels): follow migrated unread typography
tellaho Sep 15, 2026
fe20c60
test(channels): isolate mention activity evidence
tellaho Sep 15, 2026
e2533fe
fix(messages): preserve earned reading dwell
tellaho Sep 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 33 additions & 18 deletions docs/unread.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
153 changes: 153 additions & 0 deletions src/bundled/channels/ChannelActivityPopover.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<button
type="button"
className={styles.activityItem}
aria-label={`Open unread thread from ${name}: ${item.preview}`}
onClick={() => onOpen(item)}
>
<Avatar
name={name}
src={profile?.picture ? session.media(profile.picture) : undefined}
className={styles.activityAvatar ?? ""}
/>
<span className={styles.activityItemBody}>
<span className={styles.activityItemHeading}>
<strong>{name}</strong>
<span className={styles.activityTimestamp}>
{elapsed(item.createdAt)}
</span>
</span>
<span className={styles.activityItemMeta}>
Thread{item.unreadCount > 1 ? ` · ${item.unreadCount} unread` : ""}
</span>
<span className={styles.activityItemPreview}>{item.preview}</span>
</span>
</button>
);
}

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 (
<Popover.Root
modal={false}
open={open}
onOpenChange={(next) => {
setOpen(next);
if (next)
void session.profiles
.ensure(
[...new Set(items.map(({ authorId }) => authorId))],
"background",
)
.catch(() => {});
}}
>
<Popover.Trigger
render={trigger}
openOnHover
delay={250}
closeDelay={150}
/>
<Popover.Portal>
<Popover.Positioner
side="right"
align="start"
sideOffset={6}
collisionPadding={8}
>
<Popover.Popup
className={styles.activityPopover}
aria-label={`Activity in ${channelName}`}
>
{stale && (
<p className={styles.activityStale}>May be out of date</p>
)}
{open && (
<div className={styles.activityList}>
{items.map((item) => (
<ActivityRow
key={item.rootId}
item={item}
session={session}
onOpen={(selected) => {
setOpen(false);
onOpenThread(selected);
}}
/>
))}
</div>
)}
</Popover.Popup>
</Popover.Positioner>
</Popover.Portal>
</Popover.Root>
);
}
152 changes: 141 additions & 11 deletions src/bundled/channels/Channels.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading