Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export default defineConfig({
"**/identity-archive-hide.spec.ts",
"**/relay-connectivity-screenshots.spec.ts",
"**/unread-pill-screenshots.spec.ts",
"**/thread-unread-screenshots.spec.ts",
],
use: {
...devices["Desktop Chrome"],
Expand Down
17 changes: 17 additions & 0 deletions desktop/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,21 @@ export function AppShell() {
followedRootIds,
});

const getThreadReadAt = React.useCallback(
(rootId: string) => getChannelReadAt(`thread:${rootId}`),
[getChannelReadAt],
);

const markThreadRead = React.useCallback(
(rootId: string, timestamp: number) => {
markChannelRead(
`thread:${rootId}`,
new Date(timestamp * 1_000).toISOString(),
);
},
[markChannelRead],
);

// Badge count is computed here (rather than inside useHomeFeedNotifications)
// so it can consume the NIP-RS read-state lifted from the single
// ReadStateManager mounted via useUnreadChannels above. Channel-backed
Expand Down Expand Up @@ -733,6 +748,8 @@ export function AppShell() {
setIsChannelManagementOpen(true);
},
getChannelReadAt,
getThreadReadAt,
markThreadRead,
readStateVersion,
followThread: handleFollowThread,
unfollowThread: handleUnfollowThread,
Expand Down
7 changes: 7 additions & 0 deletions desktop/src/app/AppShellContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ type AppShellContextValue = {
// when unknown. Backed by the single AppShell-mounted ReadStateManager so
// every surface (sidebar, home, badges) projects from the same source.
getChannelReadAt: (channelId: string) => number | null;
// Thread read frontier as unix-seconds timestamp, or null when never read.
// Uses `thread:<rootId>` context keys in the same ReadStateManager.
getThreadReadAt: (rootId: string) => number | null;
// Advance the thread read frontier to the given unix-seconds timestamp.
markThreadRead: (rootId: string, timestamp: number) => void;
// Bump-counter that invalidates whenever the read marker changes. Include
// in memo deps that consume getChannelReadAt.
readStateVersion: number;
Expand All @@ -32,6 +37,8 @@ const AppShellContext = React.createContext<AppShellContextValue>({
openCreateChannel: () => {},
openChannelManagement: () => {},
getChannelReadAt: () => null,
getThreadReadAt: () => null,
markThreadRead: () => {},
readStateVersion: 0,
followThread: () => {},
unfollowThread: () => {},
Expand Down
21 changes: 21 additions & 0 deletions desktop/src/features/channels/readState/readStateManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,27 @@ export class ReadStateManager {
}
contexts[ctx] = ts;
}

// Evict oldest thread:* entries when approaching the MAX_CONTEXTS cap
// (10,000). Channel keys are never evicted — only thread read-state keys
// are eligible. This prevents silent blob rejection by isValidBlob().
const EVICTION_THRESHOLD = 8_000;
const contextCount = Object.keys(contexts).length;
if (contextCount > EVICTION_THRESHOLD) {
const threadEntries: [string, number][] = [];
for (const [key, ts] of Object.entries(contexts)) {
if (key.startsWith("thread:")) {
threadEntries.push([key, ts]);
}
}
// Sort oldest-first (lowest timestamp = oldest)
threadEntries.sort((a, b) => a[1] - b[1]);
const toEvict = contextCount - EVICTION_THRESHOLD;
for (let i = 0; i < Math.min(toEvict, threadEntries.length); i++) {
delete contexts[threadEntries[i][0]];
}
}

return contexts;
}

Expand Down
8 changes: 8 additions & 0 deletions desktop/src/features/channels/ui/ChannelPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,10 @@ type ChannelPaneProps = {
threadTypingPubkeys: string[];
threadReplyTargetMessage: TimelineMessage | null;
threadScrollTargetId: string | null;
/** Per-thread unread counts keyed by thread root id. */
threadUnreadCounts?: ReadonlyMap<string, number>;
/** Event id of the first unread reply in the open thread panel. */
threadFirstUnreadReplyId?: string | null;
targetMessageId: string | null;
typingPubkeys: string[];
isFollowingThread?: boolean;
Expand Down Expand Up @@ -256,6 +260,8 @@ export const ChannelPane = React.memo(function ChannelPane({
threadScrollTargetId,
threadTypingPubkeys,
threadReplyTargetMessage,
threadUnreadCounts,
threadFirstUnreadReplyId,
typingPubkeys,
}: ChannelPaneProps) {
const timelineScrollRef = React.useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -699,6 +705,7 @@ export const ChannelPane = React.memo(function ChannelPane({
searchMatchingMessageIds={channelFind.matchingMessageIds}
searchQuery={channelFind.query}
targetMessageId={targetMessageId}
threadUnreadCounts={threadUnreadCounts}
/>
{isNonMemberView ? (
<div
Expand Down Expand Up @@ -805,6 +812,7 @@ export const ChannelPane = React.memo(function ChannelPane({
currentPubkey={currentPubkey}
disabled={isComposerDisabled}
editTarget={threadEditTarget}
firstUnreadReplyId={threadFirstUnreadReplyId}
isFollowingThread={isFollowingThread}
isSending={isSending}
isSinglePanelView={
Expand Down
87 changes: 86 additions & 1 deletion desktop/src/features/channels/ui/ChannelScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@ import {
formatTimelineMessages,
} from "@/features/messages/lib/formatTimelineMessages";
import { buildThreadPanelData } from "@/features/messages/lib/threadPanel";
import { computeChannelUnreadMarker } from "@/features/messages/lib/unreadMarker";
import {
computeChannelUnreadMarker,
computeThreadUnreadMarker,
} from "@/features/messages/lib/unreadMarker";
import { imetaMediaFromTags } from "@/features/messages/lib/imetaMediaMarkdown";
import { useFetchOlderMessages } from "@/features/messages/useFetchOlderMessages";
import { useLoadMissingAncestors } from "@/features/messages/useLoadMissingAncestors";
Expand Down Expand Up @@ -81,6 +84,9 @@ export function ChannelScreen({
markChannelRead,
markChannelUnread,
getChannelReadAt,
getThreadReadAt,
markThreadRead,
readStateVersion,
openCreateChannel,
openChannelManagement,
followThread,
Expand Down Expand Up @@ -397,6 +403,83 @@ export function ChannelScreen({
const openThreadHeadMessage = threadPanelData.threadHead;
const threadMessages = threadPanelData.visibleReplies;
const threadReplyTargetMessage = threadPanelData.replyTargetMessage;

// --- 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),
);
}
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);
};
}, [openThreadHeadId]);
// Mark thread read when the panel opens (advance frontier to latest reply).
// Only persist read state for threads the user has notification interest in
// (participated, authored, or followed) to avoid bloating the context blob.
React.useEffect(() => {
if (!openThreadHeadId || threadMessages.length === 0) return;
if (!isNotifiedForCurrentThread) return;
const latestReply = threadMessages[threadMessages.length - 1].message;
markThreadRead(openThreadHeadId, latestReply.createdAt);
}, [
openThreadHeadId,
threadMessages,
markThreadRead,
isNotifiedForCurrentThread,
]);
// Compute the in-thread "New" divider position from the open-time frontier.
const { firstUnreadReplyId: threadFirstUnreadReplyId } = React.useMemo(() => {
if (!openThreadHeadId || threadMessages.length === 0) {
return { firstUnreadReplyId: null, unreadCount: 0 };
}
const replies = threadMessages.map((entry) => entry.message);
return computeThreadUnreadMarker(replies, threadOpenFrontierSeconds);
}, [openThreadHeadId, threadMessages, threadOpenFrontierSeconds]);
// Compute per-thread unread counts for summary rows in the main timeline.
// Only compute for threads the user has notification interest in — this
// aligns the badge display with the read-state write path.
// biome-ignore lint/correctness/useExhaustiveDependencies: readStateVersion invalidates getThreadReadAt and isNotifiedForThread without changing their identity
const threadUnreadCounts = React.useMemo(() => {
const counts = new Map<string, number>();
for (const message of timelineMessages) {
if (message.parentId) continue;
if (!isNotifiedForThread(message.id)) continue;
// Only compute for messages that have thread replies
const directReplies = timelineMessages.filter(
(m) => m.parentId === message.id,
);
if (directReplies.length === 0) continue;
const frontier = getThreadReadAt(message.id);
const { unreadCount } = computeThreadUnreadMarker(
directReplies,
frontier,
);
if (unreadCount > 0) {
counts.set(message.id, unreadCount);
}
}
return counts;
}, [
timelineMessages,
getThreadReadAt,
isNotifiedForThread,
readStateVersion,
]);
const editTargetMessage = React.useMemo(
() =>
timelineMessages.find((message) => message.id === editTargetId) ?? null,
Expand Down Expand Up @@ -718,6 +801,8 @@ export function ChannelScreen({
threadTypingPubkeys={threadTypingPubkeys}
threadReplyTargetMessage={threadReplyTargetMessage}
threadScrollTargetId={threadScrollTargetId}
threadUnreadCounts={threadUnreadCounts}
threadFirstUnreadReplyId={threadFirstUnreadReplyId}
isJoining={joinChannelMutation.isPending}
onJoinChannel={joinChannelMutation.mutateAsync}
typingPubkeys={humanTypingPubkeys}
Expand Down
83 changes: 82 additions & 1 deletion desktop/src/features/messages/lib/unreadMarker.test.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import assert from "node:assert/strict";
import test from "node:test";

import { computeChannelUnreadMarker } from "./unreadMarker.ts";
import {
computeChannelUnreadMarker,
computeThreadUnreadMarker,
} from "./unreadMarker.ts";

function topLevel(id, createdAt) {
return { id, createdAt, author: "a", time: "", body: "", depth: 0 };
Expand Down Expand Up @@ -91,3 +94,81 @@ test("computeChannelUnreadMarker_suppressedNeverReadChannel_returnsNoMarker", ()
assert.equal(marker.firstUnreadMessageId, null);
assert.equal(marker.unreadCount, 0);
});

// --- computeThreadUnreadMarker tests ---

test("computeThreadUnreadMarker_emptyReplies_returnsNoUnread", () => {
const marker = computeThreadUnreadMarker([], 100);
assert.equal(marker.firstUnreadReplyId, null);
assert.equal(marker.unreadCount, 0);
});

test("computeThreadUnreadMarker_nullFrontier_marksAllRepliesUnread", () => {
const replies = [
{ id: "r1", createdAt: 10 },
{ id: "r2", createdAt: 20 },
{ id: "r3", createdAt: 30 },
];
const marker = computeThreadUnreadMarker(replies, null);
assert.equal(marker.firstUnreadReplyId, "r1");
assert.equal(marker.unreadCount, 3);
});

test("computeThreadUnreadMarker_frontierBetweenReplies_countsAfterFrontier", () => {
const replies = [
{ id: "r1", createdAt: 10 },
{ id: "r2", createdAt: 20 },
{ id: "r3", createdAt: 30 },
];
const marker = computeThreadUnreadMarker(replies, 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 >).
const replies = [
{ id: "r1", createdAt: 10 },
{ id: "r2", createdAt: 20 },
];
const marker = computeThreadUnreadMarker(replies, 20);
assert.equal(marker.firstUnreadReplyId, null);
assert.equal(marker.unreadCount, 0);
});

test("computeThreadUnreadMarker_frontierAboveAll_returnsNoUnread", () => {
const replies = [
{ id: "r1", createdAt: 10 },
{ id: "r2", createdAt: 20 },
];
const marker = computeThreadUnreadMarker(replies, 100);
assert.equal(marker.firstUnreadReplyId, null);
assert.equal(marker.unreadCount, 0);
});

test("computeThreadUnreadMarker_frontierBelowAll_allUnread", () => {
const replies = [
{ id: "r1", createdAt: 10 },
{ id: "r2", createdAt: 20 },
];
const marker = computeThreadUnreadMarker(replies, 5);
assert.equal(marker.firstUnreadReplyId, "r1");
assert.equal(marker.unreadCount, 2);
});

test("computeThreadUnreadMarker_singleReplyUnread_countsOne", () => {
const replies = [
{ id: "r1", createdAt: 10 },
{ id: "r2", createdAt: 20 },
{ id: "r3", createdAt: 30 },
];
const marker = computeThreadUnreadMarker(replies, 25);
assert.equal(marker.firstUnreadReplyId, "r3");
assert.equal(marker.unreadCount, 1);
});

test("computeThreadUnreadMarker_emptyRepliesNullFrontier_returnsNoUnread", () => {
const marker = computeThreadUnreadMarker([], null);
assert.equal(marker.firstUnreadReplyId, null);
assert.equal(marker.unreadCount, 0);
});
49 changes: 49 additions & 0 deletions desktop/src/features/messages/lib/unreadMarker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,52 @@ export function computeChannelUnreadMarker(
? EMPTY_MARKER
: { firstUnreadMessageId, unreadCount };
}

/**
* Thread-scoped unread marker. Counts replies newer than the thread read
* frontier and identifies the first unread reply.
*
* Unlike the channel marker, every entry is a reply (no parentId filter) and
* there is no suppression mechanism.
*/
export type ThreadUnreadMarker = {
/** Event id of the oldest unread reply, or null if none. */
firstUnreadReplyId: string | null;
/** Count of unread replies at or after the first unread one. */
unreadCount: number;
};

const EMPTY_THREAD_MARKER: ThreadUnreadMarker = {
firstUnreadReplyId: null,
unreadCount: 0,
};

/**
* @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.
*/
export function computeThreadUnreadMarker(
replies: Pick<TimelineMessage, "id" | "createdAt">[],
frontierSeconds: number | null,
): ThreadUnreadMarker {
let firstUnreadReplyId: string | null = null;
let unreadCount = 0;

for (const reply of replies) {
const isUnread =
frontierSeconds === null || reply.createdAt > frontierSeconds;
if (!isUnread) {
continue;
}
if (firstUnreadReplyId === null) {
firstUnreadReplyId = reply.id;
}
unreadCount += 1;
}

return firstUnreadReplyId === null
? EMPTY_THREAD_MARKER
: { firstUnreadReplyId, unreadCount };
}
Loading