diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index 57db8b13c..31ffb39f6 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -31,6 +31,7 @@ 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 { KIND_STREAM_MESSAGE, KIND_SYSTEM_MESSAGE, @@ -186,6 +187,10 @@ export function useChannelMessagesQuery(channel: Channel | null) { history, ); + // 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); + return mergedHistory; }, staleTime: 5 * 60 * 1_000, @@ -211,6 +216,8 @@ export function useChannelSubscription(channel: Channel | null) { channelMessagesKey(channelId), (current = []) => mergeTimelineHistoryMessages(current, history), ); + + void backfillAuxForMessages(queryClient, channelId, history); }); const appendMessage = useEffectEvent((event: RelayEvent) => { diff --git a/desktop/src/features/messages/lib/auxBackfill.test.mjs b/desktop/src/features/messages/lib/auxBackfill.test.mjs new file mode 100644 index 000000000..df305c922 --- /dev/null +++ b/desktop/src/features/messages/lib/auxBackfill.test.mjs @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { collectMessageIdsForAuxBackfill } from "./auxBackfill.ts"; + +const CHANNEL_ID = "36411e44-0e2d-4cfe-bd6e-567eb169db9f"; + +function event(id, kind) { + return { + id, + pubkey: "a".repeat(64), + kind, + created_at: 1_700_000_000, + content: "", + tags: [["h", CHANNEL_ID]], + sig: "sig", + }; +} + +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), []); +}); diff --git a/desktop/src/features/messages/lib/auxBackfill.ts b/desktop/src/features/messages/lib/auxBackfill.ts new file mode 100644 index 000000000..17e4f86e9 --- /dev/null +++ b/desktop/src/features/messages/lib/auxBackfill.ts @@ -0,0 +1,74 @@ +import type { QueryClient } from "@tanstack/react-query"; + +import { + channelMessagesKey, + mergeTimelineHistoryMessages, +} from "@/features/messages/lib/messageQueryKeys"; +import { relayClient } from "@/shared/api/relayClient"; +import type { RelayEvent } from "@/shared/api/types"; +import { CHANNEL_TIMELINE_CONTENT_KINDS } from "@/shared/constants/kinds"; + +const TIMELINE_CONTENT_KINDS: ReadonlySet = 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); +} + +/** + * 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 { + const messageIds = collectMessageIdsForAuxBackfill(historyEvents); + if (messageIds.length === 0) { + return; + } + + try { + const auxEvents = await relayClient.fetchAuxEventsForMessages( + channelId, + messageIds, + ); + if (auxEvents.length === 0) { + return; + } + + queryClient.setQueryData( + channelMessagesKey(channelId), + (current = []) => mergeTimelineHistoryMessages(current, auxEvents), + ); + } catch (error) { + console.error( + "Failed to backfill auxiliary events for channel", + channelId, + error, + ); + } +} diff --git a/desktop/src/features/messages/lib/formatTimelineMessages.test.mjs b/desktop/src/features/messages/lib/formatTimelineMessages.test.mjs index 6526d19f8..95f0205dd 100644 --- a/desktop/src/features/messages/lib/formatTimelineMessages.test.mjs +++ b/desktop/src/features/messages/lib/formatTimelineMessages.test.mjs @@ -45,6 +45,64 @@ function deletionEvent(kind, targetId, overrides = {}) { }; } +function streamEdit(targetId, content, overrides = {}) { + return { + id: HEX64_B, + pubkey: PUBKEY_A, + kind: 40003, + created_at: 1_700_000_001, + content, + tags: [ + ["h", CHANNEL_ID], + ["e", targetId], + ], + sig: "sig", + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Keystone regression: aux events (edits/deletions) apply by `#e` reference, +// NOT by time-window overlap. This is the invariant the split-query + +// `#e`-backfill fix depends on: an edit/deletion can be loaded long after the +// message it targets — even with a far-future `created_at` — and must still +// apply. If the reducer ever gated aux application on timestamp proximity, a +// late edit/delete for a visible old message would silently render stale. +// --------------------------------------------------------------------------- + +test("a far-future edit still rewrites the body of an old message", () => { + const old = streamMessage({ created_at: 1_700_000_000 }); + const lateEdit = streamEdit(HEX64_A, "edited body", { + created_at: 1_900_000_000, + }); + const out = formatTimelineMessages([old, lateEdit], null, undefined, null); + assert.equal(out.length, 1, "the message should still render"); + assert.equal( + out[0].body, + "edited body", + "the far-future edit must overlay the old message's body regardless of the time gap", + ); + assert.equal(out[0].edited, true, "the message must be marked edited"); +}); + +test("a far-future deletion still hides an old message", () => { + const old = streamMessage({ created_at: 1_700_000_000 }); + const lateDeletion = deletionEvent(9005, HEX64_A, { + created_at: 1_900_000_000, + }); + const out = formatTimelineMessages( + [old, lateDeletion], + null, + undefined, + null, + ); + assert.equal( + out.length, + 0, + "the far-future deletion must filter out the old message regardless of the time gap", + ); +}); + test("kind:5 (NIP-09) deletion hides the target message", () => { const events = [streamMessage(), deletionEvent(5, HEX64_A)]; const out = formatTimelineMessages(events, null, undefined, null); diff --git a/desktop/src/features/messages/lib/messageQueryKeys.test.mjs b/desktop/src/features/messages/lib/messageQueryKeys.test.mjs index 6c954f34e..5c1eaa077 100644 --- a/desktop/src/features/messages/lib/messageQueryKeys.test.mjs +++ b/desktop/src/features/messages/lib/messageQueryKeys.test.mjs @@ -202,3 +202,19 @@ test("timeline history and live cache merges retain the same visible content reg assert.equal(historyThenLiveContent[0], id("old", 201)); assert.equal(historyThenLiveContent.at(-1), liveMessage.id); }); + +test("sortMessages tiebreaks same-second events on id, order-independent", () => { + // Three events sharing one created_at, fed in two different input orders. + // The (created_at, id) sort must produce the same sequence both ways, so a + // history-then-live merge and a live-then-history merge can't shuffle a + // same-second message to a different visible position. + const a = event({ id: id("aaa", 1), createdAt: 5_000 }); + const b = event({ id: id("bbb", 1), createdAt: 5_000 }); + const c = event({ id: id("ccc", 1), createdAt: 5_000 }); + + const forward = normalizeTimelineMessages([a, b, c]).map((m) => m.id); + const reverse = normalizeTimelineMessages([c, b, a]).map((m) => m.id); + + assert.deepEqual(forward, reverse); + assert.deepEqual(forward, [a.id, b.id, c.id]); +}); diff --git a/desktop/src/features/messages/lib/messageQueryKeys.ts b/desktop/src/features/messages/lib/messageQueryKeys.ts index 0ea36739f..454a6873c 100644 --- a/desktop/src/features/messages/lib/messageQueryKeys.ts +++ b/desktop/src/features/messages/lib/messageQueryKeys.ts @@ -37,9 +37,16 @@ export function dedupeMessagesById(messages: RelayEvent[]) { } export function sortMessages(messages: RelayEvent[]) { - return dedupeMessagesById(messages).sort( - (left, right) => left.created_at - right.created_at, - ); + return dedupeMessagesById(messages).sort((left, right) => { + if (left.created_at !== right.created_at) { + return left.created_at - right.created_at; + } + // Tiebreak same-second events on id so the merge order is deterministic. + // Without this, two events sharing a created_at can land in a different + // position depending on which REQ (history vs live-sub) delivered them + // first — reading as a "missing"/shuffled message at a fixed scroll offset. + return left.id < right.id ? -1 : left.id > right.id ? 1 : 0; + }); } function isTimelineWindowContentEvent(event: RelayEvent) { diff --git a/desktop/src/features/messages/useFetchOlderMessages.ts b/desktop/src/features/messages/useFetchOlderMessages.ts index ad4d39d9f..6cda1556b 100644 --- a/desktop/src/features/messages/useFetchOlderMessages.ts +++ b/desktop/src/features/messages/useFetchOlderMessages.ts @@ -2,6 +2,7 @@ import { useCallback, useRef, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { countTopLevelTimelineRows } from "@/features/messages/lib/formatTimelineMessages"; +import { backfillAuxForMessages } from "@/features/messages/lib/auxBackfill"; import { channelMessagesKey, mergeTimelineHistoryMessages, @@ -90,6 +91,10 @@ export function useFetchOlderMessages(channel: Channel | null) { mergeTimelineHistoryMessages(current, olderMessages), ); + // Backfill the older messages' reactions/edits/deletions by `#e` + // (history is content-kinds only). Background — paint immediately. + void backfillAuxForMessages(queryClient, channelId, olderMessages); + const updatedMessages = queryClient.getQueryData(queryKey) ?? []; if ( diff --git a/desktop/src/shared/api/relayChannelFilters.ts b/desktop/src/shared/api/relayChannelFilters.ts new file mode 100644 index 000000000..2f80b506f --- /dev/null +++ b/desktop/src/shared/api/relayChannelFilters.ts @@ -0,0 +1,104 @@ +import { + CHANNEL_AUX_EVENT_KINDS, + CHANNEL_EVENT_KINDS, + CHANNEL_TIMELINE_CONTENT_KINDS, + HOME_MENTION_EVENT_KINDS, +} from "@/shared/constants/kinds"; +import type { RelaySubscriptionFilter } from "@/shared/api/relayClientShared"; + +// Auxiliary-event backfill: `#e` filters reference loaded message ids to pull +// their reactions/edits/deletions. Chunk the ids so each REQ stays within +// relay filter limits, and let each chunk return up to the relay's WS cap — +// a single reaction-heavy message can have many aux events. +export const AUX_BACKFILL_CHUNK_SIZE = 100; +export const MAX_HISTORICAL_LIMIT = 10_000; + +/** + * Live-subscription filter for an open channel: the broad + * {@link CHANNEL_EVENT_KINDS} set so the tail delivers reactions/edits/ + * deletions for future messages as well as new message rows. + */ +export function buildChannelFilter( + channelId: string, + limit: number, + until?: number, +): RelaySubscriptionFilter { + const filter: RelaySubscriptionFilter = { + kinds: [...CHANNEL_EVENT_KINDS], + "#h": [channelId], + limit, + }; + + if (until !== undefined) { + filter.until = until; + } + + return filter; +} + +/** + * History filter for cold-load and scrollback: message kinds *only*, so the + * `limit` budget buys visible message depth. Auxiliary events (reactions, + * edits, deletions) are backfilled separately by `#e` reference via + * {@link buildChannelAuxFilter}, and arrive for future messages through the + * live subscription ({@link buildChannelFilter}, which keeps the broad + * {@link CHANNEL_EVENT_KINDS} set). + */ +export function buildChannelHistoryFilter( + channelId: string, + limit: number, + until?: number, +): RelaySubscriptionFilter { + const filter: RelaySubscriptionFilter = { + kinds: [...CHANNEL_TIMELINE_CONTENT_KINDS], + "#h": [channelId], + limit, + }; + + if (until !== undefined) { + filter.until = until; + } + + return filter; +} + +/** + * Aux-backfill filter for one chunk of loaded message ids: pulls reactions/ + * edits/deletions ({@link CHANNEL_AUX_EVENT_KINDS}) that reference those ids + * by `#e`. Keyed by reference, not time, so a late edit/deletion for an old + * visible message still applies — see {@link buildChannelHistoryFilter}. + */ +export function buildChannelAuxFilter( + channelId: string, + messageIds: string[], +): RelaySubscriptionFilter { + return { + kinds: [...CHANNEL_AUX_EVENT_KINDS], + "#h": [channelId], + "#e": messageIds, + limit: MAX_HISTORICAL_LIMIT, + }; +} + +export function buildGlobalStreamFilter( + limit: number, +): RelaySubscriptionFilter { + return { + kinds: [...CHANNEL_EVENT_KINDS], + limit, + }; +} + +export function buildChannelMentionFilter( + channelId: string, + pubkey: string, + limit: number, +): RelaySubscriptionFilter { + return { + kinds: [...HOME_MENTION_EVENT_KINDS], + "#h": [channelId], + "#p": [pubkey], + limit, + since: Math.floor(Date.now() / 1_000), + }; +} diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts index 1d14265a4..85cc8797e 100644 --- a/desktop/src/shared/api/relayClientSession.ts +++ b/desktop/src/shared/api/relayClientSession.ts @@ -7,8 +7,6 @@ import { } from "@/shared/api/tauri"; import type { PresenceStatus, RelayEvent } from "@/shared/api/types"; import { - CHANNEL_EVENT_KINDS, - HOME_MENTION_EVENT_KINDS, KIND_STREAM_MESSAGE, KIND_TYPING_INDICATOR, KIND_USER_STATUS, @@ -21,6 +19,14 @@ import { type RelaySubscription, type RelaySubscriptionFilter, } from "@/shared/api/relayClientShared"; +import { + AUX_BACKFILL_CHUNK_SIZE, + buildChannelAuxFilter, + buildChannelFilter, + buildChannelHistoryFilter, + buildChannelMentionFilter, + buildGlobalStreamFilter, +} from "@/shared/api/relayChannelFilters"; import { replayLiveSubscriptions } from "@/shared/api/relayReconnectReplay"; import { RelayConnectionStateEmitter } from "@/shared/api/relayConnectionStateEmitter"; import { @@ -148,7 +154,7 @@ export class RelayClient { } async fetchChannelHistory(channelId: string, limit = 50) { - return this.fetchHistory(this.buildChannelFilter(channelId, limit)); + return this.fetchHistory(buildChannelHistoryFilter(channelId, limit)); } async fetchChannelHistoryBefore( @@ -156,7 +162,48 @@ export class RelayClient { before: number, limit = 50, ) { - return this.fetchHistory(this.buildChannelFilter(channelId, limit, before)); + return this.fetchHistory( + buildChannelHistoryFilter(channelId, limit, before), + ); + } + + /** + * Fetch the auxiliary events (reactions, edits, diffs, deletions) that + * reference a set of already-loaded message ids, keyed by `#e` rather than a + * time window. + * + * History fetches deliberately request message kinds only, so the `limit` + * budget buys visible message depth instead of being diluted by aux events + * (on a reaction-heavy channel a 200-event window was only ~136 messages). + * The trade-off is that an edit/deletion for a visible message can fall + * outside any message time window — so we must pull aux by reference, not by + * time, or a visible message would render stale (un-edited / not-deleted). + * + * Batched: `#e` filters can grow large, so message ids are chunked to keep + * each REQ within relay filter limits. Results across chunks are merged. + */ + async fetchAuxEventsForMessages( + channelId: string, + messageIds: string[], + ): Promise { + if (messageIds.length === 0) { + return []; + } + + await this.ensureConnected(); + + const chunks: string[][] = []; + for (let i = 0; i < messageIds.length; i += AUX_BACKFILL_CHUNK_SIZE) { + chunks.push(messageIds.slice(i, i + AUX_BACKFILL_CHUNK_SIZE)); + } + + const batches = await Promise.all( + chunks.map((ids) => + this.requestHistory(buildChannelAuxFilter(channelId, ids)), + ), + ); + + return batches.flat(); } async fetchEvents(filter: RelaySubscriptionFilter): Promise { @@ -269,7 +316,7 @@ export class RelayClient { channelId: string, onEvent: (event: RelayEvent) => void, ) { - return this.subscribe(this.buildChannelFilter(channelId, 50), onEvent); + return this.subscribe(buildChannelFilter(channelId, 50), onEvent); } /** @@ -357,7 +404,7 @@ export class RelayClient { } async subscribeToAllStreamMessages(onEvent: (event: RelayEvent) => void) { - return this.subscribe(this.buildGlobalStreamFilter(50), onEvent); + return this.subscribe(buildGlobalStreamFilter(50), onEvent); } async subscribeLive( @@ -373,7 +420,7 @@ export class RelayClient { onEvent: (event: RelayEvent) => void, ) { return this.subscribe( - this.buildChannelMentionFilter(channelId, pubkey, 50), + buildChannelMentionFilter(channelId, pubkey, 50), onEvent, ); } @@ -487,45 +534,6 @@ export class RelayClient { this.emitReconnectIfNeeded(); } - private buildChannelFilter( - channelId: string, - limit: number, - until?: number, - ): RelaySubscriptionFilter { - const filter: RelaySubscriptionFilter = { - kinds: [...CHANNEL_EVENT_KINDS], - "#h": [channelId], - limit, - }; - - if (until !== undefined) { - filter.until = until; - } - - return filter; - } - - private buildGlobalStreamFilter(limit: number): RelaySubscriptionFilter { - return { - kinds: [...CHANNEL_EVENT_KINDS], - limit, - }; - } - - private buildChannelMentionFilter( - channelId: string, - pubkey: string, - limit: number, - ): RelaySubscriptionFilter { - return { - kinds: [...HOME_MENTION_EVENT_KINDS], - "#h": [channelId], - "#p": [pubkey], - limit, - since: Math.floor(Date.now() / 1_000), - }; - } - private async subscribe( filter: RelaySubscriptionFilter, onEvent: (event: RelayEvent) => void, diff --git a/desktop/src/shared/api/relayClientShared.test.mjs b/desktop/src/shared/api/relayClientShared.test.mjs index de3180e8a..0bbf6ba19 100644 --- a/desktop/src/shared/api/relayClientShared.test.mjs +++ b/desktop/src/shared/api/relayClientShared.test.mjs @@ -1,7 +1,33 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { isRelayConnectionDegraded } from "./relayClientShared.ts"; +import { isRelayConnectionDegraded, sortEvents } from "./relayClientShared.ts"; + +function event(id, createdAt) { + return { + id, + pubkey: "pubkey", + created_at: createdAt, + kind: 9, + tags: [], + content: "", + sig: "sig", + }; +} + +test("sortEvents — same-second events sort by id, order-independent", () => { + const a = event("aaa", 100); + const b = event("bbb", 100); + const c = event("ccc", 101); + + const forward = sortEvents([a, b, c]).map((e) => e.id); + const shuffled = sortEvents([c, b, a]).map((e) => e.id); + + // Stable (created_at, id) order regardless of input order, matching the + // cache sort (sortMessages) and the relay's id-ASC same-second tiebreak. + assert.deepEqual(forward, ["aaa", "bbb", "ccc"]); + assert.deepEqual(shuffled, ["aaa", "bbb", "ccc"]); +}); test("isRelayConnectionDegraded — healthy states are not degraded", () => { assert.equal(isRelayConnectionDegraded("idle"), false); diff --git a/desktop/src/shared/api/relayClientShared.ts b/desktop/src/shared/api/relayClientShared.ts index 996c54154..379f3b530 100644 --- a/desktop/src/shared/api/relayClientShared.ts +++ b/desktop/src/shared/api/relayClientShared.ts @@ -63,7 +63,16 @@ export type PendingEvent = { export type RelaySubscription = HistorySubscription | LiveSubscription; export function sortEvents(events: RelayEvent[]) { - return [...events].sort((left, right) => left.created_at - right.created_at); + return [...events].sort((left, right) => { + if (left.created_at !== right.created_at) { + return left.created_at - right.created_at; + } + // Same (created_at, id) tiebreak as the cache sort (sortMessages) so a + // history REQ resolves same-second events in a stable, relay-matching + // order. Currently every consumer re-sorts downstream, but keeping the + // two sorts on one invariant avoids a latent ordering drift. + return left.id < right.id ? -1 : left.id > right.id ? 1 : 0; + }); } export function getTextPayload(message: unknown) { diff --git a/desktop/src/shared/api/relayReconnectReplay.test.mjs b/desktop/src/shared/api/relayReconnectReplay.test.mjs index 4f1c2731e..a58aa7f25 100644 --- a/desktop/src/shared/api/relayReconnectReplay.test.mjs +++ b/desktop/src/shared/api/relayReconnectReplay.test.mjs @@ -5,7 +5,7 @@ import { buildReconnectReplayFilter, replayLiveSubscriptions, } from "./relayReconnectReplay.ts"; -import { RelayClient } from "./relayClientSession.ts"; +import { buildChannelFilter } from "./relayChannelFilters.ts"; function replayFilter(filter, since, until) { return buildReconnectReplayFilter(filter, since, until); @@ -111,8 +111,7 @@ test("channel reconnect replay pages the missed window until a short page", asyn eventRange("middle", 1002, 500), eventRange("oldest", 995, 8), ]; - const client = new RelayClient(); - const filter = client.buildChannelFilter("channel-1", 50); + const filter = buildChannelFilter("channel-1", 50); const subscriptions = new Map([ [ "live-1", diff --git a/desktop/src/shared/constants/kinds.ts b/desktop/src/shared/constants/kinds.ts index 89b41c3e0..3cb325ff3 100644 --- a/desktop/src/shared/constants/kinds.ts +++ b/desktop/src/shared/constants/kinds.ts @@ -65,6 +65,41 @@ export const CHANNEL_EVENT_KINDS = [ KIND_SYSTEM_MESSAGE, // 40099 — system messages (join, leave, etc.) ] as const; +// Auxiliary (non-row) timeline kinds: events that overlay onto or hide an +// existing message rather than rendering their own row — reactions, edits, and +// deletions. History fetches request the visible content kinds only, so the +// `limit` budget buys visible message depth instead of being diluted by these +// (on a reaction-heavy channel a 200-event window was only ~136 messages). +// They are backfilled separately by `#e` reference over the loaded message ids +// — by reference, not by time window, so a late edit/delete for a visible old +// message still applies. NOTE: kind:40008 (diff) renders its OWN row, so it is +// a content kind, not aux. +export const CHANNEL_AUX_EVENT_KINDS = [ + KIND_DELETION, // 5 — NIP-09 event deletions + KIND_REACTION, // 7 — NIP-25 reactions + KIND_NIP29_DELETE_EVENT, // 9005 — NIP-29 / Buzz-native deletions + KIND_STREAM_MESSAGE_EDIT, // 40003 — message edits +] as const; + +// Visible content kinds the main timeline renders as their own rows. Mirrors +// `isTimelineContentEvent` in formatTimelineMessages.ts — keep the two in sync. +// This is the kind set the history fetch requests so the `limit` budget maps +// to visible rows; auxiliary overlays (CHANNEL_AUX_EVENT_KINDS) are fetched +// separately by `#e` reference. Forum kinds (45001/45003) are excluded: forum +// channels use a different query path, not this timeline. +export const CHANNEL_TIMELINE_CONTENT_KINDS = [ + KIND_STREAM_MESSAGE, // 9 + KIND_STREAM_MESSAGE_V2, // 40002 + KIND_STREAM_MESSAGE_DIFF, // 40008 — diff messages (own row) + KIND_SYSTEM_MESSAGE, // 40099 — system rows (join/leave/channel-created) + KIND_JOB_REQUEST, // 43001 + KIND_JOB_ACCEPTED, // 43002 + KIND_JOB_PROGRESS, // 43003 + KIND_JOB_RESULT, // 43004 + KIND_JOB_CANCEL, // 43005 + KIND_JOB_ERROR, // 43006 +] as const; + // Timeline kinds that are NOT conversational: relay-signed system rows // (channel-created, member-joined) and job-lifecycle events. These render in // the timeline but must not count toward the channel's unread pill — a freshly