mirror of
https://github.com/block/buzz.git
synced 2026-11-02 07:22:55 +00:00
fix(desktop): correct thread-unread badge flicker, stale clear, phantom count, mention gate, and nested count (#1080)
Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
@@ -308,6 +308,7 @@ export function AppShell() {
|
||||
setContextParentResolver,
|
||||
participatedRootIds,
|
||||
authoredRootIds,
|
||||
mentionedRootIds,
|
||||
threadActivityItems,
|
||||
mutedRootIds,
|
||||
muteThread,
|
||||
@@ -364,8 +365,15 @@ export function AppShell() {
|
||||
!mutedRootIds.has(rootId) &&
|
||||
(followedRootIds.has(rootId) ||
|
||||
participatedRootIds.has(rootId) ||
|
||||
authoredRootIds.has(rootId)),
|
||||
[followedRootIds, mutedRootIds, participatedRootIds, authoredRootIds],
|
||||
authoredRootIds.has(rootId) ||
|
||||
mentionedRootIds.has(rootId)),
|
||||
[
|
||||
followedRootIds,
|
||||
mutedRootIds,
|
||||
participatedRootIds,
|
||||
authoredRootIds,
|
||||
mentionedRootIds,
|
||||
],
|
||||
);
|
||||
|
||||
const handleFollowThread = React.useCallback(
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from "node:test";
|
||||
|
||||
import { computeThreadUnreadMarker } from "../../messages/lib/unreadMarker.ts";
|
||||
import {
|
||||
buildDirectRepliesByParentId,
|
||||
directRepliesMaxCreatedAt,
|
||||
subtreeMaxCreatedAt,
|
||||
} from "./subtreeCreatedAt.ts";
|
||||
@@ -186,3 +187,29 @@ test("expandAfterOpen_dividerFromSnapshot_holds_whileLiveFrontierConsumes", () =
|
||||
// ...even though the live frontier has consumed the whole branch.
|
||||
assert.equal(consumeFromLive.firstUnreadReplyId, null);
|
||||
});
|
||||
|
||||
test("buildDirectRepliesByParentId_groupsDirectRepliesByParent_inOrder", () => {
|
||||
const messages = [
|
||||
{ id: "root", parentId: null, createdAt: 100 },
|
||||
{ id: "r1", parentId: "root", createdAt: 200 },
|
||||
{ id: "deep", parentId: "r1", createdAt: 300 },
|
||||
{ id: "r2", parentId: "root", createdAt: 250 },
|
||||
];
|
||||
const index = buildDirectRepliesByParentId(messages);
|
||||
// Only DIRECT children, in timeline order — not transitive descendants.
|
||||
assert.deepEqual(
|
||||
index.get("root")?.map((m) => m.id),
|
||||
["r1", "r2"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
index.get("r1")?.map((m) => m.id),
|
||||
["deep"],
|
||||
);
|
||||
// A top-level message with no replies is absent (the seed/count guard).
|
||||
assert.equal(index.has("r2"), false);
|
||||
});
|
||||
|
||||
test("buildDirectRepliesByParentId_topLevelOnly_returnsEmpty", () => {
|
||||
const messages = [{ id: "root", parentId: null, createdAt: 100 }];
|
||||
assert.equal(buildDirectRepliesByParentId(messages).size, 0);
|
||||
});
|
||||
|
||||
@@ -74,6 +74,24 @@ export function buildDirectReplyIdsByParentId(
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps each parent message id to its direct-reply objects in timeline order.
|
||||
* Built once so per-thread badge consumers resolve direct replies in O(1)
|
||||
* instead of re-scanning the whole timeline per top-level message.
|
||||
*/
|
||||
export function buildDirectRepliesByParentId<T extends ReplyGraphMessage>(
|
||||
messages: readonly T[],
|
||||
): Map<string, T[]> {
|
||||
const map = new Map<string, T[]>();
|
||||
for (const message of messages) {
|
||||
if (!message.parentId) continue;
|
||||
const currentReplies = map.get(message.parentId) ?? [];
|
||||
currentReplies.push(message);
|
||||
map.set(message.parentId, currentReplies);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/** Maps each message id to its `createdAt`. */
|
||||
export function buildCreatedAtByMessageId(
|
||||
messages: readonly ReplyGraphMessage[],
|
||||
|
||||
115
desktop/src/features/channels/lib/threadBadgeCounts.test.mjs
Normal file
115
desktop/src/features/channels/lib/threadBadgeCounts.test.mjs
Normal file
@@ -0,0 +1,115 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { computeThreadBadgeCounts } from "./threadBadgeCounts.ts";
|
||||
import { buildDirectRepliesByParentId } from "./subtreeCreatedAt.ts";
|
||||
|
||||
// Minimal TimelineMessage shape the badge counter reads: id, parentId,
|
||||
// createdAt, pubkey. createdAt defaults high so replies count unread against a
|
||||
// null frontier unless a test sets it lower.
|
||||
const msg = (id, parentId, createdAt = 100, pubkey = "author") => ({
|
||||
id,
|
||||
parentId,
|
||||
createdAt,
|
||||
pubkey,
|
||||
});
|
||||
|
||||
const countAll = () => true;
|
||||
const counts = (messages, frontiers, isNotified = countAll, currentPubkey) =>
|
||||
computeThreadBadgeCounts(
|
||||
messages,
|
||||
buildDirectRepliesByParentId(messages),
|
||||
frontiers,
|
||||
isNotified,
|
||||
currentPubkey,
|
||||
);
|
||||
|
||||
test("computeThreadBadgeCounts_directRepliesOnly_countsEach", () => {
|
||||
const messages = [msg("root", null), msg("a", "root"), msg("b", "root")];
|
||||
assert.equal(counts(messages, undefined).get("root"), 2);
|
||||
});
|
||||
|
||||
test("computeThreadBadgeCounts_nestedReply_countsTowardRoot", () => {
|
||||
// root -> a -> b: b is a reply-to-a-reply. Pre-fix it lived under a's key
|
||||
// and was never tallied toward root; the subtree walk must count it.
|
||||
const messages = [msg("root", null), msg("a", "root"), msg("b", "a")];
|
||||
assert.equal(counts(messages, undefined).get("root"), 2);
|
||||
});
|
||||
|
||||
test("computeThreadBadgeCounts_deepChain_countsWholeSubtree", () => {
|
||||
// root -> a -> b -> c -> d: every descendant tallies toward the root.
|
||||
const messages = [
|
||||
msg("root", null),
|
||||
msg("a", "root"),
|
||||
msg("b", "a"),
|
||||
msg("c", "b"),
|
||||
msg("d", "c"),
|
||||
];
|
||||
assert.equal(counts(messages, undefined).get("root"), 4);
|
||||
});
|
||||
|
||||
test("computeThreadBadgeCounts_branchingSubtree_countsAllBranches", () => {
|
||||
// root -> a -> {b, c}; root -> d. Four descendants across two branches.
|
||||
const messages = [
|
||||
msg("root", null),
|
||||
msg("a", "root"),
|
||||
msg("b", "a"),
|
||||
msg("c", "a"),
|
||||
msg("d", "root"),
|
||||
];
|
||||
assert.equal(counts(messages, undefined).get("root"), 4);
|
||||
});
|
||||
|
||||
test("computeThreadBadgeCounts_rootWithNoReplies_omitted", () => {
|
||||
const messages = [msg("root", null)];
|
||||
assert.equal(counts(messages, undefined).has("root"), false);
|
||||
});
|
||||
|
||||
test("computeThreadBadgeCounts_notNotified_omitted", () => {
|
||||
const messages = [msg("root", null), msg("a", "root"), msg("b", "a")];
|
||||
assert.equal(counts(messages, undefined, () => false).size, 0);
|
||||
});
|
||||
|
||||
test("computeThreadBadgeCounts_frontierCoversNestedReplies_excludesRead", () => {
|
||||
// Frontier 150: a (100) is read, only nested b (200) remains unread.
|
||||
const messages = [
|
||||
msg("root", null),
|
||||
msg("a", "root", 100),
|
||||
msg("b", "a", 200),
|
||||
];
|
||||
const frontiers = new Map([["root", 150]]);
|
||||
assert.equal(counts(messages, frontiers).get("root"), 1);
|
||||
});
|
||||
|
||||
test("computeThreadBadgeCounts_frontierCoversWholeSubtree_omitsRoot", () => {
|
||||
const messages = [
|
||||
msg("root", null),
|
||||
msg("a", "root", 100),
|
||||
msg("b", "a", 120),
|
||||
];
|
||||
const frontiers = new Map([["root", 150]]);
|
||||
assert.equal(counts(messages, frontiers).has("root"), false);
|
||||
});
|
||||
|
||||
test("computeThreadBadgeCounts_selfAuthoredNestedReply_notCounted", () => {
|
||||
// A nested reply authored by the current user never counts as unread.
|
||||
const messages = [
|
||||
msg("root", null),
|
||||
msg("a", "root", 100, "other"),
|
||||
msg("b", "a", 200, "ME"),
|
||||
];
|
||||
assert.equal(counts(messages, undefined, countAll, "me").get("root"), 1);
|
||||
});
|
||||
|
||||
test("computeThreadBadgeCounts_multipleRoots_eachCountsOwnSubtree", () => {
|
||||
const messages = [
|
||||
msg("root1", null),
|
||||
msg("a", "root1"),
|
||||
msg("b", "a"),
|
||||
msg("root2", null),
|
||||
msg("c", "root2"),
|
||||
];
|
||||
const result = counts(messages, undefined);
|
||||
assert.equal(result.get("root1"), 2);
|
||||
assert.equal(result.get("root2"), 1);
|
||||
});
|
||||
76
desktop/src/features/channels/lib/threadBadgeCounts.ts
Normal file
76
desktop/src/features/channels/lib/threadBadgeCounts.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { computeThreadUnreadMarker } from "@/features/messages/lib/unreadMarker";
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
|
||||
/**
|
||||
* All reply messages in a root's subtree — direct children plus every deeper
|
||||
* descendant, walked through the direct-replies adjacency map. A reply-to-a-
|
||||
* reply must count toward the root's badge, so the badge tally needs the whole
|
||||
* subtree rather than the root's direct children alone.
|
||||
*
|
||||
* Terminates without a visited-set: buildDirectRepliesByParentId places each
|
||||
* message under exactly one parent key, and the only caller seeds the walk from
|
||||
* true roots (parentId === null), so a node in a malformed parent cycle — whose
|
||||
* members all key off each other, never off a root — is unreachable from any
|
||||
* root's bucket. Seeding from a non-root id, or a builder that filed one node
|
||||
* under two keys, would break that invariant.
|
||||
*/
|
||||
function collectSubtreeReplies(
|
||||
rootId: string,
|
||||
directRepliesByParentId: ReadonlyMap<string, TimelineMessage[]>,
|
||||
): TimelineMessage[] {
|
||||
const replies: TimelineMessage[] = [];
|
||||
const pending = [...(directRepliesByParentId.get(rootId) ?? [])];
|
||||
while (pending.length > 0) {
|
||||
const reply = pending.pop();
|
||||
if (!reply) continue;
|
||||
replies.push(reply);
|
||||
pending.push(...(directRepliesByParentId.get(reply.id) ?? []));
|
||||
}
|
||||
return replies;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-thread unread reply counts for the summary rows in the main timeline.
|
||||
*
|
||||
* Counts are computed only for threads the user has notification interest in
|
||||
* (`isNotified`) and measured against the per-root frontier snapshot rather
|
||||
* than the live marker, so badges stay stable for the session (see
|
||||
* nextThreadBadgeFrontier for the snapshot-advance-on-read rationale). The
|
||||
* count spans the root's WHOLE subtree, so a reply nested under another reply
|
||||
* still tallies toward the root's badge.
|
||||
*
|
||||
* @param messages Top-level timeline entries in chronological order.
|
||||
* @param directRepliesByParentId Direct replies keyed by parent id, walked to
|
||||
* collect each root's full descendant subtree.
|
||||
* @param frontiers Per-root read frontier in unix seconds, or null/undefined
|
||||
* when the thread was never read (every reply counts unread).
|
||||
* @param isNotified Whether a thread root is one the user is notified for.
|
||||
* @param currentPubkey Replies authored by this pubkey never count as unread.
|
||||
*/
|
||||
export function computeThreadBadgeCounts(
|
||||
messages: TimelineMessage[],
|
||||
directRepliesByParentId: ReadonlyMap<string, TimelineMessage[]>,
|
||||
frontiers: ReadonlyMap<string, number | null> | undefined,
|
||||
isNotified: (rootId: string) => boolean,
|
||||
currentPubkey?: string,
|
||||
): Map<string, number> {
|
||||
const counts = new Map<string, number>();
|
||||
for (const message of messages) {
|
||||
if (message.parentId) continue;
|
||||
if (!isNotified(message.id)) continue;
|
||||
const subtreeReplies = collectSubtreeReplies(
|
||||
message.id,
|
||||
directRepliesByParentId,
|
||||
);
|
||||
if (subtreeReplies.length === 0) continue;
|
||||
const { unreadCount } = computeThreadUnreadMarker(
|
||||
subtreeReplies,
|
||||
frontiers?.get(message.id) ?? null,
|
||||
currentPubkey,
|
||||
);
|
||||
if (unreadCount > 0) {
|
||||
counts.set(message.id, unreadCount);
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { nextThreadBadgeFrontier } from "./threadBadgeFrontier.ts";
|
||||
import { seedThreadBadgeFrontiers } from "./threadBadgeFrontier.ts";
|
||||
import { buildDirectRepliesByParentId } from "./subtreeCreatedAt.ts";
|
||||
|
||||
const msg = (id, parentId) => ({ id, parentId });
|
||||
const seedAll = () => true;
|
||||
const seed = (frontiers, messages, isNotified, getReadAt) =>
|
||||
seedThreadBadgeFrontiers(
|
||||
frontiers,
|
||||
messages,
|
||||
buildDirectRepliesByParentId(messages),
|
||||
isNotified,
|
||||
getReadAt,
|
||||
);
|
||||
|
||||
test("nextThreadBadgeFrontier_unseededNullMarker_seedsNull", () => {
|
||||
// Thread never read: snapshot seeds to null (everything unread).
|
||||
assert.equal(nextThreadBadgeFrontier(undefined, null), null);
|
||||
});
|
||||
|
||||
test("nextThreadBadgeFrontier_unseededWithMarker_seedsToMarker", () => {
|
||||
assert.equal(nextThreadBadgeFrontier(undefined, 100), 100);
|
||||
});
|
||||
|
||||
test("nextThreadBadgeFrontier_readAdvancesMarker_advancesSnapshot", () => {
|
||||
// Snapshot frozen at open (null), user reads → live marker 200 → badge clears.
|
||||
assert.equal(nextThreadBadgeFrontier(null, 200), 200);
|
||||
});
|
||||
|
||||
test("nextThreadBadgeFrontier_markerNewerThanStored_advances", () => {
|
||||
assert.equal(nextThreadBadgeFrontier(100, 250), 250);
|
||||
});
|
||||
|
||||
test("nextThreadBadgeFrontier_markerOlderThanStored_keepsStored", () => {
|
||||
// Monotonic: a stale lower marker never lowers the snapshot.
|
||||
assert.equal(nextThreadBadgeFrontier(250, 100), 250);
|
||||
});
|
||||
|
||||
test("nextThreadBadgeFrontier_markerNullAfterSeed_keepsStored", () => {
|
||||
// Live marker reads null (never read) but snapshot already advanced — hold.
|
||||
assert.equal(nextThreadBadgeFrontier(150, null), 150);
|
||||
});
|
||||
|
||||
test("nextThreadBadgeFrontier_markerEqualsStored_unchanged", () => {
|
||||
assert.equal(nextThreadBadgeFrontier(150, 150), 150);
|
||||
});
|
||||
|
||||
test("nextThreadBadgeFrontier_storedNullMarkerZero_advancesToZero", () => {
|
||||
// Zero is a valid frontier (epoch); null is strictly lower than any number.
|
||||
assert.equal(nextThreadBadgeFrontier(null, 0), 0);
|
||||
});
|
||||
|
||||
test("seedThreadBadgeFrontiers_threadWithReplies_seedsToMarker", () => {
|
||||
const frontiers = new Map();
|
||||
const messages = [msg("root", null), msg("r1", "root")];
|
||||
seed(frontiers, messages, seedAll, (id) => (id === "root" ? 100 : null));
|
||||
assert.equal(frontiers.get("root"), 100);
|
||||
});
|
||||
|
||||
test("seedThreadBadgeFrontiers_threadWithoutReplies_skipped", () => {
|
||||
const frontiers = new Map();
|
||||
seed(frontiers, [msg("root", null)], seedAll, () => 100);
|
||||
assert.equal(frontiers.has("root"), false);
|
||||
});
|
||||
|
||||
test("seedThreadBadgeFrontiers_notNotified_skipped", () => {
|
||||
const frontiers = new Map();
|
||||
const messages = [msg("root", null), msg("r1", "root")];
|
||||
seed(
|
||||
frontiers,
|
||||
messages,
|
||||
() => false,
|
||||
() => 100,
|
||||
);
|
||||
assert.equal(frontiers.has("root"), false);
|
||||
});
|
||||
|
||||
test("seedThreadBadgeFrontiers_replyEntry_neverSeeded", () => {
|
||||
// A reply is never a badge root even if its id collides with a notified set.
|
||||
const frontiers = new Map();
|
||||
const messages = [msg("r1", "root"), msg("r2", "root")];
|
||||
seed(frontiers, messages, seedAll, () => 100);
|
||||
assert.equal(frontiers.size, 0);
|
||||
});
|
||||
|
||||
test("seedThreadBadgeFrontiers_reseed_advancesMonotonically", () => {
|
||||
const frontiers = new Map([["root", 100]]);
|
||||
const messages = [msg("root", null), msg("r1", "root")];
|
||||
// Re-render after the live marker advanced to 250 on read.
|
||||
seed(frontiers, messages, seedAll, () => 250);
|
||||
assert.equal(frontiers.get("root"), 250);
|
||||
// A stale lower marker never lowers an already-advanced snapshot.
|
||||
seed(frontiers, messages, seedAll, () => 100);
|
||||
assert.equal(frontiers.get("root"), 250);
|
||||
});
|
||||
57
desktop/src/features/channels/lib/threadBadgeFrontier.ts
Normal file
57
desktop/src/features/channels/lib/threadBadgeFrontier.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
|
||||
// Decide the next value for a thread's badge frontier snapshot. The snapshot is
|
||||
// seeded once at channel-open (reflecting "what was unread on open") and then
|
||||
// advanced monotonically toward the live thread read marker as the user reads,
|
||||
// so the badge clears after a read without waiting for channel re-entry.
|
||||
//
|
||||
// The advance target is ALWAYS the live marker (what the user actually
|
||||
// consumed), never "latest reply": a subsequent reply newer than the marker
|
||||
// re-raises the badge, and a collapsed-branch reply the marker never covered
|
||||
// stays unread. Monotonic `Math.max` guards against a stale lower marker.
|
||||
//
|
||||
// Returns the value the snapshot should hold:
|
||||
// - `stored === undefined` (unseeded): seed to the live marker.
|
||||
// - otherwise: the greater of the stored snapshot and the live marker, where
|
||||
// `null` (never read) is the lowest possible frontier.
|
||||
export function nextThreadBadgeFrontier(
|
||||
stored: number | null | undefined,
|
||||
liveMarker: number | null,
|
||||
): number | null {
|
||||
if (stored === undefined) {
|
||||
return liveMarker;
|
||||
}
|
||||
if (liveMarker === null) {
|
||||
return stored;
|
||||
}
|
||||
if (stored === null) {
|
||||
return liveMarker;
|
||||
}
|
||||
return Math.max(stored, liveMarker);
|
||||
}
|
||||
|
||||
// Seed/advance the per-root badge frontier snapshots for one channel, in place.
|
||||
// Captures only top-level notified threads that have replies; each entry is
|
||||
// seeded once at open then advanced toward the live marker on subsequent reads
|
||||
// (see nextThreadBadgeFrontier). Called during render so snapshots reflect
|
||||
// "what was unread on open," matching the openFrontierRef pattern.
|
||||
export function seedThreadBadgeFrontiers(
|
||||
channelFrontiers: Map<string, number | null>,
|
||||
messages: TimelineMessage[],
|
||||
directRepliesByParentId: ReadonlyMap<string, TimelineMessage[]>,
|
||||
isNotified: (rootId: string) => boolean,
|
||||
getReadAt: (rootId: string) => number | null,
|
||||
): void {
|
||||
for (const message of messages) {
|
||||
if (message.parentId) continue;
|
||||
if (!isNotified(message.id)) continue;
|
||||
if (!directRepliesByParentId.has(message.id)) continue;
|
||||
channelFrontiers.set(
|
||||
message.id,
|
||||
nextThreadBadgeFrontier(
|
||||
channelFrontiers.get(message.id),
|
||||
getReadAt(message.id),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,7 @@ export function ChannelScreen({
|
||||
unfollowThread,
|
||||
isFollowingThread,
|
||||
isNotifiedForThread,
|
||||
readStateVersion,
|
||||
setTopbarSearchHidden,
|
||||
} = useAppShell();
|
||||
const {
|
||||
@@ -369,6 +370,7 @@ export function ChannelScreen({
|
||||
markChannelUnread,
|
||||
markThreadRead,
|
||||
isNotifiedForThread,
|
||||
readStateVersion,
|
||||
});
|
||||
const editTargetMessage = React.useMemo(
|
||||
() =>
|
||||
|
||||
@@ -2,12 +2,15 @@ import * as React from "react";
|
||||
|
||||
import {
|
||||
buildCreatedAtByMessageId,
|
||||
buildDirectRepliesByParentId,
|
||||
buildDirectReplyIdsByParentId,
|
||||
collectReplyDescendantIds,
|
||||
directRepliesMaxCreatedAt,
|
||||
subtreeMaxCreatedAt,
|
||||
} from "@/features/channels/lib/subtreeCreatedAt";
|
||||
import { computeThreadReplyUnreadCounts } from "@/features/channels/lib/threadReplyUnreadCounts";
|
||||
import { computeThreadBadgeCounts } from "@/features/channels/lib/threadBadgeCounts";
|
||||
import { seedThreadBadgeFrontiers } from "@/features/channels/lib/threadBadgeFrontier";
|
||||
import {
|
||||
buildThreadPanelDataFromIndex,
|
||||
buildThreadPanelIndex,
|
||||
@@ -17,6 +20,7 @@ import {
|
||||
computeThreadUnreadMarker,
|
||||
} from "@/features/messages/lib/unreadMarker";
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import { isConversationalUnreadKind } from "@/shared/constants/kinds";
|
||||
|
||||
import { useWelcomeInitialUnreadSuppression } from "./useWelcomeInitialUnreadSuppression";
|
||||
|
||||
@@ -33,6 +37,7 @@ type UseChannelUnreadStateOptions = {
|
||||
markChannelUnread: (channelId: string) => void;
|
||||
markThreadRead: (rootId: string, timestamp: number) => void;
|
||||
isNotifiedForThread: (rootId: string) => boolean;
|
||||
readStateVersion: number;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -60,6 +65,7 @@ export function useChannelUnreadState({
|
||||
markChannelUnread,
|
||||
markThreadRead,
|
||||
isNotifiedForThread,
|
||||
readStateVersion,
|
||||
}: UseChannelUnreadStateOptions) {
|
||||
// Capture the read frontier as it stood the instant this channel was opened,
|
||||
// BEFORE the mark-read effect (in ChannelScreen) advances it to latest.
|
||||
@@ -115,6 +121,10 @@ export function useChannelUnreadState({
|
||||
() => buildDirectReplyIdsByParentId(timelineMessages),
|
||||
[timelineMessages],
|
||||
);
|
||||
const directRepliesByParentId = React.useMemo(
|
||||
() => buildDirectRepliesByParentId(timelineMessages),
|
||||
[timelineMessages],
|
||||
);
|
||||
const getFirstReplyIdForMessage = React.useCallback(
|
||||
(messageId: string) => directReplyIdsByParentId.get(messageId)?.[0] ?? null,
|
||||
[directReplyIdsByParentId],
|
||||
@@ -166,10 +176,14 @@ export function useChannelUnreadState({
|
||||
|
||||
// Oldest unread top-level message + count from the open-time frontier.
|
||||
// Keyed per channel so the pill/divider survive the mark-read effect.
|
||||
// Non-conversational kinds (system rows, job-lifecycle events) are filtered
|
||||
// out first so they don't inflate the pill; see isConversationalUnreadKind.
|
||||
const { firstUnreadMessageId, unreadCount } = React.useMemo(
|
||||
() =>
|
||||
computeChannelUnreadMarker(
|
||||
timelineMessages,
|
||||
timelineMessages.filter((message) =>
|
||||
isConversationalUnreadKind(message.kind),
|
||||
),
|
||||
openFrontierSeconds,
|
||||
isActiveChannelForcedUnread || isActiveWelcomeInitialUnreadSuppressed,
|
||||
currentPubkey,
|
||||
@@ -300,17 +314,13 @@ export function useChannelUnreadState({
|
||||
channelFrontiers = new Map();
|
||||
threadBadgeFrontiersRef.current.set(activeChannelId, channelFrontiers);
|
||||
}
|
||||
for (const message of timelineMessages) {
|
||||
if (message.parentId) continue;
|
||||
if (!isNotifiedForThread(message.id)) continue;
|
||||
if (channelFrontiers.has(message.id)) continue;
|
||||
// Only capture for messages that have thread replies
|
||||
const hasReplies = timelineMessages.some(
|
||||
(m) => m.parentId === message.id,
|
||||
);
|
||||
if (!hasReplies) continue;
|
||||
channelFrontiers.set(message.id, getThreadReadAt(message.id));
|
||||
}
|
||||
seedThreadBadgeFrontiers(
|
||||
channelFrontiers,
|
||||
timelineMessages,
|
||||
directRepliesByParentId,
|
||||
isNotifiedForThread,
|
||||
getThreadReadAt,
|
||||
);
|
||||
}
|
||||
// Clear the thread badge frontiers on channel leave (same cleanup as
|
||||
// openFrontierRef) so re-visiting captures fresh snapshots.
|
||||
@@ -321,36 +331,31 @@ export function useChannelUnreadState({
|
||||
threadBadgeFrontiersRef.current.delete(channelId);
|
||||
};
|
||||
}, [activeChannelId]);
|
||||
// Compute per-thread unread counts for summary rows in the main timeline.
|
||||
// Only compute for threads the user has notification interest in — this
|
||||
// aligns the badge display with the read-state write path. Uses the
|
||||
// snapshotted frontier (threadBadgeFrontiersRef) so badges are stable for
|
||||
// the session and don't flash when markChannelRead advances the channel
|
||||
// marker.
|
||||
const threadUnreadCounts = React.useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
const channelFrontiers = activeChannelId
|
||||
? threadBadgeFrontiersRef.current.get(activeChannelId)
|
||||
: undefined;
|
||||
for (const message of timelineMessages) {
|
||||
if (message.parentId) continue;
|
||||
if (!isNotifiedForThread(message.id)) continue;
|
||||
const directReplies = timelineMessages.filter(
|
||||
(m) => m.parentId === message.id,
|
||||
);
|
||||
if (directReplies.length === 0) continue;
|
||||
const frontier = channelFrontiers?.get(message.id) ?? null;
|
||||
const { unreadCount } = computeThreadUnreadMarker(
|
||||
directReplies,
|
||||
frontier,
|
||||
// Per-thread unread counts for the main-timeline summary rows. Pure logic
|
||||
// lives in computeThreadBadgeCounts; readStateVersion is an intentional
|
||||
// recompute trigger so the badge re-reads the snapshot the seed block above
|
||||
// advanced toward the live marker on mark-read.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: readStateVersion is the intentional recompute trigger
|
||||
const threadUnreadCounts = React.useMemo(
|
||||
() =>
|
||||
computeThreadBadgeCounts(
|
||||
timelineMessages,
|
||||
directRepliesByParentId,
|
||||
activeChannelId
|
||||
? threadBadgeFrontiersRef.current.get(activeChannelId)
|
||||
: undefined,
|
||||
isNotifiedForThread,
|
||||
currentPubkey,
|
||||
);
|
||||
if (unreadCount > 0) {
|
||||
counts.set(message.id, unreadCount);
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}, [activeChannelId, currentPubkey, timelineMessages, isNotifiedForThread]);
|
||||
),
|
||||
[
|
||||
activeChannelId,
|
||||
currentPubkey,
|
||||
timelineMessages,
|
||||
directRepliesByParentId,
|
||||
isNotifiedForThread,
|
||||
readStateVersion,
|
||||
],
|
||||
);
|
||||
|
||||
const handleMarkUnread = React.useCallback(() => {
|
||||
if (!activeChannelId) return;
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
isBroadcastReply,
|
||||
} from "@/features/messages/lib/threading";
|
||||
import {
|
||||
hasMentionForEvent,
|
||||
isHighPriorityEventForUser,
|
||||
shouldNotifyForEvent,
|
||||
} from "@/features/notifications/lib/shouldNotify";
|
||||
@@ -30,121 +31,48 @@ type UseUnreadChannelsOptions = UseLiveChannelUpdatesOptions & {
|
||||
// per-channel limit elsewhere in the app.
|
||||
const CATCH_UP_LIMIT = 1000;
|
||||
|
||||
const PARTICIPATION_STORAGE_PREFIX = "buzz-thread-participation.v1";
|
||||
const MAX_PARTICIPATION_ENTRIES = 1000;
|
||||
|
||||
function participationStorageKey(pubkey: string): string {
|
||||
return `${PARTICIPATION_STORAGE_PREFIX}:${pubkey}`;
|
||||
// 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).
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function readParticipationFromStorage(pubkey: string): Set<string> {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(participationStorageKey(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();
|
||||
}
|
||||
}
|
||||
|
||||
function writeParticipationToStorage(
|
||||
pubkey: string,
|
||||
rootIds: Set<string>,
|
||||
): void {
|
||||
try {
|
||||
const arr = [...rootIds];
|
||||
const capped =
|
||||
arr.length > MAX_PARTICIPATION_ENTRIES
|
||||
? arr.slice(arr.length - MAX_PARTICIPATION_ENTRIES)
|
||||
: arr;
|
||||
window.localStorage.setItem(
|
||||
participationStorageKey(pubkey),
|
||||
JSON.stringify(capped),
|
||||
);
|
||||
} catch {
|
||||
// Ignore storage errors (private browsing, quota exceeded).
|
||||
}
|
||||
}
|
||||
|
||||
const AUTHORED_STORAGE_PREFIX = "buzz-thread-authored.v1";
|
||||
const MAX_AUTHORED_ENTRIES = 1000;
|
||||
|
||||
function authoredStorageKey(pubkey: string): string {
|
||||
return `${AUTHORED_STORAGE_PREFIX}:${pubkey}`;
|
||||
}
|
||||
|
||||
function readAuthoredFromStorage(pubkey: string): Set<string> {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(authoredStorageKey(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();
|
||||
}
|
||||
}
|
||||
|
||||
function writeAuthoredToStorage(pubkey: string, rootIds: Set<string>): void {
|
||||
try {
|
||||
const arr = [...rootIds];
|
||||
const capped =
|
||||
arr.length > MAX_AUTHORED_ENTRIES
|
||||
? arr.slice(arr.length - MAX_AUTHORED_ENTRIES)
|
||||
: arr;
|
||||
window.localStorage.setItem(
|
||||
authoredStorageKey(pubkey),
|
||||
JSON.stringify(capped),
|
||||
);
|
||||
} catch {
|
||||
// Ignore storage errors (private browsing, quota exceeded).
|
||||
}
|
||||
}
|
||||
|
||||
const MUTED_STORAGE_PREFIX = "buzz-thread-muted.v1";
|
||||
const MAX_MUTED_ENTRIES = 1000;
|
||||
|
||||
function mutedStorageKey(pubkey: string): string {
|
||||
return `${MUTED_STORAGE_PREFIX}:${pubkey}`;
|
||||
}
|
||||
|
||||
function readMutedFromStorage(pubkey: string): Set<string> {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(mutedStorageKey(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();
|
||||
}
|
||||
}
|
||||
|
||||
function writeMutedToStorage(pubkey: string, rootIds: Set<string>): void {
|
||||
try {
|
||||
const arr = [...rootIds];
|
||||
const capped =
|
||||
arr.length > MAX_MUTED_ENTRIES
|
||||
? arr.slice(arr.length - MAX_MUTED_ENTRIES)
|
||||
: arr;
|
||||
window.localStorage.setItem(
|
||||
mutedStorageKey(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
|
||||
// badge gate ORs this in so a mention recipient who never participated,
|
||||
// authored, or followed still gets the thread-unread badge.
|
||||
const mentionedStore = makeRootIdStore("buzz-thread-mentioned.v1");
|
||||
const mutedStore = makeRootIdStore("buzz-thread-muted.v1");
|
||||
|
||||
export type ThreadActivityItem = {
|
||||
id: string;
|
||||
@@ -359,6 +287,11 @@ export function useUnreadChannels(
|
||||
// Used to notify the author when someone replies to their posts.
|
||||
const authoredRootIdsRef = React.useRef(new Set<string>());
|
||||
|
||||
// Root event IDs of threads where an external message @-mentioned the user.
|
||||
// ORed into the badge gate so a mention recipient who never participated,
|
||||
// authored, or followed the thread still gets the thread-unread badge.
|
||||
const mentionedRootIdsRef = React.useRef(new Set<string>());
|
||||
|
||||
// Root event IDs of threads the user has explicitly muted. Takes precedence
|
||||
// over participation, follow, and authorship for notification suppression.
|
||||
const mutedRootIdsRef = React.useRef(new Set<string>());
|
||||
@@ -382,6 +315,15 @@ export function useUnreadChannels(
|
||||
0,
|
||||
);
|
||||
|
||||
// Version signal bumped only when the participated/authored/mentioned
|
||||
// root-id sets change, so the gate snapshots (re-derived below) don't
|
||||
// re-allocate on every observed external message the way reusing
|
||||
// latestVersion would.
|
||||
const [membershipVersion, bumpMembershipVersion] = React.useReducer(
|
||||
(x: number) => x + 1,
|
||||
0,
|
||||
);
|
||||
|
||||
// Reset all in-session state when the identity or relay changes. Unread
|
||||
// tracking depends only on NIP-RS read markers + observed relay events for
|
||||
// this user; nothing here is persisted across restarts.
|
||||
@@ -392,14 +334,18 @@ export function useUnreadChannels(
|
||||
forcedUnreadRef.current = new Set();
|
||||
caughtUpChannelsRef.current = new Set();
|
||||
participatedRootIdsRef.current = pubkey
|
||||
? readParticipationFromStorage(pubkey)
|
||||
? participationStore.read(pubkey)
|
||||
: new Set();
|
||||
authoredRootIdsRef.current = pubkey
|
||||
? readAuthoredFromStorage(pubkey)
|
||||
? authoredStore.read(pubkey)
|
||||
: new Set();
|
||||
mutedRootIdsRef.current = pubkey ? readMutedFromStorage(pubkey) : new Set();
|
||||
mentionedRootIdsRef.current = pubkey
|
||||
? mentionedStore.read(pubkey)
|
||||
: new Set();
|
||||
mutedRootIdsRef.current = pubkey ? mutedStore.read(pubkey) : new Set();
|
||||
threadActivityRef.current = pubkey ? readActivityFromStorage(pubkey) : [];
|
||||
bumpLatestVersion();
|
||||
bumpMembershipVersion();
|
||||
}, [pubkey, relayClient]);
|
||||
|
||||
// `topLevelOnly` is the passive channel-open path (NIP-RS Option 1): the
|
||||
@@ -454,6 +400,28 @@ export function useUnreadChannels(
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Record the thread root of an EXTERNAL message that @-mentioned the user.
|
||||
// Keyed on the thread root so the badge gate trips for a mention recipient
|
||||
// who never participated/authored/followed. Top-level mentions (no rootId)
|
||||
// are ignored — thread badges only exist for replies. Returns true when the
|
||||
// set actually grew so callers can decide whether to bump the gate snapshot.
|
||||
const recordMentionedRoot = React.useCallback(
|
||||
(event: RelayEvent): boolean => {
|
||||
if (normalizedPubkey === null) return false;
|
||||
if (event.pubkey.toLowerCase() === normalizedPubkey) return false;
|
||||
if (!hasMentionForEvent(event, normalizedPubkey)) return false;
|
||||
const { rootId } = getThreadReference(event.tags);
|
||||
if (rootId === null) return false;
|
||||
const target = mentionedRootIdsRef.current;
|
||||
const sizeBefore = target.size;
|
||||
target.add(rootId);
|
||||
if (target.size === sizeBefore) return false;
|
||||
mentionedStore.write(normalizedPubkey, target);
|
||||
return true;
|
||||
},
|
||||
[normalizedPubkey],
|
||||
);
|
||||
|
||||
// Feed the in-session "latest external trigger" map from live channel
|
||||
// events. Composes with any caller-supplied onChannelMessage handler.
|
||||
// useLiveChannelUpdates already filters this callback to trigger kinds
|
||||
@@ -468,6 +436,12 @@ export function useUnreadChannels(
|
||||
bumpLatestVersion();
|
||||
}
|
||||
|
||||
// A mention on a reply makes its thread badge-eligible even when the
|
||||
// user never participated/authored/followed (the gate's missing term).
|
||||
if (recordMentionedRoot(event)) {
|
||||
bumpMembershipVersion();
|
||||
}
|
||||
|
||||
// Track high-priority events (DMs, mentions, broadcasts) separately.
|
||||
const channel = channelsRef.current.find((ch) => ch.id === channelId);
|
||||
if (
|
||||
@@ -488,25 +462,31 @@ export function useUnreadChannels(
|
||||
|
||||
callerOnChannelMessage?.(channelId, event);
|
||||
},
|
||||
[callerOnChannelMessage, normalizedPubkey],
|
||||
[callerOnChannelMessage, normalizedPubkey, recordMentionedRoot],
|
||||
);
|
||||
|
||||
const handleSelfChannelMessage = React.useCallback(
|
||||
(event: RelayEvent) => {
|
||||
const ref = getThreadReference(event.tags);
|
||||
if (ref.rootId !== null) {
|
||||
participatedRootIdsRef.current.add(ref.rootId);
|
||||
if (normalizedPubkey !== null) {
|
||||
writeParticipationToStorage(
|
||||
normalizedPubkey,
|
||||
participatedRootIdsRef.current,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
authoredRootIdsRef.current.add(event.id);
|
||||
if (normalizedPubkey !== null) {
|
||||
writeAuthoredToStorage(normalizedPubkey, authoredRootIdsRef.current);
|
||||
}
|
||||
// Participation roots key on the thread root; authored roots (no thread
|
||||
// ref) key on the event id itself.
|
||||
const isParticipation = ref.rootId !== null;
|
||||
const targetSet = isParticipation
|
||||
? participatedRootIdsRef.current
|
||||
: authoredRootIdsRef.current;
|
||||
const sizeBefore = targetSet.size;
|
||||
targetSet.add(ref.rootId ?? event.id);
|
||||
if (normalizedPubkey !== null) {
|
||||
const write = isParticipation
|
||||
? participationStore.write
|
||||
: authoredStore.write;
|
||||
write(normalizedPubkey, targetSet);
|
||||
}
|
||||
// Only re-derive the gate snapshot when the set actually grew; a self-post
|
||||
// to an already-tracked root is a no-op for the notify gate, so skipping
|
||||
// the bump avoids a wasted snapshot re-allocation + gate recompute.
|
||||
if (targetSet.size !== sizeBefore) {
|
||||
bumpMembershipVersion();
|
||||
}
|
||||
bumpLatestVersion();
|
||||
},
|
||||
@@ -547,7 +527,7 @@ export function useUnreadChannels(
|
||||
(rootId: string) => {
|
||||
mutedRootIdsRef.current.add(rootId);
|
||||
if (normalizedPubkey !== null) {
|
||||
writeMutedToStorage(normalizedPubkey, mutedRootIdsRef.current);
|
||||
mutedStore.write(normalizedPubkey, mutedRootIdsRef.current);
|
||||
}
|
||||
bumpLatestVersion();
|
||||
},
|
||||
@@ -558,7 +538,7 @@ export function useUnreadChannels(
|
||||
(rootId: string) => {
|
||||
mutedRootIdsRef.current.delete(rootId);
|
||||
if (normalizedPubkey !== null) {
|
||||
writeMutedToStorage(normalizedPubkey, mutedRootIdsRef.current);
|
||||
mutedStore.write(normalizedPubkey, mutedRootIdsRef.current);
|
||||
}
|
||||
bumpLatestVersion();
|
||||
},
|
||||
@@ -612,6 +592,15 @@ export function useUnreadChannels(
|
||||
|
||||
let isCancelled = false;
|
||||
|
||||
// Snapshot membership sizes so the `.then` can detect whether the catch-up
|
||||
// discovered new participated/authored/mentioned roots (pass 1 mutates the
|
||||
// refs in place). A pure-participation or pure-mention discovery produces no
|
||||
// maxExternal advance, so without this the notify gate would never
|
||||
// invalidate to surface the badge.
|
||||
const participatedSizeBefore = participatedRootIdsRef.current.size;
|
||||
const authoredSizeBefore = authoredRootIdsRef.current.size;
|
||||
const mentionedSizeBefore = mentionedRootIdsRef.current.size;
|
||||
|
||||
type CatchUpResult =
|
||||
| {
|
||||
channelId: string;
|
||||
@@ -638,31 +627,31 @@ export function useUnreadChannels(
|
||||
limit: CATCH_UP_LIMIT,
|
||||
});
|
||||
|
||||
// Pass 1: build participation from self-authored thread replies
|
||||
// and track self-authored top-level messages for author notifications
|
||||
// Pass 1: build participation from self-authored thread replies,
|
||||
// track self-authored top-level messages for author notifications,
|
||||
// and capture external mentions so their threads gate a badge.
|
||||
for (const event of events) {
|
||||
if (
|
||||
const isSelf =
|
||||
normalizedPubkey !== null &&
|
||||
event.pubkey.toLowerCase() === normalizedPubkey
|
||||
) {
|
||||
event.pubkey.toLowerCase() === normalizedPubkey;
|
||||
if (isSelf) {
|
||||
const ref = getThreadReference(event.tags);
|
||||
if (ref.rootId !== null) {
|
||||
participatedRootIdsRef.current.add(ref.rootId);
|
||||
} else {
|
||||
authoredRootIdsRef.current.add(event.id);
|
||||
}
|
||||
} else {
|
||||
recordMentionedRoot(event);
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedPubkey !== null) {
|
||||
writeParticipationToStorage(
|
||||
participationStore.write(
|
||||
normalizedPubkey,
|
||||
participatedRootIdsRef.current,
|
||||
);
|
||||
writeAuthoredToStorage(
|
||||
normalizedPubkey,
|
||||
authoredRootIdsRef.current,
|
||||
);
|
||||
authoredStore.write(normalizedPubkey, authoredRootIdsRef.current);
|
||||
}
|
||||
|
||||
// Pass 2: compute maxExternal and collect thread reply activity,
|
||||
@@ -792,6 +781,13 @@ export function useUnreadChannels(
|
||||
}
|
||||
}
|
||||
if (didAdvance) bumpLatestVersion();
|
||||
if (
|
||||
participatedRootIdsRef.current.size !== participatedSizeBefore ||
|
||||
authoredRootIdsRef.current.size !== authoredSizeBefore ||
|
||||
mentionedRootIdsRef.current.size !== mentionedSizeBefore
|
||||
) {
|
||||
bumpMembershipVersion();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -933,6 +929,26 @@ export function useUnreadChannels(
|
||||
bumpLatestVersion();
|
||||
}, [getEffectiveTimestamp, markContextRead]);
|
||||
|
||||
// Identity-stable snapshots of the membership sets for the notify gate.
|
||||
// Re-derived only when membershipVersion bumps (a set actually changed), so
|
||||
// `isNotifiedForThread`'s useCallback deps invalidate on async discovery
|
||||
// while live consumers keep reading the mutable refs directly.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: membershipVersion is the intentional re-derivation signal
|
||||
const participatedRootIds = React.useMemo(
|
||||
() => new Set(participatedRootIdsRef.current) as ReadonlySet<string>,
|
||||
[membershipVersion],
|
||||
);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: membershipVersion is the intentional re-derivation signal
|
||||
const authoredRootIds = React.useMemo(
|
||||
() => new Set(authoredRootIdsRef.current) as ReadonlySet<string>,
|
||||
[membershipVersion],
|
||||
);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: membershipVersion is the intentional re-derivation signal
|
||||
const mentionedRootIds = React.useMemo(
|
||||
() => new Set(mentionedRootIdsRef.current) as ReadonlySet<string>,
|
||||
[membershipVersion],
|
||||
);
|
||||
|
||||
return {
|
||||
unreadChannelIds,
|
||||
highPriorityUnreadChannelIds,
|
||||
@@ -946,8 +962,9 @@ export function useUnreadChannels(
|
||||
getEffectiveTimestamp,
|
||||
readStateVersion,
|
||||
setContextParentResolver,
|
||||
participatedRootIds: participatedRootIdsRef.current as ReadonlySet<string>,
|
||||
authoredRootIds: authoredRootIdsRef.current as ReadonlySet<string>,
|
||||
participatedRootIds,
|
||||
authoredRootIds,
|
||||
mentionedRootIds,
|
||||
threadActivityItems: threadActivityRef.current,
|
||||
mutedRootIds: mutedRootIdsRef.current as ReadonlySet<string>,
|
||||
muteThread,
|
||||
|
||||
@@ -237,3 +237,24 @@ test("computeThreadUnreadMarker_noPubkey_countsNormally", () => {
|
||||
assert.equal(marker.firstUnreadReplyId, "r1");
|
||||
assert.equal(marker.unreadCount, 2);
|
||||
});
|
||||
|
||||
test("computeChannelUnreadMarker_selfAuthoredMixedCase_skipsOwnMessages", () => {
|
||||
// Identity and signer pubkeys differing only in hex case must still match.
|
||||
const messages = [
|
||||
{ ...topLevel("a", 10), pubkey: "ABCDEF" },
|
||||
{ ...topLevel("b", 20), pubkey: "other" },
|
||||
];
|
||||
const marker = computeChannelUnreadMarker(messages, 5, false, "abcdef");
|
||||
assert.equal(marker.firstUnreadMessageId, "b");
|
||||
assert.equal(marker.unreadCount, 1);
|
||||
});
|
||||
|
||||
test("computeThreadUnreadMarker_selfAuthoredMixedCase_skipsOwnReplies", () => {
|
||||
const replies = [
|
||||
{ id: "r1", createdAt: 10, pubkey: "ABCDEF" },
|
||||
{ id: "r2", createdAt: 20, pubkey: "other" },
|
||||
];
|
||||
const marker = computeThreadUnreadMarker(replies, 5, "abcdef");
|
||||
assert.equal(marker.firstUnreadReplyId, "r2");
|
||||
assert.equal(marker.unreadCount, 1);
|
||||
});
|
||||
|
||||
@@ -43,6 +43,10 @@ export function computeChannelUnreadMarker(
|
||||
return EMPTY_MARKER;
|
||||
}
|
||||
|
||||
// Normalize once: signer-emitted and identity pubkeys are both lowercase hex
|
||||
// today, but a case mismatch would otherwise miscount a self-post as unread.
|
||||
const normalizedPubkey = currentPubkey?.toLowerCase();
|
||||
|
||||
let firstUnreadMessageId: string | null = null;
|
||||
let unreadCount = 0;
|
||||
|
||||
@@ -50,7 +54,10 @@ export function computeChannelUnreadMarker(
|
||||
if (message.parentId) {
|
||||
continue;
|
||||
}
|
||||
if (currentPubkey && message.pubkey === currentPubkey) {
|
||||
if (
|
||||
normalizedPubkey &&
|
||||
message.pubkey?.toLowerCase() === normalizedPubkey
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const isUnread =
|
||||
@@ -101,11 +108,14 @@ export function computeThreadUnreadMarker(
|
||||
frontierSeconds: number | null,
|
||||
currentPubkey?: string,
|
||||
): ThreadUnreadMarker {
|
||||
// Normalize once: see computeChannelUnreadMarker for the case-mismatch guard.
|
||||
const normalizedPubkey = currentPubkey?.toLowerCase();
|
||||
|
||||
let firstUnreadReplyId: string | null = null;
|
||||
let unreadCount = 0;
|
||||
|
||||
for (const reply of replies) {
|
||||
if (currentPubkey && reply.pubkey === currentPubkey) {
|
||||
if (normalizedPubkey && reply.pubkey?.toLowerCase() === normalizedPubkey) {
|
||||
continue;
|
||||
}
|
||||
const isUnread =
|
||||
|
||||
59
desktop/src/shared/constants/kinds.test.mjs
Normal file
59
desktop/src/shared/constants/kinds.test.mjs
Normal file
@@ -0,0 +1,59 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
isConversationalUnreadKind,
|
||||
KIND_STREAM_MESSAGE,
|
||||
KIND_STREAM_MESSAGE_V2,
|
||||
KIND_STREAM_MESSAGE_DIFF,
|
||||
KIND_SYSTEM_MESSAGE,
|
||||
KIND_JOB_REQUEST,
|
||||
KIND_JOB_ACCEPTED,
|
||||
KIND_JOB_PROGRESS,
|
||||
KIND_JOB_RESULT,
|
||||
KIND_JOB_CANCEL,
|
||||
KIND_JOB_ERROR,
|
||||
} from "./kinds.ts";
|
||||
|
||||
test("isConversationalUnreadKind_streamMessage_counts", () => {
|
||||
assert.equal(isConversationalUnreadKind(KIND_STREAM_MESSAGE), true);
|
||||
});
|
||||
|
||||
test("isConversationalUnreadKind_streamMessageV2_counts", () => {
|
||||
// 40002 is a real message edit/v2 — must stay counted.
|
||||
assert.equal(isConversationalUnreadKind(KIND_STREAM_MESSAGE_V2), true);
|
||||
});
|
||||
|
||||
test("isConversationalUnreadKind_streamMessageDiff_counts", () => {
|
||||
// 40008 is a real message diff — must stay counted.
|
||||
assert.equal(isConversationalUnreadKind(KIND_STREAM_MESSAGE_DIFF), true);
|
||||
});
|
||||
|
||||
test("isConversationalUnreadKind_systemMessage_excluded", () => {
|
||||
// 40099 channel_created / member_joined rows must not inflate the pill.
|
||||
assert.equal(isConversationalUnreadKind(KIND_SYSTEM_MESSAGE), false);
|
||||
});
|
||||
|
||||
test("isConversationalUnreadKind_allJobKinds_excluded", () => {
|
||||
for (const kind of [
|
||||
KIND_JOB_REQUEST,
|
||||
KIND_JOB_ACCEPTED,
|
||||
KIND_JOB_PROGRESS,
|
||||
KIND_JOB_RESULT,
|
||||
KIND_JOB_CANCEL,
|
||||
KIND_JOB_ERROR,
|
||||
]) {
|
||||
assert.equal(isConversationalUnreadKind(kind), false, `kind ${kind}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("isConversationalUnreadKind_undefinedKind_countsAsConversational", () => {
|
||||
// Optimistic/pending rows whose kind has not populated must not be dropped.
|
||||
assert.equal(isConversationalUnreadKind(undefined), true);
|
||||
});
|
||||
|
||||
test("isConversationalUnreadKind_unknownKind_countsAsConversational", () => {
|
||||
// An exclude-list, not an include-list: anything not explicitly excluded
|
||||
// (e.g. a future conversational kind) is kept.
|
||||
assert.equal(isConversationalUnreadKind(12345), true);
|
||||
});
|
||||
@@ -64,3 +64,25 @@ export const CHANNEL_EVENT_KINDS = [
|
||||
KIND_STREAM_MESSAGE_DIFF, // 40008 — message diffs
|
||||
KIND_SYSTEM_MESSAGE, // 40099 — system messages (join, leave, etc.)
|
||||
] as const;
|
||||
|
||||
// Timeline kinds that are NOT conversational: relay-signed system rows
|
||||
// (channel-created, member-joined) and job-lifecycle events. These render in
|
||||
// the timeline but must not count toward the channel's unread pill — a freshly
|
||||
// created channel carries one channel_created + N member_joined system rows
|
||||
// that would otherwise show as phantom unreads ("4 unread, 1 message").
|
||||
const NON_CONVERSATIONAL_UNREAD_KINDS: ReadonlySet<number> = new Set([
|
||||
KIND_SYSTEM_MESSAGE, // 40099
|
||||
KIND_JOB_REQUEST, // 43001
|
||||
KIND_JOB_ACCEPTED, // 43002
|
||||
KIND_JOB_PROGRESS, // 43003
|
||||
KIND_JOB_RESULT, // 43004
|
||||
KIND_JOB_CANCEL, // 43005
|
||||
KIND_JOB_ERROR, // 43006
|
||||
]);
|
||||
|
||||
// Whether a timeline message kind should count toward unread tallies. An
|
||||
// undefined kind (optimistic/pending rows whose kind has not populated) is
|
||||
// treated as conversational so a legitimately unread message is never dropped.
|
||||
export function isConversationalUnreadKind(kind: number | undefined): boolean {
|
||||
return kind === undefined || !NON_CONVERSATIONAL_UNREAD_KINDS.has(kind);
|
||||
}
|
||||
|
||||
@@ -783,4 +783,148 @@ test.describe("thread unread indicator screenshots", () => {
|
||||
path: `${SHOTS}/12-sidebar-dot-all-replies.png`,
|
||||
});
|
||||
});
|
||||
|
||||
// Regression guard for BUG-2 (clear-on-read): opening an unread thread marks
|
||||
// its visible direct replies read, and the depth-0 badge must clear to zero
|
||||
// IN PLACE — without leaving and re-entering the channel. Every other test
|
||||
// re-enters the channel to refresh the badge; none asserts that reading the
|
||||
// thread alone clears it. The mechanism: the mark-read effect advances the
|
||||
// thread frontier over the head + direct replies on open, bumping
|
||||
// readStateVersion, which recomputes computeThreadBadgeCounts against the
|
||||
// now-advanced snapshot. Before the fix the badge read a frozen open-time
|
||||
// snapshot that mark-read never invalidated, so it persisted until channel
|
||||
// re-entry. This walks badge=3 -> open thread -> close -> badge gone, all
|
||||
// while staying in general.
|
||||
test("13-thread-badge-clears-on-read-without-reentry", async ({ page }) => {
|
||||
await installMockBridge(page);
|
||||
await page.goto("/");
|
||||
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
await waitForMockLiveSubscription(page, "general");
|
||||
|
||||
// Read frontier over an initial reply, then close the thread (same setup as
|
||||
// test 01) so the subsequent replies land strictly past the frontier.
|
||||
await emitMockMessage(page, "general", "First reply to welcome", {
|
||||
parentEventId: "mock-general-welcome",
|
||||
pubkey: TEST_IDENTITIES.alice.pubkey,
|
||||
createdAt: Math.floor(Date.now() / 1000) - 10,
|
||||
});
|
||||
const threadSummary = page.getByTestId("message-thread-summary").first();
|
||||
await expect(threadSummary).toBeVisible();
|
||||
await threadSummary.click();
|
||||
await expect(page.getByTestId("message-thread-panel")).toBeVisible();
|
||||
await page.getByTestId("message-thread-close").click();
|
||||
await expect(page.getByTestId("message-thread-panel")).not.toBeVisible();
|
||||
|
||||
// Leave, emit unread replies, return — badge appears (same as test 01).
|
||||
await page.getByTestId("channel-random").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("random");
|
||||
const base = unreadTimestamp();
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await emitMockMessage(page, "general", `Unread reply ${i + 1}`, {
|
||||
parentEventId: "mock-general-welcome",
|
||||
pubkey: TEST_IDENTITIES.alice.pubkey,
|
||||
createdAt: base + i,
|
||||
});
|
||||
}
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
const badge = page.getByTestId("thread-unread-badge");
|
||||
await expect(badge).toBeVisible();
|
||||
await expect(badge).toContainText("3");
|
||||
|
||||
await page.screenshot({
|
||||
path: `${SHOTS}/13-thread-badge-before-read.png`,
|
||||
});
|
||||
|
||||
// The crux: open the thread (mark-read advances the frontier past all three
|
||||
// direct replies), then close it. Stay in general the entire time — no
|
||||
// channel switch. The badge must clear to zero off the readStateVersion
|
||||
// recompute alone. Before the BUG-2 fix it would persist at 3 here.
|
||||
await page.getByTestId("message-thread-summary").first().click();
|
||||
await expect(page.getByTestId("message-thread-panel")).toBeVisible();
|
||||
await page.getByTestId("message-thread-close").click();
|
||||
await expect(page.getByTestId("message-thread-panel")).not.toBeVisible();
|
||||
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
await expect(badge).toHaveCount(0);
|
||||
|
||||
await page.screenshot({
|
||||
path: `${SHOTS}/13-thread-badge-clears-on-read.png`,
|
||||
});
|
||||
});
|
||||
|
||||
// Regression guard for the mention-gate + subtree-count fixes. The viewer is
|
||||
// a pure MENTION RECIPIENT of a nested reply in a thread they never authored,
|
||||
// participated in, or followed: root `mock-general-alice` (Alice-authored) ->
|
||||
// reply A (Alice) -> reply B (Alice, @-mentions self). This fails pre-fix on
|
||||
// TWO independent defects:
|
||||
// 1. The badge gate `isNotifiedForThread` had no mention term, so a
|
||||
// recipient who never participated/authored/followed gated false and the
|
||||
// badge never appeared at all.
|
||||
// 2. `computeThreadBadgeCounts` counted only the root's DIRECT children, so
|
||||
// the nested mention reply B (under A) was never tallied toward the root.
|
||||
// After the gate fix the badge appears but undercounts (1, missing B); only
|
||||
// after the subtree-count fix does it reach 2. Asserting `2` gates both.
|
||||
test("14-mention-only-nested-thread-badge", async ({ page }) => {
|
||||
await installMockBridge(page);
|
||||
await page.goto("/");
|
||||
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
await waitForMockLiveSubscription(page, "general");
|
||||
|
||||
// The viewer never replies, authors, or follows Alice's thread — they are a
|
||||
// pure mention recipient. Leave general so it goes inactive, then emit the
|
||||
// unread chain on a thread that was never read: reply A (direct child of
|
||||
// the root) and reply B (nested under A) that @-mentions the viewer. With
|
||||
// no prior read the root's frontier seeds to null, so the whole subtree
|
||||
// counts unread — A + B = 2. The emitter derives B's true rootId from A's
|
||||
// stored thread reference, so B lands in Alice's thread at depth 2.
|
||||
const aliceSummary = page.locator(
|
||||
'[data-thread-head-id="mock-general-alice"]',
|
||||
);
|
||||
await page.getByTestId("channel-random").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("random");
|
||||
const base = unreadTimestamp();
|
||||
const replyA = await emitMockMessage(page, "general", "Reply A (depth 1)", {
|
||||
parentEventId: "mock-general-alice",
|
||||
pubkey: TEST_IDENTITIES.alice.pubkey,
|
||||
createdAt: base,
|
||||
});
|
||||
await emitMockMessage(page, "general", "Reply B mentioning you (depth 2)", {
|
||||
parentEventId: replyA?.id,
|
||||
pubkey: TEST_IDENTITIES.alice.pubkey,
|
||||
mentionPubkeys: [SELF_PUBKEY],
|
||||
createdAt: base + 1,
|
||||
});
|
||||
|
||||
// Return to general. The mention on the nested reply must surface a badge on
|
||||
// Alice's root — proving the gate now honors mentions — and the count must
|
||||
// span the subtree (A + B = 2), proving the count walks past direct
|
||||
// children. Pre-fix: no badge; post-gate-only: badge "1"; both fixes: "2".
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
|
||||
const badge = aliceSummary.getByTestId("thread-unread-badge");
|
||||
await expect(badge).toBeVisible();
|
||||
await expect(badge).toContainText("2");
|
||||
|
||||
// Clearing a NESTED unread mirrors the channel-open read model: opening the
|
||||
// thread consumes only the visible direct replies (A), so the badge drops
|
||||
// to "1" — the mention reply B stays unread inside A's collapsed branch.
|
||||
await aliceSummary.click();
|
||||
await expect(page.getByTestId("message-thread-panel")).toBeVisible();
|
||||
await expect(badge).toContainText("1");
|
||||
|
||||
// Drilling into A's branch advances the frontier over B, the deepest reply,
|
||||
// and the badge clears in place. Stays in general — no channel switch.
|
||||
await expandReply(page, replyA?.id ?? "");
|
||||
await page.getByTestId("message-thread-close").click();
|
||||
await expect(page.getByTestId("message-thread-panel")).not.toBeVisible();
|
||||
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
await expect(badge).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user