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/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/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 34f696aa515..00000000000 --- 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 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 2dac012bca4..00000000000 --- 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 00000000000..187586a3e3f --- /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 00000000000..7e59c712a4d --- /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 647ed632644..93b6defe487 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 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/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 45b14c31ff2..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", ); }); @@ -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 581f2e59868..eec2cea303b 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 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/src/shared/ui/UnreadPill.tsx b/desktop/src/shared/ui/UnreadPill.tsx index f24f762f1da..7afab32210b 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({