From f038cbbb0d4092a72ffd93f17916f84d2b39bb43 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Fri, 4 Sep 2026 16:03:41 -0700 Subject: [PATCH] fix(sidebar): simplify unread indicators and emphasize priority activity (#7134) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **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.
File changes **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.
## 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 Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz> Co-authored-by: Carl --- desktop/src-tauri/src/unread_catch_up.rs | 5 +- desktop/src/app/AppShell.tsx | 1 + .../features/channels/useUnreadChannels.ts | 30 +-- .../useOffscreenActivityChannelIds.test.mjs | 52 ---- .../lib/useOffscreenActivityChannelIds.ts | 53 ---- .../sidebar/lib/useSidebarActivityOverflow.ts | 47 ---- .../lib/useSidebarUnreadOverflow.test.mjs | 24 ++ .../sidebar/lib/useSidebarUnreadOverflow.ts | 69 +++++ .../src/features/sidebar/ui/AppSidebar.tsx | 39 +-- .../features/sidebar/ui/AppSidebar.types.ts | 1 + .../sidebar/ui/CustomChannelSection.tsx | 9 - .../sidebar/ui/MoreUnreadButton.test.mjs | 24 +- .../features/sidebar/ui/MoreUnreadButton.tsx | 12 +- .../features/sidebar/ui/SidebarSection.tsx | 37 +-- desktop/src/shared/ui/UnreadPill.tsx | 15 +- desktop/tests/e2e/badge.spec.ts | 236 ++++++++++++------ .../e2e/channel-activity-popover.spec.ts | 13 +- desktop/tests/e2e/channels.spec.ts | 10 +- desktop/tests/e2e/thread-unread.spec.ts | 20 +- 19 files changed, 353 insertions(+), 344 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 f8609ef1f6..96a740638b 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/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 071cc3b180..b4c2039023 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/channels/useUnreadChannels.ts b/desktop/src/features/channels/useUnreadChannels.ts index ceab544d8c..9e22467062 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 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); } 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 34f696aa51..0000000000 --- a/desktop/src/features/sidebar/lib/useOffscreenActivityChannelIds.test.mjs +++ /dev/null @@ -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, - ); -}); diff --git a/desktop/src/features/sidebar/lib/useOffscreenActivityChannelIds.ts b/desktop/src/features/sidebar/lib/useOffscreenActivityChannelIds.ts deleted file mode 100644 index aef953efff..0000000000 --- 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 2dac012bca..0000000000 --- a/desktop/src/features/sidebar/lib/useSidebarActivityOverflow.ts +++ /dev/null @@ -1,47 +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 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, - }), - }; -} 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 0000000000..187586a3e3 --- /dev/null +++ b/desktop/src/features/sidebar/lib/useSidebarUnreadOverflow.test.mjs @@ -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, + ); +}); diff --git a/desktop/src/features/sidebar/lib/useSidebarUnreadOverflow.ts b/desktop/src/features/sidebar/lib/useSidebarUnreadOverflow.ts new file mode 100644 index 0000000000..7e59c712a4 --- /dev/null +++ b/desktop/src/features/sidebar/lib/useSidebarUnreadOverflow.ts @@ -0,0 +1,69 @@ +import * as React from "react"; + +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, + dmChannelIds: ReadonlySet, +) { + 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; + highPriorityUnreadChannelIds: ReadonlySet; + previewActivityChannelIds: ReadonlySet; + scrollRef: ScrollRef; + unreadChannelIds: ReadonlySet; +}) { + 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, + ), + }; +} diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 647ed63264..93b6defe48 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, @@ -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(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 ? ( 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) diff --git a/desktop/src/features/sidebar/ui/AppSidebar.types.ts b/desktop/src/features/sidebar/ui/AppSidebar.types.ts index 43eb094b3a..d884c6d553 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/CustomChannelSection.tsx b/desktop/src/features/sidebar/ui/CustomChannelSection.tsx index 9885f13085..7666e704d4 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 45b14c31ff..a1316c96df 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", ); }); @@ -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, /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 581f2e5986..eec2cea303 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, ); @@ -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({ > 0 ? ( diff --git a/desktop/src/features/sidebar/ui/SidebarSection.tsx b/desktop/src/features/sidebar/ui/SidebarSection.tsx index 6386d9b3de..0100ffce85 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/src/shared/ui/UnreadPill.tsx b/desktop/src/shared/ui/UnreadPill.tsx index f24f762f1d..7afab32210 100644 --- a/desktop/src/shared/ui/UnreadPill.tsx +++ b/desktop/src/shared/ui/UnreadPill.tsx @@ -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({