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
18 changes: 6 additions & 12 deletions desktop/src/features/messages/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
channelMessagesKey,
dedupeMessagesById,
mergeTimelineHistoryMessages,
normalizeTimelineMessages,
sortMessages,
} from "@/features/messages/lib/messageQueryKeys";
Expand Down Expand Up @@ -180,10 +181,10 @@ export function useChannelMessagesQuery(channel: Channel | null) {
);
const currentMessages =
queryClient.getQueryData<RelayEvent[]>(queryKey) ?? [];
const mergedHistory = normalizeTimelineMessages([
...currentMessages,
...history,
]);
const mergedHistory = mergeTimelineHistoryMessages(
currentMessages,
history,
);

return mergedHistory;
},
Expand All @@ -208,14 +209,7 @@ export function useChannelSubscription(channel: Channel | null) {

queryClient.setQueryData<RelayEvent[]>(
channelMessagesKey(channelId),
(current = []) => {
const mergedHistory = normalizeTimelineMessages([
...current,
...history,
]);

return mergedHistory;
},
(current = []) => mergeTimelineHistoryMessages(current, history),
);
});

Expand Down
204 changes: 204 additions & 0 deletions desktop/src/features/messages/lib/messageQueryKeys.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import assert from "node:assert/strict";
import test from "node:test";

import {
mergeTimelineHistoryMessages,
normalizeTimelineMessages,
} from "./messageQueryKeys.ts";
import { mergeTimelineCacheMessages } from "../hooks.ts";

const CHANNEL_ID = "timeline-window-test";
const PUBKEY = "a".repeat(64);

function event({ id, kind = 9, createdAt, tags, content = "" }) {
return {
id,
pubkey: PUBKEY,
created_at: createdAt,
kind,
tags: tags ?? [["h", CHANNEL_ID]],
content,
sig: "mocksig".repeat(20).slice(0, 128),
};
}

function id(prefix, index) {
return `${prefix}${String(index).padStart(64 - prefix.length, "0")}`;
}

test("normalizeTimelineMessages caps visible content, not unrelated auxiliary events", () => {
const june12Roots = [
"1f86d0450b3c2c376691e7a8232fbd8a5b8408ecad2f5eb0209e7bfcfdf9af80",
"3b745de3d9ff91c464b0cbf26e3e628a5a8c05dccf3f9781b1fbd99a0f6f5e7b",
"cb404eeae1517bb2ed2e9975dfd2efd12d0a7ef17b87c3a6573b251b9865f7a4",
"337de49c712fcf84f5689a6c11ce36018817197d578468b8132c6dc3d1a13131",
"ac683f35cda2e8c1e9ae609e0b9f5dc23a7d434637b4f0505495c3a2b6f52aae",
];
const messages = [];

for (let index = 0; index < 500; index += 1) {
messages.push(event({ id: id("old", index), createdAt: 1_000 + index }));
}
for (const [index, rootId] of june12Roots.entries()) {
messages.push(
event({ id: rootId, createdAt: 2_000 + index, content: "June 12 root" }),
);
}
for (let index = 0; index < 1_303; index += 1) {
messages.push(
event({
id: id("del", index),
kind: 5,
createdAt: 3_000 + index,
tags: [
["h", CHANNEL_ID],
["e", id("zzz", index)],
],
}),
);
}
for (let index = 0; index < 231; index += 1) {
messages.push(
event({
id: id("rea", index),
kind: 7,
createdAt: 5_000 + index,
tags: [
["h", CHANNEL_ID],
["e", id("yyy", index)],
],
content: "+",
}),
);
}
for (let index = 0; index < 1_495; index += 1) {
messages.push(event({ id: id("new", index), createdAt: 6_000 + index }));
}

const normalized = normalizeTimelineMessages(messages);

assert.equal(normalized.filter((item) => item.kind === 9).length, 2_000);
assert.deepEqual(
june12Roots.map((rootId) => normalized.some((item) => item.id === rootId)),
[true, true, true, true, true],
);
assert.equal(normalized.filter((item) => item.kind === 5).length, 1_303);
assert.equal(normalized.filter((item) => item.kind === 7).length, 231);
});

test("normalizeTimelineMessages still caps old visible content", () => {
const retainedRoot = `${"a".repeat(63)}1`;
const reaction = `${"b".repeat(63)}1`;
const reactionDeletion = `${"c".repeat(63)}1`;
const messages = [];

for (let index = 0; index < 2_000; index += 1) {
messages.push(event({ id: id("old", index), createdAt: 1_000 + index }));
}
messages.push(event({ id: retainedRoot, createdAt: 4_000 }));
messages.push(
event({
id: reaction,
kind: 7,
createdAt: 4_001,
tags: [
["h", CHANNEL_ID],
["e", retainedRoot],
],
content: "+",
}),
);
messages.push(
event({
id: reactionDeletion,
kind: 5,
createdAt: 4_002,
tags: [
["h", CHANNEL_ID],
["e", reaction],
],
}),
);

const normalized = normalizeTimelineMessages(messages);

assert.equal(
normalized.some((item) => item.id === id("old", 0)),
false,
);
assert.equal(
normalized.some((item) => item.id === retainedRoot),
true,
);
assert.equal(
normalized.some((item) => item.id === reaction),
true,
);
assert.equal(
normalized.some((item) => item.id === reactionDeletion),
true,
);
});

test("timeline history and live cache merges retain the same visible content regardless of order", () => {
const seedMessages = [];
const olderPage = [];
const liveMessage = event({ id: id("liv", 0), createdAt: 20_000 });

for (let index = 0; index < 700; index += 1) {
seedMessages.push(
event({ id: id("new", index), createdAt: 10_000 + index }),
);
}
for (let index = 0; index < 1_303; index += 1) {
seedMessages.push(
event({
id: id("del", index),
kind: 5,
createdAt: 11_000 + index,
tags: [
["h", CHANNEL_ID],
["e", id("zzz", index)],
],
}),
);
}
for (let index = 0; index < 231; index += 1) {
seedMessages.push(
event({
id: id("rea", index),
kind: 7,
createdAt: 13_000 + index,
tags: [
["h", CHANNEL_ID],
["e", id("yyy", index)],
],
content: "+",
}),
);
}
for (let index = 0; index < 1_500; index += 1) {
olderPage.push(event({ id: id("old", index), createdAt: 1_000 + index }));
}

const historyThenLive = mergeTimelineCacheMessages(
mergeTimelineHistoryMessages(seedMessages, olderPage),
liveMessage,
);
const liveThenHistory = mergeTimelineHistoryMessages(
mergeTimelineCacheMessages(seedMessages, liveMessage),
olderPage,
);
const historyThenLiveContent = historyThenLive
.filter((item) => item.kind === 9)
.map((item) => item.id);
const liveThenHistoryContent = liveThenHistory
.filter((item) => item.kind === 9)
.map((item) => item.id);

assert.equal(historyThenLiveContent.length, 2_000);
assert.equal(liveThenHistoryContent.length, 2_000);
assert.deepEqual(liveThenHistoryContent, historyThenLiveContent);
assert.equal(historyThenLiveContent[0], id("old", 201));
assert.equal(historyThenLiveContent.at(-1), liveMessage.id);
});
56 changes: 51 additions & 5 deletions desktop/src/features/messages/lib/messageQueryKeys.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,16 @@
import type { RelayEvent } from "@/shared/api/types";
import {
KIND_JOB_ACCEPTED,
KIND_JOB_CANCEL,
KIND_JOB_ERROR,
KIND_JOB_PROGRESS,
KIND_JOB_REQUEST,
KIND_JOB_RESULT,
KIND_STREAM_MESSAGE,
KIND_STREAM_MESSAGE_DIFF,
KIND_STREAM_MESSAGE_V2,
KIND_SYSTEM_MESSAGE,
} from "@/shared/constants/kinds";

const MAX_TIMELINE_MESSAGES = 2_000;

Expand Down Expand Up @@ -30,17 +42,51 @@ export function sortMessages(messages: RelayEvent[]) {
);
}

function isTimelineWindowContentEvent(event: RelayEvent) {
return (
event.kind === KIND_STREAM_MESSAGE ||
event.kind === KIND_STREAM_MESSAGE_V2 ||
event.kind === KIND_STREAM_MESSAGE_DIFF ||
event.kind === KIND_SYSTEM_MESSAGE ||
event.kind === KIND_JOB_REQUEST ||
event.kind === KIND_JOB_ACCEPTED ||
event.kind === KIND_JOB_PROGRESS ||
event.kind === KIND_JOB_RESULT ||
event.kind === KIND_JOB_CANCEL ||
event.kind === KIND_JOB_ERROR
);
}

/**
* Sort, dedupe, and cap the timeline at {@link MAX_TIMELINE_MESSAGES} so
* de-virtualized rendering does not grow into an unbounded DOM during
* long-lived channel sessions.
* Sort, dedupe, and cap the timeline at {@link MAX_TIMELINE_MESSAGES} visible
* content events so de-virtualized rendering does not grow into an unbounded
* DOM during long-lived channel sessions.
*
* Auxiliary events (reactions, edits, tombstones) are kept in cache so they can
* still apply to retained or later-loaded content, but they must not consume the
* visible message window and evict older loaded roots.
*/
export function normalizeTimelineMessages(messages: RelayEvent[]) {
const normalized = sortMessages(messages);
const contentEvents = normalized.filter(isTimelineWindowContentEvent);

if (normalized.length <= MAX_TIMELINE_MESSAGES) {
if (contentEvents.length <= MAX_TIMELINE_MESSAGES) {
return normalized;
}

return normalized.slice(-MAX_TIMELINE_MESSAGES);
const retainedContentIds = new Set(
contentEvents.slice(-MAX_TIMELINE_MESSAGES).map((event) => event.id),
);

return normalized.filter(
(event) =>
!isTimelineWindowContentEvent(event) || retainedContentIds.has(event.id),
);
}

export function mergeTimelineHistoryMessages(
current: RelayEvent[],
history: RelayEvent[],
) {
return normalizeTimelineMessages([...current, ...history]);
}
4 changes: 2 additions & 2 deletions desktop/src/features/messages/useFetchOlderMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useQueryClient } from "@tanstack/react-query";

import {
channelMessagesKey,
sortMessages,
mergeTimelineHistoryMessages,
} from "@/features/messages/lib/messageQueryKeys";
import { relayClient } from "@/shared/api/relayClient";
import type { Channel, RelayEvent } from "@/shared/api/types";
Expand Down Expand Up @@ -64,7 +64,7 @@ export function useFetchOlderMessages(channel: Channel | null) {

if (olderMessages.length > 0) {
queryClient.setQueryData<RelayEvent[]>(queryKey, (current = []) =>
sortMessages([...current, ...olderMessages]),
mergeTimelineHistoryMessages(current, olderMessages),
);

const updatedMessages =
Expand Down
Loading