diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index be15c75587..4c2b6cd7fc 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -20,6 +20,7 @@ export default defineConfig({ name: "smoke", testMatch: [ "**/smoke.spec.ts", + "**/thread-head-stale-edit.spec.ts", "**/sidebar-offcanvas-rail.spec.ts", "**/tooltip-semantics.spec.ts", "**/search-scope-screenshots.spec.ts", diff --git a/desktop/src/features/messages/lib/independentThreadPanel.test.mjs b/desktop/src/features/messages/lib/independentThreadPanel.test.mjs new file mode 100644 index 0000000000..8dbc3e2840 --- /dev/null +++ b/desktop/src/features/messages/lib/independentThreadPanel.test.mjs @@ -0,0 +1,145 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildIndependentThreadPanel } from "./independentThreadPanel.ts"; + +const ROOT_ID = "6".repeat(64); +const EDIT_ID = "9".repeat(64); +const REPLY_ID = "a".repeat(64); +const DELETION_ID = "d".repeat(64); +const AUTHOR = "f".repeat(64); +const CHANNEL = "chan-uuid"; + +function contentEvent(id, content, extraTags = []) { + return { + id, + pubkey: AUTHOR, + kind: 9, + created_at: 1000, + content, + tags: [["h", CHANNEL], ...extraTags], + sig: "s", + }; +} + +function editEvent(id, targetId, content, createdAt) { + return { + id, + pubkey: AUTHOR, + kind: 40003, + created_at: createdAt, + content, + tags: [ + ["h", CHANNEL], + ["e", targetId], + ], + sig: "s", + }; +} + +function deletionEvent(id, targetId) { + return { + id, + pubkey: AUTHOR, + kind: 5, + created_at: 3000, + content: "", + tags: [ + ["h", CHANNEL], + ["e", targetId], + ], + sig: "s", + }; +} + +function head(channelEvents, replyEvents) { + return buildIndependentThreadPanel( + channelEvents, + replyEvents, + ROOT_ID, + ROOT_ID, + new Set(), + null, + AUTHOR, + null, + undefined, + undefined, + new Map(), + new Map(), + null, + undefined, + ).threadHead; +} + +// Regression: the reported bug. The head's edit is in the channel window (so the +// main timeline shows it) but has NOT yet been pulled into the thread-reply aux +// cache. Before the fix the thread head rendered the un-edited original. +test("applies the head edit carried only in the channel window", () => { + const root = contentEvent(ROOT_ID, "two PRs"); + const edit = editEvent(EDIT_ID, ROOT_ID, "these PRs (3)", 2000); + const result = head([root, edit], []); + assert.equal(result?.body, "these PRs (3)"); + assert.equal(result?.edited, true); +}); + +// The thread-aux backfill path must keep working when it is the only source. +test("applies the head edit carried only in reply-aux events", () => { + const root = contentEvent(ROOT_ID, "two PRs"); + const edit = editEvent(EDIT_ID, ROOT_ID, "these PRs (3)", 2000); + const result = head([root], [edit]); + assert.equal(result?.body, "these PRs (3)"); + assert.equal(result?.edited, true); +}); + +// Same edit in both sources must not double-apply or drop; latest content wins. +test("dedups the same edit present in both sources", () => { + const root = contentEvent(ROOT_ID, "two PRs"); + const edit = editEvent(EDIT_ID, ROOT_ID, "these PRs (3)", 2000); + const result = head([root, edit], [edit]); + assert.equal(result?.body, "these PRs (3)"); + assert.equal(result?.edited, true); +}); + +// No edit anywhere: head stays original, unedited. +test("leaves an unedited head untouched", () => { + const root = contentEvent(ROOT_ID, "two PRs"); + const result = head([root], []); + assert.equal(result?.body, "two PRs"); + assert.equal(result?.edited, false); +}); + +// A reply content event also `#e`-references the head as its parent. It must NOT +// be pulled in as head aux — replies flow through `replyEvents` only. +test("does not treat a channel-window reply as head aux", () => { + const root = contentEvent(ROOT_ID, "two PRs"); + const reply = contentEvent(REPLY_ID, "a reply", [ + ["e", ROOT_ID, "", "reply"], + ]); + const panel = buildIndependentThreadPanel( + [root, reply], + [], + ROOT_ID, + ROOT_ID, + new Set(), + null, + AUTHOR, + null, + undefined, + undefined, + new Map(), + new Map(), + null, + undefined, + ); + // The reply is not in replyEvents, so it should not surface in the panel. + assert.equal(panel.threadHead?.body, "two PRs"); + assert.deepEqual(panel.visibleReplies, []); +}); + +// A channel-window deletion of the head must hide it, matching the main timeline. +test("applies a head deletion carried in the channel window", () => { + const root = contentEvent(ROOT_ID, "two PRs"); + const deletion = deletionEvent(DELETION_ID, ROOT_ID); + const result = head([root, deletion], []); + assert.equal(result, null); +}); diff --git a/desktop/src/features/messages/lib/independentThreadPanel.ts b/desktop/src/features/messages/lib/independentThreadPanel.ts index 7652c2508c..6156c6bb8f 100644 --- a/desktop/src/features/messages/lib/independentThreadPanel.ts +++ b/desktop/src/features/messages/lib/independentThreadPanel.ts @@ -1,7 +1,35 @@ -import { formatTimelineMessages } from "@/features/messages/lib/formatTimelineMessages"; +import { + formatTimelineMessages, + isTimelineContentEvent, +} from "@/features/messages/lib/formatTimelineMessages"; import { buildThreadPanelData } from "@/features/messages/lib/threadPanel"; import type { RelayEvent } from "@/shared/api/types"; +/** + * Aux events (edits/deletions/reactions) already loaded in the channel window + * that reference `headId` via an `#e` tag. The thread head is the single + * content event found by id, but its overlay events live alongside it in the + * channel window — `formatTimelineMessages` only applies an edit/deletion when + * the aux event sits in the SAME array as its target. Carrying them here keeps + * the thread head byte-identical to the main timeline the instant the thread + * opens, instead of rendering the un-edited original until the async + * thread-aux backfill (`withThreadAux`) lands — the stale-edit-in-thread bug. + * + * Restricted to non-content kinds so reply content events (which also `#e` the + * head as their parent) never leak in here — replies come from `replyEvents`. + */ +function headAuxEventsFromChannelWindow( + channelEvents: RelayEvent[], + headId: string, +): RelayEvent[] { + return channelEvents.filter( + (event) => + event.id !== headId && + !isTimelineContentEvent(event) && + event.tags.some((tag) => tag[0] === "e" && tag[1] === headId), + ); +} + export function buildIndependentThreadPanel( channelEvents: RelayEvent[], replyEvents: RelayEvent[], @@ -17,7 +45,15 @@ export function buildIndependentThreadPanel( }; } const head = channelEvents.find((event) => event.id === rootId); - const events = head ? [head, ...replyEvents] : replyEvents; + // Dedup the channel-window head aux against `replyEvents`: `withThreadAux` + // fetches the same overlays by reference, so both sources can carry an edit. + const replyEventIds = new Set(replyEvents.map((event) => event.id)); + const headAux = head + ? headAuxEventsFromChannelWindow(channelEvents, rootId).filter( + (event) => !replyEventIds.has(event.id), + ) + : []; + const events = head ? [head, ...headAux, ...replyEvents] : replyEvents; const messages = formatTimelineMessages(events, ...formatArgs); return { ...buildThreadPanelData(messages, rootId, replyTargetId, expandedReplyIds), diff --git a/desktop/tests/e2e/thread-head-stale-edit.spec.ts b/desktop/tests/e2e/thread-head-stale-edit.spec.ts new file mode 100644 index 0000000000..529e1449d5 --- /dev/null +++ b/desktop/tests/e2e/thread-head-stale-edit.spec.ts @@ -0,0 +1,135 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; + +// Regression coverage for the reported bug: the top-level timeline shows the +// EDITED message body, but opening its thread panel shows the STALE, un-edited +// original. The thread head is taken from the channel window (which carries the +// edit) but its overlay was previously dropped, relying solely on the async +// thread-reply aux backfill — so before that fetch lands the head renders stale. +// +// We reproduce the divergence deterministically by delaying the thread-replies +// fetch (`threadRepliesDelayMs`): the channel window already holds the edit, but +// the thread-aux response is still in flight when the panel first renders. + +const CHANNEL = "general"; +const SHOT = "test-results/thread-head-stale-edit"; + +type MockMessageWindow = Window & { + __BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: { + channelName: string; + content: string; + parentEventId?: string | null; + pubkey?: string; + kind?: number; + extraTags?: string[][]; + }) => { id: string; created_at: number; pubkey: string } | undefined; +}; + +async function waitForMockLiveSubscription( + page: import("@playwright/test").Page, + channelName: string, +) { + await expect + .poll(() => + page.evaluate( + (ch) => + ( + window as Window & { + __BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: { + channelName: string; + }) => boolean; + } + ).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ channelName: ch }) ?? + false, + channelName, + ), + ) + .toBe(true); +} + +test("thread head reflects the channel-window edit even before thread aux loads", async ({ + page, +}) => { + // Long delay so the thread-replies (and its aux edit backfill) response is + // still in flight when the thread panel first renders the head. + await installMockBridge(page, { threadRepliesDelayMs: 4000 }); + await page.goto("/"); + await page.getByTestId(`channel-${CHANNEL}`).click(); + await expect(page.getByTestId("chat-title")).toHaveText(CHANNEL); + await waitForMockLiveSubscription(page, CHANNEL); + + const ORIGINAL = "can i get a review on these two PRs? (pr 6706, pr 6701)"; + const EDITED = "can i get a review on these PRs? (pr 6706, pr 6701, pr 6503)"; + + // 1. Post a top-level message, then edit it — both land in the channel window. + // The message MUST be authored by the active identity (tyler): an edit is + // only overlaid onto a message when the edit's signer matches the target's + // author (formatTimelineMessages authorization), and the mock `edit_message` + // command signs with the active identity. + const root = await page.evaluate( + ({ channelName, content, pubkey }) => + (window as MockMessageWindow).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName, + content, + pubkey, + }) ?? null, + { + channelName: CHANNEL, + content: ORIGINAL, + pubkey: TEST_IDENTITIES.tyler.pubkey, + }, + ); + expect(root?.id).toBeTruthy(); + const rootId = root?.id; + if (!rootId) throw new Error("mock message emit did not return an id"); + + await page.evaluate( + ({ channelName, rootId, content, pubkey, editKind }) => + (window as MockMessageWindow).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName, + content, + pubkey, + kind: editKind, + // kind:40003 edit event targeting the root — the same shape the relay + // delivers into the channel-window subscription. + extraTags: [["e", rootId]], + }), + { + channelName: CHANNEL, + rootId, + content: EDITED, + pubkey: TEST_IDENTITIES.tyler.pubkey, + editKind: 40003, + }, + ); + + // 2. The main timeline row shows the edited body (baseline: edit is applied + // in the channel window). + const timelineRow = page.locator( + `[data-testid="message-row"][data-message-id="${rootId}"]`, + ); + await expect(timelineRow.getByTestId("message-body")).toContainText( + "these PRs?", + ); + await expect(timelineRow.getByTestId("message-body")).not.toContainText( + "these two PRs?", + ); + + // 3. Open the thread via the reply action (the flow in the bug report). + const replyButton = page.getByTestId(`reply-message-${rootId}`); + await replyButton.click({ force: true }); + const threadPanel = page.getByTestId("message-thread-panel"); + await expect(threadPanel).toBeVisible(); + + // 4. The thread head must show the EDITED body immediately — while the + // thread-aux backfill is still delayed in flight. Pre-fix this rendered + // the stale original ("these two PRs?"). + const headBody = threadPanel + .locator(`[data-testid="message-row"][data-message-id="${rootId}"]`) + .getByTestId("message-body"); + await expect(headBody).toContainText("these PRs?"); + await expect(headBody).not.toContainText("these two PRs?"); + + await page.screenshot({ path: `${SHOT}/thread-head-edited.png` }); +});