Skip to content
Merged
4 changes: 4 additions & 0 deletions desktop/scripts/check-file-sizes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ const overrides = new Map([
["src-tauri/src/nostr_convert.rs", 1126],
["src/shared/api/relayClientSession.ts", 1022],
["src-tauri/src/migration.rs", 1295],
// onMarkRead prop-pair completion (mirrors the onMarkUnread prop already
// threaded here) — a 1-line overage, not generic debt growth. Approved
// override; still queued to split with the rest of this list.
["src/features/messages/ui/MessageThreadPanel.tsx", 1002],
]);

await runFileSizeCheck({
Expand Down
40 changes: 28 additions & 12 deletions desktop/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -367,6 +368,21 @@ export function AppShell() {
},
[markChannelRead],
);

// Per-message read frontier (LP4 v3): effective(msg:<id>) folds through the
// channel, so a channel-read clears messages older than the top-level frontier.
const getMessageReadAt = React.useCallback(
(messageId: string) => getChannelReadAt(msgContextKey(messageId)),
[getChannelReadAt],
);
const markMessageRead = React.useCallback(
(messageId: string, timestamp: number) =>
markChannelRead(
msgContextKey(messageId),
new Date(timestamp * 1_000).toISOString(),
),
[markChannelRead],
);
const threadActivityFeedItems = useThreadActivityFeedItems(
threadActivityItems,
mutedRootIds,
Expand Down Expand Up @@ -479,9 +495,10 @@ export function AppShell() {
[goSettings],
);

const handleCloseSettings = React.useCallback(() => {
closeSettings();
}, [closeSettings]);
const handleCloseSettings = React.useCallback(
() => closeSettings(),
[closeSettings],
);

// Section switches rewrite the settings entry rather than stacking one
// history entry per section, so back always exits settings in one step.
Expand Down Expand Up @@ -605,13 +622,12 @@ export function AppShell() {
};
}, []);

const handleOpenNewDm = React.useCallback(() => {
setIsNewDmOpen(true);
}, []);
const handleOpenNewDm = React.useCallback(() => setIsNewDmOpen(true), []);

const handleOpenCreateChannel = React.useCallback(() => {
setIsCreateChannelOpen(true);
}, []);
const handleOpenCreateChannel = React.useCallback(
() => setIsCreateChannelOpen(true),
[],
);

React.useLayoutEffect(() => {
if (settingsOpen) {
Expand Down Expand Up @@ -721,12 +737,12 @@ export function AppShell() {
markChannelRead,
markChannelUnread,
openCreateChannel: handleOpenCreateChannel,
openChannelManagement: () => {
setIsChannelManagementOpen(true);
},
openChannelManagement: () => setIsChannelManagementOpen(true),
getChannelReadAt,
getThreadReadAt,
markThreadRead,
getMessageReadAt,
markMessageRead,
readStateVersion,
setContextParentResolver,
followThread: handleFollowThread,
Expand Down
8 changes: 8 additions & 0 deletions desktop/src/app/AppShellContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -50,6 +56,8 @@ const AppShellContext = React.createContext<AppShellContextValue>({
getChannelReadAt: () => null,
getThreadReadAt: () => null,
markThreadRead: () => {},
getMessageReadAt: () => null,
markMessageRead: () => {},
readStateVersion: 0,
setContextParentResolver: () => {},
followThread: () => {},
Expand Down
144 changes: 0 additions & 144 deletions desktop/src/features/channels/lib/subtreeCreatedAt.test.mjs

This file was deleted.

52 changes: 19 additions & 33 deletions desktop/src/features/channels/lib/subtreeCreatedAt.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,17 @@
/**
* 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>,
): 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) ?? []));
}
return maxCreatedAt;
}

/** Minimal timeline shape the adjacency/createdAt builders read. */
interface ReplyGraphMessage {
id: string;
parentId?: string | null;
rootId?: string | null;
createdAt: number;
}

Expand All @@ -49,19 +30,24 @@ export function buildDirectReplyIdsByParentId(
}

/**
* 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.
* 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
* reply's own `rootId` (getThreadReference: the `root` e-tag that travels with
* the event), so a deep reply lands under its true root even when an
* intermediate ancestor is absent from the loaded window. Root-keyed badge
* consumers use this to roll up severed orphans the parent-chain walk misses.
* Top-level messages (no rootId) and self-referential roots are excluded.
*/
export function buildDirectRepliesByParentId<T extends ReplyGraphMessage>(
export function buildRepliesByRootId<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) ?? [];
const rootId = message.rootId;
if (!rootId || rootId === message.id) continue;
const currentReplies = map.get(rootId) ?? [];
currentReplies.push(message);
map.set(message.parentId, currentReplies);
map.set(rootId, currentReplies);
}
return map;
}
Expand Down
Loading
Loading