mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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`,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user