From 4c25af1660fa7890cee8340f201ecab26ba8f18f Mon Sep 17 00:00:00 2001 From: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz> Date: Fri, 28 Aug 2026 16:24:38 -0600 Subject: [PATCH] fix(desktop): preserve history through recovery cancellation and live updates Scope explicit Load latest intent to one fetch and its retries, retiring unclaimed intent after cancellation. Update the authoritative window on accepted deletion without erasing concurrent history or live arrivals. Admit backdated live rows in exhausted windows and reconnect gap rows before the buffered tail. Add real-hook and browser regressions; give the huddle copy-link fixture its own signed-event id. Signed-off-by: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz> --- .../features/communities/useCommunityInit.ts | 2 + desktop/src/features/messages/hooks.ts | 31 +- .../lib/channelWindowRefreshIntent.ts | 29 ++ .../messages/lib/channelWindowStore.test.mjs | 36 ++- .../messages/lib/channelWindowStore.ts | 32 +- .../messages/ui/HistoryRefreshNotice.test.mjs | 297 ++++++++++++++++++ .../messages/ui/HistoryRefreshNotice.tsx | 18 +- .../ui/historyPaginationLifecycle.test.mjs | 105 +++++++ .../ui/useBufferedTimelineMessages.test.mjs | 11 + .../ui/useBufferedTimelineMessages.ts | 24 +- desktop/tests/e2e/message-copy-link.spec.ts | 4 +- desktop/tests/e2e/relay-reconnect.spec.ts | 47 +++ 12 files changed, 590 insertions(+), 46 deletions(-) create mode 100644 desktop/src/features/messages/lib/channelWindowRefreshIntent.ts create mode 100644 desktop/src/features/messages/ui/HistoryRefreshNotice.test.mjs 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, }) => {