fix(sidebar): simplify unread indicators and emphasize priority activity (#7134)

**Category:** fix
**User Impact:** The sidebar now presents unread activity consistently:
unread rooms are bold, offscreen activity is counted by destination, and
DMs or directed activity receive stronger emphasis.

**Problem:** Sidebar unread state was split across competing signals:
agent work could trigger overflow, message totals inflated its count,
non-DM rows showed redundant numerals, and routine room activity looked
as urgent as DMs or directed messages. This made the sidebar noisy and
made the overflow value harder to interpret.

**Solution:** Use one stable offscreen unread control that counts rooms
and DMs—not messages—and remove agent work from that signal. Keep
ordinary room activity quiet; promote DMs, mentions, broadcasts, and
relevant thread replies; preserve DM avatars and targeting; bold every
unread room; and retain thread dots while removing non-DM numerals.

<details>
<summary>File changes</summary>

**desktop/src-tauri/src/unread_catch_up.rs**
Classifies relevant thread replies as priority activity during native
unread catch-up so startup state matches live rendering.

**desktop/src/app/AppShell.tsx**
Passes the priority unread destination set into the sidebar.

**desktop/src/features/channels/useUnreadChannels.ts**
Projects unread destinations, priority state, and thread replies
consistently while keeping Dock badge behavior separate from sidebar
emphasis.


**desktop/src/features/sidebar/lib/useOffscreenActivityChannelIds.test.mjs**
Removes tests for the superseded agent/activity overflow projection.

**desktop/src/features/sidebar/lib/useOffscreenActivityChannelIds.ts**
Removes the old agent-plus-message overflow projection so agent work
alone no longer creates the unread indicator.

**desktop/src/features/sidebar/lib/useSidebarActivityOverflow.ts**
Replaces activity-volume overflow state with unread-destination overflow
state.

**desktop/src/features/sidebar/lib/useSidebarUnreadOverflow.test.mjs**
Covers priority detection and destination-count labels for the new
overflow projection.

**desktop/src/features/sidebar/lib/useSidebarUnreadOverflow.ts**
Counts distinct offscreen unread destinations and determines quiet
versus primary treatment per direction.

**desktop/src/features/sidebar/ui/AppSidebar.tsx**
Renders one stable overflow control, preserves protected-DM visibility
filtering, and prioritizes visible unread DMs for previews and
navigation.

**desktop/src/features/sidebar/ui/AppSidebar.types.ts**
Adds the priority unread destination set to the sidebar contract.

**desktop/src/features/sidebar/ui/CustomChannelSection.tsx**
Stops forwarding non-DM unread counts into custom channel rows.

**desktop/src/features/sidebar/ui/MoreUnreadButton.test.mjs**
Updates control coverage for explicit emphasis, destination labels, and
DM targeting.

**desktop/src/features/sidebar/ui/MoreUnreadButton.tsx**
Applies quiet or primary treatment without changing geometry,
accessibility text, DM avatars, or click behavior.

**desktop/src/features/sidebar/ui/SidebarSection.tsx**
Removes non-DM row numerals while retaining unread weight and thread
preview affordances.

**desktop/src/shared/ui/UnreadPill.tsx**
Shares one composition between quiet and primary states so only color
treatment changes.

**desktop/tests/e2e/badge.spec.ts**
Covers destination counting, promotion without count inflation,
DM/thread priority, row bolding, and removed numerals; also captures the
reviewed UI states.

**desktop/tests/e2e/channel-activity-popover.spec.ts**
Updates channel activity assertions for the numeral-free row treatment.

**desktop/tests/e2e/channels.spec.ts**
Updates channel unread expectations to use bold text rather than a row
count.

**desktop/tests/e2e/thread-unread.spec.ts**
Keeps thread unread-dot and popover coverage while asserting the room
itself is bold.

</details>

## Reproduction steps

1. Open a workspace with enough sidebar destinations to scroll rooms
above or below the viewport.
2. Receive ordinary unread activity in an offscreen room; verify one
quiet `N unread` indicator appears and counts the room once regardless
of message volume.
3. Receive a mention, broadcast, or relevant thread reply in an
offscreen room; verify the same count becomes primary without changing
its geometry or adding a badge.
4. Receive an unread DM, including thread-only activity; verify the
indicator is primary, shows the DM avatar when available, and navigates
to that DM.
5. Scroll the destination onscreen or mark it read; verify the count and
treatment update from the remaining offscreen destinations.
6. Inspect unread non-DM rows; verify their names are bold, numeric
badges are absent, and thread dots still open the unread-thread preview.

## Screenshots

### Ordinary unread room
Routine offscreen room activity uses the quiet treatment.

![Quiet unread overflow
indicator](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/7134/sidebar-unread-overflow-default.png)

### Directed unread activity
The same destination count becomes primary when an offscreen room has
directed activity; geometry and label remain unchanged.

![Primary unread overflow
indicator](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/7134/sidebar-unread-overflow-primary.png)

### Thread-only unread DM
A DM stays primary and retains its avatar even when only its thread has
unread activity.

![Primary DM thread overflow
indicator](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/7134/sidebar-dm-thread-overflow-primary.png)

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz>
Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
This commit is contained in:
Taylor Ho
2026-09-04 16:03:41 -07:00
committed by GitHub
parent 4d447b9c20
commit f038cbbb0d
19 changed files with 353 additions and 344 deletions

View File

@@ -300,6 +300,7 @@ fn classify_batch(
let broadcast = has_exact_tag(&event.tags, "broadcast", "1");
let threaded = reference.parent_id.is_some() && !broadcast;
let high_priority = item.channel.channel_type == "dm"
|| threaded
|| broadcast
|| has_tag_value(&event.tags, "p", &self_pubkey);
max_trigger = max_trigger.max(event.created_at);
@@ -518,9 +519,9 @@ mod tests {
assert_eq!(
observed_events
.iter()
.map(|event| event.id.as_str())
.map(|event| (event.id.as_str(), event.high_priority))
.collect::<Vec<_>>(),
["external-reply"]
[("external-reply", true)]
);
assert_eq!(discovered.participated, ["root"]);
}

View File

@@ -918,6 +918,7 @@ export function AppShell() {
selectedChannelId={selectedChannelId}
selectedView={selectedView}
unreadChannelIds={unreadChannelIds}
{...{ highPriorityUnreadChannelIds }}
previewActivityChannelIds={unreadThreadChannelIds}
unreadChannelCounts={unreadChannelCounts}
mutedChannelIds={mutedChannelIds}

View File

@@ -5,7 +5,6 @@ import {
} from "@/features/channels/useLiveChannelUpdates";
import {
countUnreadAppBadgeObservedEvents,
countUnreadBadgeObservedEvents,
countUnreadHighPriorityObservedEvents,
countUnreadObservedEvents,
hasUnreadTopLevelObservedEvent,
@@ -426,13 +425,14 @@ export function useUnreadChannels(
const handleChannelMessage = React.useCallback(
(channelId: string, event: RelayEvent) => {
const channel = channelsRef.current.find((ch) => ch.id === channelId);
const isHighPriority =
channel?.channelType === "dm" ||
(normalizedPubkey !== null &&
isHighPriorityEventForUser(event, normalizedPubkey));
const isThreadedReply =
getThreadReference(event.tags).parentId !== null &&
!isBroadcastReply(event.tags);
const isHighPriority =
channel?.channelType === "dm" ||
isThreadedReply ||
(normalizedPubkey !== null &&
isHighPriorityEventForUser(event, normalizedPubkey));
const didRecordUnreadEvent = recordUnreadEvent(
channelId,
makeObservedUnreadEvent({
@@ -852,38 +852,24 @@ export function useUnreadChannels(
) {
topLevelUnread.add(channel.id);
}
const badgeCount =
nativeProjection?.badgeCount ??
countUnreadBadgeObservedEvents(
observedEvents,
readAtForObservedEvent,
);
const appBadgeCount =
nativeProjection?.appBadgeCount ??
countUnreadAppBadgeObservedEvents(
observedEvents,
readAtForObservedEvent,
);
// Sidebar numerals on non-DM rows count every unread mention and
// broadcast, including threaded ones. The Dock projection
// (appBadgeCount) keeps excluding threaded replies because Home's
// badge subtotal already counts those; reusing it here would hide
// thread mentions from the channel row.
const highPriorityCount =
nativeProjection?.highPriorityCount ??
countUnreadHighPriorityObservedEvents(
observedEvents,
readAtForObservedEvent,
);
counts.set(
channel.id,
channel.channelType === "dm" ? badgeCount : highPriorityCount,
);
counts.set(channel.id, unreadCount);
unreadChannelNotificationCount += appBadgeCount;
// DM channels: any unread DM is high-priority. Non-DM: high-priority
// only if at least one mention/broadcast remains unread in its own
// channel/thread context.
// only if at least one mention, broadcast, or relevant thread reply
// remains unread in its own channel/thread context.
if (channel.channelType === "dm" || highPriorityCount > 0) {
highPriority.add(channel.id);
}

View File

@@ -1,52 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { getOffscreenActivityChannelIds } from "./useOffscreenActivityChannelIds.ts";
import { getSidebarActivityOverflowLabel } from "./useSidebarActivityOverflow.ts";
test("keeps every unread channel navigable while adding working activity", () => {
const activity = getOffscreenActivityChannelIds({
activeWorkingByChannelId: new Map([["working", {}]]),
previewActivityChannelIds: new Set(["preview"]),
unreadChannelIds: new Set(["dm", "forum", "stream"]),
});
assert.deepEqual([...activity.messageChannelIds].sort(), [
"dm",
"forum",
"preview",
"stream",
]);
assert.deepEqual([...activity.channelIds].sort(), [
"dm",
"forum",
"preview",
"stream",
"working",
]);
});
test("keeps working-only channels out of message overflow prioritization", () => {
const activity = getOffscreenActivityChannelIds({
activeWorkingByChannelId: new Map([["read-working-dm", {}]]),
previewActivityChannelIds: new Set(),
unreadChannelIds: new Set(["unread-channel"]),
});
assert.deepEqual([...activity.messageChannelIds], ["unread-channel"]);
assert.deepEqual(
[...activity.channelIds],
["unread-channel", "read-working-dm"],
);
});
test("uses an activity-neutral overflow label when work contributes", () => {
assert.equal(
getSidebarActivityOverflowLabel({ activityCount: 2, messageCount: 1 }),
"2 new activity",
);
assert.equal(
getSidebarActivityOverflowLabel({ activityCount: 1, messageCount: 1 }),
undefined,
);
});

View File

@@ -1,53 +0,0 @@
import * as React from "react";
type OffscreenActivityChannelIds = {
messageChannelIds: ReadonlySet<string>;
channelIds: ReadonlySet<string>;
};
export function getOffscreenActivityChannelIds({
activeWorkingByChannelId,
previewActivityChannelIds,
unreadChannelIds,
}: {
activeWorkingByChannelId: ReadonlyMap<string, unknown>;
previewActivityChannelIds: ReadonlySet<string>;
unreadChannelIds: ReadonlySet<string>;
}): OffscreenActivityChannelIds {
// Every unread row must remain navigable, including top-level stream and
// forum unreads that do not have thread-preview activity.
const messageChannelIds = new Set([
...unreadChannelIds,
...previewActivityChannelIds,
]);
return {
messageChannelIds,
channelIds: new Set([
...messageChannelIds,
...activeWorkingByChannelId.keys(),
]),
};
}
export function useOffscreenActivityChannelIds(args: {
activeWorkingByChannelId: ReadonlyMap<string, unknown>;
previewActivityChannelIds: ReadonlySet<string>;
unreadChannelIds: ReadonlySet<string>;
}) {
const {
activeWorkingByChannelId,
previewActivityChannelIds,
unreadChannelIds,
} = args;
return React.useMemo(
() =>
getOffscreenActivityChannelIds({
activeWorkingByChannelId,
previewActivityChannelIds,
unreadChannelIds,
}),
[activeWorkingByChannelId, previewActivityChannelIds, unreadChannelIds],
);
}

View File

@@ -1,47 +0,0 @@
import { useOffscreenActivityChannelIds } from "@/features/sidebar/lib/useOffscreenActivityChannelIds";
import { useUnreadOverflow } from "@/features/sidebar/lib/useUnreadOverflow";
type ActivityOptions = Parameters<typeof useOffscreenActivityChannelIds>[0];
type ScrollRef = Parameters<typeof useUnreadOverflow>[0]["scrollRef"];
export function getSidebarActivityOverflowLabel({
activityCount,
messageCount,
}: {
activityCount: number;
messageCount: number;
}) {
return activityCount === messageCount
? undefined
: `${activityCount} new activity`;
}
export function useSidebarActivityOverflow({
scrollRef,
...activityOptions
}: ActivityOptions & { scrollRef: ScrollRef }) {
const { channelIds, messageChannelIds } =
useOffscreenActivityChannelIds(activityOptions);
const activityOverflow = useUnreadOverflow({
scrollRef,
unreadChannelIds: channelIds,
});
const messageOverflow = useUnreadOverflow({
scrollRef,
unreadChannelIds: messageChannelIds,
});
return {
...activityOverflow,
unreadMessageAboveChannelIds: messageOverflow.unreadAboveChannelIds,
unreadMessageBelowChannelIds: messageOverflow.unreadBelowChannelIds,
unreadAboveLabel: getSidebarActivityOverflowLabel({
activityCount: activityOverflow.unreadAboveCount,
messageCount: messageOverflow.unreadAboveCount,
}),
unreadBelowLabel: getSidebarActivityOverflowLabel({
activityCount: activityOverflow.unreadBelowCount,
messageCount: messageOverflow.unreadBelowCount,
}),
};
}

View File

@@ -0,0 +1,24 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
hasHighPriorityOverflow,
sidebarOverflowUnreadLabel,
} from "./useSidebarUnreadOverflow.ts";
test("labels the destination total as unread", () => {
assert.equal(sidebarOverflowUnreadLabel(3), "3 unread");
});
test("promotes actionable unread and every offscreen DM", () => {
const actionable = new Set(["mention"]);
const dms = new Set(["dm"]);
assert.equal(hasHighPriorityOverflow(["channel"], actionable, dms), false);
assert.equal(hasHighPriorityOverflow(["mention"], actionable, dms), true);
assert.equal(hasHighPriorityOverflow(["dm"], actionable, dms), true);
assert.equal(
hasHighPriorityOverflow(["channel", "dm"], actionable, dms),
true,
);
});

View File

@@ -0,0 +1,69 @@
import * as React from "react";
import { useUnreadOverflow } from "@/features/sidebar/lib/useUnreadOverflow";
type ScrollRef = Parameters<typeof useUnreadOverflow>[0]["scrollRef"];
/**
* Returns whether any offscreen destination is a DM or has directed unread
* activity, which should keep the sidebar overflow control emphasized.
*/
export function hasHighPriorityOverflow(
offscreenChannelIds: readonly string[],
highPriorityUnreadChannelIds: ReadonlySet<string>,
dmChannelIds: ReadonlySet<string>,
) {
return offscreenChannelIds.some(
(channelId) =>
dmChannelIds.has(channelId) ||
highPriorityUnreadChannelIds.has(channelId),
);
}
/** Formats the accessible label for a distinct unread destination count. */
export function sidebarOverflowUnreadLabel(count: number) {
return `${count} unread`;
}
/**
* Projects unread message and thread activity into offscreen destination sets.
* Message and preview destinations are unioned and deduplicated; DMs and
* destinations with directed unread activity receive high-priority treatment.
*/
export function useSidebarUnreadOverflow({
dmChannelIds,
highPriorityUnreadChannelIds,
previewActivityChannelIds,
scrollRef,
unreadChannelIds,
}: {
dmChannelIds: ReadonlySet<string>;
highPriorityUnreadChannelIds: ReadonlySet<string>;
previewActivityChannelIds: ReadonlySet<string>;
scrollRef: ScrollRef;
unreadChannelIds: ReadonlySet<string>;
}) {
const messageChannelIds = React.useMemo(
() => new Set([...unreadChannelIds, ...previewActivityChannelIds]),
[previewActivityChannelIds, unreadChannelIds],
);
const messageOverflow = useUnreadOverflow({
scrollRef,
unreadChannelIds: messageChannelIds,
});
return {
...messageOverflow,
unreadMessageBelowChannelIds: messageOverflow.unreadBelowChannelIds,
hasHighPriorityAbove: hasHighPriorityOverflow(
messageOverflow.unreadAboveChannelIds,
highPriorityUnreadChannelIds,
dmChannelIds,
),
hasHighPriorityBelow: hasHighPriorityOverflow(
messageOverflow.unreadBelowChannelIds,
highPriorityUnreadChannelIds,
dmChannelIds,
),
};
}

View File

@@ -20,7 +20,10 @@ import {
import { useChannelSortPreference } from "@/features/sidebar/lib/useChannelSortPreference";
import { useSidebarScrollLock } from "@/features/sidebar/lib/useSidebarScrollLock";
import { isSidebarBackgroundTarget } from "@/features/sidebar/lib/sidebarBackgroundTarget";
import { useSidebarActivityOverflow } from "@/features/sidebar/lib/useSidebarActivityOverflow";
import {
sidebarOverflowUnreadLabel,
useSidebarUnreadOverflow,
} from "@/features/sidebar/lib/useSidebarUnreadOverflow";
import {
CreateSectionDialog,
DeleteSectionAlertDialog,
@@ -38,7 +41,6 @@ import {
MoreUnreadButton,
preferredUnreadTarget,
} from "@/features/sidebar/ui/MoreUnreadButton";
import { unreadCountLabel } from "@/shared/ui/UnreadPill";
import { SidebarSection } from "@/features/sidebar/ui/SidebarSection";
import {
ChannelGroupSection,
@@ -96,6 +98,7 @@ export function AppSidebar({
selectedView,
unreadChannelCounts,
unreadChannelIds,
highPriorityUnreadChannelIds,
previewActivityChannelIds,
communities,
onAddCommunity,
@@ -151,10 +154,22 @@ export function AppSidebar({
const showSidebarUpdateCard =
canShowSidebarUpdateCard && !isSidebarUpdateCardDismissed;
const [dmActionsMenuOpen, setDmActionsMenuOpen] = React.useState(false);
const allDirectMessages = React.useMemo(
() => channels.filter((channel) => channel.channelType === "dm"),
[channels],
);
const directMessages = useProtectedVisibleDirectMessages(
allDirectMessages,
currentPubkey,
);
const dmChannelIds = React.useMemo(
() => new Set(directMessages.map(({ id }) => id)),
[directMessages],
);
const scrollRef = React.useRef<HTMLDivElement>(null);
useSidebarScrollLock(scrollRef);
// biome-ignore format: keep compact to stay within file size limit
const { scrollToChannel, scrollToNextAbove, scrollToNextBelow, unreadAboveCount, unreadBelowCount, unreadMessageBelowChannelIds, unreadAboveLabel, unreadBelowLabel } = useSidebarActivityOverflow({ activeWorkingByChannelId, previewActivityChannelIds, scrollRef, unreadChannelIds });
const { hasHighPriorityAbove, hasHighPriorityBelow, scrollToChannel, scrollToNextAbove, scrollToNextBelow, unreadAboveCount, unreadBelowCount, unreadMessageBelowChannelIds } = useSidebarUnreadOverflow({ dmChannelIds, highPriorityUnreadChannelIds, previewActivityChannelIds, scrollRef, unreadChannelIds });
React.useEffect(() => {
const scrollElement = scrollRef.current;
@@ -368,14 +383,6 @@ export function AppSidebar({
),
[channels, sortModeFor],
);
const allDirectMessages = React.useMemo(
() => channels.filter((channel) => channel.channelType === "dm"),
[channels],
);
const directMessages = useProtectedVisibleDirectMessages(
allDirectMessages,
currentPubkey,
);
const isSelectedDirectMessage =
selectedView === "channel" &&
directMessages.some((channel) => channel.id === selectedChannelId);
@@ -540,7 +547,8 @@ export function AppSidebar({
{unreadAboveCount > 0 ? (
<MoreUnreadButton
count={unreadAboveCount}
label={unreadAboveLabel ?? unreadCountLabel(unreadAboveCount)}
emphasis={hasHighPriorityAbove ? "primary" : "default"}
label={sidebarOverflowUnreadLabel(unreadAboveCount)}
onClick={scrollToNextAbove}
position="top"
testId="sidebar-more-unread-above"
@@ -600,7 +608,6 @@ export function AppSidebar({
onToggleCollapsed={() => toggleCollapsedGroup("starred")}
selectedChannelId={selectedChannelId}
title="Starred"
unreadChannelCounts={unreadChannelCounts}
unreadChannelIds={unreadChannelIds}
mutedChannelIds={mutedChannelIds}
onMuteChannel={onMuteChannel}
@@ -634,7 +641,6 @@ export function AppSidebar({
isActiveChannel={selectedView === "channel"}
activeWorkingByChannelId={activeWorkingByChannelId}
selectedChannelId={selectedChannelId}
unreadChannelCounts={unreadChannelCounts}
unreadChannelIds={unreadChannelIds}
sections={channelSections}
assignments={channelAssignments}
@@ -705,7 +711,6 @@ export function AppSidebar({
onToggleCollapsed={() => toggleCollapsedGroup("channels")}
selectedChannelId={selectedChannelId}
title="Channels"
unreadChannelCounts={unreadChannelCounts}
unreadChannelIds={unreadChannelIds}
sections={channelSections}
assignments={channelAssignments}
@@ -744,7 +749,6 @@ export function AppSidebar({
onToggleCollapsed={() => toggleCollapsedGroup("forums")}
selectedChannelId={selectedChannelId}
title="Forums"
unreadChannelCounts={unreadChannelCounts}
unreadChannelIds={unreadChannelIds}
mutedChannelIds={mutedChannelIds}
onMuteChannel={onMuteChannel}
@@ -814,7 +818,8 @@ export function AppSidebar({
bottomClassName="bottom-full"
count={unreadBelowCount}
dmPreviews={unreadDmPreviewsBelow}
label={unreadBelowLabel ?? unreadCountLabel(unreadBelowCount)}
emphasis={hasHighPriorityBelow ? "primary" : "default"}
label={sidebarOverflowUnreadLabel(unreadBelowCount)}
onClick={() =>
nextUnreadDmBelowId
? scrollToChannel(nextUnreadDmBelowId)

View File

@@ -48,6 +48,7 @@ export type AppSidebarProps = {
| "projects";
unreadChannelCounts: ReadonlyMap<string, number>;
unreadChannelIds: ReadonlySet<string>;
highPriorityUnreadChannelIds: ReadonlySet<string>;
previewActivityChannelIds: ReadonlySet<string>;
communities: Community[];
onAddCommunity: (community: Community) => void;

View File

@@ -358,7 +358,6 @@ export function ChannelGroupSection({
onSortModeChange,
actionsTestId,
title,
unreadChannelCounts,
unreadChannelIds,
sections,
assignments,
@@ -406,7 +405,6 @@ export function ChannelGroupSection({
onSortModeChange?: (mode: ChannelSortMode) => void;
actionsTestId?: string;
title: string;
unreadChannelCounts: ReadonlyMap<string, number>;
unreadChannelIds: ReadonlySet<string>;
hasUnread?: boolean;
onMarkAllRead?: () => void;
@@ -440,7 +438,6 @@ export function ChannelGroupSection({
channel={channel}
activeWorking={activeWorkingByChannelId?.get(channel.id)}
hasUnread={unreadChannelIds.has(channel.id)}
unreadCount={unreadChannelCounts.get(channel.id) ?? 0}
isMuted={mutedChannelIds?.has(channel.id)}
isActive={
isActiveChannel && selectedChannelId === channel.id
@@ -453,7 +450,6 @@ export function ChannelGroupSection({
channel={channel}
activeWorking={activeWorkingByChannelId?.get(channel.id)}
hasUnread={unreadChannelIds.has(channel.id)}
unreadCount={unreadChannelCounts.get(channel.id) ?? 0}
isMuted={mutedChannelIds?.has(channel.id)}
isActive={
isActiveChannel && selectedChannelId === channel.id
@@ -548,7 +544,6 @@ export function CustomChannelSection({
isActiveChannel,
activeWorkingByChannelId,
selectedChannelId,
unreadChannelCounts,
unreadChannelIds,
sections,
assignments,
@@ -585,7 +580,6 @@ export function CustomChannelSection({
isActiveChannel: boolean;
activeWorkingByChannelId?: ReadonlyMap<string, ActiveChannelTurnSummary>;
selectedChannelId: string | null;
unreadChannelCounts: ReadonlyMap<string, number>;
unreadChannelIds: ReadonlySet<string>;
sections: ChannelSection[];
assignments: Record<string, string>;
@@ -744,9 +738,6 @@ export function CustomChannelSection({
channel.id,
)}
hasUnread={unreadChannelIds.has(channel.id)}
unreadCount={
unreadChannelCounts.get(channel.id) ?? 0
}
isMuted={mutedChannelIds?.has(channel.id)}
isActive={
isActiveChannel &&

View File

@@ -77,17 +77,7 @@ describe("MoreUnreadButton model", () => {
position: "bottom",
targetChannelId: "dm",
}),
"Go to unread direct message from Alice. 2 new messages below.",
);
assert.equal(
unreadDmAccessibleLabel({
count: 2,
dmPreviews: [preview("dm", "Alice")],
label: "2 new activity",
position: "bottom",
targetChannelId: "dm",
}),
"Go to unread direct message from Alice. 2 new activity below.",
"Go to unread direct message from Alice. 2 unread below.",
);
assert.equal(
unreadDmAccessibleLabel({
@@ -96,7 +86,7 @@ describe("MoreUnreadButton model", () => {
position: "bottom",
targetChannelId: "near-group",
}),
"2 new messages below",
"2 unread below",
);
assert.equal(
unreadDmAccessibleLabel({
@@ -104,7 +94,7 @@ describe("MoreUnreadButton model", () => {
dmPreviews: [],
position: "top",
}),
"1 new message above",
"1 unread above",
);
});
@@ -118,6 +108,7 @@ describe("MoreUnreadButton model", () => {
preview("dm-three", "Group DM"),
preview("dm-four", "Dana"),
],
emphasis: "primary",
onClick() {},
position: "bottom",
targetChannelId: "dm-one",
@@ -126,13 +117,10 @@ describe("MoreUnreadButton model", () => {
);
assert.match(markup, /class="[^"]*overflow-hidden[^"]*"/);
assert.match(markup, /<span class="min-w-0 truncate">5 unread<\/span>/);
assert.match(
markup,
/<span class="min-w-0 truncate">5 new messages<\/span>/,
);
assert.match(
markup,
/aria-label="Go to unread direct message from Alice\. 5 new messages below\."/,
/aria-label="Go to unread direct message from Alice\. 5 unread below\."/,
);
assert.doesNotMatch(markup, />Next<\/span>/);
assert.match(markup, />·<\/span>/);

View File

@@ -1,6 +1,6 @@
import { topChromeInset } from "@/shared/layout/chromeLayout";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import { UnreadPill, unreadCountLabel } from "@/shared/ui/UnreadPill";
import { UnreadPill } from "@/shared/ui/UnreadPill";
export type UnreadDmPreview = {
accessibleLabel: string;
@@ -35,7 +35,7 @@ export function unreadDmAccessibleLabel({
targetChannelId?: string;
}) {
const direction = position === "top" ? "above" : "below";
const resolvedLabel = label ?? unreadCountLabel(count);
const resolvedLabel = label ?? `${count} unread`;
const targetPreview = dmPreviews.find(
({ channelId }) => channelId === targetChannelId,
);
@@ -58,6 +58,7 @@ export function MoreUnreadButton({
bottomClassName = "bottom-0",
count,
dmPreviews = [],
emphasis,
label,
onClick,
position,
@@ -67,6 +68,7 @@ export function MoreUnreadButton({
bottomClassName?: string;
count: number;
dmPreviews?: UnreadDmPreview[];
emphasis: "default" | "primary";
label?: string;
onClick: () => void;
position: "top" | "bottom";
@@ -76,7 +78,7 @@ export function MoreUnreadButton({
const positionClassName =
position === "top" ? topChromeInset.top : bottomClassName;
const visibleDmPreviews = visibleUnreadDmPreviews(dmPreviews);
const resolvedLabel = label ?? unreadCountLabel(count);
const resolvedLabel = label ?? `${count} unread`;
const accessibleLabel = unreadDmAccessibleLabel({
count,
dmPreviews,
@@ -91,9 +93,9 @@ export function MoreUnreadButton({
>
<UnreadPill
accessibleLabel={accessibleLabel}
className="max-w-full"
className="max-w-full overflow-hidden text-xs"
direction={position === "top" ? "up" : "down"}
emphasis="primary"
emphasis={emphasis}
label={resolvedLabel}
leading={
visibleDmPreviews.length > 0 ? (

View File

@@ -247,7 +247,6 @@ export function ChannelMenuButton({
label,
isActive,
hasUnread,
unreadCount = 0,
activeWorking,
isMuted,
dmParticipants,
@@ -258,7 +257,6 @@ export function ChannelMenuButton({
label?: string;
isActive: boolean;
hasUnread: boolean;
unreadCount?: number;
activeWorking?: ActiveChannelTurnSummary;
isMuted?: boolean;
dmParticipants?: SidebarDmParticipant[];
@@ -267,35 +265,19 @@ export function ChannelMenuButton({
}) {
const resolvedLabel = label ?? channel.name;
const ephemeralDisplay = getEphemeralChannelDisplay(channel);
const {
hasSidebarUnreadProjections,
topLevelUnreadChannelIds,
unreadThreadChannelIds,
} = useAppShell();
const hasTopLevelUnread =
channel.channelType === "dm"
? hasUnread
: hasSidebarUnreadProjections
? topLevelUnreadChannelIds.has(channel.id)
: hasUnread;
const { hasSidebarUnreadProjections, unreadThreadChannelIds } = useAppShell();
const hasThreadUnread =
channel.channelType !== "dm" &&
(hasSidebarUnreadProjections
? unreadThreadChannelIds.has(channel.id)
: hasUnread);
const showsUnreadCount =
!isActive && channel.channelType !== "dm" && unreadCount > 0;
const showsEphemeralBadge =
Boolean(ephemeralDisplay) &&
!activeWorking &&
!isMuted &&
!showsUnreadCount &&
!hasThreadUnread;
Boolean(ephemeralDisplay) && !activeWorking && !isMuted && !hasThreadUnread;
const inactiveContentOpacity = cn(
!isActive && !hasTopLevelUnread && !isMuted && "opacity-80",
!isActive && !hasUnread && !isMuted && "opacity-80",
!isActive &&
isMuted &&
!hasTopLevelUnread &&
!hasUnread &&
!hasThreadUnread &&
"sidebar-muted-content opacity-50 dark:opacity-45",
);
@@ -307,7 +289,7 @@ export function ChannelMenuButton({
isActive
? "group-hover/menu-item:bg-sidebar-active group-hover/menu-item:text-sidebar-active-foreground"
: "group-hover/menu-item:bg-sidebar-accent group-hover/menu-item:text-sidebar-foreground",
hasTopLevelUnread &&
hasUnread &&
"font-bold text-sidebar-foreground hover:text-sidebar-foreground data-[active=true]:font-bold",
)}
data-channel-id={channel.id}
@@ -373,13 +355,7 @@ export function ChannelMenuButton({
)}
/>
) : null}
{showsUnreadCount ? (
<UnreadCountBadge
channelName={channel.name}
className="ml-auto"
count={unreadCount}
/>
) : hasThreadUnread ? (
{hasThreadUnread ? (
<UnreadDotBadge channelName={channel.name} className="ml-auto" />
) : null}
</SidebarMenuButton>
@@ -502,7 +478,6 @@ export function SidebarSection({
activeWorking={activeWorkingByChannelId?.get(channel.id)}
dmParticipants={dmParticipantsByChannelId?.[channel.id]}
hasUnread={unreadChannelIds.has(channel.id)}
unreadCount={unreadChannelCounts.get(channel.id) ?? 0}
isMuted={mutedChannelIds?.has(channel.id)}
isActive={
isActiveChannel && selectedChannelId === channel.id

View File

@@ -4,10 +4,12 @@ import type { ReactNode } from "react";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
const UNREAD_PILL_CLASS =
"pointer-events-auto h-7 min-h-7 gap-1.5 rounded-full border-border/70 bg-background/95 px-2 py-1 text-2xs font-medium tracking-[0.02em] text-muted-foreground/70 shadow-xs backdrop-blur-sm hover:bg-muted/70 hover:text-foreground [&_svg]:size-4";
const PRIMARY_UNREAD_PILL_CLASS =
"pointer-events-auto h-7 min-h-7 max-w-[calc(100%_-_1rem)] overflow-hidden gap-1.5 rounded-full px-2 py-1 text-xs font-medium shadow-sm [&_svg]:size-4";
const UNREAD_PILL_COMPOSITION_CLASS =
"pointer-events-auto h-7 min-h-7 gap-1.5 rounded-full border px-2 py-1 text-2xs font-medium tracking-[0.02em] shadow-xs [&_svg]:size-4";
const DEFAULT_UNREAD_PILL_TREATMENT_CLASS =
"border-border/70 bg-background/95 text-muted-foreground/70 backdrop-blur-sm hover:bg-muted/70 hover:text-foreground";
const PRIMARY_UNREAD_PILL_TREATMENT_CLASS =
"border-primary bg-primary text-primary-foreground hover:bg-primary/90";
export function unreadCountLabel(count: number) {
return `${count} new message${count === 1 ? "" : "s"}`;
@@ -37,7 +39,10 @@ export function UnreadPill({
<Button
aria-label={accessibleLabel}
className={cn(
emphasis === "primary" ? PRIMARY_UNREAD_PILL_CLASS : UNREAD_PILL_CLASS,
UNREAD_PILL_COMPOSITION_CLASS,
emphasis === "primary"
? PRIMARY_UNREAD_PILL_TREATMENT_CLASS
: DEFAULT_UNREAD_PILL_TREATMENT_CLASS,
className,
)}
data-testid={testId}

View File

@@ -84,6 +84,25 @@ function withDotOnlyBadge(baseline: { state: string; count: number }) {
return baseline.count > 0 ? baseline : { state: "dot", count: 0 };
}
async function getUnreadPillComposition(
pill: import("@playwright/test").Locator,
) {
return pill.evaluate((element) => {
const style = getComputedStyle(element);
const icon = element.querySelector("svg")?.getBoundingClientRect();
return {
fontSize: style.fontSize,
gap: style.gap,
height: element.getBoundingClientRect().height,
iconHeight: icon?.height,
iconWidth: icon?.width,
letterSpacing: style.letterSpacing,
paddingBlock: `${style.paddingTop} ${style.paddingBottom}`,
paddingInline: `${style.paddingLeft} ${style.paddingRight}`,
};
});
}
test.beforeEach(async ({ page }) => {
await installMockBridge(page);
});
@@ -279,7 +298,7 @@ test("dark mode keeps selected labels regular and channel-level unread labels bo
});
});
test("offscreen top-level unread shows the primary sidebar arrow", async ({
test("offscreen unread counts destinations and promotes without incrementing", async ({
page,
}) => {
await page.setViewportSize({ width: 1280, height: 360 });
@@ -319,7 +338,45 @@ test("offscreen top-level unread shows the primary sidebar arrow", async ({
const activityArrow = page.getByTestId("sidebar-more-unread-above");
await expect(activityArrow).toBeVisible();
await expect(activityArrow).toContainText("1 unread");
await expect(activityArrow).not.toHaveClass(/bg-primary/);
await expect(activityArrow).toHaveCSS("font-size", "12px");
await waitForAnimations(page);
await page.screenshot({
path: `${SHOTS}/sidebar-unread-overflow-default.png`,
clip: { x: 0, y: 0, width: 320, height: 360 },
});
const defaultComposition = await getUnreadPillComposition(activityArrow);
await page.evaluate(
({ pubkey, mentionPubkey }) => {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "random",
content: "A priority mention for @tyler",
kind: 40002,
pubkey,
mentionPubkeys: [mentionPubkey],
});
},
{
pubkey: TEST_IDENTITIES.alice.pubkey,
mentionPubkey: DEFAULT_MOCK_PUBKEY,
},
);
// A second message in the same destination promotes the pill but does not
// increase the number of places awaiting review.
await expect(activityArrow).toContainText("1 unread");
await expect(activityArrow).toHaveClass(/bg-primary/);
await waitForAnimations(page);
await page.screenshot({
path: `${SHOTS}/sidebar-unread-overflow-primary.png`,
clip: { x: 0, y: 0, width: 320, height: 360 },
});
const primaryComposition = await getUnreadPillComposition(activityArrow);
expect(primaryComposition).toEqual(defaultComposition);
await activityArrow.click();
await expect(page.getByTestId("channel-random")).toBeInViewport();
await waitForAnimations(page);
@@ -357,9 +414,104 @@ test("offscreen unread DM shows the primary sidebar arrow", async ({
const activityArrow = page.getByTestId("sidebar-more-unread-below");
await expect(activityArrow).toBeVisible();
await expect(activityArrow).toContainText("1 unread");
await expect(activityArrow).toHaveClass(/bg-primary/);
});
test("thread-only activity in an offscreen DM stays primary", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-alice-tyler").click();
await waitForMockLiveSubscription(page, "alice-tyler");
await page.setViewportSize({ width: 1280, height: 360 });
const sidebarScroller = page
.getByTestId("app-sidebar")
.locator('[data-sidebar="content"]');
await sidebarScroller.evaluate((element) => {
element.scrollTop = 0;
});
await expect(page.getByTestId("channel-alice-tyler")).not.toBeInViewport();
const initialReplyAt = Math.floor(Date.now() / 1000) - 10;
const rootEventId = await page.evaluate((pubkey) => {
const root = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "alice-tyler",
content: "A DM thread I started",
kind: 40002,
pubkey,
});
return root?.id;
}, DEFAULT_MOCK_PUBKEY);
if (!rootEventId) throw new Error("Mock message emitter is unavailable");
await page.evaluate(
({ createdAt, parentEventId, pubkey }) => {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "alice-tyler",
content: "Initial DM thread reply",
createdAt,
kind: 40002,
parentEventId,
pubkey,
});
},
{
createdAt: initialReplyAt,
parentEventId: rootEventId,
pubkey: TEST_IDENTITIES.alice.pubkey,
},
);
const threadSummary = page.getByTestId("message-thread-summary").first();
await expect(threadSummary).toBeVisible();
await threadSummary.click();
await expect(page.getByTestId("message-thread-panel")).toBeVisible();
await page.getByTestId("auxiliary-panel-close").click();
const threadReadSecond = await page.evaluate(() =>
Math.floor(Date.now() / 1000),
);
await expect
.poll(() => page.evaluate(() => Math.floor(Date.now() / 1000)))
.toBeGreaterThan(threadReadSecond);
await page.evaluate(
({ parentEventId, pubkey }) => {
const reply = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "alice-tyler",
content: "A non-mention reply in the active DM thread",
kind: 40002,
parentEventId,
pubkey,
});
if (!reply) throw new Error("Mock message emitter is unavailable");
window.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__?.({
category: "activity",
channel_id: "f48efb06-0c93-5025-aac9-2e646bb6bfa8",
channel_name: "alice-tyler",
channel_type: "dm",
content: reply.content,
created_at: reply.created_at,
id: reply.id,
kind: reply.kind,
pubkey: reply.pubkey,
tags: reply.tags,
});
},
{ parentEventId: rootEventId, pubkey: TEST_IDENTITIES.alice.pubkey },
);
const activityArrow = page.getByTestId("sidebar-more-unread-below");
await expect(activityArrow).toBeVisible();
await expect(activityArrow).toContainText("1 unread");
await expect(activityArrow).toHaveClass(/bg-primary/);
await waitForAnimations(page);
await activityArrow.screenshot({
path: `${SHOTS}/sidebar-dm-thread-overflow-primary.png`,
});
});
test("regular message bolds inactive channel without numeric badge", async ({
page,
}) => {
@@ -402,16 +554,9 @@ test("regular message bolds inactive channel without numeric badge", async ({
);
});
test("top-level @mention shows an accent-colored numeric badge on its channel", async ({
test("top-level @mention bolds its channel without a trailing numeral", async ({
page,
}) => {
// The badge must follow the user's accent selection, not a fixed red.
// slack-ochin maps the generic destructive pair to white-on-white, so it
// doubles as an adversarial theme: the badge still renders the accent.
await page.addInitScript(() => {
window.localStorage.setItem("buzz-theme", "slack-ochin");
window.localStorage.setItem("buzz-accent-color", "#22c55e");
});
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
@@ -443,67 +588,18 @@ test("top-level @mention shows an accent-colored numeric badge on its channel",
"font-weight",
"700",
);
const mentionBadge = page.getByTestId("channel-unread-random");
await expect(mentionBadge).toHaveText("2 unread notifications");
await expect(mentionBadge).toHaveClass(/bg-primary/);
// The badge resolves to the applied accent (`--primary`), not a fixed hue.
const accentMatch = await mentionBadge.evaluate((element) => {
const probe = document.createElement("span");
probe.style.backgroundColor = "hsl(var(--primary))";
probe.style.color = "hsl(var(--primary-foreground))";
document.body.appendChild(probe);
const probeStyle = getComputedStyle(probe);
const badgeStyle = getComputedStyle(element);
const result = {
badgeBg: badgeStyle.backgroundColor,
accentBg: probeStyle.backgroundColor,
badgeFg: badgeStyle.color,
accentFg: probeStyle.color,
};
probe.remove();
return result;
});
expect(accentMatch.badgeBg).toBe(accentMatch.accentBg);
expect(accentMatch.badgeFg).toBe(accentMatch.accentFg);
// Selected accent (#22c55e) actually landed — the badge is green here, not red.
expect(accentMatch.badgeBg).toBe("rgb(34, 197, 94)");
const badgeContrast = await mentionBadge.evaluate((element) => {
const parseRgb = (value: string) =>
(value.match(/[\d.]+/g) ?? []).slice(0, 3).map(Number);
const luminance = (color: number[]) =>
color
.map((channel) => {
const value = channel / 255;
return value <= 0.04045
? value / 12.92
: ((value + 0.055) / 1.055) ** 2.4;
})
.reduce(
(sum, channel, index) =>
sum + channel * [0.2126, 0.7152, 0.0722][index],
0,
);
const style = getComputedStyle(element);
const foreground = luminance(parseRgb(style.color));
const background = luminance(parseRgb(style.backgroundColor));
return (
(Math.max(foreground, background) + 0.05) /
(Math.min(foreground, background) + 0.05)
);
});
expect(badgeContrast).toBeGreaterThanOrEqual(4.5);
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
await expect(page.getByTestId("channel-unread-dot-random")).toHaveCount(0);
await waitForBadgeState(page, withAdditionalBadgeCount(baselineBadge, 2));
});
test("@mention inside a thread shows the numeric badge and keeps hover-to-preview", async ({
test("@mention inside a thread bolds the room and keeps hover-to-preview", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "random");
const baselineBadge = await getSettledBadgeState(page);
const rootEventId = await page.evaluate(() => {
const root = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
@@ -533,13 +629,14 @@ test("@mention inside a thread shows the numeric badge and keeps hover-to-previe
},
);
// The threaded mention must produce the numeric badge, not just the dot.
const mentionBadge = page.getByTestId("channel-unread-random");
await expect(mentionBadge).toHaveText("1 unread notification");
await expect(page.getByTestId("channel-unread-dot-random")).toHaveCount(0);
await expect(page.getByTestId("channel-random")).toHaveCSS(
"font-weight",
"700",
);
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
await expect(page.getByTestId("channel-unread-dot-random")).toBeVisible();
// Hover-to-preview must survive the numeral replacing the dot: the channel
// activity popover still opens and lists the mentioning reply.
// Hover-to-preview is owned by the thread dot, not a trailing numeral.
await page.getByTestId("channel-random").hover();
const popover = page.getByTestId("channel-activity-popover-random");
await expect(popover).toBeVisible();
@@ -609,7 +706,7 @@ test("interested thread reply shows the channel preview dot without incrementing
await waitForBadgeState(page, baselineBadge);
});
test("broadcast reply shows a numeric channel badge without a thread dot", async ({
test("broadcast reply bolds its channel without a trailing numeral", async ({
page,
}) => {
await page.goto("/");
@@ -638,10 +735,7 @@ test("broadcast reply shows a numeric channel badge without a thread dot", async
"font-weight",
"700",
);
await expect(page.getByTestId("channel-unread-random")).toHaveText(
"1 unread notification",
);
await expect(page.getByTestId("channel-unread-dot-random")).toHaveCount(0);
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
await waitForBadgeState(page, withAdditionalBadgeCount(baselineBadge, 1));
});

View File

@@ -244,11 +244,14 @@ async function seedChannelActivity(
);
}
// The seeded activity includes an in-thread @mention, so the row shows the
// numeric mention badge rather than the plain thread-activity dot. The
// hover preview popover must still work either way.
await expect(page.getByTestId("channel-unread-general")).toBeVisible();
await expect(page.getByTestId("channel-unread-dot-general")).toHaveCount(0);
// Thread activity owns the trailing row affordance; mentions additionally
// bold the channel name but do not add a numeric badge.
await expect(page.getByTestId("channel-general")).toHaveCSS(
"font-weight",
"700",
);
await expect(page.getByTestId("channel-unread-general")).toHaveCount(0);
await expect(page.getByTestId("channel-unread-dot-general")).toBeVisible();
if (includeAgent) {
await expect(page.getByTestId("channel-working-general")).toBeVisible();
}

View File

@@ -1657,10 +1657,16 @@ test("create ephemeral stream shows sidebar and header affordances", async ({
},
);
await expect(page.getByTestId(`channel-unread-${channelName}`)).toBeVisible();
await expect(page.getByTestId(`channel-${channelName}`)).toHaveCSS(
"font-weight",
"700",
);
await expect(page.getByTestId(`channel-unread-${channelName}`)).toHaveCount(
0,
);
await expect(
page.getByTestId(`channel-ephemeral-${channelName}`),
).toHaveCount(0);
).toBeVisible();
});
test("ephemeral countdown refreshes when switching channels after a clock jump", async ({

View File

@@ -778,12 +778,16 @@ test.describe("thread unread indicator", () => {
mentionPubkeys: [SELF_PUBKEY],
createdAt: unreadTimestamp(),
});
// The reply mentions the user, so the row shows the numeric mention badge
// (which subsumes the thread-activity dot).
await expect(page.getByTestId("channel-unread-all-replies")).toBeVisible();
// A thread reply keeps the channel bold and retains the thread-activity dot;
// the room itself does not show a numeric badge.
await expect(page.getByTestId("channel-all-replies")).toHaveCSS(
"font-weight",
"700",
);
await expect(page.getByTestId("channel-unread-all-replies")).toHaveCount(0);
await expect(
page.getByTestId("channel-unread-dot-all-replies"),
).toHaveCount(0);
).toBeVisible();
// View all-replies while the reply is unread.
await page.getByTestId("channel-all-replies").click();
@@ -793,7 +797,13 @@ test.describe("thread unread indicator", () => {
// a channel sidebar unread indicator until the thread itself is read.
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await expect(page.getByTestId("channel-unread-all-replies")).toBeVisible();
await expect(page.getByTestId("channel-all-replies")).toHaveCSS(
"font-weight",
"700",
);
await expect(
page.getByTestId("channel-unread-dot-all-replies"),
).toBeVisible();
});
// Regression guard for BUG-2 (clear-on-read): opening an unread thread marks