From 0e69b3fd7c44c09da62e2c4e89fdb4a26e666869 Mon Sep 17 00:00:00 2001 From: tulsi Date: Mon, 24 Aug 2026 11:48:48 -0400 Subject: [PATCH] show mention counts in channel notifications (#6696) **Category:** fix **User Impact:** Stream and forum channels now show an accessible numeric badge for unread mentions while mention chips remain clear in every theme. **Problem:** Mention notifications contributed to the app and Dock badge, but inactive stream and forum rows only became bold, making it difficult to see where multiple mentions were waiting. Mention styling and generic destructive colors could also lose contrast or visual meaning in some themes. **Solution:** Use the same app-badge projection for non-DM channel mention counts, while preserving regular unread bolding, thread activity dots, DM counts, and manual unread behavior. Dedicated notification and opaque mention-highlight tokens keep the new treatments stable and readable across syntax themes.
File changes **desktop/src/features/channels/useUnreadChannels.ts** Projects app-badge-eligible mention and broadcast counts into stream and forum channel rows while retaining DM-specific counting and manual-unread semantics. **desktop/src/features/sidebar/ui/SidebarSection.tsx** Renders an accessible numeric notification pill on inactive non-DM channels and preserves the thread activity dot fallback. **desktop/src/shared/styles/globals/markdown.css** Applies the shared opaque yellow highlight to human and agent mention chips, including hover treatment. **desktop/src/shared/styles/globals/theme.css** Adds fixed notification and mention-highlight tokens with theme-independent contrast. **desktop/tailwind.config.js** Exposes the notification token pair through semantic Tailwind utilities. **desktop/tests/e2e/badge.spec.ts** Covers aggregated mention counts, broadcasts, unchanged unread tiers, exact accessible text, and badge contrast under an adversarial theme. **desktop/tests/e2e/mentions.spec.ts** Covers human and agent mention styling, hover behavior, dark mode, and WCAG text contrast.
## Reproduction steps 1. Open a stream or forum channel, then navigate to another channel. 2. Receive two messages that mention you in the inactive channel. 3. Confirm the inactive row is bold and shows a red `2` pill matching the two notifications added to the app or Dock badge. 4. Receive a regular channel message and confirm the row only becomes bold, without a numeric pill. 5. Receive a reply in an interested thread and confirm the channel retains its activity dot instead of a mention count. 6. Switch between light and dark themes and confirm human and agent mention chips remain yellow with near-black readable text, including on hover. ## Screenshots Screenshots are posted in the PR discussion using immutable repository-hosted image URLs. Signed-off-by: tulsi --- .../features/channels/useUnreadChannels.ts | 19 +++--- .../features/sidebar/ui/SidebarSection.tsx | 14 +++- .../src/shared/styles/globals/markdown.css | 15 +++++ desktop/src/shared/styles/globals/theme.css | 8 +++ desktop/tailwind.config.js | 4 ++ desktop/tests/e2e/badge.spec.ts | 65 +++++++++++++++---- desktop/tests/e2e/mentions.spec.ts | 48 ++++++++++++++ 7 files changed, 149 insertions(+), 24 deletions(-) diff --git a/desktop/src/features/channels/useUnreadChannels.ts b/desktop/src/features/channels/useUnreadChannels.ts index b956b672d0..984d69ff89 100644 --- a/desktop/src/features/channels/useUnreadChannels.ts +++ b/desktop/src/features/channels/useUnreadChannels.ts @@ -784,13 +784,8 @@ export function useUnreadChannels( relayClient, ]); - // Unread = inactive channels, plus any channel manually marked unread this - // session. A manually marked active channel must remain visible as unread - // until the user explicitly marks it read again. - // High-priority unread = DMs or channels with a mention/broadcast newer - // than the read marker. Forced-unread channels are dot tier only (not - // high-priority). Both sets share identical deps and always invalidate - // together, so they are computed in a single memo. + // Derive unread and high-priority projections together so they invalidate + // from the same read-state snapshot. const rawUnread = // biome-ignore lint/correctness/useExhaustiveDependencies: readStateVersion and latestVersion are intentional invalidation signals React.useMemo(() => { @@ -841,7 +836,7 @@ export function useUnreadChannels( if (!isForcedUnread) continue; unread.add(channel.id); topLevelUnread.add(channel.id); - counts.set(channel.id, 1); + if (channel.channelType === "dm") counts.set(channel.id, 1); unreadChannelNotificationCount += 1; continue; } @@ -863,13 +858,17 @@ export function useUnreadChannels( observedEvents, readAtForObservedEvent, ); - counts.set(channel.id, badgeCount); - unreadChannelNotificationCount += + const appBadgeCount = nativeProjection?.appBadgeCount ?? countUnreadAppBadgeObservedEvents( observedEvents, readAtForObservedEvent, ); + counts.set( + channel.id, + channel.channelType === "dm" ? badgeCount : appBadgeCount, + ); + unreadChannelNotificationCount += appBadgeCount; // DM channels: any unread DM is high-priority. if (channel.channelType === "dm") { diff --git a/desktop/src/features/sidebar/ui/SidebarSection.tsx b/desktop/src/features/sidebar/ui/SidebarSection.tsx index 1a6403fb24..0c99eaf16d 100644 --- a/desktop/src/features/sidebar/ui/SidebarSection.tsx +++ b/desktop/src/features/sidebar/ui/SidebarSection.tsx @@ -79,7 +79,10 @@ function UnreadCountBadge({ data-testid={`channel-unread-${channelName}`} > {formatUnreadCount(count)} - new comment{count === 1 ? "" : "s"} + + {" "} + unread notification{count === 1 ? "" : "s"} + ); } @@ -255,6 +258,7 @@ export function ChannelMenuButton({ label, isActive, hasUnread, + unreadCount = 0, activeWorking, isMuted, dmParticipants, @@ -354,7 +358,13 @@ export function ChannelMenuButton({ )} /> ) : null} - {hasThreadUnread ? ( + {!isActive && channel.channelType !== "dm" && unreadCount > 0 ? ( + + ) : hasThreadUnread ? ( ) : null} diff --git a/desktop/src/shared/styles/globals/markdown.css b/desktop/src/shared/styles/globals/markdown.css index 9be4548249..e443357ae8 100644 --- a/desktop/src/shared/styles/globals/markdown.css +++ b/desktop/src/shared/styles/globals/markdown.css @@ -302,6 +302,21 @@ color: hsl(var(--primary) / 0.9); } +/* @mentions use an opaque highlighter-yellow treatment so they scan + differently from channel links while retaining AA contrast on every theme. + Shared by human and agent chips in both timeline and composer. */ +.message-markdown .mention-chip.inline-chip-icon-human, +.message-markdown .mention-chip.inline-chip-icon-agent { + background: hsl(var(--mention-highlight)); + color: hsl(var(--buzz-content-dark)); +} + +.message-markdown .mention-chip-hover.inline-chip-icon-human:hover, +.message-markdown .mention-chip-hover.inline-chip-icon-agent:hover { + background: hsl(var(--mention-highlight-hover)); + color: hsl(var(--buzz-content-dark)); +} + .message-markdown .mention-prefix-hidden { display: inline-block; width: 0; diff --git a/desktop/src/shared/styles/globals/theme.css b/desktop/src/shared/styles/globals/theme.css index 5fd2593c79..875a17aedf 100644 --- a/desktop/src/shared/styles/globals/theme.css +++ b/desktop/src/shared/styles/globals/theme.css @@ -28,6 +28,14 @@ --accent-foreground: 234 16.02% 35.49%; --destructive: 347 86.67% 44.12%; --destructive-foreground: 220 23.08% 94.9%; + /* Notification attention colors are fixed across syntax themes. Unlike + destructive, these must stay recognizably red with AA label contrast. */ + --notification: 348 65% 48%; + --notification-foreground: 350 100% 98%; + /* Opaque highlighter colors keep near-black mention text readable on every + message surface instead of depending on the active theme underneath. */ + --mention-highlight: 48 96% 70%; + --mention-highlight-hover: 48 96% 62%; --border: 225 13.56% 76.86%; --input: 225 13.56% 76.86%; --ring: 234 16.02% 35.49%; diff --git a/desktop/tailwind.config.js b/desktop/tailwind.config.js index 4816b3ddc3..165d8a1ac2 100644 --- a/desktop/tailwind.config.js +++ b/desktop/tailwind.config.js @@ -110,6 +110,10 @@ export default { DEFAULT: "hsl(var(--destructive))", foreground: "hsl(var(--destructive-foreground))", }, + notification: { + DEFAULT: "hsl(var(--notification))", + foreground: "hsl(var(--notification-foreground))", + }, border: "hsl(var(--border))", input: "hsl(var(--input))", ring: "hsl(var(--ring))", diff --git a/desktop/tests/e2e/badge.spec.ts b/desktop/tests/e2e/badge.spec.ts index f1b618b0d6..e44e6e097b 100644 --- a/desktop/tests/e2e/badge.spec.ts +++ b/desktop/tests/e2e/badge.spec.ts @@ -402,9 +402,14 @@ test("regular message bolds inactive channel without numeric badge", async ({ ); }); -test("top-level @mention bolds the channel without a row badge", async ({ +test("top-level @mention shows a red numeric badge on its channel", async ({ page, }) => { + // slack-ochin maps the generic destructive pair to white-on-white. The + // notification pair must remain independently red and readable. + await page.addInitScript(() => { + window.localStorage.setItem("buzz-theme", "slack-ochin"); + }); await page.goto("/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -413,13 +418,18 @@ test("top-level @mention bolds the channel without a row badge", async ({ await page.evaluate( ({ pubkey, mentionPubkey }) => { - window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ - channelName: "random", - content: "Hey @tyler check this out", - kind: 40002, - pubkey, - mentionPubkeys: [mentionPubkey], - }); + for (const content of [ + "Hey @tyler check this out", + "One more for @tyler", + ]) { + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "random", + content, + kind: 40002, + pubkey, + mentionPubkeys: [mentionPubkey], + }); + } }, { pubkey: TEST_IDENTITIES.alice.pubkey, @@ -431,9 +441,38 @@ test("top-level @mention bolds the channel without a row badge", async ({ "font-weight", "700", ); - await expect(page.getByTestId("channel-unread-random")).toHaveCount(0); + const mentionBadge = page.getByTestId("channel-unread-random"); + await expect(mentionBadge).toHaveText("2 unread notifications"); + await expect(mentionBadge).toHaveClass(/bg-notification/); + await expect(mentionBadge).toHaveCSS("background-color", "rgb(202, 43, 75)"); + await expect(mentionBadge).toHaveCSS("color", "rgb(255, 245, 247)"); + 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-dot-random")).toHaveCount(0); - await waitForBadgeState(page, withAdditionalBadgeCount(baselineBadge, 1)); + await waitForBadgeState(page, withAdditionalBadgeCount(baselineBadge, 2)); }); test("numeric badge increments for DM message", async ({ page }) => { @@ -496,7 +535,7 @@ test("interested thread reply shows the channel preview dot without incrementing await waitForBadgeState(page, baselineBadge); }); -test("broadcast reply bolds the channel without a thread dot", async ({ +test("broadcast reply shows a numeric channel badge without a thread dot", async ({ page, }) => { await page.goto("/"); @@ -525,7 +564,9 @@ test("broadcast reply bolds the channel without a thread dot", async ({ "font-weight", "700", ); - await expect(page.getByTestId("channel-unread-random")).toHaveCount(0); + await expect(page.getByTestId("channel-unread-random")).toHaveText( + "1 unread notification", + ); await expect(page.getByTestId("channel-unread-dot-random")).toHaveCount(0); await waitForBadgeState(page, withAdditionalBadgeCount(baselineBadge, 1)); }); diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index 25d73b28ca..ad1acf87c3 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -37,6 +37,37 @@ const DM_THREAD_AGENT_MENTION_ERROR_TEXT = const DM_THREAD_MEMBERS_LOADING_ERROR_TEXT = "Checking conversation members. Try again in a moment."; +async function expectTextContrast( + locator: import("@playwright/test").Locator, + minimum = 4.5, +) { + const contrastRatio = await locator.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(contrastRatio).toBeGreaterThanOrEqual(minimum); +} + /** Locator scoped to the mention autocomplete dropdown inside the composer. */ function autocomplete(page: import("@playwright/test").Page) { return page @@ -2598,6 +2629,9 @@ test("sent non-member person mention uses the normal mention style", async ({ test("sent managed non-member agent mention uses the agent mention style", async ({ page, }) => { + await page.addInitScript(() => { + window.localStorage.setItem("buzz-theme", "buzz-dark"); + }); await installMockBridge(page, { managedAgents: [ { @@ -2627,6 +2661,13 @@ test("sent managed non-member agent mention uses the agent mention style", async await expect(mentionChip).toBeVisible(); await expect(mentionChip).toHaveText("charlie"); await expect(mentionChip).toHaveClass(/agent-mention-highlight/); + await expect(mentionChip).toHaveCSS("background-color", "rgb(252, 223, 105)"); + await expect(mentionChip).toHaveCSS("color", "rgb(26, 26, 26)"); + await expectTextContrast(mentionChip); + await mentionChip.hover(); + await expect(mentionChip).toHaveCSS("background-color", "rgb(251, 214, 65)"); + await expect(mentionChip).toHaveCSS("color", "rgb(26, 26, 26)"); + await expectTextContrast(mentionChip); }); test("mention button opens autocomplete and inserts a selected member", async ({ @@ -2732,6 +2773,13 @@ test("mention text is highlighted in sent messages", async ({ page }) => { await expect(mentionChip).toBeVisible(); await expect(mentionChip).toHaveText("bob"); await expect(mentionChip).toHaveClass(/inline-chip-icon-human/); + await expect(mentionChip).toHaveCSS("background-color", "rgb(252, 223, 105)"); + await expect(mentionChip).toHaveCSS("color", "rgb(26, 26, 26)"); + await expectTextContrast(mentionChip); + await mentionChip.hover(); + await expect(mentionChip).toHaveCSS("background-color", "rgb(251, 214, 65)"); + await expect(mentionChip).toHaveCSS("color", "rgb(26, 26, 26)"); + await expectTextContrast(mentionChip); }); test("clicking author name opens user profile panel", async ({ page }) => {