mirror of
https://github.com/block/buzz.git
synced 2026-11-05 00:41:28 +00:00
feat(channels): replace thread-badge frontier with per-message read markers (LP4 P1)
The single-per-root frontier snapshot made thread-unread badges flaky: reading an ancestor or re-entering a channel could clear a descendant's badge, and the open-time snapshot was a second source of truth for the same read-line (the original Face-2 bug). Replace it with per-message msg:<id> markers read live through getReadAt: a reply lights iff createdAt > effective(msg:<id>), so reading one reply never clears another. Open and expand now mark ONLY the revealed set read (direct children), deliberately reversing #1118's whole-subtree-on-open — a collapsed branch keeps its badge until revealed. Mark-read/mark-unread are symmetric over a message + its subtree; mark-unread is a session-local OR-overlay since markers are monotonic. Deletes the frontier/seed layer (threadBadgeFrontier.ts) and the orphaned subtree-max open-ceiling path (subtreeMaxCreatedAt) rather than re-keying them. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
parent
edb56e74a9
commit
d9ce1cd160
@@ -29,6 +29,7 @@ import {
|
||||
useOpenDmMutation,
|
||||
} from "@/features/channels/hooks";
|
||||
import { useUnreadChannels } from "@/features/channels/useUnreadChannels";
|
||||
import { msgContextKey } from "@/features/channels/readState/readStateFormat";
|
||||
import { useMembershipNotifications } from "@/features/channels/useMembershipNotifications";
|
||||
import { useFeedItemState } from "@/features/home/useFeedItemState";
|
||||
import { getThreadReference } from "@/features/messages/lib/threading";
|
||||
@@ -367,6 +368,28 @@ export function AppShell() {
|
||||
},
|
||||
[markChannelRead],
|
||||
);
|
||||
|
||||
// Per-message read frontier (LP4 v3), folded through the active channel by
|
||||
// the ChannelScreen-installed parent resolver: effective(msg:<id>) =
|
||||
// max(own(msg:<id>), channel). Unlike getThreadReadAt's seed path, the badge
|
||||
// predicate WANTS the channel fold so a channel-read clears messages older
|
||||
// than the top-level frontier. Returns null when neither the message nor its
|
||||
// channel has ever been read.
|
||||
const getMessageReadAt = React.useCallback(
|
||||
(messageId: string) => getChannelReadAt(msgContextKey(messageId)),
|
||||
[getChannelReadAt],
|
||||
);
|
||||
|
||||
// Advance a message's own read marker to the given unix-seconds timestamp.
|
||||
const markMessageRead = React.useCallback(
|
||||
(messageId: string, timestamp: number) => {
|
||||
markChannelRead(
|
||||
msgContextKey(messageId),
|
||||
new Date(timestamp * 1_000).toISOString(),
|
||||
);
|
||||
},
|
||||
[markChannelRead],
|
||||
);
|
||||
const threadActivityFeedItems = useThreadActivityFeedItems(
|
||||
threadActivityItems,
|
||||
mutedRootIds,
|
||||
@@ -727,6 +750,8 @@ export function AppShell() {
|
||||
getChannelReadAt,
|
||||
getThreadReadAt,
|
||||
markThreadRead,
|
||||
getMessageReadAt,
|
||||
markMessageRead,
|
||||
readStateVersion,
|
||||
setContextParentResolver,
|
||||
followThread: handleFollowThread,
|
||||
|
||||
@@ -25,6 +25,12 @@ type AppShellContextValue = {
|
||||
getThreadReadAt: (rootId: string, channelId?: string | null) => number | null;
|
||||
// Advance the thread read frontier to the given unix-seconds timestamp.
|
||||
markThreadRead: (rootId: string, timestamp: number) => void;
|
||||
// Per-message read frontier as unix-seconds timestamp, or null when never
|
||||
// read. Uses `msg:<id>` context keys folded through the active channel by the
|
||||
// parent resolver (LP4 v3 per-message badge model).
|
||||
getMessageReadAt: (messageId: string) => number | null;
|
||||
// Advance a single message's read marker to the given unix-seconds timestamp.
|
||||
markMessageRead: (messageId: string, timestamp: number) => void;
|
||||
// Bump-counter that invalidates whenever the read marker changes. Include
|
||||
// in memo deps that consume getChannelReadAt.
|
||||
readStateVersion: number;
|
||||
@@ -50,6 +56,8 @@ const AppShellContext = React.createContext<AppShellContextValue>({
|
||||
getChannelReadAt: () => null,
|
||||
getThreadReadAt: () => null,
|
||||
markThreadRead: () => {},
|
||||
getMessageReadAt: () => null,
|
||||
markMessageRead: () => {},
|
||||
readStateVersion: 0,
|
||||
setContextParentResolver: () => {},
|
||||
followThread: () => {},
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { computeThreadUnreadMarker } from "../../messages/lib/unreadMarker.ts";
|
||||
import {
|
||||
buildDirectRepliesByParentId,
|
||||
subtreeMaxCreatedAt,
|
||||
} from "./subtreeCreatedAt.ts";
|
||||
|
||||
// Tree: w(100)
|
||||
// ├── deep1(400) ── deep2(500)
|
||||
// └── sib(300)
|
||||
// `deep1` is the deep branch (subtree-max 500); `sib` is a shallower sibling
|
||||
// whose only reply (300) is chronologically older than the deep tail.
|
||||
function fixture() {
|
||||
const directReplyIdsByParentId = new Map([
|
||||
["w", ["deep1", "sib"]],
|
||||
["deep1", ["deep2"]],
|
||||
]);
|
||||
const createdAtByMessageId = new Map([
|
||||
["w", 100],
|
||||
["deep1", 400],
|
||||
["deep2", 500],
|
||||
["sib", 300],
|
||||
]);
|
||||
const replies = [
|
||||
{ id: "sib", createdAt: 300 },
|
||||
{ id: "deep1", createdAt: 400 },
|
||||
{ id: "deep2", createdAt: 500 },
|
||||
];
|
||||
return { directReplyIdsByParentId, createdAtByMessageId, replies };
|
||||
}
|
||||
|
||||
test("subtreeMaxCreatedAt_branchWithDescendants_returnsDeepestCreatedAt", () => {
|
||||
const { directReplyIdsByParentId, createdAtByMessageId } = fixture();
|
||||
|
||||
const result = subtreeMaxCreatedAt(
|
||||
"deep1",
|
||||
directReplyIdsByParentId,
|
||||
createdAtByMessageId,
|
||||
);
|
||||
|
||||
// Includes the descendant deep2(500), not just deep1's own 400.
|
||||
assert.equal(result, 500);
|
||||
});
|
||||
|
||||
test("subtreeMaxCreatedAt_leafBranch_returnsOwnCreatedAt", () => {
|
||||
const { directReplyIdsByParentId, createdAtByMessageId } = fixture();
|
||||
|
||||
const result = subtreeMaxCreatedAt(
|
||||
"sib",
|
||||
directReplyIdsByParentId,
|
||||
createdAtByMessageId,
|
||||
);
|
||||
|
||||
assert.equal(result, 300);
|
||||
});
|
||||
|
||||
test("subtreeMaxCreatedAt_absentMessage_returnsNull", () => {
|
||||
const { directReplyIdsByParentId, createdAtByMessageId } = fixture();
|
||||
|
||||
const result = subtreeMaxCreatedAt(
|
||||
"ghost",
|
||||
directReplyIdsByParentId,
|
||||
createdAtByMessageId,
|
||||
);
|
||||
|
||||
// Null signals the caller to skip the read-state write.
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
// Invariant 3: expanding the deep branch advances the single monotonic frontier
|
||||
// to the branch subtree-max (500), which consumes the chronologically-older
|
||||
// unexpanded sibling (300) too. This is the accepted single-frontier semantic.
|
||||
test("expandDeepBranch_advancesFrontierToSubtreeMax_consumesOlderSibling", () => {
|
||||
const { directReplyIdsByParentId, createdAtByMessageId, replies } = fixture();
|
||||
|
||||
const frontier = subtreeMaxCreatedAt(
|
||||
"deep1",
|
||||
directReplyIdsByParentId,
|
||||
createdAtByMessageId,
|
||||
);
|
||||
const marker = computeThreadUnreadMarker(replies, frontier);
|
||||
|
||||
assert.equal(frontier, 500);
|
||||
// Everything at or below 500 is read — including sib(300), never expanded.
|
||||
assert.equal(marker.firstUnreadReplyId, null);
|
||||
assert.equal(marker.unreadCount, 0);
|
||||
});
|
||||
|
||||
// Invariant 1: the session divider is computed from the open-time frontier
|
||||
// SNAPSHOT, the badge/consume from the LIVE frontier. After expand advances the
|
||||
// live frontier to the subtree-max (500), the two clocks deliberately diverge:
|
||||
// the live frontier reports everything consumed, while the divider — read from
|
||||
// the frozen open-time snapshot (100) — stays pinned on the first unread reply.
|
||||
// This is what keeps the divider from moving mid-session when you expand.
|
||||
test("expandAfterOpen_dividerFromSnapshot_holds_whileLiveFrontierConsumes", () => {
|
||||
const { directReplyIdsByParentId, createdAtByMessageId, replies } = fixture();
|
||||
|
||||
const openSnapshot = 100;
|
||||
const liveFrontierAfterExpand = subtreeMaxCreatedAt(
|
||||
"deep1",
|
||||
directReplyIdsByParentId,
|
||||
createdAtByMessageId,
|
||||
);
|
||||
|
||||
const dividerFromSnapshot = computeThreadUnreadMarker(replies, openSnapshot);
|
||||
const consumeFromLive = computeThreadUnreadMarker(
|
||||
replies,
|
||||
liveFrontierAfterExpand,
|
||||
);
|
||||
|
||||
// Divider stays on the first unread, computed against the frozen snapshot...
|
||||
assert.equal(dividerFromSnapshot.firstUnreadReplyId, "sib");
|
||||
assert.equal(dividerFromSnapshot.unreadCount, 3);
|
||||
// ...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);
|
||||
});
|
||||
@@ -1,44 +1,11 @@
|
||||
/**
|
||||
* Newest `createdAt` across a thread branch: the message itself plus every
|
||||
* descendant, walked through the direct-children adjacency map. Drilling into a
|
||||
* branch advances the thread read frontier to this value, so it determines how
|
||||
* far "expanding consumes unread" reaches. Returns null when the message is
|
||||
* absent from the timeline so the caller can skip the read-state write.
|
||||
* Reply-graph builders for the per-message thread badge model (LP4 v3). Each
|
||||
* maps the loaded timeline into an index a badge consumer reads in O(1): direct
|
||||
* children by parent, replies by their resolved thread root, createdAt by id,
|
||||
* and the descendant id walk. The old `subtreeMaxCreatedAt` frontier-advance
|
||||
* helper is gone — read state is now per-message (`effective(msg:<id>)`), so no
|
||||
* subtree ceiling is computed.
|
||||
*/
|
||||
export function subtreeMaxCreatedAt(
|
||||
messageId: string,
|
||||
directReplyIdsByParentId: ReadonlyMap<string, string[]>,
|
||||
createdAtByMessageId: ReadonlyMap<string, number>,
|
||||
repliesByRootId?: ReadonlyMap<string, readonly ReplyGraphMessage[]>,
|
||||
): number | null {
|
||||
const ownCreatedAt = createdAtByMessageId.get(messageId);
|
||||
if (ownCreatedAt === undefined) return null;
|
||||
|
||||
let maxCreatedAt = ownCreatedAt;
|
||||
const pendingIds = [...(directReplyIdsByParentId.get(messageId) ?? [])];
|
||||
while (pendingIds.length > 0) {
|
||||
const currentId = pendingIds.pop();
|
||||
if (!currentId) continue;
|
||||
const createdAt = createdAtByMessageId.get(currentId);
|
||||
if (createdAt !== undefined && createdAt > maxCreatedAt) {
|
||||
maxCreatedAt = createdAt;
|
||||
}
|
||||
pendingIds.push(...(directReplyIdsByParentId.get(currentId) ?? []));
|
||||
}
|
||||
// Orphan-immune ceiling: also fold in replies that resolve to this id by
|
||||
// rootId. When the timeline window drops a middle ancestor, a deep reply
|
||||
// keys under its absent parent and the adjacency walk above can't reach it,
|
||||
// so the root-started ceiling stops short and the channel-root badge can
|
||||
// never clear. rootId travels with the event (getThreadReference), so a root
|
||||
// reaches its severed orphans here. A BRANCH node is no reply's rootId, so
|
||||
// its rootId-bucket is empty and the branch-scoped ceiling is unchanged.
|
||||
for (const reply of repliesByRootId?.get(messageId) ?? []) {
|
||||
if (reply.createdAt > maxCreatedAt) {
|
||||
maxCreatedAt = reply.createdAt;
|
||||
}
|
||||
}
|
||||
return maxCreatedAt;
|
||||
}
|
||||
|
||||
/** Minimal timeline shape the adjacency/createdAt builders read. */
|
||||
interface ReplyGraphMessage {
|
||||
@@ -62,24 +29,6 @@ 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 thread root id to every reply that resolves to it by `rootId`,
|
||||
* in timeline order. Unlike the parent-keyed maps above, this groups by the
|
||||
|
||||
@@ -2,151 +2,121 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { computeThreadBadgeCounts } from "./threadBadgeCounts.ts";
|
||||
import { nextThreadBadgeFrontier } from "./threadBadgeFrontier.ts";
|
||||
import {
|
||||
buildCreatedAtByMessageId,
|
||||
buildDirectReplyIdsByParentId,
|
||||
buildRepliesByRootId,
|
||||
subtreeMaxCreatedAt,
|
||||
} from "./subtreeCreatedAt.ts";
|
||||
import { buildRepliesByRootId } from "./subtreeCreatedAt.ts";
|
||||
|
||||
// End-to-end model of the mark-read-on-thread-open pipeline in
|
||||
// useChannelUnreadState. The open effect computes a read ceiling for the thread
|
||||
// head, markThreadRead advances the thread-OWN marker toward it (monotonic, per
|
||||
// advanceContext), the badge frontier snapshot then advances toward that live
|
||||
// marker (seedThreadBadgeFrontiers -> nextThreadBadgeFrontier), and the
|
||||
// summary badge counts the whole subtree against that snapshot
|
||||
// (computeThreadBadgeCounts). The fix changed the open ceiling from the
|
||||
// direct-replies max (head + direct children) to the full-subtree max
|
||||
// (subtreeMaxCreatedAt); these tests pin that the badge collapses to 0 on open
|
||||
// whether or not the OWN marker actually advances.
|
||||
// Open-at-level contract (LP4 v3). Opening a thread no longer collapses the
|
||||
// whole subtree badge (#1118's behavior, deliberately reversed). The on-open
|
||||
// effect marks read ONLY the replies revealed on open — each gets its own
|
||||
// msg:<id> marker advanced to its createdAt — so a reply in a still-collapsed
|
||||
// branch keeps its badge until it too is revealed. The root summary badge
|
||||
// (computeThreadBadgeCounts) reads effective(msg:<id>) live: a reply counts
|
||||
// iff createdAt > readAt, so reading one reply never clears another.
|
||||
|
||||
// rootId is "root" on every reply: these threads are all rooted at "root", and
|
||||
// the badge roll-up groups by rootId (getThreadReference's `root` e-tag), so the
|
||||
// nested b under a still tallies at root. Top-level "root" carries its own id.
|
||||
const msg = (id, parentId, createdAt, pubkey = "author") => ({
|
||||
// rootId travels with every reply (getThreadReference's `root` e-tag), so a
|
||||
// nested reply rolls up to the thread root even when an ancestor is collapsed.
|
||||
const msg = (id, parentId, createdAt = 100, pubkey = "author", rootId) => ({
|
||||
id,
|
||||
parentId,
|
||||
rootId: parentId === null ? id : "root",
|
||||
rootId: rootId ?? parentId ?? id,
|
||||
createdAt,
|
||||
pubkey,
|
||||
});
|
||||
|
||||
// The ceiling the open effect now writes: full-subtree max over the head.
|
||||
const openCeiling = (rootId, messages) =>
|
||||
subtreeMaxCreatedAt(
|
||||
rootId,
|
||||
buildDirectReplyIdsByParentId(messages),
|
||||
buildCreatedAtByMessageId(messages),
|
||||
);
|
||||
const countAll = () => true;
|
||||
|
||||
// Drive one thread-open through the pipeline. `priorOwnMarker` is the thread's
|
||||
// OWN read marker before this open (null = never read). Returns the resulting
|
||||
// badge count for the root after open.
|
||||
const badgeAfterOpen = (rootId, messages, priorOwnMarker, currentPubkey) => {
|
||||
const ceiling = openCeiling(rootId, messages);
|
||||
// markThreadRead -> advanceContext: monotonic max of prior own marker and the
|
||||
// new ceiling. A null ceiling means no replies; the effect early-returns.
|
||||
const liveMarker =
|
||||
ceiling === null
|
||||
? priorOwnMarker
|
||||
: priorOwnMarker === null
|
||||
? ceiling
|
||||
: Math.max(priorOwnMarker, ceiling);
|
||||
// seedThreadBadgeFrontiers advances the snapshot toward the live marker.
|
||||
const frontier = nextThreadBadgeFrontier(undefined, liveMarker);
|
||||
return computeThreadBadgeCounts(
|
||||
// Model the on-open mark-read effect: each revealed reply's msg:<id> marker is
|
||||
// advanced to its own createdAt (useChannelUnreadState's open effect maps
|
||||
// markMessageRead(id, createdAt) over the visible set). A reply absent from the
|
||||
// revealed set was never read, so its resolver returns null and it stays
|
||||
// unread. Returns the live per-message getReadAt resolver after the open.
|
||||
function openMarksRevealed(messages, revealedIds) {
|
||||
const revealed = new Set(revealedIds);
|
||||
const createdAtById = new Map(messages.map((m) => [m.id, m.createdAt]));
|
||||
return (id) => (revealed.has(id) ? (createdAtById.get(id) ?? null) : null);
|
||||
}
|
||||
|
||||
const rootBadge = (messages, getReadAt, currentPubkey) =>
|
||||
computeThreadBadgeCounts(
|
||||
messages,
|
||||
buildRepliesByRootId(messages),
|
||||
new Map([[rootId, frontier]]),
|
||||
() => true,
|
||||
getReadAt,
|
||||
countAll,
|
||||
currentPubkey,
|
||||
).get(rootId);
|
||||
};
|
||||
|
||||
test("openThreadWithUnreadNestedReply_advancesFrontierToSubtreeMax", () => {
|
||||
// root -> a(100) -> b(200): the unread lives in nested reply b.
|
||||
// The OLD direct-replies ceiling stopped at a(100) (b is a grandchild, not a
|
||||
// direct reply of root); only subtree-max reaches the nested b(200).
|
||||
const messages = [
|
||||
msg("root", null, 50),
|
||||
msg("a", "root", 100),
|
||||
msg("b", "a", 200),
|
||||
];
|
||||
const ids = buildDirectReplyIdsByParentId(messages);
|
||||
const createdAt = buildCreatedAtByMessageId(messages);
|
||||
assert.equal(subtreeMaxCreatedAt("root", ids, createdAt), 200);
|
||||
});
|
||||
|
||||
test("openThreadWithUnreadNestedReply_collapsesBadgeToZero", () => {
|
||||
// The reported bug: before the fix the frontier sat at the direct-replies
|
||||
// ceiling (100) and the nested reply b(200) kept the badge lit. The fix
|
||||
// advances to subtree-max (200), so the badge recomputes to 0 on open.
|
||||
const messages = [
|
||||
msg("root", null, 50),
|
||||
msg("a", "root", 100),
|
||||
msg("b", "a", 200),
|
||||
];
|
||||
assert.equal(badgeAfterOpen("root", messages, null), undefined);
|
||||
});
|
||||
|
||||
test("openThreadWithUnreadNestedReply_oldDirectCeilingLeftBadgeLit", () => {
|
||||
// Regression guard: the OLD behavior advanced the frontier only to the
|
||||
// direct-replies ceiling — max over root(50) and its DIRECT reply a(100),
|
||||
// i.e. 100. The nested grandchild b(200) was excluded, so the badge stayed
|
||||
// lit (count=1). This pins the exact gap the fix closes: had the fix been
|
||||
// reverted to that ceiling, the badge would NOT clear. The subtree-max
|
||||
// ceiling (200) is asserted to clear the badge in the test above.
|
||||
const messages = [
|
||||
msg("root", null, 50),
|
||||
msg("a", "root", 100),
|
||||
msg("b", "a", 200),
|
||||
];
|
||||
const oldDirectCeiling = 100;
|
||||
const frontier = nextThreadBadgeFrontier(undefined, oldDirectCeiling);
|
||||
const count = computeThreadBadgeCounts(
|
||||
messages,
|
||||
buildRepliesByRootId(messages),
|
||||
new Map([["root", frontier]]),
|
||||
() => true,
|
||||
).get("root");
|
||||
assert.equal(count, 1);
|
||||
});
|
||||
|
||||
test("ownMarkerAlreadyAtSubtreeMax_stillCollapsesBadgeToZero", () => {
|
||||
// Prior-session expand synced the OWN marker to subtree-max BEFORE this
|
||||
// session's first open. markThreadRead's advance is then a no-op
|
||||
// (advanceContext early-returns, no notify), but the badge still reads 0
|
||||
// because the frontier snapshot is seeded from the live marker on render,
|
||||
// independent of whether the advance notified. Pins the no-op-return path.
|
||||
test("openRevealingOnlyDirectChild_keepsCollapsedGrandchildBadge", () => {
|
||||
// root -> a -> b: opening reveals direct child a but b is nested under a
|
||||
// still-collapsed branch. The OLD whole-subtree-on-open would have cleared
|
||||
// the badge entirely; v3 marks only a read, so b keeps the root badge lit.
|
||||
const messages = [
|
||||
msg("root", null, 50),
|
||||
msg("a", "root", 100),
|
||||
msg("b", "a", 200),
|
||||
msg("b", "a", 200, "author", "root"),
|
||||
];
|
||||
assert.equal(badgeAfterOpen("root", messages, 200), undefined);
|
||||
assert.equal(rootBadge(messages, openMarksRevealed(messages, ["a"])), 1);
|
||||
});
|
||||
|
||||
test("openRevealingWholeSubtree_clearsRootBadge", () => {
|
||||
// When every reply is revealed on open, each is marked read and the badge
|
||||
// clears — the only case the old subtree-collapse and the new open-at-level
|
||||
// agree on.
|
||||
const messages = [
|
||||
msg("root", null, 50),
|
||||
msg("a", "root", 100),
|
||||
msg("b", "a", 200, "author", "root"),
|
||||
];
|
||||
assert.equal(
|
||||
rootBadge(messages, openMarksRevealed(messages, ["a", "b"])),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test("openRevealingOneBranch_keepsOtherCollapsedBranchBadge", () => {
|
||||
// root -> {a -> a1, c -> c1}: opening reveals branch a (a, a1) but leaves
|
||||
// branch c collapsed. The two unread replies under c keep the root badge.
|
||||
const messages = [
|
||||
msg("root", null, 50),
|
||||
msg("a", "root", 100),
|
||||
msg("a1", "a", 110, "author", "root"),
|
||||
msg("c", "root", 120),
|
||||
msg("c1", "c", 130, "author", "root"),
|
||||
];
|
||||
assert.equal(
|
||||
rootBadge(messages, openMarksRevealed(messages, ["a", "a1"])),
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
test("newerReplyAfterOpen_relightsRootBadge", () => {
|
||||
// Open marks a(100) read at its createdAt. A newer reply b(200) arrives in
|
||||
// the same revealed branch; the predicate is strictly createdAt > readAt, so
|
||||
// b is unread against a's marker and the badge relights. Models a reply
|
||||
// landing after the open snapshot without re-marking.
|
||||
const messages = [
|
||||
msg("root", null, 50),
|
||||
msg("a", "root", 100),
|
||||
msg("b", "root", 200),
|
||||
];
|
||||
// Only a was present/revealed at open; b is unread (never marked).
|
||||
assert.equal(rootBadge(messages, openMarksRevealed(messages, ["a"])), 1);
|
||||
});
|
||||
|
||||
test("openThreadWhereOnlyUnreadIsOwnReply_neverShowsBadge", () => {
|
||||
// Will "commented back": a nested reply authored by the current user. Self
|
||||
// authored replies are excluded from the count, so no badge ever shows and
|
||||
// the fix is inert — the badge is already absent before and after open.
|
||||
// A nested reply authored by the current user. Self-authored replies are
|
||||
// excluded from the count, so no badge shows regardless of read state — the
|
||||
// open-at-level change is inert here.
|
||||
const messages = [
|
||||
msg("root", null, 50),
|
||||
msg("a", "root", 100, "other"),
|
||||
msg("b", "a", 200, "ME"),
|
||||
msg("b", "a", 200, "ME", "root"),
|
||||
];
|
||||
// Frontier below every reply (never read) — only "other"'s reply a counts.
|
||||
const beforeOpen = computeThreadBadgeCounts(
|
||||
messages,
|
||||
buildRepliesByRootId(messages),
|
||||
new Map([["root", null]]),
|
||||
() => true,
|
||||
"me",
|
||||
).get("root");
|
||||
assert.equal(beforeOpen, 1);
|
||||
// After open the frontier reaches subtree-max (200), clearing a as well.
|
||||
assert.equal(badgeAfterOpen("root", messages, null, "me"), undefined);
|
||||
// Nothing revealed (never read), only "other"'s reply a could count.
|
||||
assert.equal(rootBadge(messages, () => null, "me"), 1);
|
||||
// After revealing a, only the self-authored b remains — no badge.
|
||||
assert.equal(
|
||||
rootBadge(messages, openMarksRevealed(messages, ["a"]), "me"),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test("openThreadWhereEveryUnreadIsOwnReply_inertNoBadgeEver", () => {
|
||||
@@ -154,15 +124,11 @@ test("openThreadWhereEveryUnreadIsOwnReply_inertNoBadgeEver", () => {
|
||||
const messages = [
|
||||
msg("root", null, 50),
|
||||
msg("a", "root", 100, "ME"),
|
||||
msg("b", "a", 200, "ME"),
|
||||
msg("b", "a", 200, "ME", "root"),
|
||||
];
|
||||
const before = computeThreadBadgeCounts(
|
||||
messages,
|
||||
buildRepliesByRootId(messages),
|
||||
new Map([["root", null]]),
|
||||
() => true,
|
||||
"me",
|
||||
).get("root");
|
||||
assert.equal(before, undefined);
|
||||
assert.equal(badgeAfterOpen("root", messages, null, "me"), undefined);
|
||||
assert.equal(rootBadge(messages, () => null, "me"), undefined);
|
||||
assert.equal(
|
||||
rootBadge(messages, openMarksRevealed(messages, ["a", "b"]), "me"),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import { buildRepliesByRootId } from "./subtreeCreatedAt.ts";
|
||||
|
||||
// Minimal TimelineMessage shape the badge counter reads: id, parentId, rootId,
|
||||
// createdAt, pubkey. createdAt defaults high so replies count unread against a
|
||||
// null frontier unless a test sets it lower. `rootId` defaults to the parent
|
||||
// never-read resolver unless a test sets it lower. `rootId` defaults to the parent
|
||||
// (mirroring getThreadReference's `rootTag?.[1] ?? parentId` fallback), which is
|
||||
// correct for a DIRECT reply (parent IS the root); nested replies must pass
|
||||
// their true thread root explicitly, exactly as getThreadReference resolves the
|
||||
@@ -22,18 +22,36 @@ const msg = (id, parentId, createdAt = 100, pubkey = "author", rootId) => ({
|
||||
});
|
||||
|
||||
const countAll = () => true;
|
||||
const counts = (messages, frontiers, isNotified = countAll, currentPubkey) =>
|
||||
|
||||
// LP4 v3: badges read a per-message resolver, not a per-root frontier. These
|
||||
// helpers translate the legacy test intents into resolvers:
|
||||
// - `neverRead` — no message has been read (the old null-frontier case).
|
||||
// - `readLineByRoot(map)` — a uniform read-line per thread root, applied to
|
||||
// every reply that resolves to that root by rootId. Reproduces the old
|
||||
// "frontier covers part of the subtree" cases without a single global line.
|
||||
const neverRead = () => null;
|
||||
function readLineByRoot(messages, frontiersByRoot) {
|
||||
const lineByMessageId = new Map();
|
||||
for (const message of messages) {
|
||||
const root = message.rootId ?? message.parentId ?? message.id;
|
||||
const line = frontiersByRoot.get(root);
|
||||
if (line !== undefined) lineByMessageId.set(message.id, line);
|
||||
}
|
||||
return (id) => lineByMessageId.get(id) ?? null;
|
||||
}
|
||||
|
||||
const counts = (messages, getReadAt, isNotified = countAll, currentPubkey) =>
|
||||
computeThreadBadgeCounts(
|
||||
messages,
|
||||
buildRepliesByRootId(messages),
|
||||
frontiers,
|
||||
getReadAt,
|
||||
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);
|
||||
assert.equal(counts(messages, neverRead).get("root"), 2);
|
||||
});
|
||||
|
||||
test("computeThreadBadgeCounts_nestedReply_countsTowardRoot", () => {
|
||||
@@ -44,7 +62,7 @@ test("computeThreadBadgeCounts_nestedReply_countsTowardRoot", () => {
|
||||
msg("a", "root"),
|
||||
msg("b", "a", 100, "author", "root"),
|
||||
];
|
||||
assert.equal(counts(messages, undefined).get("root"), 2);
|
||||
assert.equal(counts(messages, neverRead).get("root"), 2);
|
||||
});
|
||||
|
||||
test("computeThreadBadgeCounts_deepChain_countsWholeSubtree", () => {
|
||||
@@ -57,7 +75,7 @@ test("computeThreadBadgeCounts_deepChain_countsWholeSubtree", () => {
|
||||
msg("c", "b", 100, "author", "root"),
|
||||
msg("d", "c", 100, "author", "root"),
|
||||
];
|
||||
assert.equal(counts(messages, undefined).get("root"), 4);
|
||||
assert.equal(counts(messages, neverRead).get("root"), 4);
|
||||
});
|
||||
|
||||
test("computeThreadBadgeCounts_branchingSubtree_countsAllBranches", () => {
|
||||
@@ -70,12 +88,12 @@ test("computeThreadBadgeCounts_branchingSubtree_countsAllBranches", () => {
|
||||
msg("c", "a", 100, "author", "root"),
|
||||
msg("d", "root"),
|
||||
];
|
||||
assert.equal(counts(messages, undefined).get("root"), 4);
|
||||
assert.equal(counts(messages, neverRead).get("root"), 4);
|
||||
});
|
||||
|
||||
test("computeThreadBadgeCounts_rootWithNoReplies_omitted", () => {
|
||||
const messages = [msg("root", null)];
|
||||
assert.equal(counts(messages, undefined).has("root"), false);
|
||||
assert.equal(counts(messages, neverRead).has("root"), false);
|
||||
});
|
||||
|
||||
test("computeThreadBadgeCounts_notNotified_omitted", () => {
|
||||
@@ -84,28 +102,62 @@ test("computeThreadBadgeCounts_notNotified_omitted", () => {
|
||||
msg("a", "root"),
|
||||
msg("b", "a", 100, "author", "root"),
|
||||
];
|
||||
assert.equal(counts(messages, undefined, () => false).size, 0);
|
||||
assert.equal(counts(messages, neverRead, () => false).size, 0);
|
||||
});
|
||||
|
||||
test("computeThreadBadgeCounts_frontierCoversNestedReplies_excludesRead", () => {
|
||||
// Frontier 150: a (100) is read, only nested b (200) remains unread.
|
||||
test("computeThreadBadgeCounts_readLineCoversNestedReplies_excludesRead", () => {
|
||||
// Read-line 150 across the root's subtree: a (100) is read, only nested
|
||||
// b (200) remains unread.
|
||||
const messages = [
|
||||
msg("root", null),
|
||||
msg("a", "root", 100),
|
||||
msg("b", "a", 200, "author", "root"),
|
||||
];
|
||||
const frontiers = new Map([["root", 150]]);
|
||||
assert.equal(counts(messages, frontiers).get("root"), 1);
|
||||
const readAt = readLineByRoot(messages, new Map([["root", 150]]));
|
||||
assert.equal(counts(messages, readAt).get("root"), 1);
|
||||
});
|
||||
|
||||
test("computeThreadBadgeCounts_frontierCoversWholeSubtree_omitsRoot", () => {
|
||||
test("computeThreadBadgeCounts_readLineCoversWholeSubtree_omitsRoot", () => {
|
||||
const messages = [
|
||||
msg("root", null),
|
||||
msg("a", "root", 100),
|
||||
msg("b", "a", 120, "author", "root"),
|
||||
];
|
||||
const frontiers = new Map([["root", 150]]);
|
||||
assert.equal(counts(messages, frontiers).has("root"), false);
|
||||
const readAt = readLineByRoot(messages, new Map([["root", 150]]));
|
||||
assert.equal(counts(messages, readAt).has("root"), false);
|
||||
});
|
||||
|
||||
test("computeThreadBadgeCounts_perMessageMarker_readDeepReplyKeepsSiblingBadge", () => {
|
||||
// The per-message model's defining case: marking the deep reply b read
|
||||
// leaves direct sibling a unread independently — a single subtree frontier
|
||||
// could not express "b read but a not".
|
||||
const messages = [
|
||||
msg("root", null),
|
||||
msg("a", "root", 100),
|
||||
msg("b", "a", 200, "author", "root"),
|
||||
];
|
||||
const readAt = (id) => (id === "b" ? 200 : null);
|
||||
assert.equal(counts(messages, readAt).get("root"), 1);
|
||||
});
|
||||
|
||||
test("computeThreadBadgeCounts_forcedUnread_relightsReadReply", () => {
|
||||
// Session-local mark-unread forces a (read by its marker) back to unread.
|
||||
const messages = [
|
||||
msg("root", null),
|
||||
msg("a", "root", 100),
|
||||
msg("b", "a", 200, "author", "root"),
|
||||
];
|
||||
const allRead = () => 1000;
|
||||
const isForcedUnread = (id) => id === "a";
|
||||
const result = computeThreadBadgeCounts(
|
||||
messages,
|
||||
buildRepliesByRootId(messages),
|
||||
allRead,
|
||||
countAll,
|
||||
undefined,
|
||||
isForcedUnread,
|
||||
);
|
||||
assert.equal(result.get("root"), 1);
|
||||
});
|
||||
|
||||
test("computeThreadBadgeCounts_selfAuthoredNestedReply_notCounted", () => {
|
||||
@@ -115,7 +167,7 @@ test("computeThreadBadgeCounts_selfAuthoredNestedReply_notCounted", () => {
|
||||
msg("a", "root", 100, "other"),
|
||||
msg("b", "a", 200, "ME", "root"),
|
||||
];
|
||||
assert.equal(counts(messages, undefined, countAll, "me").get("root"), 1);
|
||||
assert.equal(counts(messages, neverRead, countAll, "me").get("root"), 1);
|
||||
});
|
||||
|
||||
test("computeThreadBadgeCounts_multipleRoots_eachCountsOwnSubtree", () => {
|
||||
@@ -126,7 +178,7 @@ test("computeThreadBadgeCounts_multipleRoots_eachCountsOwnSubtree", () => {
|
||||
msg("root2", null),
|
||||
msg("c", "root2"),
|
||||
];
|
||||
const result = counts(messages, undefined);
|
||||
const result = counts(messages, neverRead);
|
||||
assert.equal(result.get("root1"), 2);
|
||||
assert.equal(result.get("root2"), 1);
|
||||
});
|
||||
@@ -155,7 +207,7 @@ test("computeThreadBadgeCounts_brokenParentChain_orphanedReplyRollsUpToRoot", ()
|
||||
// msg("b", "a") — intentionally absent: unloaded intermediate ancestor.
|
||||
msg("c", "b", 100, "author", "root"),
|
||||
];
|
||||
assert.equal(counts(loaded, undefined).get("root"), 2);
|
||||
assert.equal(counts(loaded, neverRead).get("root"), 2);
|
||||
});
|
||||
|
||||
test("computeThreadBadgeCounts_brokenParentChain_orphanedSoleReply_showsBadge", () => {
|
||||
@@ -168,7 +220,7 @@ test("computeThreadBadgeCounts_brokenParentChain_orphanedSoleReply_showsBadge",
|
||||
// msg("b", "root") — intentionally absent: unloaded intermediate ancestor.
|
||||
msg("c", "b", 100, "author", "root"),
|
||||
];
|
||||
assert.equal(counts(loaded, undefined).get("root"), 1);
|
||||
assert.equal(counts(loaded, neverRead).get("root"), 1);
|
||||
});
|
||||
|
||||
test("computeThreadBadgeCounts_fullParentChain_orphanRollsUp_DESIRED", () => {
|
||||
@@ -182,5 +234,5 @@ test("computeThreadBadgeCounts_fullParentChain_orphanRollsUp_DESIRED", () => {
|
||||
msg("b", "a", 100, "author", "root"),
|
||||
msg("c", "b", 100, "author", "root"),
|
||||
];
|
||||
assert.equal(counts(loaded, undefined).get("root"), 3);
|
||||
assert.equal(counts(loaded, neverRead).get("root"), 3);
|
||||
});
|
||||
|
||||
@@ -5,11 +5,8 @@ import type { TimelineMessage } from "@/features/messages/types";
|
||||
* 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.
|
||||
* (`isNotified`). The count spans the root's WHOLE subtree, so a reply nested
|
||||
* under another reply still tallies toward the root's badge.
|
||||
*
|
||||
* Subtree membership is keyed on each reply's `rootId` rather than walked
|
||||
* through the parent chain: a reply whose intermediate ancestor is absent from
|
||||
@@ -19,19 +16,25 @@ import type { TimelineMessage } from "@/features/messages/types";
|
||||
* to the old adjacency walk. Each reply has exactly one rootId, so it is
|
||||
* counted once and a malformed parent cycle keys off no root.
|
||||
*
|
||||
* Unread is decided per-reply against `getReadAt` (LP4 v3): each reply lights
|
||||
* iff `createdAt > effective(msg:<id>)`, so reading one reply never clears
|
||||
* another and a collapsed-branch reply keeps its badge until revealed.
|
||||
*
|
||||
* @param messages Top-level timeline entries in chronological order.
|
||||
* @param repliesByRootId Replies grouped by their resolved thread root id.
|
||||
* @param frontiers Per-root read frontier in unix seconds, or null/undefined
|
||||
* when the thread was never read (every reply counts unread).
|
||||
* @param getReadAt Per-message read resolver; `null` means never read.
|
||||
* @param isNotified Whether a thread root is one the user is notified for.
|
||||
* @param currentPubkey Replies authored by this pubkey never count as unread.
|
||||
* @param isForcedUnread Session-local OR-overlay: a reply forced unread this
|
||||
* session counts regardless of its marker (per-message mark-unread).
|
||||
*/
|
||||
export function computeThreadBadgeCounts(
|
||||
messages: TimelineMessage[],
|
||||
repliesByRootId: ReadonlyMap<string, TimelineMessage[]>,
|
||||
frontiers: ReadonlyMap<string, number | null> | undefined,
|
||||
getReadAt: (messageId: string) => number | null,
|
||||
isNotified: (rootId: string) => boolean,
|
||||
currentPubkey?: string,
|
||||
isForcedUnread: (messageId: string) => boolean = () => false,
|
||||
): Map<string, number> {
|
||||
const counts = new Map<string, number>();
|
||||
for (const message of messages) {
|
||||
@@ -41,8 +44,9 @@ export function computeThreadBadgeCounts(
|
||||
if (!subtreeReplies || subtreeReplies.length === 0) continue;
|
||||
const { unreadCount } = computeThreadUnreadMarker(
|
||||
subtreeReplies,
|
||||
frontiers?.get(message.id) ?? null,
|
||||
getReadAt,
|
||||
currentPubkey,
|
||||
isForcedUnread,
|
||||
);
|
||||
if (unreadCount > 0) {
|
||||
counts.set(message.id, unreadCount);
|
||||
|
||||
@@ -1,257 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { nextThreadBadgeFrontier } from "./threadBadgeFrontier.ts";
|
||||
import { seedThreadBadgeFrontiers } from "./threadBadgeFrontier.ts";
|
||||
import { buildRepliesByRootId } from "./subtreeCreatedAt.ts";
|
||||
import { computeThreadBadgeCounts } from "./threadBadgeCounts.ts";
|
||||
|
||||
const msg = (id, parentId) => ({ id, parentId, rootId: parentId ?? id });
|
||||
const seedAll = () => true;
|
||||
const seed = (frontiers, messages, isNotified, getReadAt) =>
|
||||
seedThreadBadgeFrontiers(
|
||||
frontiers,
|
||||
messages,
|
||||
buildRepliesByRootId(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);
|
||||
});
|
||||
|
||||
// --- LP4 Case 3, seed-timing face: channel marker must not bleed into seed ---
|
||||
//
|
||||
// seedThreadBadgeFrontiers seeds each root via getReadAt(root). AppShell's
|
||||
// getThreadReadAt(rootId, channelId?) returns the thread's OWN marker when no
|
||||
// channelId is passed, but max(thread_own, channel) when one is — the NIP-RS
|
||||
// hierarchical fold. Channel-open markChannelRead advances the channel marker
|
||||
// to the newest top-level message; if the seed reads the FOLDED marker, that
|
||||
// fresh channel timestamp seeds the frontier PAST an unread reply and the badge
|
||||
// vanishes everywhere (computeThreadBadgeCounts then reads zero). The seed is
|
||||
// monotonic (nextThreadBadgeFrontier uses Math.max), so once the channel marker
|
||||
// has advanced, any re-render re-bleeds it back — the flaky "no badge" the user
|
||||
// saw correlated with newer channel messages.
|
||||
//
|
||||
// The fix: the seed reads the thread's OWN marker (getThreadReadAt(root) with no
|
||||
// channelId), never the folded marker. Thread badges are channel-independent by
|
||||
// design (NIP-RS Option 1: channel-open leaves them intact until each thread is
|
||||
// read); the own marker advances only when the thread itself is read, which is
|
||||
// exactly the advance-on-read the monotonic seed wants.
|
||||
//
|
||||
// These tests model getThreadReadAt exactly as AppShell defines it and drive
|
||||
// seed -> compute end-to-end. The folded path is the regression tripwire (badge
|
||||
// vanishes); the own-marker path is the fixed behavior (badge survives).
|
||||
|
||||
// Richer message shape than the file-level `msg`: computeThreadBadgeCounts reads
|
||||
// createdAt and pubkey, which the frontier-only helper omits. rootId defaults to
|
||||
// the parent (getThreadReference's fallback) so these fixtures stay falsifiable
|
||||
// once the seed/count pipeline re-keys on rootId.
|
||||
const reply = (id, parentId, createdAt, rootId) => ({
|
||||
id,
|
||||
parentId,
|
||||
rootId: rootId ?? parentId ?? id,
|
||||
createdAt,
|
||||
pubkey: "author",
|
||||
});
|
||||
|
||||
// Faithful model of AppShell's getThreadReadAt(rootId, channelId?): own marker
|
||||
// alone, or folded with the channel marker via Math.max when a channelId is
|
||||
// passed. The seed must invoke this WITHOUT a channelId.
|
||||
const makeGetThreadReadAt = (threadOwn, channel) => (_rootId, channelId) => {
|
||||
if (channelId == null) return threadOwn;
|
||||
if (threadOwn === null) return channel;
|
||||
if (channel === null) return threadOwn;
|
||||
return Math.max(threadOwn, channel);
|
||||
};
|
||||
|
||||
const seedAndCount = (messages, getReadAt) => {
|
||||
const repliesByRootId = buildRepliesByRootId(messages);
|
||||
const frontiers = new Map();
|
||||
seedThreadBadgeFrontiers(
|
||||
frontiers,
|
||||
messages,
|
||||
repliesByRootId,
|
||||
seedAll,
|
||||
(id) => getReadAt(id),
|
||||
);
|
||||
return {
|
||||
frontier: frontiers.get("root"),
|
||||
counts: computeThreadBadgeCounts(
|
||||
messages,
|
||||
repliesByRootId,
|
||||
frontiers,
|
||||
seedAll,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
test("seedThreadBadgeFrontiers_channelMarkerFoldedIntoSeed_badgeVanishes", () => {
|
||||
// Thread "root" has one unread reply at 200. Own marker is 100 (reply is
|
||||
// genuinely unread), but channel-open advanced the channel marker to 250.
|
||||
// Seeding via the FOLDED accessor (channelId passed) reads max(100, 250)=250.
|
||||
const messages = [reply("root", null, 50), reply("r1", "root", 200)];
|
||||
const getThreadReadAt = makeGetThreadReadAt(100, 250);
|
||||
|
||||
const { frontier, counts } = seedAndCount(messages, (id) =>
|
||||
getThreadReadAt(id, "channel-1"),
|
||||
);
|
||||
// Regression tripwire: folded marker seeds past the unread reply -> no badge.
|
||||
assert.equal(frontier, 250);
|
||||
assert.equal(counts.has("root"), false);
|
||||
});
|
||||
|
||||
test("seedThreadBadgeFrontiers_ownMarkerSeeded_badgeSurvives", () => {
|
||||
// Identical thread, but the seed reads the thread's OWN marker (no channelId),
|
||||
// so the channel fold never applies — the fixed wiring.
|
||||
const messages = [reply("root", null, 50), reply("r1", "root", 200)];
|
||||
const getThreadReadAt = makeGetThreadReadAt(100, 250);
|
||||
|
||||
const { frontier, counts } = seedAndCount(messages, (id) =>
|
||||
getThreadReadAt(id),
|
||||
);
|
||||
// Fixed: frontier seeded to the own marker (100), behind the unread reply.
|
||||
assert.equal(frontier, 100);
|
||||
assert.equal(counts.get("root"), 1);
|
||||
});
|
||||
|
||||
test("seedThreadBadgeFrontiers_threadReadNewerThanChannel_noSpuriousShift", () => {
|
||||
// Edge case: the THREAD was read more recently (180) than the channel (120),
|
||||
// and one reply at 200 is still unread. The own marker and the folded marker
|
||||
// happen to coincide here (max(180, 120) = 180), so both paths agree the reply
|
||||
// at 200 is unread — the own-marker path must not under- or over-clear it.
|
||||
const messages = [reply("root", null, 50), reply("r1", "root", 200)];
|
||||
const getThreadReadAt = makeGetThreadReadAt(180, 120);
|
||||
|
||||
const own = seedAndCount(messages, (id) => getThreadReadAt(id));
|
||||
assert.equal(own.frontier, 180);
|
||||
assert.equal(own.counts.get("root"), 1);
|
||||
});
|
||||
|
||||
test("seedThreadBadgeFrontiers_ownMarkerCoversReply_badgeClears", () => {
|
||||
// The thread itself was read past the reply (own marker 250 >= reply at 200),
|
||||
// so the badge correctly clears regardless of the channel marker. Confirms the
|
||||
// own-marker seed still advances-on-read — it isn't pinned at "always unread".
|
||||
const messages = [reply("root", null, 50), reply("r1", "root", 200)];
|
||||
const getThreadReadAt = makeGetThreadReadAt(250, 120);
|
||||
|
||||
const { frontier, counts } = seedAndCount(messages, (id) =>
|
||||
getThreadReadAt(id),
|
||||
);
|
||||
assert.equal(frontier, 250);
|
||||
assert.equal(counts.has("root"), false);
|
||||
});
|
||||
|
||||
// --- LP4 Case 3, second face: orphan-only root IS seed-eligible ---
|
||||
//
|
||||
// seedThreadBadgeFrontiers gates seed-eligibility on a reply existing under the
|
||||
// root by rootId: `if (!repliesByRootId.has(message.id)) continue;`. A root
|
||||
// whose ONLY reply is a deep orphan — the middle ancestor unloaded, so the
|
||||
// orphan keys under its absent parent in the direct-reply map — still owns that
|
||||
// reply by rootId (getThreadReference resolves rootId from the event's own
|
||||
// `root` e-tag regardless of ancestor load state). The old direct-reply gate
|
||||
// skipped such a root entirely, so its frontier never existed and its badge
|
||||
// could never clear; keying on rootId makes it seed-eligible. This is a face of
|
||||
// Case 3 distinct from a wrong COUNT — here the frontier snapshot itself was
|
||||
// missing, so even a corrected count path had nothing to measure against.
|
||||
|
||||
test("seedThreadBadgeFrontiers_orphanOnlyRoot_seedEligible", () => {
|
||||
// root's only reply is `c`, whose middle ancestor `b` is unloaded. `c` keys
|
||||
// under "b" (absent) in the direct-reply map but carries rootId === "root",
|
||||
// so repliesByRootId has an entry for "root" and the root seeds to marker 100.
|
||||
const messages = [
|
||||
reply("root", null, 50),
|
||||
// reply("b", "root", ...) — intentionally absent: unloaded ancestor.
|
||||
reply("c", "b", 200, "root"),
|
||||
];
|
||||
const frontiers = new Map();
|
||||
seed(frontiers, messages, seedAll, () => 100);
|
||||
assert.equal(frontiers.get("root"), 100);
|
||||
});
|
||||
|
||||
test("seedThreadBadgeFrontiers_directReplyRoot_seedEligible_DESIRED", () => {
|
||||
// Control: the SAME root with a DIRECT reply present. repliesByRootId has an
|
||||
// entry for "root", so it is seed-eligible — the intact-chain baseline.
|
||||
const messages = [reply("root", null, 50), reply("r1", "root", 200)];
|
||||
const frontiers = new Map();
|
||||
seed(frontiers, messages, seedAll, () => 100);
|
||||
assert.equal(frontiers.get("root"), 100);
|
||||
});
|
||||
@@ -1,64 +0,0 @@
|
||||
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.
|
||||
//
|
||||
// Reply-presence is keyed on `repliesByRootId`, not the direct-parent map: a
|
||||
// root whose only reply is a deep orphan (intermediate ancestor absent from the
|
||||
// loaded window) has no direct child but still owns that reply by rootId. Gating
|
||||
// on direct children alone skipped such a root entirely, so its frontier never
|
||||
// existed and its badge could never clear — the seed-side face of the orphan
|
||||
// defect that the count rollup fixes on the tally side.
|
||||
export function seedThreadBadgeFrontiers(
|
||||
channelFrontiers: Map<string, number | null>,
|
||||
messages: TimelineMessage[],
|
||||
repliesByRootId: 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 (!repliesByRootId.has(message.id)) continue;
|
||||
channelFrontiers.set(
|
||||
message.id,
|
||||
nextThreadBadgeFrontier(
|
||||
channelFrontiers.get(message.id),
|
||||
getReadAt(message.id),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,26 +2,23 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { computeThreadBadgeCounts } from "./threadBadgeCounts.ts";
|
||||
import {
|
||||
nextThreadBadgeFrontier,
|
||||
seedThreadBadgeFrontiers,
|
||||
} from "./threadBadgeFrontier.ts";
|
||||
import { buildRepliesByRootId } from "./subtreeCreatedAt.ts";
|
||||
|
||||
// LP4 characterization invariants for thread-unread badges.
|
||||
// LP4 v3 characterization invariants for thread-unread badges.
|
||||
//
|
||||
// Each invariant pins a contract the badge pipeline holds TODAY and that the
|
||||
// redesign (re-key the roll-up + frontier on rootId, lift the orphan-only-root
|
||||
// seed skip) must preserve. They are green on current code and stay green after
|
||||
// the collapse: a redesign that changes any one of (a)-(g) has broken behavior
|
||||
// Will depends on, not just refactored an internal walk.
|
||||
// Each invariant pins a contract the per-message badge pipeline holds. They are
|
||||
// the observable behaviors Will depends on: a change that breaks any one of
|
||||
// (a)-(g) has regressed behavior, not just refactored an internal path.
|
||||
//
|
||||
// Fixtures carry `rootId` alongside `parentId` so they remain falsifiable once
|
||||
// the pipeline re-keys on rootId — a rootId-keyed implementation that ignored
|
||||
// parentId, or a parentId-keyed one that ignored rootId, must still satisfy the
|
||||
// same observable counts here. `rootId` defaults to the thread root for the
|
||||
// happy-path fixtures; the orphan/sibling defect tests live in the dedicated
|
||||
// _DEFECT suites and threadOpenCeiling.test.mjs.
|
||||
// The model is per-message read markers (`msg:<id>`) read through a resolver,
|
||||
// not a per-thread-root frontier snapshot. `getReadAt(id)` returns the
|
||||
// effective read time for a reply (`null` = never read); a reply counts unread
|
||||
// iff `createdAt > getReadAt(id)`. Reading one reply's marker never touches
|
||||
// another's — independence is structural, not enforced by a separate seed.
|
||||
//
|
||||
// Fixtures carry `rootId` alongside `parentId` so the root-keyed roll-up stays
|
||||
// falsifiable: a rootId-keyed implementation that ignored parentId, or the
|
||||
// reverse, must still satisfy the same observable counts here.
|
||||
|
||||
const msg = (id, parentId, rootId, createdAt = 100, pubkey = "author") => ({
|
||||
id,
|
||||
@@ -32,25 +29,30 @@ const msg = (id, parentId, rootId, createdAt = 100, pubkey = "author") => ({
|
||||
});
|
||||
|
||||
const notifiedAll = () => true;
|
||||
const counts = (messages, frontiers, isNotified = notifiedAll, currentPubkey) =>
|
||||
const neverRead = () => null;
|
||||
|
||||
// A uniform read-line per thread root, applied to every reply resolving to that
|
||||
// root by rootId. Translates the legacy "frontier covers part of a subtree"
|
||||
// intents into the per-message resolver without a single global line.
|
||||
function readLineByRoot(messages, frontiersByRoot) {
|
||||
const lineByMessageId = new Map();
|
||||
for (const message of messages) {
|
||||
const root = message.rootId ?? message.parentId ?? message.id;
|
||||
const line = frontiersByRoot.get(root);
|
||||
if (line !== undefined) lineByMessageId.set(message.id, line);
|
||||
}
|
||||
return (id) => lineByMessageId.get(id) ?? null;
|
||||
}
|
||||
|
||||
const counts = (messages, getReadAt, isNotified = notifiedAll, currentPubkey) =>
|
||||
computeThreadBadgeCounts(
|
||||
messages,
|
||||
buildRepliesByRootId(messages),
|
||||
frontiers,
|
||||
getReadAt,
|
||||
isNotified,
|
||||
currentPubkey,
|
||||
);
|
||||
|
||||
const seedAll = () => true;
|
||||
const seed = (frontiers, messages, getReadAt, isNotified = seedAll) =>
|
||||
seedThreadBadgeFrontiers(
|
||||
frontiers,
|
||||
messages,
|
||||
buildRepliesByRootId(messages),
|
||||
isNotified,
|
||||
getReadAt,
|
||||
);
|
||||
|
||||
// (a) A root's badge counts EVERY descendant in its subtree, at any depth, not
|
||||
// just direct replies. The whole connected subtree rolls up to one badge.
|
||||
test("invariant_a_subtreeRollsUpToOneRootBadge", () => {
|
||||
@@ -60,7 +62,7 @@ test("invariant_a_subtreeRollsUpToOneRootBadge", () => {
|
||||
msg("b", "a", "root"),
|
||||
msg("c", "b", "root"),
|
||||
];
|
||||
const result = counts(messages, undefined);
|
||||
const result = counts(messages, neverRead);
|
||||
assert.equal(result.get("root"), 3);
|
||||
assert.equal(result.size, 1);
|
||||
});
|
||||
@@ -74,21 +76,21 @@ test("invariant_b_onlyNotifiedRootsBadge", () => {
|
||||
msg("root2", null, "root2"),
|
||||
msg("b", "root2", "root2"),
|
||||
];
|
||||
const result = counts(messages, undefined, (id) => id === "root1");
|
||||
const result = counts(messages, neverRead, (id) => id === "root1");
|
||||
assert.equal(result.get("root1"), 1);
|
||||
assert.equal(result.has("root2"), false);
|
||||
});
|
||||
|
||||
// (c) The frontier is the read boundary: replies at or below it are read and do
|
||||
// NOT count; only replies strictly newer than the frontier raise the badge.
|
||||
test("invariant_c_frontierExcludesReadReplies", () => {
|
||||
// (c) The marker is the read boundary: replies at or below it are read and do
|
||||
// NOT count; only replies strictly newer than the marker raise the badge.
|
||||
test("invariant_c_readMarkerExcludesReadReplies", () => {
|
||||
const messages = [
|
||||
msg("root", null, "root", 50),
|
||||
msg("read", "root", "root", 100),
|
||||
msg("unread", "root", "root", 200),
|
||||
];
|
||||
const frontiers = new Map([["root", 100]]);
|
||||
assert.equal(counts(messages, frontiers).get("root"), 1);
|
||||
const readAt = readLineByRoot(messages, new Map([["root", 100]]));
|
||||
assert.equal(counts(messages, readAt).get("root"), 1);
|
||||
});
|
||||
|
||||
// (d) The current user's own replies never count as unread, at any depth.
|
||||
@@ -98,7 +100,7 @@ test("invariant_d_selfAuthoredRepliesNeverUnread", () => {
|
||||
msg("a", "root", "root", 100, "other"),
|
||||
msg("mine", "a", "root", 200, "me"),
|
||||
];
|
||||
assert.equal(counts(messages, undefined, notifiedAll, "me").get("root"), 1);
|
||||
assert.equal(counts(messages, neverRead, notifiedAll, "me").get("root"), 1);
|
||||
});
|
||||
|
||||
// (e) A notified root with no unread content produces NO entry — absence, not a
|
||||
@@ -108,28 +110,30 @@ test("invariant_e_noUnreadMeansNoEntry", () => {
|
||||
msg("root", null, "root", 50),
|
||||
msg("a", "root", "root", 100),
|
||||
];
|
||||
const frontiers = new Map([["root", 100]]);
|
||||
const result = counts(messages, frontiers);
|
||||
const readAt = readLineByRoot(messages, new Map([["root", 100]]));
|
||||
const result = counts(messages, readAt);
|
||||
assert.equal(result.has("root"), false);
|
||||
});
|
||||
|
||||
// (f) Seed is monotonic and frozen-at-open: once advanced toward a live marker,
|
||||
// a later stale (lower) marker never lowers the snapshot. This is what keeps a
|
||||
// badge from flickering back after a read, and what the redesign's rootId
|
||||
// re-key must not regress.
|
||||
test("invariant_f_seedMonotonicNeverLowers", () => {
|
||||
assert.equal(nextThreadBadgeFrontier(undefined, null), null); // unseeded
|
||||
assert.equal(nextThreadBadgeFrontier(null, 200), 200); // first read advances
|
||||
assert.equal(nextThreadBadgeFrontier(200, 100), 200); // stale marker held
|
||||
assert.equal(nextThreadBadgeFrontier(200, null), 200); // null never lowers
|
||||
// (f) PER-MESSAGE INDEPENDENCE — reading one reply's marker never clears
|
||||
// another's. This is the structural fix for the original Issue 2 (an ancestor
|
||||
// read covering a descendant): each reply is judged against its OWN marker, so
|
||||
// reading the older reply leaves the newer one lit. A resolver that folded
|
||||
// reply→reply, or keyed all replies to one shared line, would fail here.
|
||||
test("invariant_f_readOneReplyLeavesOthersUnread", () => {
|
||||
const messages = [
|
||||
msg("root", null, "root", 50),
|
||||
msg("older", "root", "root", 100),
|
||||
msg("newer", "root", "root", 200),
|
||||
];
|
||||
// Only `older` is read (marker at its own timestamp); `newer` untouched.
|
||||
const readAt = (id) => (id === "older" ? 100 : null);
|
||||
assert.equal(counts(messages, readAt).get("root"), 1);
|
||||
});
|
||||
|
||||
// (g) FALSIFIABLE LOCK — two distinct roots keep INDEPENDENT frontiers and
|
||||
// badges; reading one never collapses the other. The redesign re-keys on
|
||||
// rootId; if that re-key ever conflated two roots' frontiers (e.g. keyed on a
|
||||
// shared channel id, or folded sibling roots into one bucket), this fails.
|
||||
// Concretely: root1 read up to its newest reply (badge clears), root2 unread.
|
||||
// A correct pipeline shows root2 only; a collapsing bug shows neither or both.
|
||||
// (g) FALSIFIABLE LOCK — two distinct roots keep INDEPENDENT badges; reading
|
||||
// one never collapses the other (the original Face-2 cross-thread bug). root1
|
||||
// read through its newest reply (badge clears), root2 unread.
|
||||
test("invariant_g_distinctRootsDoNotCollapse", () => {
|
||||
const messages = [
|
||||
msg("root1", null, "root1", 10),
|
||||
@@ -137,30 +141,16 @@ test("invariant_g_distinctRootsDoNotCollapse", () => {
|
||||
msg("root2", null, "root2", 20),
|
||||
msg("r2reply", "root2", "root2", 200),
|
||||
];
|
||||
// root1 read through its reply (frontier 100); root2 never read (null).
|
||||
const frontiers = new Map([
|
||||
["root1", 100],
|
||||
["root2", null],
|
||||
]);
|
||||
const result = counts(messages, frontiers);
|
||||
// root1 read through its reply (marker 100); root2 never read.
|
||||
const readAt = readLineByRoot(
|
||||
messages,
|
||||
new Map([
|
||||
["root1", 100],
|
||||
["root2", null],
|
||||
]),
|
||||
);
|
||||
const result = counts(messages, readAt);
|
||||
assert.equal(result.has("root1"), false); // root1 fully read — no badge
|
||||
assert.equal(result.get("root2"), 1); // root2 independently still unread
|
||||
assert.equal(result.size, 1);
|
||||
});
|
||||
|
||||
// (g) seed companion — seeding one root's frontier leaves the other untouched,
|
||||
// so the two-frontier independence holds through the seed path, not only the
|
||||
// count path.
|
||||
test("invariant_g_seedOneRootLeavesOtherUntouched", () => {
|
||||
const frontiers = new Map();
|
||||
const messages = [
|
||||
msg("root1", null, "root1", 10),
|
||||
msg("r1reply", "root1", "root1", 100),
|
||||
msg("root2", null, "root2", 20),
|
||||
msg("r2reply", "root2", "root2", 200),
|
||||
];
|
||||
seed(frontiers, messages, (id) => (id === "root1" ? 100 : null));
|
||||
assert.equal(frontiers.get("root1"), 100);
|
||||
assert.equal(frontiers.get("root2"), null);
|
||||
assert.equal(frontiers.size, 2);
|
||||
});
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
subtreeMaxCreatedAt,
|
||||
buildDirectReplyIdsByParentId,
|
||||
buildCreatedAtByMessageId,
|
||||
buildRepliesByRootId,
|
||||
} from "./subtreeCreatedAt.ts";
|
||||
|
||||
// The thread-open read ceiling: opening a thread advances its read frontier to
|
||||
// subtreeMaxCreatedAt(headId), the newest createdAt anywhere in the head's
|
||||
// subtree. The thread-open caller starts the walk at the ROOT (consume the
|
||||
// whole thread) and folds in rootId-matched orphans; the expand caller starts
|
||||
// at a BRANCH node (consume only that branch) with no rootId folding. These
|
||||
// tests pin both — Case 1's orphan-misses-ceiling defect and the call-site
|
||||
// split that must NOT let expand cross into a sibling branch.
|
||||
|
||||
const msg = (id, parentId, createdAt, rootId) => ({
|
||||
id,
|
||||
parentId,
|
||||
rootId: rootId ?? parentId ?? id,
|
||||
createdAt,
|
||||
});
|
||||
|
||||
// Branch-scoped ceiling — the expand caller. Parent-chain walk only.
|
||||
const ceiling = (headId, messages) =>
|
||||
subtreeMaxCreatedAt(
|
||||
headId,
|
||||
buildDirectReplyIdsByParentId(messages),
|
||||
buildCreatedAtByMessageId(messages),
|
||||
);
|
||||
|
||||
// Root-scoped ceiling — the thread-open caller. Folds in replies that resolve
|
||||
// to the head by rootId, so a severed orphan still raises the ceiling.
|
||||
const rootCeiling = (headId, messages) =>
|
||||
subtreeMaxCreatedAt(
|
||||
headId,
|
||||
buildDirectReplyIdsByParentId(messages),
|
||||
buildCreatedAtByMessageId(messages),
|
||||
buildRepliesByRootId(messages),
|
||||
);
|
||||
|
||||
// (3) Case 1 — orphan misses the thread-open ceiling. The deep reply `c` is the
|
||||
// newest content (300), but its middle ancestor `b` is unloaded, so `c` keys
|
||||
// under absent "b" and the parentId-only walk never reaches it. The root-scoped
|
||||
// ceiling folds in `c` by its rootId ("root", which travels with the event via
|
||||
// getThreadReference), so it reaches 300 and the channel-root badge can clear.
|
||||
test("openThreadCeiling_deepOrphanMissingAncestor_includedInCeiling", () => {
|
||||
const loaded = [
|
||||
msg("root", null, 50, "root"),
|
||||
msg("a", "root", 200, "root"),
|
||||
// msg("b", "a", ...) — intentionally absent: unloaded middle ancestor.
|
||||
msg("c", "b", 300, "root"),
|
||||
];
|
||||
assert.equal(rootCeiling("root", loaded), 300);
|
||||
});
|
||||
|
||||
// (3) control — with the middle ancestor present the chain is intact and the
|
||||
// root-started walk already reaches `c`, so the ceiling is 300 today.
|
||||
test("openThreadCeiling_fullChain_reachesDeepest", () => {
|
||||
const loaded = [
|
||||
msg("root", null, 50, "root"),
|
||||
msg("a", "root", 200, "root"),
|
||||
msg("b", "a", 250, "root"),
|
||||
msg("c", "b", 300, "root"),
|
||||
];
|
||||
assert.equal(ceiling("root", loaded), 300);
|
||||
});
|
||||
|
||||
// (4) Expand does NOT cross siblings — the call-site split. Expanding branch
|
||||
// `a` starts the ceiling walk at `a`, so it consumes only `a`'s subtree
|
||||
// (newest = a2 at 220) and must NOT advance past sibling branch `d`'s unread
|
||||
// reply (`d1` at 400). If expand keyed the ceiling on the ROOT, it would jump
|
||||
// to 400 and silently consume the sibling — the defect Thufir flagged.
|
||||
//
|
||||
// GREEN on current code: subtreeMaxCreatedAt is branch-scoped by construction
|
||||
// when started at the branch node. This test LOCKS that property so the
|
||||
// redesign's rootId re-key cannot accidentally make expand root-scoped.
|
||||
test("openThreadCeiling_expandBranch_doesNotCrossSibling", () => {
|
||||
const loaded = [
|
||||
msg("root", null, 50, "root"),
|
||||
msg("a", "root", 100, "root"),
|
||||
msg("a1", "a", 210, "root"),
|
||||
msg("a2", "a", 220, "root"),
|
||||
msg("d", "root", 120, "root"),
|
||||
msg("d1", "d", 400, "root"), // sibling branch's newer unread reply
|
||||
];
|
||||
// Expanding branch `a` reaches only a/a1/a2 — ceiling is 220, NOT 400.
|
||||
assert.equal(ceiling("a", loaded), 220);
|
||||
// The root-started ceiling DOES span everything, 400 — proving the two
|
||||
// call-sites are genuinely different scopes, not the same value by accident.
|
||||
assert.equal(ceiling("root", loaded), 400);
|
||||
});
|
||||
|
||||
// (4) companion — expanding a branch returns just the branch head's own
|
||||
// createdAt when the branch has no replies, never reaching across to siblings.
|
||||
test("openThreadCeiling_expandLeafBranch_ownCreatedAtOnly", () => {
|
||||
const loaded = [
|
||||
msg("root", null, 50, "root"),
|
||||
msg("a", "root", 100, "root"),
|
||||
msg("d", "root", 120, "root"),
|
||||
msg("d1", "d", 400, "root"),
|
||||
];
|
||||
// Branch `a` is a leaf: ceiling is its own 100, unaffected by sibling d1@400.
|
||||
assert.equal(ceiling("a", loaded), 100);
|
||||
});
|
||||
@@ -23,15 +23,21 @@ function fixture() {
|
||||
|
||||
const ROOT_SUBTREE = ["a", "b", "a1", "b1", "b2"];
|
||||
|
||||
// LP4 v3: the panel badge reads a per-message resolver, not an open-time
|
||||
// frontier snapshot, and there is no separate expanded-subtree gate — the
|
||||
// per-message marker already distinguishes a read parent from its still-unread
|
||||
// descendant. A uniform read-line at `seconds` (or null = never read)
|
||||
// reproduces the legacy boundary cases.
|
||||
const uniformReadAt = (seconds) => () => seconds;
|
||||
|
||||
test("computeThreadReplyUnreadCounts_collapsedBranch_countsUnreadDescendants", () => {
|
||||
// Frontier 350: a1(400), b1(500), b2(600) are unread.
|
||||
// Read-line 350: a1(400), b1(500), b2(600) are unread.
|
||||
const counts = computeThreadReplyUnreadCounts({
|
||||
timelineMessages: fixture(),
|
||||
subtreeReplyIds: ROOT_SUBTREE,
|
||||
visibleReplyIds: ["a", "b"],
|
||||
expandedReplyIds: new Set(),
|
||||
expandedSubtreeReplyIds: new Set(),
|
||||
frontierSeconds: 350,
|
||||
getReadAt: uniformReadAt(350),
|
||||
});
|
||||
assert.equal(counts.get("a"), 1); // a1
|
||||
assert.equal(counts.get("b"), 2); // b1, b2
|
||||
@@ -43,66 +49,66 @@ test("computeThreadReplyUnreadCounts_expandedBranch_omitsBadge", () => {
|
||||
subtreeReplyIds: ROOT_SUBTREE,
|
||||
visibleReplyIds: ["a", "b"],
|
||||
expandedReplyIds: new Set(["b"]),
|
||||
expandedSubtreeReplyIds: new Set(["b1", "b2"]),
|
||||
frontierSeconds: 350,
|
||||
getReadAt: uniformReadAt(350),
|
||||
});
|
||||
assert.equal(counts.get("a"), 1);
|
||||
// b renders its children inline, so it carries no summary badge.
|
||||
assert.equal(counts.has("b"), false);
|
||||
});
|
||||
|
||||
test("computeThreadReplyUnreadCounts_expandedBranch_revealedChildNoStaleBadge", () => {
|
||||
// Expand b: mark-read-on-expand reads b's whole subtree, and the panel now
|
||||
// reveals collapsed child b1 (descendant b2 still unread vs the open-time
|
||||
// frontier). b1 must carry NO badge — the expanded subtree is excluded.
|
||||
test("computeThreadReplyUnreadCounts_revealedCollapsedChild_keepsOwnSubtreeBadge", () => {
|
||||
// v3 open-at-level: expanding b reveals direct child b1 but marks only the
|
||||
// revealed set read — it does NOT clear b1's still-collapsed descendant b2.
|
||||
// The per-message marker leaves b2 unread, so the now-visible (collapsed) b1
|
||||
// carries a badge of 1. This is the deliberate reversal of the #1118
|
||||
// whole-subtree-on-open behavior.
|
||||
const counts = computeThreadReplyUnreadCounts({
|
||||
timelineMessages: fixture(),
|
||||
subtreeReplyIds: ROOT_SUBTREE,
|
||||
visibleReplyIds: ["a", "b", "b1"],
|
||||
expandedReplyIds: new Set(["b"]),
|
||||
expandedSubtreeReplyIds: new Set(["b1", "b2"]),
|
||||
frontierSeconds: 350,
|
||||
// b and its revealed direct child b1 are read; b2 (collapsed under b1)
|
||||
// is still unread.
|
||||
getReadAt: (id) => (id === "b1" || id === "b" ? 1000 : 350),
|
||||
});
|
||||
assert.equal(counts.get("a"), 1);
|
||||
assert.equal(counts.has("b"), false);
|
||||
assert.equal(counts.has("b1"), false);
|
||||
assert.equal(counts.has("b"), false); // expanded -> no summary badge
|
||||
assert.equal(counts.get("b1"), 1); // collapsed b1 keeps its b2 badge
|
||||
});
|
||||
|
||||
test("computeThreadReplyUnreadCounts_descendantsButNoneUnread_noBadge", () => {
|
||||
// Frontier 1000: nothing is newer, so no unread descendants anywhere.
|
||||
// Read-line 1000: nothing is newer, so no unread descendants anywhere.
|
||||
const counts = computeThreadReplyUnreadCounts({
|
||||
timelineMessages: fixture(),
|
||||
subtreeReplyIds: ROOT_SUBTREE,
|
||||
visibleReplyIds: ["a", "b"],
|
||||
expandedReplyIds: new Set(),
|
||||
expandedSubtreeReplyIds: new Set(),
|
||||
frontierSeconds: 1000,
|
||||
getReadAt: uniformReadAt(1000),
|
||||
});
|
||||
assert.equal(counts.size, 0);
|
||||
});
|
||||
|
||||
test("computeThreadReplyUnreadCounts_nullFrontier_allDescendantsUnread", () => {
|
||||
test("computeThreadReplyUnreadCounts_neverRead_allDescendantsUnread", () => {
|
||||
const counts = computeThreadReplyUnreadCounts({
|
||||
timelineMessages: fixture(),
|
||||
subtreeReplyIds: ROOT_SUBTREE,
|
||||
visibleReplyIds: ["a", "b"],
|
||||
expandedReplyIds: new Set(),
|
||||
expandedSubtreeReplyIds: new Set(),
|
||||
frontierSeconds: null,
|
||||
getReadAt: uniformReadAt(null),
|
||||
});
|
||||
assert.equal(counts.get("a"), 1); // a1
|
||||
assert.equal(counts.get("b"), 2); // b1, b2
|
||||
});
|
||||
|
||||
test("computeThreadReplyUnreadCounts_otherThreadReply_notCounted", () => {
|
||||
// other1(800) is unread by frontier but outside root's subtree — its
|
||||
// ancestor "other" is not a visible row here and must never be keyed.
|
||||
// other1(800) is unread but outside root's subtree — its ancestor "other"
|
||||
// is not in subtreeReplyIds and must never be keyed.
|
||||
const counts = computeThreadReplyUnreadCounts({
|
||||
timelineMessages: fixture(),
|
||||
subtreeReplyIds: ROOT_SUBTREE,
|
||||
visibleReplyIds: ["a", "b", "other"],
|
||||
expandedReplyIds: new Set(),
|
||||
expandedSubtreeReplyIds: new Set(),
|
||||
frontierSeconds: 350,
|
||||
getReadAt: uniformReadAt(350),
|
||||
});
|
||||
assert.equal(counts.has("other"), false);
|
||||
});
|
||||
@@ -114,8 +120,7 @@ test("computeThreadReplyUnreadCounts_onlyVisibleRowsKeyed", () => {
|
||||
subtreeReplyIds: ROOT_SUBTREE,
|
||||
visibleReplyIds: ["a"],
|
||||
expandedReplyIds: new Set(),
|
||||
expandedSubtreeReplyIds: new Set(),
|
||||
frontierSeconds: 350,
|
||||
getReadAt: uniformReadAt(350),
|
||||
});
|
||||
assert.equal(counts.get("a"), 1);
|
||||
assert.equal(counts.has("b"), false);
|
||||
@@ -137,39 +142,38 @@ test("computeThreadReplyUnreadCounts_selfAuthored_skipsOwnReplies", () => {
|
||||
subtreeReplyIds: ["a", "b", "a1", "b1", "b2"],
|
||||
visibleReplyIds: ["a", "b"],
|
||||
expandedReplyIds: new Set(),
|
||||
expandedSubtreeReplyIds: new Set(),
|
||||
frontierSeconds: 350,
|
||||
getReadAt: uniformReadAt(350),
|
||||
currentPubkey: "me",
|
||||
});
|
||||
assert.equal(counts.has("a"), false); // a1 is self-authored, so 0 unread
|
||||
assert.equal(counts.get("b"), 2); // b1, b2 are by "other"
|
||||
});
|
||||
|
||||
test("computeThreadReplyUnreadCounts_openTimeSnapshot_survivesChannelMarkRead", () => {
|
||||
// Regression (Fix 1): the in-panel badge must reflect "what was unread when
|
||||
// the thread opened", NOT the live root marker. On channel-open
|
||||
// markChannelRead advances the channel marker to the newest TOP-LEVEL
|
||||
// message; effective(thread) = max(thread_own, channel_marker), so the live
|
||||
// value can jump PAST the nested replies and zero every badge. Passing the
|
||||
// open-time snapshot (frontier 350, captured before the advance) keeps the
|
||||
// badges; passing the post-advance live value (650, past b2(600)) loses them.
|
||||
const args = {
|
||||
test("computeThreadReplyUnreadCounts_perMessageMarkers_readOneDescendantKeepsRest", () => {
|
||||
// The defining per-message case: marking b1 read leaves sibling-line b2
|
||||
// unread independently. b's badge counts only the still-unread b2.
|
||||
const counts = computeThreadReplyUnreadCounts({
|
||||
timelineMessages: fixture(),
|
||||
subtreeReplyIds: ROOT_SUBTREE,
|
||||
visibleReplyIds: ["a", "b"],
|
||||
expandedReplyIds: new Set(),
|
||||
expandedSubtreeReplyIds: new Set(),
|
||||
};
|
||||
const snapshotCounts = computeThreadReplyUnreadCounts({
|
||||
...args,
|
||||
frontierSeconds: 350,
|
||||
getReadAt: (id) => (id === "b1" || id === "a1" ? 1000 : 350),
|
||||
});
|
||||
assert.equal(snapshotCounts.get("a"), 1);
|
||||
assert.equal(snapshotCounts.get("b"), 2);
|
||||
|
||||
const liveAdvancedCounts = computeThreadReplyUnreadCounts({
|
||||
...args,
|
||||
frontierSeconds: 650,
|
||||
});
|
||||
assert.equal(liveAdvancedCounts.size, 0);
|
||||
assert.equal(counts.has("a"), false); // a1 read
|
||||
assert.equal(counts.get("b"), 1); // b1 read, only b2 remains
|
||||
});
|
||||
|
||||
test("computeThreadReplyUnreadCounts_forcedUnread_relightsReadDescendant", () => {
|
||||
// Session-local mark-unread forces a1 (read by its marker) back to unread,
|
||||
// so collapsed parent a regains its badge.
|
||||
const counts = computeThreadReplyUnreadCounts({
|
||||
timelineMessages: fixture(),
|
||||
subtreeReplyIds: ROOT_SUBTREE,
|
||||
visibleReplyIds: ["a", "b"],
|
||||
expandedReplyIds: new Set(),
|
||||
getReadAt: uniformReadAt(1000), // everything read by marker
|
||||
isForcedUnread: (id) => id === "a1",
|
||||
});
|
||||
assert.equal(counts.get("a"), 1); // a1 forced unread
|
||||
assert.equal(counts.has("b"), false); // b subtree still read
|
||||
});
|
||||
|
||||
@@ -4,54 +4,56 @@ import type { TimelineMessage } from "@/features/messages/types";
|
||||
/**
|
||||
* Per-row subtree unread counts for the in-panel thread summary rows. A
|
||||
* collapsed branch's badge counts unread replies anywhere beneath it; the
|
||||
* count is omitted for expanded branches (suppress-on-expand happens here,
|
||||
* upstream of the panel, so the panel needs no gate) and for rows with zero
|
||||
* unread descendants (no "0" badge).
|
||||
* count is omitted for expanded branches (their children render inline, so no
|
||||
* summary badge) and for rows with zero unread descendants (no "0" badge).
|
||||
*
|
||||
* Unread is measured against the open-time frontier snapshot — the same
|
||||
* boundary the in-thread divider uses — so the mark-read-on-open advance does
|
||||
* not zero the badges the instant the panel opens. A null frontier (thread
|
||||
* never read) treats every subtree reply as unread.
|
||||
* Unread is decided per-reply against `getReadAt` (LP4 v3): each reply counts
|
||||
* iff `createdAt > effective(msg:<id>)`. Expanding a branch marks only the
|
||||
* revealed (direct-child) set read, so a collapsed grandchild keeps its badge
|
||||
* until it too is revealed — no separate expanded-subtree gate is needed,
|
||||
* because the per-message marker already distinguishes a read parent from its
|
||||
* still-unread descendant. A `null` marker (reply never read) counts as unread.
|
||||
*
|
||||
* @param subtreeReplyIds Descendant reply ids of the open thread head. Scoping
|
||||
* the unread set to this subtree keeps one thread's frontier from marking
|
||||
* replies that belong to a different thread.
|
||||
* the unread set to this subtree keeps replies in a different thread from
|
||||
* ever being counted here.
|
||||
* @param visibleReplyIds Ids of the rows actually rendered in the panel; only
|
||||
* these are keyed, keeping the map consistent with row presence.
|
||||
* @param expandedSubtreeReplyIds Reply ids beneath any expanded row. Expanding
|
||||
* a branch persistently marks its whole subtree read (mark-read-on-expand),
|
||||
* so those replies are dropped from the unread set — otherwise a revealed
|
||||
* child would carry a stale badge for a reply the same gesture just read.
|
||||
* @param expandedReplyIds Ids of rows whose children are rendered inline; these
|
||||
* rows carry no summary badge.
|
||||
* @param getReadAt Per-message read resolver; `null` means never read.
|
||||
* @param isForcedUnread Session-local OR-overlay: a reply forced unread this
|
||||
* session counts regardless of its marker (per-message mark-unread).
|
||||
*/
|
||||
export function computeThreadReplyUnreadCounts(params: {
|
||||
timelineMessages: TimelineMessage[];
|
||||
subtreeReplyIds: Iterable<string>;
|
||||
visibleReplyIds: Iterable<string>;
|
||||
expandedReplyIds: ReadonlySet<string>;
|
||||
expandedSubtreeReplyIds: ReadonlySet<string>;
|
||||
frontierSeconds: number | null;
|
||||
getReadAt: (messageId: string) => number | null;
|
||||
currentPubkey?: string;
|
||||
isForcedUnread?: (messageId: string) => boolean;
|
||||
}): Map<string, number> {
|
||||
const {
|
||||
timelineMessages,
|
||||
subtreeReplyIds,
|
||||
visibleReplyIds,
|
||||
expandedReplyIds,
|
||||
expandedSubtreeReplyIds,
|
||||
frontierSeconds,
|
||||
getReadAt,
|
||||
currentPubkey,
|
||||
isForcedUnread = () => false,
|
||||
} = params;
|
||||
|
||||
const subtree = new Set(subtreeReplyIds);
|
||||
const unreadReplyIds = new Set(
|
||||
timelineMessages
|
||||
.filter(
|
||||
(message) =>
|
||||
subtree.has(message.id) &&
|
||||
!expandedSubtreeReplyIds.has(message.id) &&
|
||||
(!currentPubkey || message.pubkey !== currentPubkey) &&
|
||||
(frontierSeconds === null || message.createdAt > frontierSeconds),
|
||||
)
|
||||
.filter((message) => {
|
||||
if (!subtree.has(message.id)) return false;
|
||||
if (currentPubkey && message.pubkey === currentPubkey) return false;
|
||||
if (isForcedUnread(message.id)) return true;
|
||||
const readAt = getReadAt(message.id);
|
||||
return readAt === null || message.createdAt > readAt;
|
||||
})
|
||||
.map((message) => message.id),
|
||||
);
|
||||
|
||||
|
||||
@@ -99,6 +99,7 @@ type ChannelPaneProps = {
|
||||
onEdit?: (message: TimelineMessage) => void;
|
||||
onEditSave?: (content: string, mediaTags?: string[][]) => Promise<void>;
|
||||
onMarkUnread?: (message: TimelineMessage) => void;
|
||||
onMarkRead?: (message: TimelineMessage) => void;
|
||||
onExpandThreadReplies: (message: TimelineMessage) => void;
|
||||
onJoinChannel?: () => Promise<void>;
|
||||
onOpenAgentSession: (pubkey: string) => void;
|
||||
@@ -205,6 +206,7 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
onEditSave,
|
||||
onFollowThread,
|
||||
onMarkUnread,
|
||||
onMarkRead,
|
||||
onExpandThreadReplies,
|
||||
onJoinChannel,
|
||||
onOpenAgentSession,
|
||||
@@ -678,6 +680,7 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
onDelete={onDelete}
|
||||
onEdit={onEdit}
|
||||
onMarkUnread={onMarkUnread}
|
||||
onMarkRead={onMarkRead}
|
||||
onReply={activeChannel?.archivedAt ? undefined : onOpenThread}
|
||||
channelName={activeChannel?.name}
|
||||
channelType={activeChannel?.channelType ?? null}
|
||||
@@ -818,6 +821,7 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
onEditSave={onEditSave}
|
||||
onFollowThread={onFollowThread}
|
||||
onMarkUnread={onMarkUnread}
|
||||
onMarkRead={onMarkRead}
|
||||
onExpandReplies={onExpandThreadReplies}
|
||||
onSelectReplyTarget={onSelectThreadReplyTarget}
|
||||
onSend={onSendThreadReply}
|
||||
|
||||
@@ -6,6 +6,10 @@ import {
|
||||
useChannelMembersQuery,
|
||||
useJoinChannelMutation,
|
||||
} from "@/features/channels/hooks";
|
||||
import {
|
||||
MSG_PREFIX,
|
||||
THREAD_PREFIX,
|
||||
} from "@/features/channels/readState/readStateFormat";
|
||||
import { ChannelScreenEmptyState } from "@/features/channels/ui/ChannelScreenEmptyState";
|
||||
import { ChannelScreenHeader } from "@/features/channels/ui/ChannelScreenHeader";
|
||||
import {
|
||||
@@ -42,6 +46,7 @@ import {
|
||||
import { useFetchOlderMessages } from "@/features/messages/useFetchOlderMessages";
|
||||
import { useLoadMissingAncestors } from "@/features/messages/useLoadMissingAncestors";
|
||||
import { useChannelTyping } from "@/features/messages/useChannelTyping";
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import { useUsersBatchQuery } from "@/features/profile/hooks";
|
||||
import { mergeCurrentProfileIntoLookup } from "@/features/profile/lib/identity";
|
||||
import type { RespondToMode } from "@/shared/api/types";
|
||||
@@ -86,8 +91,8 @@ export function ChannelScreen({
|
||||
markChannelRead,
|
||||
markChannelUnread,
|
||||
getChannelReadAt,
|
||||
getThreadReadAt,
|
||||
markThreadRead,
|
||||
getMessageReadAt,
|
||||
markMessageRead,
|
||||
setContextParentResolver,
|
||||
openCreateChannel,
|
||||
openChannelManagement,
|
||||
@@ -204,19 +209,25 @@ export function ChannelScreen({
|
||||
// thread itself is read.
|
||||
markChannelRead(activeChannelId, activeReadAt, { topLevelOnly: true });
|
||||
}, [activeChannel?.isMember, activeChannelId, activeReadAt, markChannelRead]);
|
||||
// Install the NIP-RS parent resolver: every `thread:<root>` context evaluated
|
||||
// while this channel is active belongs to it (getThreadReadAt is only ever
|
||||
// called on the active channel's timeline messages), so the parent is always
|
||||
// the active channel. Non-thread keys (channels) have no parent → null, which
|
||||
// degrades effective() to the own term. Cleared on channel leave / unmount so
|
||||
// a stale channel id never becomes the parent of another channel's threads.
|
||||
// Install the NIP-RS parent resolver: every `thread:<root>` or `msg:<id>`
|
||||
// context evaluated while this channel is active belongs to it (both are only
|
||||
// ever read for the active channel's timeline messages), so the parent is
|
||||
// always the active channel. Folding `msg:` to the channel — never to another
|
||||
// message — means reading an ancestor never covers a descendant (LP4 Issue 2
|
||||
// by construction); a channel-read still clears any message older than the
|
||||
// top-level channel frontier. Non-thread/non-message keys (channels) have no
|
||||
// parent → null, which degrades effective() to the own term. Cleared on
|
||||
// channel leave / unmount so a stale channel id never becomes the parent of
|
||||
// another channel's contexts.
|
||||
React.useEffect(() => {
|
||||
if (!activeChannelId) {
|
||||
setContextParentResolver(null);
|
||||
return;
|
||||
}
|
||||
setContextParentResolver((contextId) =>
|
||||
contextId.startsWith("thread:") ? activeChannelId : null,
|
||||
contextId.startsWith(THREAD_PREFIX) || contextId.startsWith(MSG_PREFIX)
|
||||
? activeChannelId
|
||||
: null,
|
||||
);
|
||||
return () => setContextParentResolver(null);
|
||||
}, [activeChannelId, setContextParentResolver]);
|
||||
@@ -381,8 +392,9 @@ export function ChannelScreen({
|
||||
firstUnreadMessageId,
|
||||
getFirstReplyIdForMessage,
|
||||
getReplyDescendantIdsForMessage,
|
||||
getSubtreeMaxCreatedAt,
|
||||
handleMarkUnread,
|
||||
handleMarkMessageRead,
|
||||
handleMarkMessageUnread,
|
||||
markRevealedRepliesRead,
|
||||
openThreadHeadMessage,
|
||||
threadFirstUnreadReplyId,
|
||||
threadMessages,
|
||||
@@ -398,9 +410,9 @@ export function ChannelScreen({
|
||||
threadReplyTargetId,
|
||||
expandedThreadReplyIds,
|
||||
getChannelReadAt,
|
||||
getThreadReadAt,
|
||||
getMessageReadAt,
|
||||
markChannelUnread,
|
||||
markThreadRead,
|
||||
markMessageRead,
|
||||
isThreadMuted,
|
||||
readStateVersion,
|
||||
});
|
||||
@@ -429,8 +441,7 @@ export function ChannelScreen({
|
||||
expandedThreadReplyIds,
|
||||
getFirstReplyIdForMessage,
|
||||
getReplyDescendantIdsForMessage,
|
||||
getSubtreeMaxCreatedAt,
|
||||
markThreadRead,
|
||||
markRevealedRepliesRead,
|
||||
openThreadHeadId: effectiveOpenThreadHeadId,
|
||||
onOptimisticOpenThreadHeadIdChange: setOptimisticOpenThreadHeadId,
|
||||
sendMessageMutation,
|
||||
@@ -449,6 +460,17 @@ export function ChannelScreen({
|
||||
: undefined,
|
||||
[activeChannel, handleToggleReaction],
|
||||
);
|
||||
// The menu actions are typed (message) => void; the per-message read-state
|
||||
// handlers key off the message id (message + subtree). Adapt at the seam so
|
||||
// the handlers stay id-based and the menu stays message-based.
|
||||
const handleMessageMarkUnread = React.useCallback(
|
||||
(message: TimelineMessage) => handleMarkMessageUnread(message.id),
|
||||
[handleMarkMessageUnread],
|
||||
);
|
||||
const handleMessageMarkRead = React.useCallback(
|
||||
(message: TimelineMessage) => handleMarkMessageRead(message.id),
|
||||
[handleMarkMessageRead],
|
||||
);
|
||||
const handleSendVideoReviewComment = React.useCallback(
|
||||
async (
|
||||
message: { id: string },
|
||||
@@ -746,7 +768,8 @@ export function ChannelScreen({
|
||||
onEditSave={
|
||||
activeChannel?.archivedAt ? undefined : handleEditSave
|
||||
}
|
||||
onMarkUnread={handleMarkUnread}
|
||||
onMarkUnread={handleMessageMarkUnread}
|
||||
onMarkRead={handleMessageMarkRead}
|
||||
onExpandThreadReplies={handleExpandThreadReplies}
|
||||
onOpenAgentSession={handleOpenAgentSession}
|
||||
onOpenDm={handleOpenDm}
|
||||
|
||||
@@ -5,11 +5,9 @@ import {
|
||||
buildDirectReplyIdsByParentId,
|
||||
buildRepliesByRootId,
|
||||
collectReplyDescendantIds,
|
||||
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,
|
||||
@@ -31,9 +29,9 @@ type UseChannelUnreadStateOptions = {
|
||||
threadReplyTargetId: string | null;
|
||||
expandedThreadReplyIds: ReadonlySet<string>;
|
||||
getChannelReadAt: (channelId: string) => number | null;
|
||||
getThreadReadAt: (rootId: string, channelId?: string | null) => number | null;
|
||||
getMessageReadAt: (messageId: string) => number | null;
|
||||
markChannelUnread: (channelId: string) => void;
|
||||
markThreadRead: (rootId: string, timestamp: number) => void;
|
||||
markMessageRead: (messageId: string, timestamp: number) => void;
|
||||
isThreadMuted: (rootId: string) => boolean;
|
||||
readStateVersion: number;
|
||||
};
|
||||
@@ -58,9 +56,9 @@ export function useChannelUnreadState({
|
||||
threadReplyTargetId,
|
||||
expandedThreadReplyIds,
|
||||
getChannelReadAt,
|
||||
getThreadReadAt,
|
||||
getMessageReadAt,
|
||||
markChannelUnread,
|
||||
markThreadRead,
|
||||
markMessageRead,
|
||||
isThreadMuted,
|
||||
readStateVersion,
|
||||
}: UseChannelUnreadStateOptions) {
|
||||
@@ -89,6 +87,17 @@ export function useChannelUnreadState({
|
||||
// is cleared on re-open (a fresh snapshot is recomputed for the channel).
|
||||
const forcedUnreadRef = React.useRef(new Set<string>());
|
||||
const [, forceUnreadRender] = React.useReducer((n: number) => n + 1, 0);
|
||||
// Per-message analog of forcedUnreadRef (LP4 v3 mark-unread). A monotonic
|
||||
// grow-only msg:<id> marker cannot move the read-line backward, so a
|
||||
// deliberate mark-unread lives in this session-local set, read ONLY as an
|
||||
// OR-overlay by the badge predicates below — never written to the marker
|
||||
// store. Cleared on channel-leave (same lifecycle as the channel set), so
|
||||
// it does not survive reload, exactly like channel mark-unread today.
|
||||
const forcedUnreadMsgRef = React.useRef(new Set<string>());
|
||||
const isMsgForcedUnread = React.useCallback(
|
||||
(messageId: string) => forcedUnreadMsgRef.current.has(messageId),
|
||||
[],
|
||||
);
|
||||
const isActiveChannelForcedUnread =
|
||||
!!activeChannelId && forcedUnreadRef.current.has(activeChannelId);
|
||||
const isActiveWelcomeInitialUnreadSuppressed =
|
||||
@@ -100,6 +109,9 @@ export function useChannelUnreadState({
|
||||
if (!channelId) return;
|
||||
return () => {
|
||||
forcedUnreadRef.current.delete(channelId);
|
||||
// Clear per-message forced-unread too: switching channels ends the
|
||||
// session window for both the channel-level and message-level overlays.
|
||||
forcedUnreadMsgRef.current.clear();
|
||||
};
|
||||
}, [activeChannelId]);
|
||||
// Clear the open-time frontier on channel leave so re-visiting captures a
|
||||
@@ -135,37 +147,6 @@ export function useChannelUnreadState({
|
||||
() => buildCreatedAtByMessageId(timelineMessages),
|
||||
[timelineMessages],
|
||||
);
|
||||
// Newest createdAt across an expanded branch (the message itself plus every
|
||||
// descendant). Drilling into a branch advances the thread frontier to this,
|
||||
// consuming everything chronologically up to the deepest reply read. Returns
|
||||
// null when the message is absent so the caller skips the read-state write.
|
||||
const getSubtreeMaxCreatedAt = React.useCallback(
|
||||
(messageId: string) =>
|
||||
subtreeMaxCreatedAt(
|
||||
messageId,
|
||||
directReplyIdsByParentId,
|
||||
createdAtByMessageId,
|
||||
),
|
||||
[createdAtByMessageId, directReplyIdsByParentId],
|
||||
);
|
||||
// Root-scoped variant of the ceiling, used only by the thread-open mark-read
|
||||
// effect below. Folds in replies that resolve to the root by rootId, so a
|
||||
// severed orphan (intermediate ancestor outside the loaded window) still
|
||||
// raises the ceiling and the channel-root badge can clear on open. The
|
||||
// branch-scoped getSubtreeMaxCreatedAt above stays as-is for the expand
|
||||
// caller, which must advance only its own branch — a branch node owns no
|
||||
// rootId bucket, so passing repliesByRootId there would be a no-op anyway,
|
||||
// but keeping the two callbacks distinct makes the scope intent explicit.
|
||||
const getRootSubtreeMaxCreatedAt = React.useCallback(
|
||||
(rootId: string) =>
|
||||
subtreeMaxCreatedAt(
|
||||
rootId,
|
||||
directReplyIdsByParentId,
|
||||
createdAtByMessageId,
|
||||
repliesByRootId,
|
||||
),
|
||||
[createdAtByMessageId, directReplyIdsByParentId, repliesByRootId],
|
||||
);
|
||||
const threadPanelIndex = React.useMemo(
|
||||
() => buildThreadPanelIndex(timelineMessages),
|
||||
[timelineMessages],
|
||||
@@ -213,80 +194,68 @@ export function useChannelUnreadState({
|
||||
);
|
||||
|
||||
// --- Thread unread state ---
|
||||
// Capture the thread read frontier on open (same pattern as channel frontier).
|
||||
// Keyed per thread root so switching threads captures a fresh frontier.
|
||||
const threadOpenFrontierRef = React.useRef(new Map<string, number | null>());
|
||||
if (
|
||||
openThreadHeadId &&
|
||||
!threadOpenFrontierRef.current.has(openThreadHeadId)
|
||||
) {
|
||||
threadOpenFrontierRef.current.set(
|
||||
openThreadHeadId,
|
||||
getThreadReadAt(openThreadHeadId, activeChannelId),
|
||||
);
|
||||
// Snapshot the per-message read state for the open thread's visible replies
|
||||
// the instant the thread opens, BEFORE the on-open mark-read effect advances
|
||||
// those markers. This anchors the in-thread "New" divider to "what was unread
|
||||
// when I opened this thread" — the exact thread-level analog of the channel
|
||||
// divider's openFrontierRef. Read ONLY by the divider below; the badge
|
||||
// predicates read effective(msg:<id>) live, so this snapshot is a separate
|
||||
// concern (divider position) from the badge read-line — not a second source
|
||||
// of truth for the same read-line. Keyed per thread root so switching threads
|
||||
// captures a fresh snapshot; cleared on close so re-opening re-snapshots.
|
||||
const threadOpenReadSnapshotRef = React.useRef(
|
||||
new Map<string, Map<string, number | null>>(),
|
||||
);
|
||||
if (openThreadHeadId && !threadOpenReadSnapshotRef.current.has(openThreadHeadId)) {
|
||||
const snapshot = new Map<string, number | null>();
|
||||
for (const entry of threadMessages) {
|
||||
snapshot.set(entry.message.id, getMessageReadAt(entry.message.id));
|
||||
}
|
||||
threadOpenReadSnapshotRef.current.set(openThreadHeadId, snapshot);
|
||||
}
|
||||
const threadOpenFrontierSeconds = openThreadHeadId
|
||||
? (threadOpenFrontierRef.current.get(openThreadHeadId) ?? null)
|
||||
: null;
|
||||
// Clear the thread frontier when the thread closes so re-opening captures fresh.
|
||||
React.useEffect(() => {
|
||||
const rootId = openThreadHeadId;
|
||||
if (!rootId) return;
|
||||
return () => {
|
||||
threadOpenFrontierRef.current.delete(rootId);
|
||||
threadOpenReadSnapshotRef.current.delete(rootId);
|
||||
};
|
||||
}, [openThreadHeadId]);
|
||||
// Mark thread read when the panel opens, advancing the frontier to the max
|
||||
// createdAt over the head and its ENTIRE subtree — every reply, including
|
||||
// ones nested in collapsed branches. Opening a badge-eligible thread means
|
||||
// engaging with it, so the badge must collapse the instant the panel opens
|
||||
// (not wait for a channel change or for each branch to be expanded). The
|
||||
// badge counts the whole subtree (computeThreadBadgeCounts), so marking only
|
||||
// the visible direct replies would leave it lit whenever the unread lives in
|
||||
// a nested reply — the reported bug. Consuming collapsed branches here is not
|
||||
// lossy: a NEWER reply re-raises the badge, because the unread comparison is
|
||||
// strictly `createdAt > frontier` (computeThreadUnreadMarker) and the badge
|
||||
// snapshot advances toward the live marker (nextThreadBadgeFrontier).
|
||||
// Mark the revealed set read when the thread opens (LP4 v3): only the replies
|
||||
// visible on open are read, never the whole subtree. A reply nested in a
|
||||
// still-collapsed branch keeps its badge until it too is revealed (the
|
||||
// deliberate reversal of #1118's whole-subtree-on-open). Each revealed reply
|
||||
// gets its own msg:<id> marker advanced to its createdAt; a NEWER reply
|
||||
// re-raises the badge because the predicate is strictly createdAt > read.
|
||||
React.useEffect(() => {
|
||||
if (!openThreadHeadId) return;
|
||||
if (isThreadMuted(openThreadHeadId)) return;
|
||||
const openReadCeiling = getRootSubtreeMaxCreatedAt(openThreadHeadId);
|
||||
if (openReadCeiling === null) return;
|
||||
markThreadRead(openThreadHeadId, openReadCeiling);
|
||||
}, [
|
||||
openThreadHeadId,
|
||||
getRootSubtreeMaxCreatedAt,
|
||||
markThreadRead,
|
||||
isThreadMuted,
|
||||
]);
|
||||
// Compute the in-thread "New" divider position from the open-time frontier.
|
||||
for (const entry of threadMessages) {
|
||||
markMessageRead(entry.message.id, entry.message.createdAt);
|
||||
}
|
||||
}, [openThreadHeadId, threadMessages, markMessageRead, isThreadMuted]);
|
||||
// In-thread "New" divider position. Reads the open-time snapshot (frozen
|
||||
// before the mark-read effect above), so the divider does not collapse the
|
||||
// instant open marks the revealed replies read. A reply absent from the
|
||||
// snapshot (loaded after open) falls back to its live marker.
|
||||
const { firstUnreadReplyId: threadFirstUnreadReplyId } = React.useMemo(() => {
|
||||
if (!openThreadHeadId || threadMessages.length === 0) {
|
||||
return { firstUnreadReplyId: null, unreadCount: 0 };
|
||||
}
|
||||
const snapshot = threadOpenReadSnapshotRef.current.get(openThreadHeadId);
|
||||
const replies = threadMessages.map((entry) => entry.message);
|
||||
return computeThreadUnreadMarker(
|
||||
replies,
|
||||
threadOpenFrontierSeconds,
|
||||
(replyId) => snapshot?.get(replyId) ?? getMessageReadAt(replyId),
|
||||
currentPubkey,
|
||||
);
|
||||
}, [
|
||||
currentPubkey,
|
||||
openThreadHeadId,
|
||||
threadMessages,
|
||||
threadOpenFrontierSeconds,
|
||||
]);
|
||||
}, [currentPubkey, getMessageReadAt, openThreadHeadId, threadMessages]);
|
||||
// Per-row subtree unread counts for the in-panel thread summary rows. Scoped
|
||||
// to the open thread's subtree and measured against the open-time frontier
|
||||
// snapshot (threadOpenFrontierSeconds) — the same boundary the in-thread
|
||||
// divider uses (above). The LIVE root marker can't be used here: on
|
||||
// channel-open markChannelRead advances the channel marker to the newest
|
||||
// top-level message, and effective(thread) = max(thread_own, channel_marker),
|
||||
// so a channel marker past the nested replies would zero every badge the
|
||||
// instant the panel opens. The snapshot reflects "what was unread on open."
|
||||
// Expand-clears-badge is preserved independently: it's driven by the
|
||||
// expandedSubtreeReplyIds gate inside computeThreadReplyUnreadCounts, not by
|
||||
// the frontier.
|
||||
// to the open thread's subtree and decided per-reply against the live
|
||||
// per-message read state (getMessageReadAt): each collapsed row's badge
|
||||
// counts unread replies anywhere beneath it. Expanding a branch marks only
|
||||
// its revealed direct children read, so a collapsed grandchild keeps its
|
||||
// badge — the per-message marker distinguishes the read parent from the
|
||||
// unread descendant with no separate expanded-subtree gate.
|
||||
const threadReplyUnreadCounts = React.useMemo(
|
||||
() =>
|
||||
openThreadHeadId
|
||||
@@ -295,91 +264,47 @@ export function useChannelUnreadState({
|
||||
subtreeReplyIds: getReplyDescendantIdsForMessage(openThreadHeadId),
|
||||
visibleReplyIds: threadMessages.map((entry) => entry.message.id),
|
||||
expandedReplyIds: expandedThreadReplyIds,
|
||||
expandedSubtreeReplyIds: new Set(
|
||||
[...expandedThreadReplyIds].flatMap((id) =>
|
||||
getReplyDescendantIdsForMessage(id),
|
||||
),
|
||||
),
|
||||
frontierSeconds: threadOpenFrontierSeconds,
|
||||
getReadAt: getMessageReadAt,
|
||||
currentPubkey,
|
||||
isForcedUnread: isMsgForcedUnread,
|
||||
})
|
||||
: new Map<string, number>(),
|
||||
[
|
||||
openThreadHeadId,
|
||||
threadMessages,
|
||||
timelineMessages,
|
||||
threadOpenFrontierSeconds,
|
||||
getMessageReadAt,
|
||||
expandedThreadReplyIds,
|
||||
getReplyDescendantIdsForMessage,
|
||||
currentPubkey,
|
||||
isMsgForcedUnread,
|
||||
readStateVersion,
|
||||
],
|
||||
);
|
||||
// Snapshot per-thread read frontiers at channel-open time. Same pattern as
|
||||
// openFrontierRef: captured during render (before the mark-read effect) so
|
||||
// the badge reflects "what was unread on open" rather than the post-advance
|
||||
// frontier. Keyed by activeChannelId → rootId → frontier value.
|
||||
const threadBadgeFrontiersRef = React.useRef(
|
||||
new Map<string, Map<string, number | null>>(),
|
||||
);
|
||||
if (activeChannelId) {
|
||||
let channelFrontiers = threadBadgeFrontiersRef.current.get(activeChannelId);
|
||||
if (!channelFrontiers) {
|
||||
channelFrontiers = new Map();
|
||||
threadBadgeFrontiersRef.current.set(activeChannelId, channelFrontiers);
|
||||
}
|
||||
// Seed from the thread's OWN read marker, never the channel-folded
|
||||
// effective marker. getThreadReadAt WITH activeChannelId returns
|
||||
// max(thread_own, channel) (AppShell), and channel-open markChannelRead
|
||||
// advances the channel term to the newest top-level message — so a folded
|
||||
// marker seeds the frontier PAST an unread reply and the badge vanishes
|
||||
// (LP4 Case 3, seed-timing face). The seed is monotonic, so any re-render
|
||||
// after the channel marker advanced would otherwise bleed it back via
|
||||
// Math.max. Omitting the channelId reads the own marker directly (no parent
|
||||
// term), so the badge clears only when the THREAD itself is read. This
|
||||
// matches the #1114 topLevelOnly channel-open convention already shipped on
|
||||
// main — the sidebar dot persists for unopened thread replies — a codebase
|
||||
// layer on top of NIP-RS, not NIP-RS spec itself. The thread-own marker
|
||||
// advances only via the thread-open mark-read effect above, preserving
|
||||
// advance-on-read.
|
||||
seedThreadBadgeFrontiers(
|
||||
channelFrontiers,
|
||||
timelineMessages,
|
||||
repliesByRootId,
|
||||
(rootId) => !isThreadMuted(rootId),
|
||||
(rootId) => getThreadReadAt(rootId),
|
||||
);
|
||||
}
|
||||
// Clear the thread badge frontiers on channel leave (same cleanup as
|
||||
// openFrontierRef) so re-visiting captures fresh snapshots.
|
||||
React.useEffect(() => {
|
||||
const channelId = activeChannelId;
|
||||
if (!channelId) return;
|
||||
return () => {
|
||||
threadBadgeFrontiersRef.current.delete(channelId);
|
||||
};
|
||||
}, [activeChannelId]);
|
||||
// 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.
|
||||
// Per-thread unread counts for the main-timeline summary rows. Unread is
|
||||
// decided per-reply against the live per-message read state: each reply
|
||||
// lights iff createdAt > effective(msg:<id>), folded channel→message only by
|
||||
// the parent resolver, so reading an ancestor never clears a descendant
|
||||
// (LP4 Issue 2 by construction). readStateVersion is an intentional recompute
|
||||
// trigger so the badge re-reads after any marker advances.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: readStateVersion is the intentional recompute trigger
|
||||
const threadUnreadCounts = React.useMemo(
|
||||
() =>
|
||||
computeThreadBadgeCounts(
|
||||
timelineMessages,
|
||||
repliesByRootId,
|
||||
activeChannelId
|
||||
? threadBadgeFrontiersRef.current.get(activeChannelId)
|
||||
: undefined,
|
||||
getMessageReadAt,
|
||||
(rootId) => !isThreadMuted(rootId),
|
||||
currentPubkey,
|
||||
isMsgForcedUnread,
|
||||
),
|
||||
[
|
||||
activeChannelId,
|
||||
currentPubkey,
|
||||
timelineMessages,
|
||||
repliesByRootId,
|
||||
getMessageReadAt,
|
||||
isThreadMuted,
|
||||
isMsgForcedUnread,
|
||||
readStateVersion,
|
||||
],
|
||||
);
|
||||
@@ -393,14 +318,65 @@ export function useChannelUnreadState({
|
||||
markChannelUnread(activeChannelId);
|
||||
}, [activeChannelId, markChannelUnread]);
|
||||
|
||||
// Mark a message's directly-revealed children read (LP4 v3 open-at-level):
|
||||
// expanding a branch reveals only its direct replies, so only those get a
|
||||
// msg:<id> marker advanced to their createdAt. A reply still nested in a
|
||||
// collapsed grandchild branch keeps its badge until it too is revealed.
|
||||
const markRevealedRepliesRead = React.useCallback(
|
||||
(messageId: string) => {
|
||||
for (const replyId of directReplyIdsByParentId.get(messageId) ?? []) {
|
||||
const createdAt = createdAtByMessageId.get(replyId);
|
||||
if (createdAt !== undefined) markMessageRead(replyId, createdAt);
|
||||
}
|
||||
},
|
||||
[createdAtByMessageId, directReplyIdsByParentId, markMessageRead],
|
||||
);
|
||||
|
||||
// Mark a message and its whole subtree READ (LP4 v3 menu action). Writes a
|
||||
// msg:<id> marker at each message's createdAt — a real, persisted advance —
|
||||
// and clears those same ids from the forced-unread overlay, so mark-read is
|
||||
// the exact inverse of mark-unread over the same id set.
|
||||
const handleMarkMessageRead = React.useCallback(
|
||||
(messageId: string) => {
|
||||
const ids = [messageId, ...getReplyDescendantIdsForMessage(messageId)];
|
||||
for (const id of ids) {
|
||||
forcedUnreadMsgRef.current.delete(id);
|
||||
const createdAt = createdAtByMessageId.get(id);
|
||||
if (createdAt !== undefined) markMessageRead(id, createdAt);
|
||||
}
|
||||
forceUnreadRender();
|
||||
},
|
||||
[createdAtByMessageId, getReplyDescendantIdsForMessage, markMessageRead],
|
||||
);
|
||||
|
||||
// Mark a message and its whole subtree UNREAD (LP4 v3 menu action). Markers
|
||||
// are monotonic and cannot move backward, so this writes NO marker: it adds
|
||||
// the ids to the session-local forced-unread overlay the badge predicates OR
|
||||
// in. Cleared on channel-leave; does not survive reload (symmetric with the
|
||||
// shipped channel mark-unread).
|
||||
const handleMarkMessageUnread = React.useCallback(
|
||||
(messageId: string) => {
|
||||
for (const id of [
|
||||
messageId,
|
||||
...getReplyDescendantIdsForMessage(messageId),
|
||||
]) {
|
||||
forcedUnreadMsgRef.current.add(id);
|
||||
}
|
||||
forceUnreadRender();
|
||||
},
|
||||
[getReplyDescendantIdsForMessage],
|
||||
);
|
||||
|
||||
return {
|
||||
createdAtByMessageId,
|
||||
directReplyIdsByParentId,
|
||||
firstUnreadMessageId,
|
||||
getFirstReplyIdForMessage,
|
||||
getReplyDescendantIdsForMessage,
|
||||
getSubtreeMaxCreatedAt,
|
||||
handleMarkMessageRead,
|
||||
handleMarkMessageUnread,
|
||||
handleMarkUnread,
|
||||
markRevealedRepliesRead,
|
||||
openThreadHeadMessage,
|
||||
threadFirstUnreadReplyId,
|
||||
threadMessages,
|
||||
|
||||
@@ -24,8 +24,7 @@ export function useChannelPaneHandlers({
|
||||
expandedThreadReplyIds,
|
||||
getFirstReplyIdForMessage,
|
||||
getReplyDescendantIdsForMessage,
|
||||
getSubtreeMaxCreatedAt,
|
||||
markThreadRead,
|
||||
markRevealedRepliesRead,
|
||||
onOptimisticOpenThreadHeadIdChange,
|
||||
openThreadHeadId,
|
||||
sendMessageMutation,
|
||||
@@ -43,8 +42,7 @@ export function useChannelPaneHandlers({
|
||||
expandedThreadReplyIds: ReadonlySet<string>;
|
||||
getFirstReplyIdForMessage: (messageId: string) => string | null;
|
||||
getReplyDescendantIdsForMessage: (messageId: string) => string[];
|
||||
getSubtreeMaxCreatedAt: (messageId: string) => number | null;
|
||||
markThreadRead: (rootId: string, timestamp: number) => void;
|
||||
markRevealedRepliesRead: (messageId: string) => void;
|
||||
onOptimisticOpenThreadHeadIdChange: React.Dispatch<
|
||||
React.SetStateAction<string | null | undefined>
|
||||
>;
|
||||
@@ -201,16 +199,12 @@ export function useChannelPaneHandlers({
|
||||
return next;
|
||||
});
|
||||
|
||||
// Drilling into a branch consumes its unread, persistently: advance the
|
||||
// thread frontier to the branch's newest reply. Monotonic Math.max means
|
||||
// this marks read everything chronologically up to it (channel-open
|
||||
// parity). The open-time snapshot pins the session divider, so it never
|
||||
// moves mid-session.
|
||||
const rootId = openThreadHeadIdRef.current;
|
||||
const subtreeMaxCreatedAt = getSubtreeMaxCreatedAt(message.id);
|
||||
if (rootId && subtreeMaxCreatedAt !== null) {
|
||||
markThreadRead(rootId, subtreeMaxCreatedAt);
|
||||
}
|
||||
// Drilling into a branch reveals only its direct replies (LP4 v3
|
||||
// open-at-level): mark exactly those read, never the whole subtree. A
|
||||
// reply still nested in a collapsed grandchild branch keeps its badge
|
||||
// until it too is revealed — the deliberate reversal of #1118's
|
||||
// whole-subtree-on-open collapse.
|
||||
markRevealedRepliesRead(message.id);
|
||||
|
||||
if (firstReplyId) {
|
||||
setThreadScrollTargetId(firstReplyId);
|
||||
@@ -219,8 +213,7 @@ export function useChannelPaneHandlers({
|
||||
[
|
||||
getFirstReplyIdForMessage,
|
||||
getReplyDescendantIdsForMessage,
|
||||
getSubtreeMaxCreatedAt,
|
||||
markThreadRead,
|
||||
markRevealedRepliesRead,
|
||||
setExpandedThreadReplyIds,
|
||||
setThreadScrollTargetId,
|
||||
],
|
||||
|
||||
@@ -14,6 +14,13 @@ function reply(id, createdAt, parentId) {
|
||||
return { id, createdAt, author: "a", time: "", body: "", depth: 1, parentId };
|
||||
}
|
||||
|
||||
// LP4 v3: the thread marker now reads a per-message resolver instead of a
|
||||
// single frontier. A uniform read-line at `seconds` (or null = never read)
|
||||
// reproduces the old frontier semantics for the shared-boundary cases.
|
||||
function uniformReadAt(seconds) {
|
||||
return () => seconds;
|
||||
}
|
||||
|
||||
test("computeChannelUnreadMarker_emptyTimeline_returnsNoUnread", () => {
|
||||
const marker = computeChannelUnreadMarker([], 100);
|
||||
assert.equal(marker.firstUnreadMessageId, null);
|
||||
@@ -98,60 +105,74 @@ test("computeChannelUnreadMarker_suppressedNeverReadChannel_returnsNoMarker", ()
|
||||
// --- computeThreadUnreadMarker tests ---
|
||||
|
||||
test("computeThreadUnreadMarker_emptyReplies_returnsNoUnread", () => {
|
||||
const marker = computeThreadUnreadMarker([], 100);
|
||||
const marker = computeThreadUnreadMarker([], uniformReadAt(100));
|
||||
assert.equal(marker.firstUnreadReplyId, null);
|
||||
assert.equal(marker.unreadCount, 0);
|
||||
});
|
||||
|
||||
test("computeThreadUnreadMarker_nullFrontier_marksAllRepliesUnread", () => {
|
||||
test("computeThreadUnreadMarker_neverRead_marksAllRepliesUnread", () => {
|
||||
const replies = [
|
||||
{ id: "r1", createdAt: 10 },
|
||||
{ id: "r2", createdAt: 20 },
|
||||
{ id: "r3", createdAt: 30 },
|
||||
];
|
||||
const marker = computeThreadUnreadMarker(replies, null);
|
||||
const marker = computeThreadUnreadMarker(replies, uniformReadAt(null));
|
||||
assert.equal(marker.firstUnreadReplyId, "r1");
|
||||
assert.equal(marker.unreadCount, 3);
|
||||
});
|
||||
|
||||
test("computeThreadUnreadMarker_frontierBetweenReplies_countsAfterFrontier", () => {
|
||||
test("computeThreadUnreadMarker_readLineBetweenReplies_countsAfterLine", () => {
|
||||
const replies = [
|
||||
{ id: "r1", createdAt: 10 },
|
||||
{ id: "r2", createdAt: 20 },
|
||||
{ id: "r3", createdAt: 30 },
|
||||
];
|
||||
const marker = computeThreadUnreadMarker(replies, 15);
|
||||
const marker = computeThreadUnreadMarker(replies, uniformReadAt(15));
|
||||
assert.equal(marker.firstUnreadReplyId, "r2");
|
||||
assert.equal(marker.unreadCount, 2);
|
||||
});
|
||||
|
||||
test("computeThreadUnreadMarker_frontierAtReplyTimestamp_isRead", () => {
|
||||
// A reply whose createdAt equals the frontier is considered read (strictly >).
|
||||
test("computeThreadUnreadMarker_readAtEqualsReplyTimestamp_isRead", () => {
|
||||
// A reply whose createdAt equals its read marker is read (strictly >).
|
||||
const replies = [
|
||||
{ id: "r1", createdAt: 10 },
|
||||
{ id: "r2", createdAt: 20 },
|
||||
];
|
||||
const marker = computeThreadUnreadMarker(replies, 20);
|
||||
const marker = computeThreadUnreadMarker(replies, uniformReadAt(20));
|
||||
assert.equal(marker.firstUnreadReplyId, null);
|
||||
assert.equal(marker.unreadCount, 0);
|
||||
});
|
||||
|
||||
test("computeThreadUnreadMarker_frontierAboveAll_returnsNoUnread", () => {
|
||||
test("computeThreadUnreadMarker_readLineAboveAll_returnsNoUnread", () => {
|
||||
const replies = [
|
||||
{ id: "r1", createdAt: 10 },
|
||||
{ id: "r2", createdAt: 20 },
|
||||
];
|
||||
const marker = computeThreadUnreadMarker(replies, 100);
|
||||
const marker = computeThreadUnreadMarker(replies, uniformReadAt(100));
|
||||
assert.equal(marker.firstUnreadReplyId, null);
|
||||
assert.equal(marker.unreadCount, 0);
|
||||
});
|
||||
|
||||
test("computeThreadUnreadMarker_frontierBelowAll_allUnread", () => {
|
||||
test("computeThreadUnreadMarker_readLineBelowAll_allUnread", () => {
|
||||
const replies = [
|
||||
{ id: "r1", createdAt: 10 },
|
||||
{ id: "r2", createdAt: 20 },
|
||||
];
|
||||
const marker = computeThreadUnreadMarker(replies, 5);
|
||||
const marker = computeThreadUnreadMarker(replies, uniformReadAt(5));
|
||||
assert.equal(marker.firstUnreadReplyId, "r1");
|
||||
assert.equal(marker.unreadCount, 2);
|
||||
});
|
||||
|
||||
test("computeThreadUnreadMarker_perMessageMarkers_countOnlyUnreadReply", () => {
|
||||
// The point of the per-message resolver: reading r2 leaves r1 and r3
|
||||
// unread independently — no single frontier could express this.
|
||||
const replies = [
|
||||
{ id: "r1", createdAt: 10 },
|
||||
{ id: "r2", createdAt: 20 },
|
||||
{ id: "r3", createdAt: 30 },
|
||||
];
|
||||
const readAt = (id) => (id === "r2" ? 20 : null);
|
||||
const marker = computeThreadUnreadMarker(replies, readAt);
|
||||
assert.equal(marker.firstUnreadReplyId, "r1");
|
||||
assert.equal(marker.unreadCount, 2);
|
||||
});
|
||||
@@ -162,17 +183,34 @@ test("computeThreadUnreadMarker_singleReplyUnread_countsOne", () => {
|
||||
{ id: "r2", createdAt: 20 },
|
||||
{ id: "r3", createdAt: 30 },
|
||||
];
|
||||
const marker = computeThreadUnreadMarker(replies, 25);
|
||||
const marker = computeThreadUnreadMarker(replies, uniformReadAt(25));
|
||||
assert.equal(marker.firstUnreadReplyId, "r3");
|
||||
assert.equal(marker.unreadCount, 1);
|
||||
});
|
||||
|
||||
test("computeThreadUnreadMarker_emptyRepliesNullFrontier_returnsNoUnread", () => {
|
||||
const marker = computeThreadUnreadMarker([], null);
|
||||
test("computeThreadUnreadMarker_emptyRepliesNeverRead_returnsNoUnread", () => {
|
||||
const marker = computeThreadUnreadMarker([], uniformReadAt(null));
|
||||
assert.equal(marker.firstUnreadReplyId, null);
|
||||
assert.equal(marker.unreadCount, 0);
|
||||
});
|
||||
|
||||
test("computeThreadUnreadMarker_forcedUnread_overridesReadMarker", () => {
|
||||
// Session-local mark-unread: r1 is read by its marker but forced unread,
|
||||
// so it counts; the OR-overlay never clears an otherwise-unread reply.
|
||||
const replies = [
|
||||
{ id: "r1", createdAt: 10 },
|
||||
{ id: "r2", createdAt: 20 },
|
||||
];
|
||||
const marker = computeThreadUnreadMarker(
|
||||
replies,
|
||||
uniformReadAt(100),
|
||||
undefined,
|
||||
(id) => id === "r1",
|
||||
);
|
||||
assert.equal(marker.firstUnreadReplyId, "r1");
|
||||
assert.equal(marker.unreadCount, 1);
|
||||
});
|
||||
|
||||
// --- Self-authored skip tests ---
|
||||
|
||||
test("computeChannelUnreadMarker_selfAuthored_skipsOwnMessages", () => {
|
||||
@@ -213,7 +251,7 @@ test("computeThreadUnreadMarker_selfAuthored_skipsOwnReplies", () => {
|
||||
{ id: "r2", createdAt: 20, pubkey: "other" },
|
||||
{ id: "r3", createdAt: 30, pubkey: "me" },
|
||||
];
|
||||
const marker = computeThreadUnreadMarker(replies, 5, "me");
|
||||
const marker = computeThreadUnreadMarker(replies, uniformReadAt(5), "me");
|
||||
assert.equal(marker.firstUnreadReplyId, "r2");
|
||||
assert.equal(marker.unreadCount, 1);
|
||||
});
|
||||
@@ -223,7 +261,7 @@ test("computeThreadUnreadMarker_allSelfAuthored_returnsNoUnread", () => {
|
||||
{ id: "r1", createdAt: 10, pubkey: "me" },
|
||||
{ id: "r2", createdAt: 20, pubkey: "me" },
|
||||
];
|
||||
const marker = computeThreadUnreadMarker(replies, 5, "me");
|
||||
const marker = computeThreadUnreadMarker(replies, uniformReadAt(5), "me");
|
||||
assert.equal(marker.firstUnreadReplyId, null);
|
||||
assert.equal(marker.unreadCount, 0);
|
||||
});
|
||||
@@ -233,7 +271,7 @@ test("computeThreadUnreadMarker_noPubkey_countsNormally", () => {
|
||||
{ id: "r1", createdAt: 10, pubkey: "me" },
|
||||
{ id: "r2", createdAt: 20, pubkey: "other" },
|
||||
];
|
||||
const marker = computeThreadUnreadMarker(replies, 5);
|
||||
const marker = computeThreadUnreadMarker(replies, uniformReadAt(5));
|
||||
assert.equal(marker.firstUnreadReplyId, "r1");
|
||||
assert.equal(marker.unreadCount, 2);
|
||||
});
|
||||
@@ -254,7 +292,7 @@ test("computeThreadUnreadMarker_selfAuthoredMixedCase_skipsOwnReplies", () => {
|
||||
{ id: "r1", createdAt: 10, pubkey: "ABCDEF" },
|
||||
{ id: "r2", createdAt: 20, pubkey: "other" },
|
||||
];
|
||||
const marker = computeThreadUnreadMarker(replies, 5, "abcdef");
|
||||
const marker = computeThreadUnreadMarker(replies, uniformReadAt(5), "abcdef");
|
||||
assert.equal(marker.firstUnreadReplyId, "r2");
|
||||
assert.equal(marker.unreadCount, 1);
|
||||
});
|
||||
|
||||
@@ -97,16 +97,24 @@ const EMPTY_THREAD_MARKER: ThreadUnreadMarker = {
|
||||
|
||||
/**
|
||||
* @param replies Thread replies in chronological order.
|
||||
* @param frontierSeconds Read frontier in unix seconds captured at thread
|
||||
* open. `null` means the thread was never read, so every reply counts as
|
||||
* unread.
|
||||
* @param getReadAt Per-message read resolver (LP4 v3). A reply is unread when
|
||||
* its `createdAt` is strictly newer than `getReadAt(reply.id)`; a `null`
|
||||
* marker means the reply was never read, so it counts as unread. Folding the
|
||||
* channel term into each marker happens upstream in the resolver, never
|
||||
* reply→reply, so reading one reply never clears another.
|
||||
* @param currentPubkey When provided, replies authored by this pubkey are
|
||||
* never counted as unread (the user knows about their own posts).
|
||||
* @param isForcedUnread Session-local OR-overlay (LP4 v3). When it returns
|
||||
* true for a reply, the reply counts as unread regardless of its marker —
|
||||
* the per-message analog of channel mark-unread. Markers are monotonic and
|
||||
* cannot move backward, so a deliberate mark-unread lives in this transient
|
||||
* overlay, never in the read-line. Defaults to never-forced.
|
||||
*/
|
||||
export function computeThreadUnreadMarker(
|
||||
replies: Pick<TimelineMessage, "id" | "createdAt" | "pubkey">[],
|
||||
frontierSeconds: number | null,
|
||||
getReadAt: (messageId: string) => number | null,
|
||||
currentPubkey?: string,
|
||||
isForcedUnread: (messageId: string) => boolean = () => false,
|
||||
): ThreadUnreadMarker {
|
||||
// Normalize once: see computeChannelUnreadMarker for the case-mismatch guard.
|
||||
const normalizedPubkey = currentPubkey?.toLowerCase();
|
||||
@@ -118,8 +126,9 @@ export function computeThreadUnreadMarker(
|
||||
if (normalizedPubkey && reply.pubkey?.toLowerCase() === normalizedPubkey) {
|
||||
continue;
|
||||
}
|
||||
const readAt = getReadAt(reply.id);
|
||||
const isUnread =
|
||||
frontierSeconds === null || reply.createdAt > frontierSeconds;
|
||||
isForcedUnread(reply.id) || readAt === null || reply.createdAt > readAt;
|
||||
if (!isUnread) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
CornerUpLeft,
|
||||
EllipsisVertical,
|
||||
Link2,
|
||||
MailCheck,
|
||||
MailOpen,
|
||||
Pencil,
|
||||
SmilePlus,
|
||||
@@ -77,6 +78,7 @@ function MoreActionsMenu({
|
||||
onEdit,
|
||||
onFollowThread,
|
||||
onMarkUnread,
|
||||
onMarkRead,
|
||||
onOpenChange,
|
||||
onRemindLater,
|
||||
onUnfollowThread,
|
||||
@@ -91,6 +93,7 @@ function MoreActionsMenu({
|
||||
onEdit?: (message: TimelineMessage) => void;
|
||||
onFollowThread?: (message: TimelineMessage) => void;
|
||||
onMarkUnread?: (message: TimelineMessage) => void;
|
||||
onMarkRead?: (message: TimelineMessage) => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onRemindLater?: (message: TimelineMessage) => void;
|
||||
onUnfollowThread?: (message: TimelineMessage) => void;
|
||||
@@ -165,6 +168,17 @@ function MoreActionsMenu({
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
|
||||
{onMarkRead ? (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
onMarkRead(message);
|
||||
}}
|
||||
>
|
||||
<MailCheck className="h-4 w-4" />
|
||||
Mark read
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
|
||||
{onFollowThread || onUnfollowThread ? (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
@@ -333,6 +347,7 @@ export function MessageActionBar({
|
||||
onEdit,
|
||||
onFollowThread,
|
||||
onMarkUnread,
|
||||
onMarkRead,
|
||||
onReactionBadgeBurstRequest,
|
||||
onReactionSelect,
|
||||
onRemindLater,
|
||||
@@ -350,6 +365,7 @@ export function MessageActionBar({
|
||||
onEdit?: (message: TimelineMessage) => void;
|
||||
onFollowThread?: (message: TimelineMessage) => void;
|
||||
onMarkUnread?: (message: TimelineMessage) => void;
|
||||
onMarkRead?: (message: TimelineMessage) => void;
|
||||
onReactionBadgeBurstRequest?: (emoji: string) => void;
|
||||
onReactionSelect?: (emoji: string) => Promise<void>;
|
||||
onRemindLater?: (message: TimelineMessage) => void;
|
||||
@@ -382,6 +398,7 @@ export function MessageActionBar({
|
||||
Boolean(onEdit) ||
|
||||
Boolean(onDelete) ||
|
||||
Boolean(onMarkUnread) ||
|
||||
Boolean(onMarkRead) ||
|
||||
Boolean(onFollowThread) ||
|
||||
Boolean(onUnfollowThread) ||
|
||||
Boolean(onRemindLater) ||
|
||||
@@ -529,6 +546,7 @@ export function MessageActionBar({
|
||||
onEdit={onEdit}
|
||||
onFollowThread={onFollowThread}
|
||||
onMarkUnread={onMarkUnread}
|
||||
onMarkRead={onMarkRead}
|
||||
onOpenChange={setIsDropdownOpen}
|
||||
onRemindLater={onRemindLater}
|
||||
onUnfollowThread={onUnfollowThread}
|
||||
|
||||
@@ -66,6 +66,7 @@ export const MessageRow = React.memo(
|
||||
onEdit,
|
||||
onFollowThread,
|
||||
onMarkUnread,
|
||||
onMarkRead,
|
||||
onToggleReaction,
|
||||
onReply,
|
||||
onUnfollowThread,
|
||||
@@ -104,6 +105,7 @@ export const MessageRow = React.memo(
|
||||
onEdit?: (message: TimelineMessage) => void;
|
||||
onFollowThread?: (message: TimelineMessage) => void;
|
||||
onMarkUnread?: (message: TimelineMessage) => void;
|
||||
onMarkRead?: (message: TimelineMessage) => void;
|
||||
onToggleReaction?: (
|
||||
message: TimelineMessage,
|
||||
emoji: string,
|
||||
@@ -349,6 +351,7 @@ export const MessageRow = React.memo(
|
||||
onEdit={onEdit}
|
||||
onFollowThread={onFollowThread}
|
||||
onMarkUnread={onMarkUnread}
|
||||
onMarkRead={onMarkRead}
|
||||
onReactionBadgeBurstRequest={
|
||||
reactionPending ? undefined : setBadgeBurstEmoji
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ type MessageThreadPanelProps = {
|
||||
onEditLastOwnMessage?: () => boolean;
|
||||
onEditSave?: (content: string, mediaTags?: string[][]) => Promise<void>;
|
||||
onMarkUnread?: (message: TimelineMessage) => void;
|
||||
onMarkRead?: (message: TimelineMessage) => void;
|
||||
onExpandReplies: (message: TimelineMessage) => void;
|
||||
onScrollTargetResolved: () => void;
|
||||
onSelectReplyTarget: (message: TimelineMessage) => void;
|
||||
@@ -356,6 +357,7 @@ export function MessageThreadPanel({
|
||||
onEditSave,
|
||||
onFollowThread,
|
||||
onMarkUnread,
|
||||
onMarkRead,
|
||||
onExpandReplies,
|
||||
onScrollTargetResolved,
|
||||
onSelectReplyTarget,
|
||||
@@ -664,6 +666,7 @@ export function MessageThreadPanel({
|
||||
onFollowThread ? (_msg) => onFollowThread() : undefined
|
||||
}
|
||||
onMarkUnread={onMarkUnread}
|
||||
onMarkRead={onMarkRead}
|
||||
onToggleReaction={onToggleReaction}
|
||||
onUnfollowThread={
|
||||
onUnfollowThread ? (_msg) => onUnfollowThread() : undefined
|
||||
@@ -790,6 +793,7 @@ export function MessageThreadPanel({
|
||||
: undefined
|
||||
}
|
||||
onMarkUnread={onMarkUnread}
|
||||
onMarkRead={onMarkRead}
|
||||
onReply={onSelectReplyTarget}
|
||||
onToggleReaction={onToggleReaction}
|
||||
profiles={profiles}
|
||||
|
||||
@@ -57,6 +57,7 @@ type MessageTimelineProps = {
|
||||
onDelete?: (message: TimelineMessage) => void;
|
||||
onEdit?: (message: TimelineMessage) => void;
|
||||
onMarkUnread?: (message: TimelineMessage) => void;
|
||||
onMarkRead?: (message: TimelineMessage) => void;
|
||||
onReply?: (message: TimelineMessage) => void;
|
||||
isSendingVideoReviewComment?: boolean;
|
||||
onSendVideoReviewComment?: (
|
||||
@@ -151,6 +152,7 @@ const MessageTimelineBase = React.forwardRef<
|
||||
onDelete,
|
||||
onEdit,
|
||||
onMarkUnread,
|
||||
onMarkRead,
|
||||
onReply,
|
||||
channelName,
|
||||
channelType,
|
||||
@@ -538,6 +540,7 @@ const MessageTimelineBase = React.forwardRef<
|
||||
onDelete={onDelete}
|
||||
onEdit={onEdit}
|
||||
onMarkUnread={onMarkUnread}
|
||||
onMarkRead={onMarkRead}
|
||||
onReply={onReply}
|
||||
isSendingVideoReviewComment={isSendingVideoReviewComment}
|
||||
onSendVideoReviewComment={onSendVideoReviewComment}
|
||||
|
||||
@@ -41,6 +41,7 @@ type TimelineMessageListProps = {
|
||||
onDelete?: (message: TimelineMessage) => void;
|
||||
onEdit?: (message: TimelineMessage) => void;
|
||||
onMarkUnread?: (message: TimelineMessage) => void;
|
||||
onMarkRead?: (message: TimelineMessage) => void;
|
||||
onReply?: (message: TimelineMessage) => void;
|
||||
isSendingVideoReviewComment?: boolean;
|
||||
onSendVideoReviewComment?: (
|
||||
@@ -192,6 +193,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
|
||||
onDelete,
|
||||
onEdit,
|
||||
onMarkUnread,
|
||||
onMarkRead,
|
||||
onReply,
|
||||
isSendingVideoReviewComment = false,
|
||||
onSendVideoReviewComment,
|
||||
@@ -240,6 +242,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
|
||||
onDelete={onDelete}
|
||||
onEdit={onEdit}
|
||||
onMarkUnread={onMarkUnread}
|
||||
onMarkRead={onMarkRead}
|
||||
onReply={onReply}
|
||||
onSendVideoReviewComment={onSendVideoReviewComment}
|
||||
onToggleReaction={onToggleReaction}
|
||||
@@ -281,6 +284,7 @@ const TimelineRenderRowView = React.memo(function TimelineRenderRowView({
|
||||
onDelete,
|
||||
onEdit,
|
||||
onMarkUnread,
|
||||
onMarkRead,
|
||||
onReply,
|
||||
onSendVideoReviewComment,
|
||||
onToggleReaction,
|
||||
@@ -385,6 +389,7 @@ const TimelineRenderRowView = React.memo(function TimelineRenderRowView({
|
||||
followThreadById ? () => followThreadById(message.id) : undefined
|
||||
}
|
||||
onMarkUnread={onMarkUnread}
|
||||
onMarkRead={onMarkRead}
|
||||
onToggleReaction={onToggleReaction}
|
||||
onReply={onReply}
|
||||
onUnfollowThread={
|
||||
@@ -431,6 +436,7 @@ const TimelineRenderRowView = React.memo(function TimelineRenderRowView({
|
||||
: undefined
|
||||
}
|
||||
onMarkUnread={onMarkUnread}
|
||||
onMarkRead={onMarkRead}
|
||||
onToggleReaction={onToggleReaction}
|
||||
onReply={onReply}
|
||||
profiles={profiles}
|
||||
|
||||
Reference in New Issue
Block a user