From 5e674b24f5a64e9722c339ff9fc751fec35b20b4 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Mon, 31 Aug 2026 11:23:10 -0700 Subject: [PATCH 1/9] fix(sidebar): prioritize actionable overflow activity Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz> Co-authored-by: Carl Signed-off-by: Taylor Ho --- desktop/src/app/AppShell.tsx | 1 + .../useOffscreenActivityChannelIds.test.mjs | 37 ++++++- .../sidebar/lib/useSidebarActivityOverflow.ts | 56 ++++++++++- .../src/features/sidebar/ui/AppSidebar.tsx | 16 ++- .../features/sidebar/ui/AppSidebar.types.ts | 1 + .../sidebar/ui/MoreUnreadButton.test.mjs | 14 +++ .../features/sidebar/ui/MoreUnreadButton.tsx | 99 ++++++++++++------- 7 files changed, 186 insertions(+), 38 deletions(-) diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 071cc3b1803..b4c2039023e 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -918,6 +918,7 @@ export function AppShell() { selectedChannelId={selectedChannelId} selectedView={selectedView} unreadChannelIds={unreadChannelIds} + {...{ highPriorityUnreadChannelIds }} previewActivityChannelIds={unreadThreadChannelIds} unreadChannelCounts={unreadChannelCounts} mutedChannelIds={mutedChannelIds} diff --git a/desktop/src/features/sidebar/lib/useOffscreenActivityChannelIds.test.mjs b/desktop/src/features/sidebar/lib/useOffscreenActivityChannelIds.test.mjs index 34f696aa515..94e1cf911b0 100644 --- a/desktop/src/features/sidebar/lib/useOffscreenActivityChannelIds.test.mjs +++ b/desktop/src/features/sidebar/lib/useOffscreenActivityChannelIds.test.mjs @@ -2,7 +2,11 @@ import assert from "node:assert/strict"; import test from "node:test"; import { getOffscreenActivityChannelIds } from "./useOffscreenActivityChannelIds.ts"; -import { getSidebarActivityOverflowLabel } from "./useSidebarActivityOverflow.ts"; +import { + getOffscreenMentionCount, + getSidebarActivityOverflowLabel, + hasHighPriorityOverflow, +} from "./useSidebarActivityOverflow.ts"; test("keeps every unread channel navigable while adding working activity", () => { const activity = getOffscreenActivityChannelIds({ @@ -50,3 +54,34 @@ test("uses an activity-neutral overflow label when work contributes", () => { undefined, ); }); + +test("counts offscreen mentions without treating unread DMs as mentions", () => { + const highPriority = new Set(["dm", "mention", "mention-without-count"]); + const dmChannels = new Set(["dm"]); + const counts = new Map([ + ["dm", 3], + ["mention", 2], + ["channel", 8], + ]); + + assert.equal( + getOffscreenMentionCount( + ["dm", "mention", "mention-without-count", "channel"], + dmChannels, + highPriority, + counts, + ), + 2, + ); +}); + +test("promotes only when the offscreen set includes actionable unread", () => { + const actionable = new Set(["dm", "mention"]); + + assert.equal( + hasHighPriorityOverflow(["channel", "working"], actionable), + false, + ); + assert.equal(hasHighPriorityOverflow(["channel", "dm"], actionable), true); + assert.equal(hasHighPriorityOverflow(["mention"], actionable), true); +}); diff --git a/desktop/src/features/sidebar/lib/useSidebarActivityOverflow.ts b/desktop/src/features/sidebar/lib/useSidebarActivityOverflow.ts index 2dac012bca4..5a500d9234e 100644 --- a/desktop/src/features/sidebar/lib/useSidebarActivityOverflow.ts +++ b/desktop/src/features/sidebar/lib/useSidebarActivityOverflow.ts @@ -16,10 +16,44 @@ export function getSidebarActivityOverflowLabel({ : `${activityCount} new activity`; } +export function hasHighPriorityOverflow( + offscreenChannelIds: readonly string[], + highPriorityUnreadChannelIds: ReadonlySet, +) { + return offscreenChannelIds.some((channelId) => + highPriorityUnreadChannelIds.has(channelId), + ); +} + +export function getOffscreenMentionCount( + offscreenChannelIds: readonly string[], + dmChannelIds: ReadonlySet, + highPriorityUnreadChannelIds: ReadonlySet, + unreadChannelCounts: ReadonlyMap, +) { + return offscreenChannelIds.reduce( + (count, channelId) => + count + + (!dmChannelIds.has(channelId) && + highPriorityUnreadChannelIds.has(channelId) + ? (unreadChannelCounts.get(channelId) ?? 0) + : 0), + 0, + ); +} + export function useSidebarActivityOverflow({ + dmChannelIds, + highPriorityUnreadChannelIds, scrollRef, + unreadChannelCounts, ...activityOptions -}: ActivityOptions & { scrollRef: ScrollRef }) { +}: ActivityOptions & { + dmChannelIds: ReadonlySet; + highPriorityUnreadChannelIds: ReadonlySet; + scrollRef: ScrollRef; + unreadChannelCounts: ReadonlyMap; +}) { const { channelIds, messageChannelIds } = useOffscreenActivityChannelIds(activityOptions); const activityOverflow = useUnreadOverflow({ @@ -35,6 +69,26 @@ export function useSidebarActivityOverflow({ ...activityOverflow, unreadMessageAboveChannelIds: messageOverflow.unreadAboveChannelIds, unreadMessageBelowChannelIds: messageOverflow.unreadBelowChannelIds, + hasHighPriorityAbove: hasHighPriorityOverflow( + messageOverflow.unreadAboveChannelIds, + highPriorityUnreadChannelIds, + ), + hasHighPriorityBelow: hasHighPriorityOverflow( + messageOverflow.unreadBelowChannelIds, + highPriorityUnreadChannelIds, + ), + mentionAboveCount: getOffscreenMentionCount( + messageOverflow.unreadAboveChannelIds, + dmChannelIds, + highPriorityUnreadChannelIds, + unreadChannelCounts, + ), + mentionBelowCount: getOffscreenMentionCount( + messageOverflow.unreadBelowChannelIds, + dmChannelIds, + highPriorityUnreadChannelIds, + unreadChannelCounts, + ), unreadAboveLabel: getSidebarActivityOverflowLabel({ activityCount: activityOverflow.unreadAboveCount, messageCount: messageOverflow.unreadAboveCount, diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 647ed632644..f10d4ea95b9 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -96,6 +96,7 @@ export function AppSidebar({ selectedView, unreadChannelCounts, unreadChannelIds, + highPriorityUnreadChannelIds, previewActivityChannelIds, communities, onAddCommunity, @@ -153,8 +154,17 @@ export function AppSidebar({ const [dmActionsMenuOpen, setDmActionsMenuOpen] = React.useState(false); const scrollRef = React.useRef(null); useSidebarScrollLock(scrollRef); + const dmChannelIds = React.useMemo( + () => + new Set( + channels + .filter((channel) => channel.channelType === "dm") + .map((channel) => channel.id), + ), + [channels], + ); // 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, mentionAboveCount, mentionBelowCount, scrollToChannel, scrollToNextAbove, scrollToNextBelow, unreadAboveCount, unreadBelowCount, unreadMessageBelowChannelIds, unreadAboveLabel, unreadBelowLabel } = useSidebarActivityOverflow({ activeWorkingByChannelId, dmChannelIds, highPriorityUnreadChannelIds, previewActivityChannelIds, scrollRef, unreadChannelCounts, unreadChannelIds }); React.useEffect(() => { const scrollElement = scrollRef.current; @@ -540,7 +550,9 @@ export function AppSidebar({ {unreadAboveCount > 0 ? ( nextUnreadDmBelowId ? scrollToChannel(nextUnreadDmBelowId) diff --git a/desktop/src/features/sidebar/ui/AppSidebar.types.ts b/desktop/src/features/sidebar/ui/AppSidebar.types.ts index 43eb094b3a4..d884c6d5536 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.types.ts +++ b/desktop/src/features/sidebar/ui/AppSidebar.types.ts @@ -48,6 +48,7 @@ export type AppSidebarProps = { | "projects"; unreadChannelCounts: ReadonlyMap; unreadChannelIds: ReadonlySet; + highPriorityUnreadChannelIds: ReadonlySet; previewActivityChannelIds: ReadonlySet; communities: Community[]; onAddCommunity: (community: Community) => void; diff --git a/desktop/src/features/sidebar/ui/MoreUnreadButton.test.mjs b/desktop/src/features/sidebar/ui/MoreUnreadButton.test.mjs index 45b14c31ff2..86e83c2f56f 100644 --- a/desktop/src/features/sidebar/ui/MoreUnreadButton.test.mjs +++ b/desktop/src/features/sidebar/ui/MoreUnreadButton.test.mjs @@ -7,6 +7,7 @@ import { canPreviewUnreadDm, MoreUnreadButton, preferredUnreadTarget, + sidebarOverflowTooltipLabel, unreadDmAccessibleLabel, visibleUnreadDmPreviews, } from "./MoreUnreadButton.tsx"; @@ -118,6 +119,7 @@ describe("MoreUnreadButton model", () => { preview("dm-three", "Group DM"), preview("dm-four", "Dana"), ], + emphasis: "primary", onClick() {}, position: "bottom", targetChannelId: "dm-one", @@ -156,3 +158,15 @@ describe("MoreUnreadButton model", () => { ); }); }); + +describe("sidebar overflow tooltip", () => { + it("labels unread overflow without changing its unit", () => { + assert.equal(sidebarOverflowTooltipLabel(1, 0), "1 unread"); + assert.equal(sidebarOverflowTooltipLabel(13, 0), "13 unreads"); + }); + + it("adds a concise singular or plural mention breakdown", () => { + assert.equal(sidebarOverflowTooltipLabel(13, 1), "13 unreads (1 mention)"); + assert.equal(sidebarOverflowTooltipLabel(13, 2), "13 unreads (2 mentions)"); + }); +}); diff --git a/desktop/src/features/sidebar/ui/MoreUnreadButton.tsx b/desktop/src/features/sidebar/ui/MoreUnreadButton.tsx index 581f2e59868..7ecf4d00ed5 100644 --- a/desktop/src/features/sidebar/ui/MoreUnreadButton.tsx +++ b/desktop/src/features/sidebar/ui/MoreUnreadButton.tsx @@ -1,6 +1,12 @@ import { topChromeInset } from "@/shared/layout/chromeLayout"; import { UserAvatar } from "@/shared/ui/UserAvatar"; import { UnreadPill, unreadCountLabel } from "@/shared/ui/UnreadPill"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/shared/ui/tooltip"; export type UnreadDmPreview = { accessibleLabel: string; @@ -54,11 +60,24 @@ export function preferredUnreadTarget( ); } +export function sidebarOverflowTooltipLabel( + unreadCount: number, + mentionCount: number, +) { + return `${unreadCount} unread${unreadCount === 1 ? "" : "s"}${ + mentionCount > 0 + ? ` (${mentionCount} mention${mentionCount === 1 ? "" : "s"})` + : "" + }`; +} + export function MoreUnreadButton({ bottomClassName = "bottom-0", count, dmPreviews = [], + emphasis, label, + mentionCount = 0, onClick, position, targetChannelId, @@ -67,7 +86,9 @@ export function MoreUnreadButton({ bottomClassName?: string; count: number; dmPreviews?: UnreadDmPreview[]; + emphasis: "default" | "primary"; label?: string; + mentionCount?: number; onClick: () => void; position: "top" | "bottom"; targetChannelId?: string; @@ -77,6 +98,7 @@ export function MoreUnreadButton({ position === "top" ? topChromeInset.top : bottomClassName; const visibleDmPreviews = visibleUnreadDmPreviews(dmPreviews); const resolvedLabel = label ?? unreadCountLabel(count); + const tooltipLabel = sidebarOverflowTooltipLabel(count, mentionCount); const accessibleLabel = unreadDmAccessibleLabel({ count, dmPreviews, @@ -89,44 +111,51 @@ export function MoreUnreadButton({
- 0 ? ( - - ) : undefined - } - onClick={onClick} - testId={testId} - /> + ) : undefined + } + onClick={onClick} + testId={testId} + /> + + {tooltipLabel} + +
); } From 22e1f1de3c4ae320568dafeb2493c23a40562f71 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Mon, 31 Aug 2026 12:04:11 -0700 Subject: [PATCH 2/9] fix(sidebar): keep overflow status in one indicator Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz> Signed-off-by: Taylor Ho --- .../useOffscreenActivityChannelIds.test.mjs | 21 ---- .../sidebar/lib/useSidebarActivityOverflow.ts | 33 ------- .../src/features/sidebar/ui/AppSidebar.tsx | 13 +-- .../sidebar/ui/MoreUnreadButton.test.mjs | 13 --- .../features/sidebar/ui/MoreUnreadButton.tsx | 97 +++++++------------ 5 files changed, 36 insertions(+), 141 deletions(-) diff --git a/desktop/src/features/sidebar/lib/useOffscreenActivityChannelIds.test.mjs b/desktop/src/features/sidebar/lib/useOffscreenActivityChannelIds.test.mjs index 94e1cf911b0..4408789f15c 100644 --- a/desktop/src/features/sidebar/lib/useOffscreenActivityChannelIds.test.mjs +++ b/desktop/src/features/sidebar/lib/useOffscreenActivityChannelIds.test.mjs @@ -3,7 +3,6 @@ import test from "node:test"; import { getOffscreenActivityChannelIds } from "./useOffscreenActivityChannelIds.ts"; import { - getOffscreenMentionCount, getSidebarActivityOverflowLabel, hasHighPriorityOverflow, } from "./useSidebarActivityOverflow.ts"; @@ -55,26 +54,6 @@ test("uses an activity-neutral overflow label when work contributes", () => { ); }); -test("counts offscreen mentions without treating unread DMs as mentions", () => { - const highPriority = new Set(["dm", "mention", "mention-without-count"]); - const dmChannels = new Set(["dm"]); - const counts = new Map([ - ["dm", 3], - ["mention", 2], - ["channel", 8], - ]); - - assert.equal( - getOffscreenMentionCount( - ["dm", "mention", "mention-without-count", "channel"], - dmChannels, - highPriority, - counts, - ), - 2, - ); -}); - test("promotes only when the offscreen set includes actionable unread", () => { const actionable = new Set(["dm", "mention"]); diff --git a/desktop/src/features/sidebar/lib/useSidebarActivityOverflow.ts b/desktop/src/features/sidebar/lib/useSidebarActivityOverflow.ts index 5a500d9234e..cf70ca3b429 100644 --- a/desktop/src/features/sidebar/lib/useSidebarActivityOverflow.ts +++ b/desktop/src/features/sidebar/lib/useSidebarActivityOverflow.ts @@ -25,34 +25,13 @@ export function hasHighPriorityOverflow( ); } -export function getOffscreenMentionCount( - offscreenChannelIds: readonly string[], - dmChannelIds: ReadonlySet, - highPriorityUnreadChannelIds: ReadonlySet, - unreadChannelCounts: ReadonlyMap, -) { - return offscreenChannelIds.reduce( - (count, channelId) => - count + - (!dmChannelIds.has(channelId) && - highPriorityUnreadChannelIds.has(channelId) - ? (unreadChannelCounts.get(channelId) ?? 0) - : 0), - 0, - ); -} - export function useSidebarActivityOverflow({ - dmChannelIds, highPriorityUnreadChannelIds, scrollRef, - unreadChannelCounts, ...activityOptions }: ActivityOptions & { - dmChannelIds: ReadonlySet; highPriorityUnreadChannelIds: ReadonlySet; scrollRef: ScrollRef; - unreadChannelCounts: ReadonlyMap; }) { const { channelIds, messageChannelIds } = useOffscreenActivityChannelIds(activityOptions); @@ -77,18 +56,6 @@ export function useSidebarActivityOverflow({ messageOverflow.unreadBelowChannelIds, highPriorityUnreadChannelIds, ), - mentionAboveCount: getOffscreenMentionCount( - messageOverflow.unreadAboveChannelIds, - dmChannelIds, - highPriorityUnreadChannelIds, - unreadChannelCounts, - ), - mentionBelowCount: getOffscreenMentionCount( - messageOverflow.unreadBelowChannelIds, - dmChannelIds, - highPriorityUnreadChannelIds, - unreadChannelCounts, - ), unreadAboveLabel: getSidebarActivityOverflowLabel({ activityCount: activityOverflow.unreadAboveCount, messageCount: messageOverflow.unreadAboveCount, diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index f10d4ea95b9..2eca30d66df 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -154,17 +154,8 @@ export function AppSidebar({ const [dmActionsMenuOpen, setDmActionsMenuOpen] = React.useState(false); const scrollRef = React.useRef(null); useSidebarScrollLock(scrollRef); - const dmChannelIds = React.useMemo( - () => - new Set( - channels - .filter((channel) => channel.channelType === "dm") - .map((channel) => channel.id), - ), - [channels], - ); // biome-ignore format: keep compact to stay within file size limit - const { hasHighPriorityAbove, hasHighPriorityBelow, mentionAboveCount, mentionBelowCount, scrollToChannel, scrollToNextAbove, scrollToNextBelow, unreadAboveCount, unreadBelowCount, unreadMessageBelowChannelIds, unreadAboveLabel, unreadBelowLabel } = useSidebarActivityOverflow({ activeWorkingByChannelId, dmChannelIds, highPriorityUnreadChannelIds, previewActivityChannelIds, scrollRef, unreadChannelCounts, unreadChannelIds }); + const { hasHighPriorityAbove, hasHighPriorityBelow, scrollToChannel, scrollToNextAbove, scrollToNextBelow, unreadAboveCount, unreadBelowCount, unreadMessageBelowChannelIds, unreadAboveLabel, unreadBelowLabel } = useSidebarActivityOverflow({ activeWorkingByChannelId, highPriorityUnreadChannelIds, previewActivityChannelIds, scrollRef, unreadChannelIds }); React.useEffect(() => { const scrollElement = scrollRef.current; @@ -552,7 +543,6 @@ export function AppSidebar({ count={unreadAboveCount} emphasis={hasHighPriorityAbove ? "primary" : "default"} label={unreadAboveLabel ?? unreadCountLabel(unreadAboveCount)} - mentionCount={mentionAboveCount} onClick={scrollToNextAbove} position="top" testId="sidebar-more-unread-above" @@ -828,7 +818,6 @@ export function AppSidebar({ dmPreviews={unreadDmPreviewsBelow} emphasis={hasHighPriorityBelow ? "primary" : "default"} label={unreadBelowLabel ?? unreadCountLabel(unreadBelowCount)} - mentionCount={mentionBelowCount} onClick={() => nextUnreadDmBelowId ? scrollToChannel(nextUnreadDmBelowId) diff --git a/desktop/src/features/sidebar/ui/MoreUnreadButton.test.mjs b/desktop/src/features/sidebar/ui/MoreUnreadButton.test.mjs index 86e83c2f56f..0dd603cd16c 100644 --- a/desktop/src/features/sidebar/ui/MoreUnreadButton.test.mjs +++ b/desktop/src/features/sidebar/ui/MoreUnreadButton.test.mjs @@ -7,7 +7,6 @@ import { canPreviewUnreadDm, MoreUnreadButton, preferredUnreadTarget, - sidebarOverflowTooltipLabel, unreadDmAccessibleLabel, visibleUnreadDmPreviews, } from "./MoreUnreadButton.tsx"; @@ -158,15 +157,3 @@ describe("MoreUnreadButton model", () => { ); }); }); - -describe("sidebar overflow tooltip", () => { - it("labels unread overflow without changing its unit", () => { - assert.equal(sidebarOverflowTooltipLabel(1, 0), "1 unread"); - assert.equal(sidebarOverflowTooltipLabel(13, 0), "13 unreads"); - }); - - it("adds a concise singular or plural mention breakdown", () => { - assert.equal(sidebarOverflowTooltipLabel(13, 1), "13 unreads (1 mention)"); - assert.equal(sidebarOverflowTooltipLabel(13, 2), "13 unreads (2 mentions)"); - }); -}); diff --git a/desktop/src/features/sidebar/ui/MoreUnreadButton.tsx b/desktop/src/features/sidebar/ui/MoreUnreadButton.tsx index 7ecf4d00ed5..b97788aad4f 100644 --- a/desktop/src/features/sidebar/ui/MoreUnreadButton.tsx +++ b/desktop/src/features/sidebar/ui/MoreUnreadButton.tsx @@ -1,12 +1,6 @@ import { topChromeInset } from "@/shared/layout/chromeLayout"; import { UserAvatar } from "@/shared/ui/UserAvatar"; import { UnreadPill, unreadCountLabel } from "@/shared/ui/UnreadPill"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/shared/ui/tooltip"; export type UnreadDmPreview = { accessibleLabel: string; @@ -60,24 +54,12 @@ export function preferredUnreadTarget( ); } -export function sidebarOverflowTooltipLabel( - unreadCount: number, - mentionCount: number, -) { - return `${unreadCount} unread${unreadCount === 1 ? "" : "s"}${ - mentionCount > 0 - ? ` (${mentionCount} mention${mentionCount === 1 ? "" : "s"})` - : "" - }`; -} - export function MoreUnreadButton({ bottomClassName = "bottom-0", count, dmPreviews = [], emphasis, label, - mentionCount = 0, onClick, position, targetChannelId, @@ -88,7 +70,6 @@ export function MoreUnreadButton({ dmPreviews?: UnreadDmPreview[]; emphasis: "default" | "primary"; label?: string; - mentionCount?: number; onClick: () => void; position: "top" | "bottom"; targetChannelId?: string; @@ -98,7 +79,6 @@ export function MoreUnreadButton({ position === "top" ? topChromeInset.top : bottomClassName; const visibleDmPreviews = visibleUnreadDmPreviews(dmPreviews); const resolvedLabel = label ?? unreadCountLabel(count); - const tooltipLabel = sidebarOverflowTooltipLabel(count, mentionCount); const accessibleLabel = unreadDmAccessibleLabel({ count, dmPreviews, @@ -111,51 +91,44 @@ export function MoreUnreadButton({
- - - - 0 ? ( + 0 ? ( + - {tooltipLabel} - - + ))} + + · + + ) : undefined + } + onClick={onClick} + testId={testId} + />
); } From 80dba617625df9d9f785dadcca56f439ab74a86a Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Mon, 31 Aug 2026 13:14:04 -0700 Subject: [PATCH 3/9] test(sidebar): expect quiet channel overflow Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz> Signed-off-by: Taylor Ho --- desktop/tests/e2e/badge.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/desktop/tests/e2e/badge.spec.ts b/desktop/tests/e2e/badge.spec.ts index 5875cf555b4..73bd2664782 100644 --- a/desktop/tests/e2e/badge.spec.ts +++ b/desktop/tests/e2e/badge.spec.ts @@ -279,7 +279,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 top-level unread shows the secondary sidebar arrow", async ({ page, }) => { await page.setViewportSize({ width: 1280, height: 360 }); @@ -319,7 +319,7 @@ 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).toHaveClass(/bg-primary/); + await expect(activityArrow).not.toHaveClass(/bg-primary/); await activityArrow.click(); await expect(page.getByTestId("channel-random")).toBeInViewport(); await waitForAnimations(page); From 51be39f5a26400d91e59fd9d5a83a13dea565882 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 2 Sep 2026 15:49:20 -0700 Subject: [PATCH 4/9] fix(sidebar): align overflow with unread messages Remove agent-only overflow activity, count all unread messages, and reserve primary emphasis for DMs, mentions, broadcasts, and relevant thread replies. Bold unread rooms and remove non-DM row counts while preserving thread previews. Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz> Signed-off-by: Taylor Ho --- desktop/src-tauri/src/unread_catch_up.rs | 5 +- .../features/channels/useUnreadChannels.ts | 28 ++----- .../useOffscreenActivityChannelIds.test.mjs | 66 --------------- .../lib/useOffscreenActivityChannelIds.ts | 53 ------------ .../sidebar/lib/useSidebarActivityOverflow.ts | 68 --------------- .../lib/useSidebarUnreadOverflow.test.mjs | 33 ++++++++ .../sidebar/lib/useSidebarUnreadOverflow.ts | 72 ++++++++++++++++ .../src/features/sidebar/ui/AppSidebar.tsx | 16 ++-- .../sidebar/ui/CustomChannelSection.tsx | 9 -- .../sidebar/ui/MoreUnreadButton.test.mjs | 23 ++--- .../features/sidebar/ui/MoreUnreadButton.tsx | 6 +- .../features/sidebar/ui/SidebarSection.tsx | 37 ++------ desktop/tests/e2e/badge.spec.ts | 84 ++++--------------- .../e2e/channel-activity-popover.spec.ts | 13 +-- desktop/tests/e2e/channels.spec.ts | 10 ++- desktop/tests/e2e/thread-unread.spec.ts | 20 +++-- 16 files changed, 181 insertions(+), 362 deletions(-) delete mode 100644 desktop/src/features/sidebar/lib/useOffscreenActivityChannelIds.test.mjs delete mode 100644 desktop/src/features/sidebar/lib/useOffscreenActivityChannelIds.ts delete mode 100644 desktop/src/features/sidebar/lib/useSidebarActivityOverflow.ts create mode 100644 desktop/src/features/sidebar/lib/useSidebarUnreadOverflow.test.mjs create mode 100644 desktop/src/features/sidebar/lib/useSidebarUnreadOverflow.ts diff --git a/desktop/src-tauri/src/unread_catch_up.rs b/desktop/src-tauri/src/unread_catch_up.rs index f8609ef1f60..96a740638b9 100644 --- a/desktop/src-tauri/src/unread_catch_up.rs +++ b/desktop/src-tauri/src/unread_catch_up.rs @@ -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::>(), - ["external-reply"] + [("external-reply", true)] ); assert_eq!(discovered.participated, ["root"]); } diff --git a/desktop/src/features/channels/useUnreadChannels.ts b/desktop/src/features/channels/useUnreadChannels.ts index ceab544d8cb..9e224670622 100644 --- a/desktop/src/features/channels/useUnreadChannels.ts +++ b/desktop/src/features/channels/useUnreadChannels.ts @@ -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 isThreadedReply = + getThreadReference(event.tags).parentId !== null && + !isBroadcastReply(event.tags); const isHighPriority = channel?.channelType === "dm" || + isThreadedReply || (normalizedPubkey !== null && isHighPriorityEventForUser(event, normalizedPubkey)); - const isThreadedReply = - getThreadReference(event.tags).parentId !== null && - !isBroadcastReply(event.tags); 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); } diff --git a/desktop/src/features/sidebar/lib/useOffscreenActivityChannelIds.test.mjs b/desktop/src/features/sidebar/lib/useOffscreenActivityChannelIds.test.mjs deleted file mode 100644 index 4408789f15c..00000000000 --- a/desktop/src/features/sidebar/lib/useOffscreenActivityChannelIds.test.mjs +++ /dev/null @@ -1,66 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { getOffscreenActivityChannelIds } from "./useOffscreenActivityChannelIds.ts"; -import { - getSidebarActivityOverflowLabel, - hasHighPriorityOverflow, -} 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, - ); -}); - -test("promotes only when the offscreen set includes actionable unread", () => { - const actionable = new Set(["dm", "mention"]); - - assert.equal( - hasHighPriorityOverflow(["channel", "working"], actionable), - false, - ); - assert.equal(hasHighPriorityOverflow(["channel", "dm"], actionable), true); - assert.equal(hasHighPriorityOverflow(["mention"], actionable), true); -}); diff --git a/desktop/src/features/sidebar/lib/useOffscreenActivityChannelIds.ts b/desktop/src/features/sidebar/lib/useOffscreenActivityChannelIds.ts deleted file mode 100644 index aef953efffe..00000000000 --- a/desktop/src/features/sidebar/lib/useOffscreenActivityChannelIds.ts +++ /dev/null @@ -1,53 +0,0 @@ -import * as React from "react"; - -type OffscreenActivityChannelIds = { - messageChannelIds: ReadonlySet; - channelIds: ReadonlySet; -}; - -export function getOffscreenActivityChannelIds({ - activeWorkingByChannelId, - previewActivityChannelIds, - unreadChannelIds, -}: { - activeWorkingByChannelId: ReadonlyMap; - previewActivityChannelIds: ReadonlySet; - unreadChannelIds: ReadonlySet; -}): 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; - previewActivityChannelIds: ReadonlySet; - unreadChannelIds: ReadonlySet; -}) { - const { - activeWorkingByChannelId, - previewActivityChannelIds, - unreadChannelIds, - } = args; - - return React.useMemo( - () => - getOffscreenActivityChannelIds({ - activeWorkingByChannelId, - previewActivityChannelIds, - unreadChannelIds, - }), - [activeWorkingByChannelId, previewActivityChannelIds, unreadChannelIds], - ); -} diff --git a/desktop/src/features/sidebar/lib/useSidebarActivityOverflow.ts b/desktop/src/features/sidebar/lib/useSidebarActivityOverflow.ts deleted file mode 100644 index cf70ca3b429..00000000000 --- a/desktop/src/features/sidebar/lib/useSidebarActivityOverflow.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { useOffscreenActivityChannelIds } from "@/features/sidebar/lib/useOffscreenActivityChannelIds"; -import { useUnreadOverflow } from "@/features/sidebar/lib/useUnreadOverflow"; - -type ActivityOptions = Parameters[0]; -type ScrollRef = Parameters[0]["scrollRef"]; - -export function getSidebarActivityOverflowLabel({ - activityCount, - messageCount, -}: { - activityCount: number; - messageCount: number; -}) { - return activityCount === messageCount - ? undefined - : `${activityCount} new activity`; -} - -export function hasHighPriorityOverflow( - offscreenChannelIds: readonly string[], - highPriorityUnreadChannelIds: ReadonlySet, -) { - return offscreenChannelIds.some((channelId) => - highPriorityUnreadChannelIds.has(channelId), - ); -} - -export function useSidebarActivityOverflow({ - highPriorityUnreadChannelIds, - scrollRef, - ...activityOptions -}: ActivityOptions & { - highPriorityUnreadChannelIds: ReadonlySet; - 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, - hasHighPriorityAbove: hasHighPriorityOverflow( - messageOverflow.unreadAboveChannelIds, - highPriorityUnreadChannelIds, - ), - hasHighPriorityBelow: hasHighPriorityOverflow( - messageOverflow.unreadBelowChannelIds, - highPriorityUnreadChannelIds, - ), - unreadAboveLabel: getSidebarActivityOverflowLabel({ - activityCount: activityOverflow.unreadAboveCount, - messageCount: messageOverflow.unreadAboveCount, - }), - unreadBelowLabel: getSidebarActivityOverflowLabel({ - activityCount: activityOverflow.unreadBelowCount, - messageCount: messageOverflow.unreadBelowCount, - }), - }; -} diff --git a/desktop/src/features/sidebar/lib/useSidebarUnreadOverflow.test.mjs b/desktop/src/features/sidebar/lib/useSidebarUnreadOverflow.test.mjs new file mode 100644 index 00000000000..3c751c69b80 --- /dev/null +++ b/desktop/src/features/sidebar/lib/useSidebarUnreadOverflow.test.mjs @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + countOffscreenUnreadMessages, + hasHighPriorityOverflow, + sidebarOverflowUnreadLabel, +} from "./useSidebarUnreadOverflow.ts"; + +test("counts unread messages across offscreen channels", () => { + assert.equal( + countOffscreenUnreadMessages( + ["ordinary", "mention", "manual"], + new Map([ + ["ordinary", 10], + ["mention", 1], + ]), + ), + 12, + ); +}); + +test("labels the stable total as unread", () => { + assert.equal(sidebarOverflowUnreadLabel(11), "11 unread"); +}); + +test("promotes only when the offscreen set includes actionable unread", () => { + const actionable = new Set(["dm", "mention"]); + + assert.equal(hasHighPriorityOverflow(["channel"], actionable), false); + assert.equal(hasHighPriorityOverflow(["channel", "dm"], actionable), true); + assert.equal(hasHighPriorityOverflow(["mention"], actionable), true); +}); diff --git a/desktop/src/features/sidebar/lib/useSidebarUnreadOverflow.ts b/desktop/src/features/sidebar/lib/useSidebarUnreadOverflow.ts new file mode 100644 index 00000000000..4f92ffaec5f --- /dev/null +++ b/desktop/src/features/sidebar/lib/useSidebarUnreadOverflow.ts @@ -0,0 +1,72 @@ +import * as React from "react"; + +import { useUnreadOverflow } from "@/features/sidebar/lib/useUnreadOverflow"; + +type ScrollRef = Parameters[0]["scrollRef"]; + +export function hasHighPriorityOverflow( + offscreenChannelIds: readonly string[], + highPriorityUnreadChannelIds: ReadonlySet, +) { + return offscreenChannelIds.some((channelId) => + highPriorityUnreadChannelIds.has(channelId), + ); +} + +export function sidebarOverflowUnreadLabel(count: number) { + return `${count} unread`; +} + +export function countOffscreenUnreadMessages( + offscreenChannelIds: readonly string[], + unreadChannelCounts: ReadonlyMap, +) { + return offscreenChannelIds.reduce( + (total, channelId) => total + (unreadChannelCounts.get(channelId) ?? 1), + 0, + ); +} + +export function useSidebarUnreadOverflow({ + highPriorityUnreadChannelIds, + previewActivityChannelIds, + scrollRef, + unreadChannelCounts, + unreadChannelIds, +}: { + highPriorityUnreadChannelIds: ReadonlySet; + previewActivityChannelIds: ReadonlySet; + scrollRef: ScrollRef; + unreadChannelCounts: ReadonlyMap; + unreadChannelIds: ReadonlySet; +}) { + const messageChannelIds = React.useMemo( + () => new Set([...unreadChannelIds, ...previewActivityChannelIds]), + [previewActivityChannelIds, unreadChannelIds], + ); + const messageOverflow = useUnreadOverflow({ + scrollRef, + unreadChannelIds: messageChannelIds, + }); + + return { + ...messageOverflow, + unreadAboveCount: countOffscreenUnreadMessages( + messageOverflow.unreadAboveChannelIds, + unreadChannelCounts, + ), + unreadBelowCount: countOffscreenUnreadMessages( + messageOverflow.unreadBelowChannelIds, + unreadChannelCounts, + ), + unreadMessageBelowChannelIds: messageOverflow.unreadBelowChannelIds, + hasHighPriorityAbove: hasHighPriorityOverflow( + messageOverflow.unreadAboveChannelIds, + highPriorityUnreadChannelIds, + ), + hasHighPriorityBelow: hasHighPriorityOverflow( + messageOverflow.unreadBelowChannelIds, + highPriorityUnreadChannelIds, + ), + }; +} diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 2eca30d66df..910f05ed109 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -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, @@ -155,7 +157,7 @@ export function AppSidebar({ const scrollRef = React.useRef(null); useSidebarScrollLock(scrollRef); // biome-ignore format: keep compact to stay within file size limit - const { hasHighPriorityAbove, hasHighPriorityBelow, scrollToChannel, scrollToNextAbove, scrollToNextBelow, unreadAboveCount, unreadBelowCount, unreadMessageBelowChannelIds, unreadAboveLabel, unreadBelowLabel } = useSidebarActivityOverflow({ activeWorkingByChannelId, highPriorityUnreadChannelIds, previewActivityChannelIds, scrollRef, unreadChannelIds }); + const { hasHighPriorityAbove, hasHighPriorityBelow, scrollToChannel, scrollToNextAbove, scrollToNextBelow, unreadAboveCount, unreadBelowCount, unreadMessageBelowChannelIds } = useSidebarUnreadOverflow({ highPriorityUnreadChannelIds, previewActivityChannelIds, scrollRef, unreadChannelCounts, unreadChannelIds }); React.useEffect(() => { const scrollElement = scrollRef.current; @@ -542,7 +544,7 @@ export function AppSidebar({ toggleCollapsedGroup("starred")} selectedChannelId={selectedChannelId} title="Starred" - unreadChannelCounts={unreadChannelCounts} unreadChannelIds={unreadChannelIds} mutedChannelIds={mutedChannelIds} onMuteChannel={onMuteChannel} @@ -636,7 +637,6 @@ export function AppSidebar({ isActiveChannel={selectedView === "channel"} activeWorkingByChannelId={activeWorkingByChannelId} selectedChannelId={selectedChannelId} - unreadChannelCounts={unreadChannelCounts} unreadChannelIds={unreadChannelIds} sections={channelSections} assignments={channelAssignments} @@ -707,7 +707,6 @@ export function AppSidebar({ onToggleCollapsed={() => toggleCollapsedGroup("channels")} selectedChannelId={selectedChannelId} title="Channels" - unreadChannelCounts={unreadChannelCounts} unreadChannelIds={unreadChannelIds} sections={channelSections} assignments={channelAssignments} @@ -746,7 +745,6 @@ export function AppSidebar({ onToggleCollapsed={() => toggleCollapsedGroup("forums")} selectedChannelId={selectedChannelId} title="Forums" - unreadChannelCounts={unreadChannelCounts} unreadChannelIds={unreadChannelIds} mutedChannelIds={mutedChannelIds} onMuteChannel={onMuteChannel} @@ -817,7 +815,7 @@ export function AppSidebar({ count={unreadBelowCount} dmPreviews={unreadDmPreviewsBelow} emphasis={hasHighPriorityBelow ? "primary" : "default"} - label={unreadBelowLabel ?? unreadCountLabel(unreadBelowCount)} + label={sidebarOverflowUnreadLabel(unreadBelowCount)} onClick={() => nextUnreadDmBelowId ? scrollToChannel(nextUnreadDmBelowId) diff --git a/desktop/src/features/sidebar/ui/CustomChannelSection.tsx b/desktop/src/features/sidebar/ui/CustomChannelSection.tsx index 9885f130850..7666e704d45 100644 --- a/desktop/src/features/sidebar/ui/CustomChannelSection.tsx +++ b/desktop/src/features/sidebar/ui/CustomChannelSection.tsx @@ -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; unreadChannelIds: ReadonlySet; 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; selectedChannelId: string | null; - unreadChannelCounts: ReadonlyMap; unreadChannelIds: ReadonlySet; sections: ChannelSection[]; assignments: Record; @@ -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 && diff --git a/desktop/src/features/sidebar/ui/MoreUnreadButton.test.mjs b/desktop/src/features/sidebar/ui/MoreUnreadButton.test.mjs index 0dd603cd16c..a1316c96df6 100644 --- a/desktop/src/features/sidebar/ui/MoreUnreadButton.test.mjs +++ b/desktop/src/features/sidebar/ui/MoreUnreadButton.test.mjs @@ -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", ); }); @@ -127,13 +117,10 @@ describe("MoreUnreadButton model", () => { ); assert.match(markup, /class="[^"]*overflow-hidden[^"]*"/); + assert.match(markup, /5 unread<\/span>/); assert.match( markup, - /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>/); diff --git a/desktop/src/features/sidebar/ui/MoreUnreadButton.tsx b/desktop/src/features/sidebar/ui/MoreUnreadButton.tsx index b97788aad4f..6349f4564e0 100644 --- a/desktop/src/features/sidebar/ui/MoreUnreadButton.tsx +++ b/desktop/src/features/sidebar/ui/MoreUnreadButton.tsx @@ -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, ); @@ -78,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, diff --git a/desktop/src/features/sidebar/ui/SidebarSection.tsx b/desktop/src/features/sidebar/ui/SidebarSection.tsx index 6386d9b3def..0100ffce855 100644 --- a/desktop/src/features/sidebar/ui/SidebarSection.tsx +++ b/desktop/src/features/sidebar/ui/SidebarSection.tsx @@ -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 ? ( - - ) : hasThreadUnread ? ( + {hasThreadUnread ? ( ) : null} @@ -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 diff --git a/desktop/tests/e2e/badge.spec.ts b/desktop/tests/e2e/badge.spec.ts index 73bd2664782..f16103e8adb 100644 --- a/desktop/tests/e2e/badge.spec.ts +++ b/desktop/tests/e2e/badge.spec.ts @@ -319,6 +319,7 @@ test("offscreen top-level unread shows the secondary 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 activityArrow.click(); await expect(page.getByTestId("channel-random")).toBeInViewport(); @@ -357,6 +358,7 @@ 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/); }); @@ -402,16 +404,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 +438,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 +479,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 +556,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 +585,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)); }); diff --git a/desktop/tests/e2e/channel-activity-popover.spec.ts b/desktop/tests/e2e/channel-activity-popover.spec.ts index 415e0db1a57..0e6caac7762 100644 --- a/desktop/tests/e2e/channel-activity-popover.spec.ts +++ b/desktop/tests/e2e/channel-activity-popover.spec.ts @@ -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(); } diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index 9db4370b7cd..f9fd6dca58b 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -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 ({ diff --git a/desktop/tests/e2e/thread-unread.spec.ts b/desktop/tests/e2e/thread-unread.spec.ts index 358d5f55843..95a707a9863 100644 --- a/desktop/tests/e2e/thread-unread.spec.ts +++ b/desktop/tests/e2e/thread-unread.spec.ts @@ -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 From ea51c6ae8dc98a061072f8991757492f7a46f8c9 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 2 Sep 2026 15:49:25 -0700 Subject: [PATCH 5/9] fix(sidebar): keep unread pill composition stable Use one geometry and type composition for quiet and primary unread overflow states, with emphasis expressed only through treatment. Add browser coverage for the state transition. Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz> Signed-off-by: Taylor Ho --- .../features/sidebar/ui/MoreUnreadButton.tsx | 2 +- desktop/src/shared/ui/UnreadPill.tsx | 15 ++++-- desktop/tests/e2e/badge.spec.ts | 53 +++++++++++++++++++ 3 files changed, 64 insertions(+), 6 deletions(-) diff --git a/desktop/src/features/sidebar/ui/MoreUnreadButton.tsx b/desktop/src/features/sidebar/ui/MoreUnreadButton.tsx index 6349f4564e0..b5c61e72a79 100644 --- a/desktop/src/features/sidebar/ui/MoreUnreadButton.tsx +++ b/desktop/src/features/sidebar/ui/MoreUnreadButton.tsx @@ -93,7 +93,7 @@ export function MoreUnreadButton({ > 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); }); @@ -321,6 +340,40 @@ test("offscreen top-level unread shows the secondary sidebar arrow", async ({ await expect(activityArrow).toBeVisible(); await expect(activityArrow).toContainText("1 unread"); await expect(activityArrow).not.toHaveClass(/bg-primary/); + 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, + }, + ); + + await expect(activityArrow).toContainText("2 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); From 30b17b1799664179a6729bd9d2075f6548fbacba Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 2 Sep 2026 16:32:43 -0700 Subject: [PATCH 6/9] fix(sidebar): enlarge overflow unread label Increase only the sidebar overflow pill label to the standard small-text step while preserving identical quiet and primary composition. Cover the rendered size in the browser regression. Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz> Signed-off-by: Taylor Ho --- desktop/src/features/sidebar/ui/MoreUnreadButton.tsx | 2 +- desktop/tests/e2e/badge.spec.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/desktop/src/features/sidebar/ui/MoreUnreadButton.tsx b/desktop/src/features/sidebar/ui/MoreUnreadButton.tsx index b5c61e72a79..eec2cea303b 100644 --- a/desktop/src/features/sidebar/ui/MoreUnreadButton.tsx +++ b/desktop/src/features/sidebar/ui/MoreUnreadButton.tsx @@ -93,7 +93,7 @@ export function MoreUnreadButton({ > Date: Thu, 3 Sep 2026 09:59:29 -0700 Subject: [PATCH 7/9] fix(sidebar): count unread destinations Keep the overflow number scoped to distinct offscreen rooms and DMs while preserving priority emphasis for directed unread activity. Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz> Signed-off-by: Taylor Ho --- .../lib/useSidebarUnreadOverflow.test.mjs | 18 ++--------------- .../sidebar/lib/useSidebarUnreadOverflow.ts | 20 ------------------- .../src/features/sidebar/ui/AppSidebar.tsx | 2 +- desktop/tests/e2e/badge.spec.ts | 6 ++++-- 4 files changed, 7 insertions(+), 39 deletions(-) diff --git a/desktop/src/features/sidebar/lib/useSidebarUnreadOverflow.test.mjs b/desktop/src/features/sidebar/lib/useSidebarUnreadOverflow.test.mjs index 3c751c69b80..6aa1e81d627 100644 --- a/desktop/src/features/sidebar/lib/useSidebarUnreadOverflow.test.mjs +++ b/desktop/src/features/sidebar/lib/useSidebarUnreadOverflow.test.mjs @@ -2,26 +2,12 @@ import assert from "node:assert/strict"; import test from "node:test"; import { - countOffscreenUnreadMessages, hasHighPriorityOverflow, sidebarOverflowUnreadLabel, } from "./useSidebarUnreadOverflow.ts"; -test("counts unread messages across offscreen channels", () => { - assert.equal( - countOffscreenUnreadMessages( - ["ordinary", "mention", "manual"], - new Map([ - ["ordinary", 10], - ["mention", 1], - ]), - ), - 12, - ); -}); - -test("labels the stable total as unread", () => { - assert.equal(sidebarOverflowUnreadLabel(11), "11 unread"); +test("labels the destination total as unread", () => { + assert.equal(sidebarOverflowUnreadLabel(3), "3 unread"); }); test("promotes only when the offscreen set includes actionable unread", () => { diff --git a/desktop/src/features/sidebar/lib/useSidebarUnreadOverflow.ts b/desktop/src/features/sidebar/lib/useSidebarUnreadOverflow.ts index 4f92ffaec5f..00bbea51ba7 100644 --- a/desktop/src/features/sidebar/lib/useSidebarUnreadOverflow.ts +++ b/desktop/src/features/sidebar/lib/useSidebarUnreadOverflow.ts @@ -17,27 +17,15 @@ export function sidebarOverflowUnreadLabel(count: number) { return `${count} unread`; } -export function countOffscreenUnreadMessages( - offscreenChannelIds: readonly string[], - unreadChannelCounts: ReadonlyMap, -) { - return offscreenChannelIds.reduce( - (total, channelId) => total + (unreadChannelCounts.get(channelId) ?? 1), - 0, - ); -} - export function useSidebarUnreadOverflow({ highPriorityUnreadChannelIds, previewActivityChannelIds, scrollRef, - unreadChannelCounts, unreadChannelIds, }: { highPriorityUnreadChannelIds: ReadonlySet; previewActivityChannelIds: ReadonlySet; scrollRef: ScrollRef; - unreadChannelCounts: ReadonlyMap; unreadChannelIds: ReadonlySet; }) { const messageChannelIds = React.useMemo( @@ -51,14 +39,6 @@ export function useSidebarUnreadOverflow({ return { ...messageOverflow, - unreadAboveCount: countOffscreenUnreadMessages( - messageOverflow.unreadAboveChannelIds, - unreadChannelCounts, - ), - unreadBelowCount: countOffscreenUnreadMessages( - messageOverflow.unreadBelowChannelIds, - unreadChannelCounts, - ), unreadMessageBelowChannelIds: messageOverflow.unreadBelowChannelIds, hasHighPriorityAbove: hasHighPriorityOverflow( messageOverflow.unreadAboveChannelIds, diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 910f05ed109..f647509e133 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -157,7 +157,7 @@ export function AppSidebar({ const scrollRef = React.useRef(null); useSidebarScrollLock(scrollRef); // biome-ignore format: keep compact to stay within file size limit - const { hasHighPriorityAbove, hasHighPriorityBelow, scrollToChannel, scrollToNextAbove, scrollToNextBelow, unreadAboveCount, unreadBelowCount, unreadMessageBelowChannelIds } = useSidebarUnreadOverflow({ highPriorityUnreadChannelIds, previewActivityChannelIds, scrollRef, unreadChannelCounts, unreadChannelIds }); + const { hasHighPriorityAbove, hasHighPriorityBelow, scrollToChannel, scrollToNextAbove, scrollToNextBelow, unreadAboveCount, unreadBelowCount, unreadMessageBelowChannelIds } = useSidebarUnreadOverflow({ highPriorityUnreadChannelIds, previewActivityChannelIds, scrollRef, unreadChannelIds }); React.useEffect(() => { const scrollElement = scrollRef.current; diff --git a/desktop/tests/e2e/badge.spec.ts b/desktop/tests/e2e/badge.spec.ts index 6bb91b2755a..6f3897fcb8d 100644 --- a/desktop/tests/e2e/badge.spec.ts +++ b/desktop/tests/e2e/badge.spec.ts @@ -298,7 +298,7 @@ test("dark mode keeps selected labels regular and channel-level unread labels bo }); }); -test("offscreen top-level unread shows the secondary sidebar arrow", async ({ +test("offscreen unread counts destinations and promotes without incrementing", async ({ page, }) => { await page.setViewportSize({ width: 1280, height: 360 }); @@ -365,7 +365,9 @@ test("offscreen top-level unread shows the secondary sidebar arrow", async ({ }, ); - await expect(activityArrow).toContainText("2 unread"); + // 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({ From 048929a7e251d088c668ee02b5c2f56af97ec72b Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Fri, 4 Sep 2026 08:48:03 -0700 Subject: [PATCH 8/9] fix(sidebar): prioritize thread activity in DMs Keep every offscreen DM in the primary overflow state, including when its only unread signal is a non-mention thread reply. Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz> Signed-off-by: Taylor Ho --- .../lib/useSidebarUnreadOverflow.test.mjs | 15 ++- .../sidebar/lib/useSidebarUnreadOverflow.ts | 11 ++- .../src/features/sidebar/ui/AppSidebar.tsx | 22 +++-- desktop/tests/e2e/badge.spec.ts | 94 +++++++++++++++++++ 4 files changed, 126 insertions(+), 16 deletions(-) diff --git a/desktop/src/features/sidebar/lib/useSidebarUnreadOverflow.test.mjs b/desktop/src/features/sidebar/lib/useSidebarUnreadOverflow.test.mjs index 6aa1e81d627..187586a3e3f 100644 --- a/desktop/src/features/sidebar/lib/useSidebarUnreadOverflow.test.mjs +++ b/desktop/src/features/sidebar/lib/useSidebarUnreadOverflow.test.mjs @@ -10,10 +10,15 @@ test("labels the destination total as unread", () => { assert.equal(sidebarOverflowUnreadLabel(3), "3 unread"); }); -test("promotes only when the offscreen set includes actionable unread", () => { - const actionable = new Set(["dm", "mention"]); +test("promotes actionable unread and every offscreen DM", () => { + const actionable = new Set(["mention"]); + const dms = new Set(["dm"]); - assert.equal(hasHighPriorityOverflow(["channel"], actionable), false); - assert.equal(hasHighPriorityOverflow(["channel", "dm"], actionable), true); - assert.equal(hasHighPriorityOverflow(["mention"], actionable), true); + 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, + ); }); diff --git a/desktop/src/features/sidebar/lib/useSidebarUnreadOverflow.ts b/desktop/src/features/sidebar/lib/useSidebarUnreadOverflow.ts index 00bbea51ba7..287395fed7d 100644 --- a/desktop/src/features/sidebar/lib/useSidebarUnreadOverflow.ts +++ b/desktop/src/features/sidebar/lib/useSidebarUnreadOverflow.ts @@ -7,9 +7,12 @@ type ScrollRef = Parameters[0]["scrollRef"]; export function hasHighPriorityOverflow( offscreenChannelIds: readonly string[], highPriorityUnreadChannelIds: ReadonlySet, + dmChannelIds: ReadonlySet, ) { - return offscreenChannelIds.some((channelId) => - highPriorityUnreadChannelIds.has(channelId), + return offscreenChannelIds.some( + (channelId) => + dmChannelIds.has(channelId) || + highPriorityUnreadChannelIds.has(channelId), ); } @@ -18,11 +21,13 @@ export function sidebarOverflowUnreadLabel(count: number) { } export function useSidebarUnreadOverflow({ + dmChannelIds, highPriorityUnreadChannelIds, previewActivityChannelIds, scrollRef, unreadChannelIds, }: { + dmChannelIds: ReadonlySet; highPriorityUnreadChannelIds: ReadonlySet; previewActivityChannelIds: ReadonlySet; scrollRef: ScrollRef; @@ -43,10 +48,12 @@ export function useSidebarUnreadOverflow({ hasHighPriorityAbove: hasHighPriorityOverflow( messageOverflow.unreadAboveChannelIds, highPriorityUnreadChannelIds, + dmChannelIds, ), hasHighPriorityBelow: hasHighPriorityOverflow( messageOverflow.unreadBelowChannelIds, highPriorityUnreadChannelIds, + dmChannelIds, ), }; } diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index f647509e133..93b6defe487 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -154,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(null); useSidebarScrollLock(scrollRef); // biome-ignore format: keep compact to stay within file size limit - const { hasHighPriorityAbove, hasHighPriorityBelow, scrollToChannel, scrollToNextAbove, scrollToNextBelow, unreadAboveCount, unreadBelowCount, unreadMessageBelowChannelIds } = useSidebarUnreadOverflow({ highPriorityUnreadChannelIds, previewActivityChannelIds, scrollRef, unreadChannelIds }); + const { hasHighPriorityAbove, hasHighPriorityBelow, scrollToChannel, scrollToNextAbove, scrollToNextBelow, unreadAboveCount, unreadBelowCount, unreadMessageBelowChannelIds } = useSidebarUnreadOverflow({ dmChannelIds, highPriorityUnreadChannelIds, previewActivityChannelIds, scrollRef, unreadChannelIds }); React.useEffect(() => { const scrollElement = scrollRef.current; @@ -371,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); diff --git a/desktop/tests/e2e/badge.spec.ts b/desktop/tests/e2e/badge.spec.ts index 6f3897fcb8d..0ff43f3be47 100644 --- a/desktop/tests/e2e/badge.spec.ts +++ b/desktop/tests/e2e/badge.spec.ts @@ -418,6 +418,100 @@ test("offscreen unread DM shows the primary sidebar arrow", async ({ 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, }) => { From 0f35b3165f22ecdafb864505ee0e2e4af8083c1e Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Fri, 4 Sep 2026 15:30:15 -0700 Subject: [PATCH 9/9] docs(sidebar): document unread overflow APIs Describe destination deduplication and the priority treatment applied to DMs and directed unread activity. Co-authored-by: Carl Signed-off-by: Taylor Ho --- .../features/sidebar/lib/useSidebarUnreadOverflow.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/desktop/src/features/sidebar/lib/useSidebarUnreadOverflow.ts b/desktop/src/features/sidebar/lib/useSidebarUnreadOverflow.ts index 287395fed7d..7e59c712a4d 100644 --- a/desktop/src/features/sidebar/lib/useSidebarUnreadOverflow.ts +++ b/desktop/src/features/sidebar/lib/useSidebarUnreadOverflow.ts @@ -4,6 +4,10 @@ import { useUnreadOverflow } from "@/features/sidebar/lib/useUnreadOverflow"; type ScrollRef = Parameters[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, @@ -16,10 +20,16 @@ export function hasHighPriorityOverflow( ); } +/** 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,