Skip to content
Merged
23 changes: 14 additions & 9 deletions desktop/src/features/channels/ui/ChannelScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -511,18 +511,23 @@ export function ChannelScreen({
});
// `data !== undefined` is not "loaded": the cache is seeded early by stale
// placeholders and the live subscription. Wait for the history fetch to settle.
const timelineLoadingNow =
activeChannel !== null &&
activeChannel.channelType !== "forum" &&
selectTimelineLoadingState({
isPending: messagesQuery.isPending,
isFetching: messagesQuery.isFetching,
isPlaceholderData: messagesQuery.isPlaceholderData,
dataLength: messagesQuery.data?.length ?? null,
});
// Latch loaded per channel so a later background refetch can't flip back to
// the skeleton — that re-flip is the "skeleton bouncing up and down" on entry.
const settledChannelIdRef = React.useRef<string | null>(null);
const hasSettledThisChannel =
activeChannelId !== null && settledChannelIdRef.current === activeChannelId;
const timelineLoadingNow =
activeChannel !== null &&
activeChannel.channelType !== "forum" &&
selectTimelineLoadingState(
{
isPending: messagesQuery.isPending,
isFetching: messagesQuery.isFetching,
isPlaceholderData: messagesQuery.isPlaceholderData,
dataLength: messagesQuery.data?.length ?? null,
},
hasSettledThisChannel,
);
const { settledChannelId, isLoading: isTimelineLoading } =
resolveTimelineLoadingLatch(
settledChannelIdRef.current,
Expand Down
32 changes: 29 additions & 3 deletions desktop/src/features/messages/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ import type { Channel, Identity, RelayEvent } from "@/shared/api/types";
// Same .mjs the renderer uses, so the cache-update projection can't drift
// from the on-render overlay.
import { applyEditTagOverlay } from "@/features/messages/lib/applyEditTagOverlay.mjs";
import { backfillAuxForMessages } from "@/features/messages/lib/auxBackfill";
import { countTopLevelTimelineRows } from "@/features/messages/lib/formatTimelineMessages";
import {
MIN_TOP_LEVEL_ROWS_PER_FETCH,
pageOlderMessagesUntilRowFloor,
} from "@/features/messages/lib/pageOlderMessages";
import {
KIND_STREAM_MESSAGE,
KIND_SYSTEM_MESSAGE,
Expand All @@ -42,7 +48,7 @@ type MessageQueryContext = {
queryKey: ReturnType<typeof channelMessagesKey>;
};

const CHANNEL_HISTORY_LIMIT = 200;
const CHANNEL_HISTORY_LIMIT = 300;

function getLocalRenderKey(message: RelayEvent) {
return message.localKey ?? message.id;
Expand Down Expand Up @@ -186,7 +192,25 @@ export function useChannelMessagesQuery(channel: Channel | null) {
history,
);

return mergedHistory;
// Paint messages immediately; backfill their reactions/edits/deletions
// by `#e` in the background (it self-merges into the same cache key).
void backfillAuxForMessages(queryClient, channel.id, history);

// Seed the cache, then — only if the cold window renders thinner than a
// normal scroll page — top it up to the same visible-row floor. A
// reply-heavy channel's 300-message cold load can be ~12 rows; a normal
// channel already clears the floor and skips the extra fetch entirely.
queryClient.setQueryData<RelayEvent[]>(queryKey, mergedHistory);
if (
countTopLevelTimelineRows(mergedHistory) < MIN_TOP_LEVEL_ROWS_PER_FETCH
) {
await pageOlderMessagesUntilRowFloor(
queryClient,
channel.id,
() => true,
);
}
return queryClient.getQueryData<RelayEvent[]>(queryKey) ?? mergedHistory;
},
staleTime: 5 * 60 * 1_000,
gcTime: 5 * 60 * 1_000,
Expand All @@ -211,6 +235,8 @@ export function useChannelSubscription(channel: Channel | null) {
channelMessagesKey(channelId),
(current = []) => mergeTimelineHistoryMessages(current, history),
);

void backfillAuxForMessages(queryClient, channelId, history);
});

const appendMessage = useEffectEvent((event: RelayEvent) => {
Expand Down Expand Up @@ -278,7 +304,7 @@ export function useChannelSubscription(channel: Channel | null) {

cleanup = dispose;
// No post-subscribe history refetch: useChannelMessagesQuery already
// loaded the latest CHANNEL_HISTORY_LIMIT (200) events, and the live
// loaded the latest CHANNEL_HISTORY_LIMIT (300) events, and the live
// subscription itself backfills up to 50 most-recent events via its
// initial REQ (buildChannelFilter(id, 50)). Both write into the same
// channelMessagesKey cache, so any window between the two REQs is
Expand Down
128 changes: 128 additions & 0 deletions desktop/src/features/messages/lib/auxBackfill.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import assert from "node:assert/strict";
import test from "node:test";

import {
collectAuxEventIdsForDeletionBackfill,
collectMessageIdsForAuxBackfill,
mergeAuxEventsWithDeletionBackfill,
} from "./auxBackfill.ts";

const CHANNEL_ID = "36411e44-0e2d-4cfe-bd6e-567eb169db9f";

function event(id, kind, overrides = {}) {
return {
id,
pubkey: "a".repeat(64),
kind,
created_at: 1_700_000_000,
content: "",
tags: [["h", CHANNEL_ID]],
sig: "sig",
...overrides,
};
}

function hex(char) {
return char.repeat(64);
}

test("collects content-kind message ids (stream, v2, diff, system, jobs)", () => {
const events = [
event(hex("1"), 9), // stream message
event(hex("2"), 40002), // v2 stream message
event(hex("3"), 40008), // diff (own row)
event(hex("4"), 40099), // system message
event(hex("5"), 43001), // job request
];
assert.deepEqual(collectMessageIdsForAuxBackfill(events), [
hex("1"),
hex("2"),
hex("3"),
hex("4"),
hex("5"),
]);
});

test("excludes auxiliary kinds (reactions, edits, deletions)", () => {
const events = [
event(hex("1"), 9), // message — kept
event(hex("2"), 7), // reaction — excluded
event(hex("3"), 40003), // edit — excluded
event(hex("4"), 5), // NIP-09 deletion — excluded
event(hex("5"), 9005), // Buzz-native deletion — excluded
];
assert.deepEqual(collectMessageIdsForAuxBackfill(events), [hex("1")]);
});

test("returns empty for a window of only auxiliary events", () => {
const events = [event(hex("2"), 7), event(hex("3"), 40003)];
assert.deepEqual(collectMessageIdsForAuxBackfill(events), []);
});

test("collects reaction and edit ids for deletion-marker backfill", () => {
const events = [
event(hex("1"), 9),
event(hex("2"), 7),
event(hex("3"), 40003),
event(hex("4"), 5),
event(hex("5"), 9005),
];

assert.deepEqual(collectAuxEventIdsForDeletionBackfill(events), [
hex("2"),
hex("3"),
]);
});

test("merges deletion markers that target cached or fetched auxiliary event ids", async () => {
const messageId = hex("1");
const cachedReactionId = hex("2");
const fetchedReactionId = hex("3");
const cachedReactionDeletionId = hex("4");
const fetchedReactionDeletionId = hex("5");
const cachedReaction = event(cachedReactionId, 7, {
content: "+",
tags: [
["h", CHANNEL_ID],
["e", messageId],
],
});
const fetchedReaction = event(fetchedReactionId, 7, {
content: "-",
tags: [
["h", CHANNEL_ID],
["e", messageId],
],
});
const cachedReactionDeletion = event(cachedReactionDeletionId, 5, {
tags: [
["h", CHANNEL_ID],
["e", cachedReactionId],
],
});
const fetchedReactionDeletion = event(fetchedReactionDeletionId, 5, {
tags: [
["h", CHANNEL_ID],
["e", fetchedReactionId],
],
});
const calls = [];

const merged = await mergeAuxEventsWithDeletionBackfill({
channelId: CHANNEL_ID,
cachedEvents: [cachedReaction],
fetchedAuxEvents: [fetchedReaction],
fetchAuxEventsForMessages: async (channelId, ids) => {
calls.push({ channelId, ids });
return [cachedReactionDeletion, fetchedReactionDeletion];
},
});

assert.deepEqual(calls, [
{ channelId: CHANNEL_ID, ids: [cachedReactionId, fetchedReactionId] },
]);
assert.deepEqual(
merged.map((cachedEvent) => cachedEvent.id),
[fetchedReactionId, cachedReactionDeletionId, fetchedReactionDeletionId],
);
});
120 changes: 120 additions & 0 deletions desktop/src/features/messages/lib/auxBackfill.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import type { QueryClient } from "@tanstack/react-query";

import {
channelMessagesKey,
sortMessages,
} from "@/features/messages/lib/messageQueryKeys";
import { relayClient } from "@/shared/api/relayClient";
import type { RelayEvent } from "@/shared/api/types";
import {
CHANNEL_TIMELINE_CONTENT_KINDS,
KIND_REACTION,
KIND_STREAM_MESSAGE_EDIT,
} from "@/shared/constants/kinds";

const TIMELINE_CONTENT_KINDS: ReadonlySet<number> = new Set(
CHANNEL_TIMELINE_CONTENT_KINDS,
);

/**
* Extract the ids of the visible content messages from a freshly-fetched
* history window. Auxiliary events (reactions, edits, deletions) are then
* backfilled by `#e` reference over exactly these ids. Pure so it can be
* unit-tested without a relay or query client.
*/
export function collectMessageIdsForAuxBackfill(
historyEvents: RelayEvent[],
): string[] {
return historyEvents
.filter((event) => TIMELINE_CONTENT_KINDS.has(event.kind))
.map((event) => event.id);
}

export function collectAuxEventIdsForDeletionBackfill(
auxEvents: RelayEvent[],
): string[] {
return auxEvents
.filter(
(event) =>
event.kind === KIND_REACTION || event.kind === KIND_STREAM_MESSAGE_EDIT,
)
.map((event) => event.id);
}

export async function mergeAuxEventsWithDeletionBackfill(input: {
channelId: string;
cachedEvents: RelayEvent[];
fetchedAuxEvents: RelayEvent[];
fetchAuxEventsForMessages: (
channelId: string,
messageIds: string[],
) => Promise<RelayEvent[]>;
}): Promise<RelayEvent[]> {
const auxEventIds = [
...new Set([
...collectAuxEventIdsForDeletionBackfill(input.cachedEvents),
...collectAuxEventIdsForDeletionBackfill(input.fetchedAuxEvents),
]),
];
const auxDeletionEvents =
auxEventIds.length > 0
? await input.fetchAuxEventsForMessages(input.channelId, auxEventIds)
: [];

return [...input.fetchedAuxEvents, ...auxDeletionEvents];
}

/**
* After a content-kinds-only history fetch, pull the auxiliary events
* (reactions, edits, deletions) that reference the loaded messages — keyed by
* `#e` over their ids, not by a time window — and merge them into the same
* channel cache.
*
* History fetches request content kinds only so the `limit` budget buys
* visible message depth (a reaction-heavy 200-event window was only ~136
* messages). The cost is that an edit/deletion for a visible message can fall
* outside any fetched time window — so aux must be pulled by reference, or a
* visible message renders stale (un-edited / not-deleted).
*
* Best-effort: failures are logged but never reject, so a flaky overlay fetch
* can't blank the freshly-loaded messages.
*/
export async function backfillAuxForMessages(
queryClient: QueryClient,
channelId: string,
historyEvents: RelayEvent[],
): Promise<void> {
const messageIds = collectMessageIdsForAuxBackfill(historyEvents);
if (messageIds.length === 0) {
return;
}

try {
const cacheKey = channelMessagesKey(channelId);
const cachedEvents = queryClient.getQueryData<RelayEvent[]>(cacheKey) ?? [];
const auxEvents = await relayClient.fetchAuxEventsForMessages(
channelId,
messageIds,
);
const mergedAuxEvents = await mergeAuxEventsWithDeletionBackfill({
channelId,
cachedEvents,
fetchedAuxEvents: auxEvents,
fetchAuxEventsForMessages: (...args) =>
relayClient.fetchAuxDeletionEventsForAuxEvents(...args),
});
if (mergedAuxEvents.length === 0) {
return;
}

queryClient.setQueryData<RelayEvent[]>(cacheKey, (current = []) =>
sortMessages([...current, ...mergedAuxEvents]),
);
} catch (error) {
console.error(
"Failed to backfill auxiliary events for channel",
channelId,
error,
);
}
}
Loading