fix(desktop): restore channel unread badges (#1218)

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Wes
2026-06-23 13:59:21 -07:00
committed by GitHub
co-authored by Pinky
parent b4e75a1e41
commit 89aaa26443
13 changed files with 326 additions and 140 deletions
+29
View File
@@ -0,0 +1,29 @@
import assert from "node:assert/strict";
import test from "node:test";
import { shouldBounceForChannelNotification } from "./AppShell.helpers.ts";
test("shouldBounceForChannelNotification_allowsTopLevelChannelMessages", () => {
assert.equal(shouldBounceForChannelNotification([["h", "channel"]]), true);
});
test("shouldBounceForChannelNotification_suppressesThreadReplies", () => {
assert.equal(
shouldBounceForChannelNotification([
["h", "channel"],
["e", "root", "", "reply"],
]),
false,
);
});
test("shouldBounceForChannelNotification_allowsBroadcastReplies", () => {
assert.equal(
shouldBounceForChannelNotification([
["h", "channel"],
["e", "root", "", "reply"],
["broadcast", "1"],
]),
true,
);
});
+5
View File
@@ -1,3 +1,4 @@
import { isThreadReply } from "@/features/messages/lib/threading";
import type { DesktopNotificationTarget } from "@/features/notifications/lib/desktop";
import type { SearchHit } from "@/shared/api/types";
@@ -25,6 +26,10 @@ export function isWindowDragHandleEvent(event: MouseEvent | PointerEvent) {
);
}
export function shouldBounceForChannelNotification(tags: string[][]): boolean {
return !isThreadReply(tags);
}
export function toSearchHit(
target: DesktopNotificationTarget,
): SearchHit | null {
+14 -15
View File
@@ -6,6 +6,7 @@ import { Outlet, useLocation } from "@tanstack/react-router";
import {
deriveShellRoute,
isWindowDragHandleEvent,
shouldBounceForChannelNotification,
toSearchHit,
} from "@/app/AppShell.helpers";
import { AppShellProvider } from "@/app/AppShellContext";
@@ -179,7 +180,8 @@ export function AppShell() {
refetchHomeFeedFromLiveSignal,
);
const handleChannelNotification = React.useEffectEvent(
(_channelId: string, _event: RelayEvent) => {
(_channelId: string, event: RelayEvent) => {
if (!shouldBounceForChannelNotification(event.tags)) return;
if (!notificationSettings.settings.desktopEnabled) return;
void requestDockBounce();
},
@@ -390,11 +392,6 @@ export function AppShell() {
channels,
);
// Badge count is computed here (rather than inside useHomeFeedNotifications)
// so it can consume the NIP-RS read-state lifted from the single
// ReadStateManager mounted via useUnreadChannels above. Channel-backed
// feed items contribute to the badge iff strictly newer than that
// channel's read marker; non-channel items keep their seen-set fallback.
const { homeBadgeCount, homeBadgeCountExcludingHighPriority } =
useHomeFeedNotificationState(
homeFeedQuery.data,
@@ -412,8 +409,6 @@ export function AppShell() {
getThreadReadAt,
);
// Raw add to the in-app nav badge, mirroring the inbox filter badge; gated by
// homeBadgeEnabled to match every other badge contribution.
const dueReminderBadge = useDueReminderBadgeCount(
identityQuery.data?.pubkey,
notificationSettings.settings.homeBadgeEnabled,
@@ -592,14 +587,18 @@ export function AppShell() {
}, []);
React.useEffect(() => {
const numericCount =
const count =
unreadChannelNotificationCount + homeBadgeCountExcludingHighPriority;
if (numericCount > 0) {
void setDesktopAppBadge({ kind: "count", count: numericCount });
} else {
void setDesktopAppBadge({ kind: "none" });
}
}, [homeBadgeCountExcludingHighPriority, unreadChannelNotificationCount]);
void setDesktopAppBadge(
count
? { kind: "count", count }
: { kind: unreadChannelIds.size ? "dot" : "none" },
);
}, [
homeBadgeCountExcludingHighPriority,
unreadChannelIds,
unreadChannelNotificationCount,
]);
// Dispatch `buzz://message` deep links into the router.
useMessageDeepLinks();
@@ -3,8 +3,30 @@ export type ObservedUnreadEvent = {
createdAt: number;
rootId: string | null;
highPriority: boolean;
countsTowardBadge: boolean;
countsTowardAppBadge: boolean;
};
export function makeObservedUnreadEvent(input: {
id: string;
createdAt: number;
rootId: string | null;
highPriority: boolean;
channelType: string | undefined;
isThreadedReply: boolean;
}): ObservedUnreadEvent {
const isDm = input.channelType === "dm";
return {
id: input.id,
createdAt: input.createdAt,
rootId: input.rootId,
highPriority: input.highPriority,
countsTowardBadge: isDm || input.isThreadedReply || input.highPriority,
countsTowardAppBadge:
isDm || (!input.isThreadedReply && input.highPriority),
};
}
export function mapsEqual(
a: ReadonlyMap<string, number>,
b: ReadonlyMap<string, number>,
@@ -54,6 +76,34 @@ export function countUnreadObservedEvents(
return count;
}
export function countUnreadBadgeObservedEvents(
eventsById: ReadonlyMap<string, ObservedUnreadEvent> | undefined,
getReadAt: (event: ObservedUnreadEvent) => number | null,
): number {
if (!eventsById) return 0;
let count = 0;
for (const event of eventsById.values()) {
if (!event.countsTowardBadge) continue;
const readAt = getReadAt(event);
if (readAt === null || event.createdAt > readAt) count += 1;
}
return count;
}
export function countUnreadAppBadgeObservedEvents(
eventsById: ReadonlyMap<string, ObservedUnreadEvent> | undefined,
getReadAt: (event: ObservedUnreadEvent) => number | null,
): number {
if (!eventsById) return 0;
let count = 0;
for (const event of eventsById.values()) {
if (!event.countsTowardAppBadge) continue;
const readAt = getReadAt(event);
if (readAt === null || event.createdAt > readAt) count += 1;
}
return count;
}
export function countUnreadHighPriorityObservedEvents(
eventsById: ReadonlyMap<string, ObservedUnreadEvent> | undefined,
getReadAt: (event: ObservedUnreadEvent) => number | null,
@@ -3,6 +3,8 @@ import test from "node:test";
import { computeChannelUnreadMarker } from "../messages/lib/unreadMarker.ts";
import {
countUnreadAppBadgeObservedEvents,
countUnreadBadgeObservedEvents,
countUnreadHighPriorityObservedEvents,
countUnreadObservedEvents,
observedUnreadEventReadAt,
@@ -136,8 +138,22 @@ test("observedUnreadEventReadAt_nullChannelMarkerThreadMarkerCanClear", () => {
// --- Fix 2b: sidebar badge evaluates all observed events, not a single aggregate frontier ---
function observed(id, createdAt, rootId = null, highPriority = false) {
return { id, createdAt, rootId, highPriority };
function observed(
id,
createdAt,
rootId = null,
highPriority = false,
countsTowardBadge = true,
countsTowardAppBadge = countsTowardBadge,
) {
return {
id,
createdAt,
rootId,
highPriority,
countsTowardBadge,
countsTowardAppBadge,
};
}
function readAtFor(channelMarker, threadMarkers) {
@@ -219,6 +235,51 @@ test("countUnreadObservedEvents_topLevelUsesChannelMarker", () => {
assert.equal(countUnreadObservedEvents(events, readAtFor(300, new Map())), 1);
});
test("countUnreadBadgeObservedEvents_skipsBoldOnlyGeneralChannelItems", () => {
const events = new Map([
["plain", observed("plain", 500, null, false, false)],
["thread", observed("thread", 600, "root-1")],
]);
assert.equal(countUnreadObservedEvents(events, readAtFor(300, new Map())), 2);
assert.equal(
countUnreadBadgeObservedEvents(events, readAtFor(300, new Map())),
1,
);
assert.equal(
countUnreadAppBadgeObservedEvents(events, readAtFor(300, new Map())),
1,
);
});
test("countUnreadObservedEvents_countsThreadRepliesForChannelUnread", () => {
const events = new Map([
["reply", observed("reply", 500, "root-1", false, true, false)],
]);
assert.equal(countUnreadObservedEvents(events, readAtFor(300, new Map())), 1);
assert.equal(
countUnreadBadgeObservedEvents(events, readAtFor(300, new Map())),
1,
);
assert.equal(
countUnreadAppBadgeObservedEvents(events, readAtFor(300, new Map())),
0,
);
});
test("highPriorityObservedEvents_countsMentionBadgeForGeneralMessage", () => {
const events = new Map([
["mention", observed("mention", 500, null, true, true)],
]);
const getReadAt = readAtFor(300, new Map());
assert.equal(countUnreadObservedEvents(events, getReadAt), 1);
assert.equal(countUnreadBadgeObservedEvents(events, getReadAt), 1);
assert.equal(countUnreadAppBadgeObservedEvents(events, getReadAt), 1);
assert.equal(countUnreadHighPriorityObservedEvents(events, getReadAt), 1);
});
test("recordObservedUnreadEvent_reportsOutOfOrderInsertForInvalidation", () => {
const channelId = "chan";
const observedByChannel = new Map();
@@ -0,0 +1,30 @@
// Per-pubkey JSON array of thread root ids, capped to newest entries and
// tolerant of malformed or unavailable localStorage.
export function makeRootIdStore(prefix: string, maxEntries = 1000) {
const storageKey = (pubkey: string) => `${prefix}:${pubkey}`;
return {
read(pubkey: string): Set<string> {
try {
const raw = window.localStorage.getItem(storageKey(pubkey));
if (!raw) return new Set();
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return new Set();
return new Set(
parsed.filter((id): id is string => typeof id === "string"),
);
} catch {
return new Set();
}
},
write(pubkey: string, rootIds: Set<string>): void {
try {
const arr = [...rootIds];
const capped =
arr.length > maxEntries ? arr.slice(arr.length - maxEntries) : arr;
window.localStorage.setItem(storageKey(pubkey), JSON.stringify(capped));
} catch {
// Ignore storage errors (private browsing, quota exceeded).
}
},
};
}
@@ -1,22 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { shouldRouteChannelUnreadEvent } from "./useLiveChannelUpdates.ts";
test("main-channel messages route to channel unread tracking", () => {
assert.equal(shouldRouteChannelUnreadEvent(undefined, false), true);
});
test("non-DM thread replies do not route to channel unread tracking", () => {
assert.equal(
shouldRouteChannelUnreadEvent({ channelType: "stream" }, true),
false,
);
});
test("DM thread replies route to channel unread tracking", () => {
assert.equal(
shouldRouteChannelUnreadEvent({ channelType: "dm" }, true),
true,
);
});
@@ -28,12 +28,11 @@ export type UseLiveChannelUpdatesOptions = {
onDmMessage?: (event: RelayEvent, channel: Channel) => void;
onLiveMention?: () => void;
/**
* Fired for live main-channel "new content" events in a member channel
* authored by someone other than the current user. Non-DM thread replies
* are routed through onThreadReplyNotification instead; DM thread replies
* also fire this callback so the DM unread dot/count stays channel-level.
* Used to drive the in-session "latest message at" map that powers sidebar
* unread badges. See `UNREAD_TRIGGER_KINDS` for the exact kind set.
* Fired for live "new content" events in a member channel authored by
* someone other than the current user. Thread replies also fire
* onThreadReplyNotification so Home inbox activity stays in sync. Used to
* drive the observed unread-event map that powers sidebar unread state.
* See `UNREAD_TRIGGER_KINDS` for the exact kind set.
*/
onChannelMessage?: (channelId: string, event: RelayEvent) => void;
/**
@@ -73,13 +72,6 @@ const UNREAD_TRIGGER_KINDS = new Set<number>(CHANNEL_MESSAGE_EVENT_KINDS);
export const EMPTY_SET: ReadonlySet<string> = new Set();
export function shouldRouteChannelUnreadEvent(
channel: Pick<Channel, "channelType"> | undefined,
isThreadedReply: boolean,
): boolean {
return !isThreadedReply || channel?.channelType === "dm";
}
function isExternalMentionEvent(event: RelayEvent, currentPubkey: string) {
return (
currentPubkey.length > 0 && event.pubkey.toLowerCase() !== currentPubkey
@@ -237,18 +229,11 @@ export function useLiveChannelUpdates(
if (isThreadedReply) {
options.onThreadReplyCandidate?.(channelId, event);
}
} else if (
shouldRouteChannelUnreadEvent(
dmChannelMap.get(channelId),
isThreadedReply,
)
) {
} else {
options.onChannelMessage?.(channelId, event);
if (isThreadedReply) {
options.onThreadReplyNotification?.(channelId, event);
}
} else {
options.onThreadReplyNotification?.(channelId, event);
}
if (shouldNotify && isThreadedReply) {
@@ -1,19 +1,22 @@
import * as React from "react";
import {
EMPTY_SET,
shouldRouteChannelUnreadEvent,
useLiveChannelUpdates,
type UseLiveChannelUpdatesOptions,
} from "@/features/channels/useLiveChannelUpdates";
import {
countUnreadAppBadgeObservedEvents,
countUnreadBadgeObservedEvents,
countUnreadHighPriorityObservedEvents,
countUnreadObservedEvents,
makeObservedUnreadEvent,
mapsEqual,
observedUnreadEventReadAt,
recordObservedUnreadEvent,
type ObservedUnreadEvent,
} from "@/features/channels/unreadChannelCounts";
import { useReadState } from "@/features/channels/readState/useReadState";
import { makeRootIdStore } from "@/features/channels/unreadRootIdStore";
import {
getThreadReference,
isBroadcastReply,
@@ -40,41 +43,6 @@ type UseUnreadChannelsOptions = UseLiveChannelUpdatesOptions & {
// per-channel limit elsewhere in the app.
const CATCH_UP_LIMIT = 1000;
// All four thread root-id sets (participation, authored, mentioned, muted)
// share the same localStorage shape: a per-pubkey JSON array of ids, capped to
// the newest N entries on write and tolerant of malformed/absent data on read.
// One factory yields the read/write pair for each so the only difference is the
// key prefix. The closures capture the prefix lexically (no `this`), so a
// caller can alias one store's `write` into a variable and call it bare.
function makeRootIdStore(prefix: string, maxEntries = 1000) {
const storageKey = (pubkey: string) => `${prefix}:${pubkey}`;
return {
read(pubkey: string): Set<string> {
try {
const raw = window.localStorage.getItem(storageKey(pubkey));
if (!raw) return new Set();
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return new Set();
return new Set(
parsed.filter((id): id is string => typeof id === "string"),
);
} catch {
return new Set();
}
},
write(pubkey: string, rootIds: Set<string>): void {
try {
const arr = [...rootIds];
const capped =
arr.length > maxEntries ? arr.slice(arr.length - maxEntries) : arr;
window.localStorage.setItem(storageKey(pubkey), JSON.stringify(capped));
} catch {
// Ignore storage errors (private browsing, quota exceeded).
}
},
};
}
const participationStore = makeRootIdStore("buzz-thread-participation.v1");
const authoredStore = makeRootIdStore("buzz-thread-authored.v1");
// Thread roots where an external message @-mentioned the current user. The
@@ -436,12 +404,20 @@ export function useUnreadChannels(
channel?.channelType === "dm" ||
(normalizedPubkey !== null &&
isHighPriorityEventForUser(event, normalizedPubkey));
const didRecordUnreadEvent = recordUnreadEvent(channelId, {
id: event.id,
createdAt: event.created_at,
rootId: resolveObservedUnreadRootId(event.tags),
highPriority: isHighPriority,
});
const isThreadedReply =
getThreadReference(event.tags).parentId !== null &&
!isBroadcastReply(event.tags);
const didRecordUnreadEvent = recordUnreadEvent(
channelId,
makeObservedUnreadEvent({
id: event.id,
createdAt: event.created_at,
rootId: resolveObservedUnreadRootId(event.tags),
highPriority: isHighPriority,
channelType: channel?.channelType,
isThreadedReply,
}),
);
const current = latestByChannelRef.current.get(channelId) ?? 0;
if (event.created_at > current) {
latestByChannelRef.current.set(channelId, event.created_at);
@@ -694,21 +670,23 @@ export function useUnreadChannels(
const evtRef = getThreadReference(event.tags);
const isThreadedReply =
evtRef.parentId !== null && !isBroadcastReply(event.tags);
if (shouldRouteChannelUnreadEvent(ch, isThreadedReply)) {
if (event.created_at > maxExternal) {
maxExternal = event.created_at;
}
const isHighPriority =
chType === "dm" ||
(normalizedPubkey !== null &&
isHighPriorityEventForUser(event, normalizedPubkey));
unreadEvents.push({
if (event.created_at > maxExternal) {
maxExternal = event.created_at;
}
const isHighPriority =
chType === "dm" ||
(normalizedPubkey !== null &&
isHighPriorityEventForUser(event, normalizedPubkey));
unreadEvents.push(
makeObservedUnreadEvent({
id: event.id,
createdAt: event.created_at,
rootId: resolveObservedUnreadRootId(event.tags),
highPriority: isHighPriority,
});
}
channelType: chType,
isThreadedReply,
}),
);
if (isThreadedReply) {
threadReplies.push({
id: event.id,
@@ -821,12 +799,14 @@ export function useUnreadChannels(
unreadChannelIds: new Set<string>(),
highPriorityUnreadChannelIds: new Set<string>(),
unreadChannelCounts: new Map<string, number>(),
unreadChannelNotificationCount: 0,
};
}
const unread = new Set<string>();
const highPriority = new Set<string>();
const counts = new Map<string, number>();
let unreadChannelNotificationCount = 0;
for (const channel of channels) {
if (channel.id === activeChannelId) continue;
@@ -835,6 +815,7 @@ export function useUnreadChannels(
// Forced-unread is dot tier only — not high-priority.
unread.add(channel.id);
counts.set(channel.id, 1);
unreadChannelNotificationCount += 1;
continue;
}
@@ -856,7 +837,15 @@ export function useUnreadChannels(
if (unreadCount === 0) continue;
unread.add(channel.id);
counts.set(channel.id, unreadCount);
const badgeCount = countUnreadBadgeObservedEvents(
observedEvents,
readAtForObservedEvent,
);
counts.set(channel.id, badgeCount);
unreadChannelNotificationCount += countUnreadAppBadgeObservedEvents(
observedEvents,
readAtForObservedEvent,
);
// DM channels: any unread DM is high-priority.
if (channel.channelType === "dm") {
@@ -877,6 +866,7 @@ export function useUnreadChannels(
unreadChannelIds: unread,
highPriorityUnreadChannelIds: highPriority,
unreadChannelCounts: counts,
unreadChannelNotificationCount,
};
}, [
activeChannelId,
@@ -919,9 +909,8 @@ export function useUnreadChannels(
? prevUnreadCountsRef.current
: rawUnread.unreadChannelCounts;
prevUnreadCountsRef.current = unreadChannelCounts;
const unreadChannelNotificationCount = [
...unreadChannelCounts.values(),
].reduce((total, count) => total + count, 0);
const unreadChannelNotificationCount =
rawUnread.unreadChannelNotificationCount;
const unreadChannelIdsRef = React.useRef(unreadChannelIds);
unreadChannelIdsRef.current = unreadChannelIds;
@@ -233,7 +233,10 @@ export function ChannelMenuButton({
)}
/>
) : null}
{hasUnread && !isActive && channel.channelType !== "dm" ? (
{hasUnread &&
unreadCount > 0 &&
!isActive &&
channel.channelType !== "dm" ? (
<UnreadCountBadge
channelName={channel.name}
className="ml-auto"
+56 -4
View File
@@ -71,11 +71,15 @@ function withAdditionalBadgeCount(baseline: { count: number }, count: number) {
return { state: "count", count: baseline.count + count };
}
function withDotOnlyBadge(baseline: { state: string; count: number }) {
return baseline.count > 0 ? baseline : { state: "dot", count: 0 };
}
test.beforeEach(async ({ page }) => {
await installMockBridge(page);
});
test("numeric badge increments for regular message in inactive channel", async ({
test("regular message bolds inactive channel without numeric badge", async ({
page,
}) => {
await page.goto("/");
@@ -96,8 +100,12 @@ test("numeric badge increments for regular message in inactive channel", async (
{ pubkey: TEST_IDENTITIES.alice.pubkey },
);
await expect(page.getByTestId("channel-unread-random")).toBeVisible();
await waitForBadgeState(page, withAdditionalBadgeCount(baselineBadge, 1));
await expect(page.getByTestId("channel-random")).toHaveCSS(
"font-weight",
"600",
);
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
await waitForBadgeState(page, withDotOnlyBadge(baselineBadge));
});
test("numeric badge increments for @mention in inactive channel", async ({
@@ -148,6 +156,42 @@ test("numeric badge increments for DM message", async ({ page }) => {
await waitForBadgeState(page, withAdditionalBadgeCount(baselineBadge, 1));
});
test("numeric badge increments for interested thread reply in inactive channel", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "random");
const baselineBadge = await getSettledBadgeState(page);
const rootEventId = await page.evaluate(() => {
const root = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "random",
content: "Conversation I started",
kind: 40002,
pubkey: "deadbeef".repeat(8),
});
return root?.id;
});
await page.evaluate(
({ parentEventId, pubkey }) => {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "random",
content: "Thread reply to a followed conversation",
kind: 40002,
parentEventId,
pubkey,
});
},
{ parentEventId: rootEventId, pubkey: TEST_IDENTITIES.alice.pubkey },
);
await expect(page.getByTestId("channel-unread-random")).toBeVisible();
await waitForBadgeState(page, withAdditionalBadgeCount(baselineBadge, 1));
});
test("numeric badge increments for broadcast reply in inactive channel", async ({
page,
}) => {
@@ -202,11 +246,19 @@ test("mark-as-read via context menu clears channel unread indicator", async ({
{ pubkey: TEST_IDENTITIES.alice.pubkey },
);
await expect(page.getByTestId("channel-unread-random")).toBeVisible();
await expect(page.getByTestId("channel-random")).toHaveCSS(
"font-weight",
"600",
);
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
await page.getByTestId("channel-random").click({ button: "right" });
await page.getByText("Mark as read").click();
await expect(page.getByTestId("channel-random")).not.toHaveCSS(
"font-weight",
"600",
);
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
await waitForBadgeState(page, baselineBadge);
});
+10 -2
View File
@@ -1039,7 +1039,11 @@ test("sidebar shows unread indicator for newly active channels", async ({
{ pubkey: TEST_IDENTITIES.alice.pubkey },
);
await expect(page.getByTestId("channel-unread-random")).toBeVisible();
await expect(page.getByTestId("channel-random")).toHaveCSS(
"font-weight",
"600",
);
await expect(page.getByTestId("channel-unread-random")).toHaveCount(0);
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
@@ -1068,7 +1072,11 @@ test("sidebar shows unread indicator for new forum posts", async ({ page }) => {
{ pubkey: TEST_IDENTITIES.alice.pubkey },
);
await expect(page.getByTestId("channel-unread-watercooler")).toBeVisible();
await expect(page.getByTestId("channel-watercooler")).toHaveCSS(
"font-weight",
"600",
);
await expect(page.getByTestId("channel-unread-watercooler")).toHaveCount(0);
await page.getByTestId("channel-watercooler").click();
await expect(page.getByTestId("chat-title")).toHaveText("watercooler");
@@ -762,10 +762,9 @@ test.describe("thread unread indicator screenshots", () => {
});
});
// Thread-only replies now route through Inbox instead of lighting the
// channel's sidebar dot. Viewing the channel should still leave the channel
// dot clear when the only new item is an unopened thread reply.
test("11-thread-reply-does-not-light-sidebar-dot-after-channel-view", async ({
// Thread-only replies now also light the channel sidebar badge. Viewing the
// channel should leave unopened thread replies unread until the thread is read.
test("11-thread-reply-lights-sidebar-badge-after-channel-view", async ({
page,
}) => {
await installMockBridge(page);
@@ -799,27 +798,25 @@ test.describe("thread unread indicator screenshots", () => {
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
// The crux: leave general. Its sidebar dot must stay clear because
// thread-only reply activity belongs in Inbox, not the channel nav.
// The crux: leave general. The unopened thread reply should still keep a
// numeric channel sidebar badge until the thread itself is read.
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
await expect(page.getByTestId("channel-unread-general")).toHaveCount(0);
await expect(page.getByTestId("channel-unread-general")).toBeVisible();
await page.screenshot({
path: `${SHOTS}/11-thread-reply-no-sidebar-dot.png`,
path: `${SHOTS}/11-thread-reply-sidebar-badge.png`,
});
});
// Regression guard for the all-replies window: when the loaded window holds
// ONLY thread replies (the top-level root has scrolled past the history
// limit), thread-only activity should still stay out of channel unread dots.
// limit), thread-only activity should still light the channel sidebar badge.
//
// The `all-replies` fixture carries a far-future `lastMessageAt` (standing in
// for the backend's reply-inclusive MAX) with no top-level message in its
// window.
test("12-thread-reply-does-not-light-all-replies-sidebar-dot", async ({
page,
}) => {
test("12-thread-reply-lights-all-replies-sidebar-badge", async ({ page }) => {
await installMockBridge(page);
await page.goto("/");
@@ -843,14 +840,14 @@ test.describe("thread unread indicator screenshots", () => {
await page.getByTestId("channel-all-replies").click();
await expect(page.getByTestId("chat-title")).toHaveText("all-replies");
// The crux: leave the channel. Its sidebar dot should remain clear because
// thread-only reply activity belongs in Inbox.
// The crux: leave the channel. Its unopened thread reply should still keep
// a numeric channel sidebar badge until the thread itself is read.
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await expect(page.getByTestId("channel-unread-all-replies")).toHaveCount(0);
await expect(page.getByTestId("channel-unread-all-replies")).toBeVisible();
await page.screenshot({
path: `${SHOTS}/12-thread-reply-no-all-replies-sidebar-dot.png`,
path: `${SHOTS}/12-thread-reply-all-replies-sidebar-badge.png`,
});
});