diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx index 992702f431..d9a911eaad 100644 --- a/desktop/src/features/home/ui/HomeView.tsx +++ b/desktop/src/features/home/ui/HomeView.tsx @@ -491,7 +491,11 @@ export function HomeView({ ? (feedProfiles?.[currentPubkey.toLowerCase()]?.avatarUrl ?? null) : null; const timelineMessages = formatTimelineMessages( - [...threadContext.events, ...reactionEvents], + [ + ...threadContext.events, + ...reactionEvents, + ...threadContext.deletionEvents, + ], selectedChannel, currentPubkey, currentUserAvatarUrl, @@ -522,6 +526,7 @@ export function HomeView({ selectedItem, threadContext.events, threadContext.reactionEvents, + threadContext.deletionEvents, ]); const selectedItemReplies = React.useMemo(() => { if (!selectedItem) return []; diff --git a/desktop/src/features/home/useInboxThreadContext.ts b/desktop/src/features/home/useInboxThreadContext.ts index b3c1da9485..5033f1003d 100644 --- a/desktop/src/features/home/useInboxThreadContext.ts +++ b/desktop/src/features/home/useInboxThreadContext.ts @@ -4,7 +4,10 @@ import { isInboxThreadContextEvent } from "@/features/home/lib/inboxViewHelpers" import { relayEventFromFeedItem } from "@/features/home/lib/inbox"; import { getThreadReference } from "@/features/messages/lib/threading"; import { relayClient } from "@/shared/api/relayClient"; -import { buildChannelReactionAuxFilter } from "@/shared/api/relayChannelFilters"; +import { + buildChannelReactionAuxFilter, + buildChannelStructuralAuxFilter, +} from "@/shared/api/relayChannelFilters"; import { getEventById } from "@/shared/api/tauri"; import type { FeedItem, RelayEvent } from "@/shared/api/types"; import { HOME_MENTION_EVENT_KINDS } from "@/shared/constants/kinds"; @@ -14,6 +17,16 @@ type InboxThreadContextResult = { isLoading: boolean; /** kind:7 events referencing the context messages, fetched by `#e`. */ reactionEvents: RelayEvent[]; + /** + * kind:5 / kind:9005 deletion (and kind:40003 edit) markers referencing the + * context messages, fetched by `#e`. The relay omits soft-deleted rows and + * never returns the deletion markers themselves through the content-kind + * descendant fetch, so a deleted message can survive in `events` via the + * latched feed item or the local channel-window cache. Feeding these markers + * into `formatTimelineMessages` lets its existing suppression pass drop the + * deleted rows — matching the channel timeline's structural-aux backfill. + */ + deletionEvents: RelayEvent[]; /** Re-fetch reaction events (e.g. after a toggle) without reloading context. */ refreshReactions: () => Promise; }; @@ -238,10 +251,57 @@ export function useInboxThreadContext( } }, [fetchReactions]); + // The relay never returns kind:5/9005 deletion markers through the + // content-kind descendant fetch above, and it omits soft-deleted rows — but + // a deleted message can still reach `events` via the latched feed item or the + // local channel-window cache. Fetch the structural markers (deletions + edits) + // by `#e` over the rendered context ids so `formatTimelineMessages` can apply + // them, exactly as the channel timeline does with its structural-aux backfill. + const [deletionEvents, setDeletionEvents] = React.useState([]); + + const fetchDeletions = React.useCallback(async (): Promise< + RelayEvent[] | null + > => { + const eventIds = contextEventIdsKey ? contextEventIdsKey.split(",") : []; + if (!selectedChannelId || eventIds.length === 0) { + return []; + } + + try { + return await relayClient.fetchAuxEventsByReference( + selectedChannelId, + eventIds, + buildChannelStructuralAuxFilter, + ); + } catch (error) { + console.error( + "Failed to hydrate deletion markers for Inbox context messages", + selectedChannelId, + error, + ); + return null; + } + }, [contextEventIdsKey, selectedChannelId]); + + React.useEffect(() => { + let isCancelled = false; + + void fetchDeletions().then((fetched) => { + if (!isCancelled && fetched !== null) { + setDeletionEvents(fetched); + } + }); + + return () => { + isCancelled = true; + }; + }, [fetchDeletions]); + return { events, isLoading, reactionEvents, + deletionEvents, refreshReactions, }; } diff --git a/desktop/tests/e2e/inbox-live-update.spec.ts b/desktop/tests/e2e/inbox-live-update.spec.ts index 94e0b7b54a..46af19aebb 100644 --- a/desktop/tests/e2e/inbox-live-update.spec.ts +++ b/desktop/tests/e2e/inbox-live-update.spec.ts @@ -1533,4 +1533,99 @@ test.describe("inbox stable-conversation regressions", () => { // Drift compensation uses scrollBy — must NOT call scrollIntoView again. expect(await getScrollIntoViewCount(page)).toBe(1); }); + + // Regression: a deleted thread reply must NOT survive in the inbox detail + // pane. The relay omits soft-deleted rows AND never returns the kind:5/9005 + // deletion marker through the content-kind descendant fetch, so the inbox + // could still render the deleted message re-injected from the latched feed + // item / local channel-window cache. `useInboxThreadContext` now backfills + // the structural (deletion) markers by `#e` and feeds them into + // `formatTimelineMessages`, which suppresses the deleted row — mirroring the + // channel timeline. Before the fix, "Secret reply to delete" stayed visible. + test("deleted thread reply is suppressed in the inbox detail pane", async ({ + page, + }) => { + await installMockBridge(page); + await page.goto("/"); + await expect(getListPane(page)).toBeVisible(); + await waitForBridgeReady(page); + + const { root, deletedReply } = await page.evaluate( + ({ channelId, currentPubkey, senderPubkey }) => { + const win = window as MockWindow; + const emit = win.__BUZZ_E2E_EMIT_MOCK_MESSAGE__; + const push = win.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__; + if (!emit || !push) throw new Error("Bridge helpers not ready"); + + const root = emit({ + channelName: "general", + content: "Thread root that mentions me — the inbox item.", + pubkey: senderPubkey, + mentionPubkeys: [currentPubkey], + id: "d0".repeat(32), + }); + + const deletedReply = emit({ + channelName: "general", + content: "Secret reply to delete.", + parentEventId: root.id, + pubkey: senderPubkey, + id: "d1".repeat(32), + }); + + const survivingReply = emit({ + channelName: "general", + content: "Reply that stays visible.", + parentEventId: root.id, + pubkey: senderPubkey, + id: "d2".repeat(32), + }); + + // kind:9005 (Buzz-native) deletion marker referencing the reply. It has + // no channel content kind, so the content-kind descendant fetch skips + // it; the structural-aux backfill picks it up by `#e`. + emit({ + channelName: "general", + content: "", + pubkey: senderPubkey, + kind: 9005, + extraTags: [["e", deletedReply.id]], + id: "d9".repeat(32), + }); + + push({ + id: root.id, + kind: root.kind, + pubkey: root.pubkey, + content: root.content, + created_at: root.created_at, + channel_id: channelId, + channel_name: "general", + tags: root.tags, + category: "mention", + }); + + return { root, deletedReply, survivingReply }; + }, + { + channelId: GENERAL_CHANNEL_ID, + currentPubkey: TEST_IDENTITIES.tyler.pubkey, + senderPubkey: TEST_IDENTITIES.alice.pubkey, + }, + ); + + // Open the inbox item. + await page.getByTestId(`home-inbox-item-${root.id}`).click(); + const detail = getDetailPane(page); + + // The surviving reply must render — proves context loaded and the + // structural-aux fetch did not over-suppress. + await expect(detail).toContainText("Reply that stays visible."); + + // The deleted reply must NOT render. Assert after the surviving reply is + // present so we know context hydration completed before checking absence. + await expect(detail).not.toContainText("Secret reply to delete."); + // Belt-and-suspenders: no row keyed by the deleted event id. + void deletedReply; + }); });