diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index c565ee0f7b..e221c72db9 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -1,3 +1,4 @@ +import { resetChannelWindowRefreshIntents } from "@/features/messages/lib/channelWindowRefreshIntent"; import { useEffect, useRef, useState } from "react"; import { isTauri } from "@tauri-apps/api/core"; import { isMacPlatform } from "@/shared/lib/platform"; @@ -59,6 +60,7 @@ async function resetCommunityState({ relayClient.disconnect(); await resetNavigationDeepLinkDrain(); resetRateLimitGate(); + resetChannelWindowRefreshIntents(); clearAllDrafts(); resetAgentObserverStore(); resetActiveAgentTurnsStore(); diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index f4b30e6eb5..9c0e59a5b2 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -1,8 +1,5 @@ -import { - appendOlderChannelWindow, - type ChannelWindowPage, -} from "./lib/channelWindowStore"; import { revalidateChannelWindow } from "./lib/revalidateChannelWindow"; +import { consumeChannelWindowRefreshIntent } from "./lib/channelWindowRefreshIntent"; import { useEffect, useEffectEvent } from "react"; import { type QueryClient, @@ -66,6 +63,8 @@ import type { Channel, Identity, RelayEvent } from "@/shared/api/types"; // from the on-render overlay. import { applyEditTagOverlay } from "@/features/messages/lib/applyEditTagOverlay.mjs"; import { + appendOlderChannelWindow, + type ChannelWindowPage, emptyChannelWindowStore, mapChannelWindowEvents, mergeLiveChannelWindowEvent, @@ -314,11 +313,17 @@ export function useChannelMessagesQuery(channel: Channel | null) { queryKey, queryFn: async ({ signal }) => { if (!channel) throw new Error("No channel selected."); + const latestOnly = consumeChannelWindowRefreshIntent( + queryClient, + channel.id, + signal, + ); // Persisted heads seed asynchronously; wait for that seed so a channel // opened during boot takes the hydrated path instead of racing it with // a cold relay fetch. await channelHeadHydration(queryClient); - if (consumeHydratedChannel(queryClient, channel.id)) { + signal.throwIfAborted(); + if (consumeHydratedChannel(queryClient, channel.id) && !latestOnly) { return queryClient.getQueryData(queryKey) ?? []; } const previousMessages = @@ -331,11 +336,9 @@ export function useChannelMessagesQuery(channel: Channel | null) { const events = await getChannelWindowEvents(channel.id); return await revalidateChannelWindow({ head: parseChannelWindowResponse(events, channel.id, null), - retained: retained.refreshLatestOnly - ? emptyChannelWindowStore() - : retained, + retained: latestOnly ? emptyChannelWindowStore() : retained, readCurrent: () => - retained.refreshLatestOnly + latestOnly ? emptyChannelWindowStore() : (queryClient.getQueryData( channelWindowKey(channel.id), @@ -365,7 +368,6 @@ export function useChannelMessagesQuery(channel: Channel | null) { channelWindowKey(channel.id), (current) => ({ ...(current ?? retained), - refreshLatestOnly: false, refreshError: (current ?? retained).pages.length ? "Couldn’t refresh messages. Your loaded history is still available." : "Couldn’t load messages. Try again.", @@ -882,6 +884,15 @@ export function useDeleteMessageMutation(channel: Channel | null) { }, onSuccess: (_data, { eventId }) => { if (!channel) return; + queryClient.setQueryData( + channelWindowKey(channel.id), + (current) => + current + ? mapChannelWindowEvents(current, (event) => + event.id === eventId ? null : event, + ) + : current, + ); queryClient.setQueryData( channelMessagesKey(channel.id), (current = []) => current.filter((message) => message.id !== eventId), diff --git a/desktop/src/features/messages/lib/channelWindowRefreshIntent.ts b/desktop/src/features/messages/lib/channelWindowRefreshIntent.ts new file mode 100644 index 0000000000..002634aa16 --- /dev/null +++ b/desktop/src/features/messages/lib/channelWindowRefreshIntent.ts @@ -0,0 +1,29 @@ +import type { QueryClient } from "@tanstack/react-query"; +import type { ChannelWindowStore } from "./channelWindowStore"; +import { channelWindowKey } from "./messageQueryKeys"; + +// TanStack reuses one AbortSignal across a fetch's retry attempts, but creates +// a new signal for every later fetch. Never leave destructive intent in the +// durable window after the request has claimed it. +let latestRequests = new WeakSet(); + +/** Retire request intent along with the other community-scoped state. */ +export function resetChannelWindowRefreshIntents() { + latestRequests = new WeakSet(); +} + +/** Claim queued latest-only intent once, retaining it only for this fetch’s retries. */ +export function consumeChannelWindowRefreshIntent( + client: QueryClient, + channelId: string, + signal: AbortSignal, +) { + signal.throwIfAborted(); + const key = channelWindowKey(channelId); + const current = client.getQueryData(key); + if (current?.refreshLatestOnly) { + latestRequests.add(signal); + client.setQueryData(key, { ...current, refreshLatestOnly: undefined }); + } + return latestRequests.has(signal); +} diff --git a/desktop/src/features/messages/lib/channelWindowStore.test.mjs b/desktop/src/features/messages/lib/channelWindowStore.test.mjs index b9ccae0db7..9c4f64506d 100644 --- a/desktop/src/features/messages/lib/channelWindowStore.test.mjs +++ b/desktop/src/features/messages/lib/channelWindowStore.test.mjs @@ -177,13 +177,18 @@ test("same-second live rows enter an exhausted short window regardless of id ord ); }); -test("older live rows stay below an exhausted window", () => { +test("older live rows enter an exhausted window because there is no unretained gap", () => { const store = replaceNewestChannelWindow( emptyChannelWindowStore(), page(null, [event("a", 100)], { hasMore: false }), ); - assert.equal(mergeLiveChannelWindowEvent(store, event("old", 99)), store); + const withLive = mergeLiveChannelWindowEvent(store, event("old", 99)); + assert.deepEqual( + flattenChannelWindowEvents(withLive).map((e) => e.content), + ["old", "a"], + ); + assert.equal(withLive.revision, store.revision); }); test("live aux stays separate from authoritative page closure", () => { @@ -450,3 +455,30 @@ test("exhaustion tracks only a resolved tail page's hasMore", () => { assert.equal(channelWindowHistoryExhausted(closed), true); assert.equal(channelWindowHasMore(closed), false); }); + +test("local removal updates page rows and overlays without moving cursor boundaries", () => { + let store = replaceNewestChannelWindow( + emptyChannelWindowStore(), + page(null, [event("head", 100), event("boundary", 90)], { + aux: [event("page-aux", 95, 7)], + }), + ); + store = mergeLiveChannelWindowEvent(store, event("live", 110)); + store = mergeLiveChannelWindowEvent(store, event("live-aux", 105, 7), false); + const before = store; + store = mapChannelWindowEvents(store, (event) => + event.content === "head" ? event : null, + ); + assert.deepEqual( + flattenChannelWindowEvents(store).map((e) => e.content), + ["head"], + ); + assert.equal(store.pages[0].startCursor, before.pages[0].startCursor); + assert.equal(store.pages[0].nextCursor, before.pages[0].nextCursor); + assert.equal(store.pages[0].hasMore, before.pages[0].hasMore); + assert.equal(store.revision, before.revision); + assert.equal( + mapChannelWindowEvents(store, (e) => e), + store, + ); +}); diff --git a/desktop/src/features/messages/lib/channelWindowStore.ts b/desktop/src/features/messages/lib/channelWindowStore.ts index 70ee1a887a..49c9d731a5 100644 --- a/desktop/src/features/messages/lib/channelWindowStore.ts +++ b/desktop/src/features/messages/lib/channelWindowStore.ts @@ -31,8 +31,8 @@ export type ChannelWindowStore = { pages: ChannelWindowPage[]; /** A recoverable head refresh failure; retained history remains readable. */ refreshError?: string; - /** Explicit recovery request; retire retained history only after a new head succeeds. */ - refreshLatestOnly?: boolean; + /** Unclaimed explicit recovery token; consumed at fetch start, never by later refreshes. */ + refreshLatestOnly?: string; /** Top-level live events not represented in an authoritative relay page. */ liveOverlay: RelayEvent[]; /** Live structural events retained independently from frozen page closure. */ @@ -218,11 +218,7 @@ export function mergeLiveChannelWindowEvent( } const oldestPage = current.pages[current.pages.length - 1]; const oldest = oldestPage?.rows[oldestPage.rows.length - 1]?.event; - if ( - oldest && - (event.created_at < oldest.created_at || - (oldestPage.hasMore && compareRelayOrder(event, oldest) >= 0)) - ) { + if (oldest && oldestPage.hasMore && compareRelayOrder(event, oldest) >= 0) { return current; } return { @@ -237,7 +233,7 @@ export function mergeLiveChannelWindowEvent( /** * Apply a per-event transform across every event the store holds (page rows, * page aux, live overlay, live aux), returning the same store reference when - * nothing changed. + * nothing changed. Return null to remove an event without changing page cursors. * * Local writes MUST go through this rather than patching the flattened * `channelMessagesKey` array alone: the window store is the source of truth, @@ -251,7 +247,7 @@ export function mergeLiveChannelWindowEvent( */ export function mapChannelWindowEvents( store: ChannelWindowStore, - map: (event: RelayEvent) => RelayEvent, + map: (event: RelayEvent) => RelayEvent | null, ): ChannelWindowStore { let changed = false; const mapEvent = (event: RelayEvent) => { @@ -260,18 +256,24 @@ export function mapChannelWindowEvents( return next; }; const pages = store.pages.map((page) => { - const rows = page.rows.map((row) => { + const rows = page.rows.flatMap((row) => { const event = mapEvent(row.event); - return event === row.event ? row : { ...row, event }; + return event === null + ? [] + : [event === row.event ? row : { ...row, event }]; }); - const aux = page.aux.map(mapEvent); - return rows.every((row, index) => row === page.rows[index]) && + const aux = page.aux.map(mapEvent).filter((event) => event !== null); + return rows.length === page.rows.length && + rows.every((row, index) => row === page.rows[index]) && + aux.length === page.aux.length && aux.every((event, index) => event === page.aux[index]) ? page : { ...page, rows, aux }; }); - const liveOverlay = store.liveOverlay.map(mapEvent); - const liveAux = store.liveAux.map(mapEvent); + const liveOverlay = store.liveOverlay + .map(mapEvent) + .filter((event) => event !== null); + const liveAux = store.liveAux.map(mapEvent).filter((event) => event !== null); return changed ? { ...store, pages, liveOverlay, liveAux } : store; } diff --git a/desktop/src/features/messages/ui/HistoryRefreshNotice.test.mjs b/desktop/src/features/messages/ui/HistoryRefreshNotice.test.mjs new file mode 100644 index 0000000000..6800b8eb59 --- /dev/null +++ b/desktop/src/features/messages/ui/HistoryRefreshNotice.test.mjs @@ -0,0 +1,297 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { JSDOM } from "jsdom"; +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useChannelMessagesQuery, useSendMessageMutation } from "../hooks.ts"; +import { HistoryRefreshNotice } from "./HistoryRefreshNotice.tsx"; +import { relayClient } from "../../../shared/api/relayClient.ts"; +import { + channelMessagesKey, + channelWindowKey, +} from "../lib/messageQueryKeys.ts"; +import { parseChannelWindowResponse } from "../lib/channelWindowResponse.ts"; +import { + appendOlderChannelWindow, + emptyChannelWindowStore, + replaceNewestChannelWindow, +} from "../lib/channelWindowStore.ts"; +import { + projectChannelWindowMessages, + refreshChannelWindowMessages, +} from "../lib/projectChannelWindow.ts"; + +const channel = { id: "channel", channelType: "stream" }; +const event = (index) => ({ + id: index.toString(16).padStart(64, "0"), + pubkey: "a".repeat(64), + created_at: 5000 - index, + kind: 9, + tags: [["h", channel.id]], + content: `row ${index}`, + sig: "", +}); +const server = Array.from({ length: 150 }, (_, i) => event(i)); +function wirePage(cursor, limit = 50) { + const start = cursor + ? server.findIndex((e) => e.id === cursor.event_id) + 1 + : 0; + const rows = server.slice(start, start + limit); + const more = start + rows.length < server.length; + const last = rows.at(-1); + return [ + ...rows, + { + ...event(999), + kind: 39006, + tags: [ + [ + "d", + cursor + ? `channel:${cursor.created_at}:${cursor.event_id}` + : "channel:head", + ], + ], + content: JSON.stringify({ + has_more: more, + next_cursor: more ? { created_at: last.created_at, id: last.id } : null, + }), + }, + ]; +} +const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); +async function until(predicate) { + for (let i = 0; i < 100 && !predicate(); i++) await act(tick); + assert.ok(predicate(), "expected async state to settle"); +} +async function setup(t, retry = false) { + const dom = new JSDOM("
", { url: "http://localhost" }); + const globals = { + window: dom.window, + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + Node: dom.window.Node, + IS_REACT_ACT_ENVIRONMENT: true, + }; + const saved = Object.fromEntries( + Object.keys(globals).map((key) => [ + key, + Object.getOwnPropertyDescriptor(globalThis, key), + ]), + ); + for (const [key, value] of Object.entries(globals)) + Object.defineProperty(globalThis, key, { + value, + configurable: true, + writable: true, + }); + const client = new QueryClient({ + defaultOptions: { + queries: { retry, retryDelay: 0, gcTime: Infinity }, + mutations: { retry: false, gcTime: Infinity }, + }, + }); + let store = emptyChannelWindowStore(); + for (let i = 0; i < 3; i++) { + const cursor = store.pages.at(-1)?.nextCursor ?? null; + const wireCursor = cursor + ? { created_at: cursor.createdAt, event_id: cursor.eventId } + : null; + const page = parseChannelWindowResponse( + wirePage(wireCursor), + channel.id, + cursor, + ); + store = i + ? appendOlderChannelWindow(store, page) + : replaceNewestChannelWindow(store, page); + } + client.setQueryData(channelWindowKey(channel.id), { + ...store, + refreshError: + "Couldn’t refresh messages. Your loaded history is still available.", + }); + projectChannelWindowMessages(client, channel.id); + const requests = []; + let respond = async () => {}; + window.__TAURI_INTERNALS__ = { + invoke: async (command, args) => { + assert.equal(command, "get_channel_window"); + requests.push(args.cursor ? "page" : "head"); + const response = wirePage(args.cursor, args.limitRows); + await respond(args); + return response; + }, + }; + t.mock.method(relayClient, "sendMessage", async () => ({ + ...event(1000), + created_at: 6000, + content: "sent while refreshing", + })); + let mutation; + let navigations = 0; + function Screen({ active }) { + useChannelMessagesQuery(active ? channel : null); + mutation = useSendMessageMutation(channel, { pubkey: "a".repeat(64) }); + return React.createElement(HistoryRefreshNotice, { + channelId: active ? channel.id : null, + onLoadLatest: () => navigations++, + }); + } + const root = createRoot(document.getElementById("root")); + const render = async (active = true) => + act(async () => + root.render( + React.createElement( + QueryClientProvider, + { client }, + React.createElement(Screen, { active }), + ), + ), + ); + await render(); + t.after(async () => { + await act(async () => { + root.unmount(); + client.clear(); + await tick(); + }); + dom.window.close(); + for (const [key, descriptor] of Object.entries(saved)) { + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else delete globalThis[key]; + } + }); + const read = () => client.getQueryData(channelWindowKey(channel.id)); + return { + client, + requests, + read, + render, + navigations: () => navigations, + respond: (fn) => { + respond = fn; + }, + send: () => + act(async () => { + await mutation.mutateAsync({ content: "sent while refreshing" }); + }), + click: async (label = "Load latest") => { + const button = [...document.querySelectorAll("button")].find( + (b) => b.textContent === label, + ); + assert.ok(button); + assert.equal(button.disabled, false); + await act(async () => button.click()); + }, + refresh: () => + act(async () => { + await refreshChannelWindowMessages(client, channel.id); + }), + idle: () => + until( + () => + client.isFetching({ queryKey: channelMessagesKey(channel.id) }) === 0, + ), + rows: () => read().pages.reduce((n, page) => n + page.rows.length, 0), + }; +} + +for (const cancel of ["send", "navigation"]) { + test(`Load latest canceled by ${cancel} cannot discard history on a later implicit refresh`, async (t) => { + const h = await setup(t); + let release; + h.respond( + () => + new Promise((resolve) => { + release = resolve; + }), + ); + await h.click(); + await until(() => !!release); + if (cancel === "send") await h.send(); + else await h.render(false); + await h.idle(); + assert.equal(h.rows(), 150); + await act(async () => { + release(); + await tick(); + }); + h.respond(async () => {}); + if (cancel === "navigation") await h.render(); + await h.refresh(); + assert.equal( + h.rows(), + 150, + "unrelated refresh must not spend canceled navigation intent", + ); + assert.equal(h.navigations(), 0); + }); +} + +test("successful Load latest replaces retained pages even when the head is unchanged", async (t) => { + const h = await setup(t); + await h.click(); + await h.idle(); + assert.equal(h.rows(), 50); + assert.equal(h.read().pages.length, 1); + assert.equal(h.read().refreshError, undefined); + assert.equal(h.navigations(), 1); + assert.deepEqual(h.requests, ["head"]); +}); + +test("Load latest keeps its intent across real TanStack retry: 1 attempts", async (t) => { + const h = await setup(t, 1); + let attempts = 0; + h.respond(async () => { + if (++attempts === 1) throw Error("first attempt failed"); + }); + await h.click(); + await h.idle(); + assert.equal(h.rows(), 50); + assert.equal(h.read().pages.length, 1); + assert.equal(h.navigations(), 1); + assert.deepEqual(h.requests, ["head", "head"]); +}); + +for (const label of ["Retry", "Load latest"]) { + test(`${label} exhausted retries preserve history and never navigate`, async (t) => { + const h = await setup(t, 1); + h.respond(async () => { + throw Error("offline"); + }); + await h.click(label); + await h.idle(); + assert.equal(h.rows(), 150); + assert.ok(h.read().refreshError); + assert.equal(h.navigations(), 0); + assert.deepEqual(h.requests, ["head", "head"]); + h.respond(async () => {}); + await h.refresh(); + assert.equal(h.rows(), 150); + assert.equal(h.navigations(), 0); + }); +} + +test("unmounting before invalidation starts cannot leave an unclaimed Load latest token", async (t) => { + const h = await setup(t); + const originalInvalidate = h.client.invalidateQueries.bind(h.client); + let release; + t.mock.method(h.client, "invalidateQueries", async (...args) => { + await new Promise((resolve) => { + release = resolve; + }); + return originalInvalidate(...args); + }); + await h.click(); + await until(() => !!release); + await h.render(false); + await act(async () => { + release(); + await tick(); + }); + assert.equal(h.read().refreshLatestOnly, undefined); + assert.equal(h.navigations(), 0); + assert.deepEqual(h.requests, []); +}); diff --git a/desktop/src/features/messages/ui/HistoryRefreshNotice.tsx b/desktop/src/features/messages/ui/HistoryRefreshNotice.tsx index 7cd304c354..201a5405f3 100644 --- a/desktop/src/features/messages/ui/HistoryRefreshNotice.tsx +++ b/desktop/src/features/messages/ui/HistoryRefreshNotice.tsx @@ -41,14 +41,28 @@ export function HistoryRefreshNotice({ }, [scope]); if (!channelId || !window?.refreshError) return null; const retry = async (latest: boolean) => { + const token = latest ? crypto.randomUUID() : undefined; client.setQueryData( channelWindowKey(channelId), (current) => ({ ...(current ?? emptyChannelWindowStore()), - refreshLatestOnly: latest, + refreshLatestOnly: token, }), ); - await refreshChannelWindowMessages(client, channelId); + try { + await refreshChannelWindowMessages(client, channelId); + } finally { + // The observer can unmount before hydration/invalidation starts a fetch. + // Retire an unclaimed token too, without clearing a newer button click. + if (token) + client.setQueryData( + channelWindowKey(channelId), + (current) => + current?.refreshLatestOnly === token + ? { ...current, refreshLatestOnly: undefined } + : current, + ); + } const refreshed = client.getQueryData( channelWindowKey(channelId), ); diff --git a/desktop/src/features/messages/ui/historyPaginationLifecycle.test.mjs b/desktop/src/features/messages/ui/historyPaginationLifecycle.test.mjs index e51db2f2cb..d2b6a8363f 100644 --- a/desktop/src/features/messages/ui/historyPaginationLifecycle.test.mjs +++ b/desktop/src/features/messages/ui/historyPaginationLifecycle.test.mjs @@ -826,3 +826,108 @@ test("failed send removes only its optimistic row, retaining concurrent history ["older", "head", "live", "other-pending"], ); }); + +const { useDeleteMessageMutation } = await import("../hooks.ts"); + +test("accepted deletion changes the authoritative window even without a live deletion echo", async (t) => { + const env = await setup(t); + const client = new QueryClient({ + defaultOptions: { + queries: { gcTime: Infinity }, + mutations: { retry: false, gcTime: Infinity }, + }, + }); + t.after(() => client.clear()); + let acceptDelete; + let deleteStarted; + const started = new Promise((resolve) => { + deleteStarted = resolve; + }); + window.__TAURI_INTERNALS__ = { + invoke: async (command) => { + assert.equal(command, "delete_message"); + deleteStarted(); + await new Promise((resolve) => { + acceptDelete = resolve; + }); + }, + }; + const event = (id, created_at) => ({ + id, + created_at, + kind: 9, + pubkey: "p", + content: id, + tags: [["h", "a"]], + sig: "", + }); + const target = event("target", 100); + const key = channelWindowKey("a"); + client.setQueryData( + key, + replaceNewestChannelWindow(emptyChannelWindowStore(), { + startCursor: null, + rows: [{ event: target, thread: null }], + aux: [], + nextCursor: { createdAt: 100, eventId: target.id }, + hasMore: true, + }), + ); + projectChannelWindowMessages(client, "a"); + let mutation; + function Harness() { + mutation = useDeleteMessageMutation({ id: "a", channelType: "stream" }); + return null; + } + await act(async () => + env.root.render( + React.createElement( + QueryClientProvider, + { client }, + React.createElement(Harness), + ), + ), + ); + let result; + await act(async () => { + result = mutation.mutateAsync({ eventId: target.id }); + await started; + }); + const before = client.getQueryData(key); + let current = appendOlderChannelWindow(before, { + startCursor: before.pages[0].nextCursor, + rows: [{ event: event("older", 90), thread: null }], + aux: [], + nextCursor: null, + hasMore: false, + }); + current = mergeLiveChannelWindowEvent(current, event("concurrent-live", 110)); + client.setQueryData(key, current); + projectChannelWindowMessages(client, "a"); + await act(async () => { + acceptDelete(); + await result; + }); + const after = client.getQueryData(key); + assert.equal( + after.pages.length, + 2, + "deletion retains a concurrent older page", + ); + assert.equal(after.revision, current.revision); + assert.equal(after.pages[0].rows.length, 0); + assert.equal( + after.pages[0].nextCursor.eventId, + target.id, + "removing a boundary row must not invalidate its cursor", + ); + client.setQueryData( + key, + mergeLiveChannelWindowEvent(after, event("live", 120)), + ); + projectChannelWindowMessages(client, "a"); + assert.deepEqual( + client.getQueryData(channelMessagesKey("a")).map((e) => e.id), + ["older", "concurrent-live", "live"], + ); +}); diff --git a/desktop/src/features/messages/ui/useBufferedTimelineMessages.test.mjs b/desktop/src/features/messages/ui/useBufferedTimelineMessages.test.mjs index 0d581506ae..9da3bfa2ba 100644 --- a/desktop/src/features/messages/ui/useBufferedTimelineMessages.test.mjs +++ b/desktop/src/features/messages/ui/useBufferedTimelineMessages.test.mjs @@ -50,3 +50,14 @@ test("accepts an authoritative replacement when its old tail disappeared", () => messages, ); }); + +test("admits reconnect gap rows inside the frozen window, buffering only rows after its tail", () => { + assert.deepEqual( + selectBufferedTimelineMessages({ + frozenMessageIds: ["old-live", "newer", "tail"], + isAtBottom: false, + messages: rows("old-live", "gap-1", "gap-2", "newer", "tail", "live"), + }).map((e) => e.id), + ["old-live", "gap-1", "gap-2", "newer", "tail"], + ); +}); diff --git a/desktop/src/features/messages/ui/useBufferedTimelineMessages.ts b/desktop/src/features/messages/ui/useBufferedTimelineMessages.ts index 46b2a5a577..cd1cd06f43 100644 --- a/desktop/src/features/messages/ui/useBufferedTimelineMessages.ts +++ b/desktop/src/features/messages/ui/useBufferedTimelineMessages.ts @@ -27,23 +27,15 @@ export function selectBufferedTimelineMessages({ return messages; } - const firstFrozenIndex = messages.findIndex( - (message) => message.id === frozenMessageIds[0], + // Freeze a tail boundary, not membership. A reconnect can fill a gap + // between retained rows (including an old live overlay); those are history, + // not newer output, and must not vanish behind the live-message buffer. + const tailIndex = messages.findIndex( + (message) => message.id === frozenMessageIds[frozenMessageIds.length - 1], ); - const prepended = messages.slice(0, firstFrozenIndex); - const frozen = frozenMessageIds.map((id) => currentById.get(id) as T); - const buffered = [...prepended, ...frozen]; - if ( - buffered.length === messages.length && - buffered.every((message, index) => message.id === messages[index]?.id) - ) { - // Crossing the bottom threshold without a live arrival must be a semantic - // no-op for Virtua. Preserve the source array identity until there is - // actually something to buffer; otherwise the threshold transition can - // rebuild its model while a prepend is starting. - return messages; - } - return buffered; + return tailIndex === messages.length - 1 + ? messages + : messages.slice(0, tailIndex + 1); } export function useBufferedTimelineMessages({ diff --git a/desktop/tests/e2e/message-copy-link.spec.ts b/desktop/tests/e2e/message-copy-link.spec.ts index b2e1b0f5a2..248ed9bf0b 100644 --- a/desktop/tests/e2e/message-copy-link.spec.ts +++ b/desktop/tests/e2e/message-copy-link.spec.ts @@ -185,7 +185,9 @@ test("pending and huddle rows omit both copy-link surfaces", async ({ content: JSON.stringify({ ephemeral_channel_id: "10000000-0000-4000-8000-000000000001", }), - id: "d".repeat(64), + // "d".repeat(64) is already the seeded custom-emoji reaction target. + // A live event cannot change the kind of an existing signed event id. + id: "d1".repeat(32), kind: huddleKind, }); return { huddleId: huddle.id, pendingId: pending.id }; diff --git a/desktop/tests/e2e/relay-reconnect.spec.ts b/desktop/tests/e2e/relay-reconnect.spec.ts index 7868e577da..c1f90bfe9b 100644 --- a/desktop/tests/e2e/relay-reconnect.spec.ts +++ b/desktop/tests/e2e/relay-reconnect.spec.ts @@ -654,6 +654,53 @@ test("auth-required CLOSED restores the active live subscription", async ({ .toBe(true); }); +for (const age of [0, 300]) { + test(`fresh exhausted channel admits a live event backdated ${age}s without a reader gesture`, async ({ + page, + }) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await expect + .poll(() => + page.evaluate(() => + window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ + channelName: "general", + kind: 9, + }), + ), + ) + .toBe(true); + await expect + .poll(() => + page.evaluate(() => { + const client = window.__BUZZ_E2E_QUERY_CLIENT__ as unknown as { + getQueryData: ( + key: string[], + ) => { pages: Array<{ hasMore: boolean }> } | undefined; + isFetching: (filters: { queryKey: string[] }) => number; + }; + const channelId = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; + return ( + client.getQueryData(["channel-window", channelId])?.pages.at(-1) + ?.hasMore === false && + client.isFetching({ queryKey: ["channel-messages", channelId] }) === + 0 + ); + }), + ) + .toBe(true); + + // No reconnect or refresh can mask admission: this is a single live + // delivery into a fully retained window, including below its oldest row. + const content = `fresh live delivery backdated ${age}s`; + await emitMockMessages(page, [ + { content, createdAt: Math.floor(Date.now() / 1_000) - age }, + ]); + await expect(page.getByTestId("message-timeline")).toContainText(content); + }); +} + test("reconnect backfills more missed channel messages than the live subscription limit", async ({ page, }) => {