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.

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

**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.

</details>

## 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 <tulsi@block.xyz>
This commit is contained in:
tulsi
2026-08-24 11:48:48 -04:00
committed by GitHub
parent 2f13e30e88
commit 0e69b3fd7c
7 changed files with 149 additions and 24 deletions

View File

@@ -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") {

View File

@@ -79,7 +79,10 @@ function UnreadCountBadge({
data-testid={`channel-unread-${channelName}`}
>
{formatUnreadCount(count)}
<span className="sr-only"> new comment{count === 1 ? "" : "s"}</span>
<span className="sr-only">
{" "}
unread notification{count === 1 ? "" : "s"}
</span>
</span>
);
}
@@ -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 ? (
<UnreadCountBadge
channelName={channel.name}
className="ml-auto bg-notification text-notification-foreground"
count={unreadCount}
/>
) : hasThreadUnread ? (
<UnreadDotBadge channelName={channel.name} className="ml-auto" />
) : null}
</SidebarMenuButton>

View File

@@ -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;

View File

@@ -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%;

View File

@@ -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))",

View File

@@ -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));
});

View File

@@ -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 }) => {