From 3a9bb708c8b4e3cb0fef80b1deb2317973d90ec7 Mon Sep 17 00:00:00 2001 From: npub1ft62tztwwm2x9xamk25smmuaj4sfckdkldksruf2x2jwqalffkrq0g7arr <4af4a5896e76d4629bbbb2a90def9d95609c59b6fb6d01f12a32a4e077e94d86@sprout-oss.stage.blox.sqprod.co> Date: Mon, 20 Jul 2026 18:47:04 -0700 Subject: [PATCH 1/3] fix(desktop): enable Inbox message editing Co-authored-by: Tom Brow Signed-off-by: Tom Brow --- desktop/playwright.config.ts | 1 + desktop/src/features/home/ui/HomeView.tsx | 13 +- .../src/features/home/ui/InboxDetailPane.tsx | 67 ++++++++- .../src/features/home/ui/InboxMessageRow.tsx | 5 +- .../home/useHomeInboxContextMessages.ts | 5 +- .../src/features/home/useInboxEditMessage.ts | 36 +++++ .../features/home/useInboxThreadContext.ts | 70 ++++++++- desktop/tests/e2e/inbox-edit.spec.ts | 136 ++++++++++++++++++ 8 files changed, 323 insertions(+), 10 deletions(-) create mode 100644 desktop/src/features/home/useInboxEditMessage.ts create mode 100644 desktop/tests/e2e/inbox-edit.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 7ce7f48389..ef07ade6d5 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -100,6 +100,7 @@ export default defineConfig({ "**/reaction-order.spec.ts", "**/reaction-names.spec.ts", "**/inbox-reactions.spec.ts", + "**/inbox-edit.spec.ts", "**/send-channel-binding.spec.ts", "**/project-commit-detail.spec.ts", "**/project-inbox.spec.ts", diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx index 0f7c851643..d4fe01cdcd 100644 --- a/desktop/src/features/home/ui/HomeView.tsx +++ b/desktop/src/features/home/ui/HomeView.tsx @@ -16,6 +16,7 @@ import { getInboxItemConversationId, } from "@/features/home/lib/inbox"; import { useInboxSelectionAnchor } from "@/features/home/useInboxSelectionAnchor"; +import { useInboxEditMessage } from "@/features/home/useInboxEditMessage"; import { useOwnedAgentPubkeys } from "@/features/home/useOwnedAgentPubkeys"; import { filterInboxItems, @@ -167,6 +168,7 @@ export function HomeView({ ); const { goChannel } = useAppNavigation(); const openDmMutation = useOpenDmMutation(); + const openDm = openDmMutation.mutateAsync; const handleUserSelectItem = React.useCallback( (itemId: string | null) => { setAutoSelectedEventId(null); @@ -212,10 +214,10 @@ export function HomeView({ const [isSendingReply, setIsSendingReply] = React.useState(false); const handleOpenDm = React.useCallback( async (pubkeys: string[]) => { - const dm = await openDmMutation.mutateAsync({ pubkeys }); + const dm = await openDm({ pubkeys }); await goChannel(dm.id); }, - [goChannel, openDmMutation], + [goChannel, openDm], ); const { activeReminderEventIds, openReminder } = useRemindLater(); const [localRepliesByItemId, setLocalRepliesByItemId] = React.useState< @@ -301,6 +303,10 @@ export function HomeView({ isChannelLoading: channelMessagesQuery.isPending, }, ); + const { editMessage, isEditingMessage } = useInboxEditMessage( + selectedChannel, + threadContext.refreshStructuralEvents, + ); const feedProfilePubkeys = React.useMemo( () => [ @@ -460,6 +466,7 @@ export function HomeView({ selectedChannel, selectedEventId, selectedItem, + structuralEvents: threadContext.structuralEvents, }); const selectedItemReplies = React.useMemo(() => { if (!selectedItem) return []; @@ -745,6 +752,7 @@ export function HomeView({ currentPubkey={currentPubkey} disabledReplyReason={disabledReplyReason} isDeletingMessage={isDeletingMessage} + isEditingMessage={isEditingMessage} isSendingReply={isSendingReply} isSinglePanelView={isSinglePanelDetailView} hasThreadContextLoadError={threadContext.hasLoadError} @@ -784,6 +792,7 @@ export function HomeView({ handleCloseProfilePanel(); setManagedChannelId(channelId); }} + onEditSave={editMessage} onOpenContext={onOpenContext} onSendReply={async ({ content, diff --git a/desktop/src/features/home/ui/InboxDetailPane.tsx b/desktop/src/features/home/ui/InboxDetailPane.tsx index 9b6ff57e17..1571be532f 100644 --- a/desktop/src/features/home/ui/InboxDetailPane.tsx +++ b/desktop/src/features/home/ui/InboxDetailPane.tsx @@ -31,6 +31,8 @@ import { isWithinGroupingWindow, } from "@/features/messages/lib/messageGrouping"; import { orderMentionPubkeysByText } from "@/features/messages/lib/orderMentionPubkeys"; +import { canManageMessageForCurrentUser } from "@/features/messages/lib/canManageMessage"; +import { imetaMediaFromTags } from "@/features/messages/lib/imetaMediaMarkdown"; import { getThreadReference } from "@/features/messages/lib/threading"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { MessageComposer } from "@/features/messages/ui/MessageComposer"; @@ -67,6 +69,7 @@ type InboxDetailPaneProps = { canReply: boolean; disabledReplyReason?: string | null; isDeletingMessage?: boolean; + isEditingMessage?: boolean; isSendingReply?: boolean; isSinglePanelView?: boolean; hasThreadContextLoadError?: boolean; @@ -96,6 +99,12 @@ type InboxDetailPaneProps = { latchedDefaultParentId?: string | null; onBack?: () => void; onDelete: () => void; + onEditSave: (input: { + content: string; + eventId: string; + mediaTags?: string[][]; + mentionPubkeys?: string[]; + }) => Promise; onManageChannel: (channelId: string) => void; onOpenContext: ( channelId: string, @@ -138,6 +147,7 @@ function InboxMessageDetailPane({ canReply, disabledReplyReason, isDeletingMessage = false, + isEditingMessage = false, isSendingReply = false, isSinglePanelView = false, hasThreadContextLoadError = false, @@ -154,6 +164,7 @@ function InboxMessageDetailPane({ latchedDefaultParentId = null, onBack, onDelete, + onEditSave, onManageChannel, onOpenContext, onSendReply, @@ -166,6 +177,7 @@ function InboxMessageDetailPane({ const contentRef = React.useRef(null); const composerWrapperRef = React.useRef(null); const [replyTargetId, setReplyTargetId] = React.useState(null); + const [editTargetId, setEditTargetId] = React.useState(null); const [isFocusHighlightVisible, setIsFocusHighlightVisible] = React.useState(true); const [isMembersSidebarOpen, setIsMembersSidebarOpen] = React.useState(false); @@ -264,6 +276,7 @@ function InboxMessageDetailPane({ React.useEffect(() => { void conversationId; setReplyTargetId(null); + setEditTargetId(null); }, [conversationId]); React.useEffect(() => { @@ -382,6 +395,16 @@ function InboxMessageDetailPane({ const replyTarget = displayMessages.find((message) => message.id === replyTargetId) ?? null; + const editTarget = + displayMessages.find((message) => message.id === editTargetId) ?? null; + const composerEditTarget = editTarget + ? { + author: editTarget.authorLabel, + body: editTarget.content, + id: editTarget.id, + imetaMedia: imetaMediaFromTags(editTarget.tags), + } + : null; // Explicit sub-message reply wins. Otherwise use the captured default parent // (derived from the selected-event anchor at conversation entry), which does // not change when a live incoming message advances the representative item. @@ -429,6 +452,14 @@ function InboxMessageDetailPane({ setReplyTargetId((currentReplyTargetId) => currentReplyTargetId === message.id ? null : message.id, ); + setEditTargetId(null); + focusComposer(); + }; + const handleSelectEditTarget = (message: InboxDisplayMessage) => { + setEditTargetId((currentEditTargetId) => + currentEditTargetId === message.id ? null : message.id, + ); + setReplyTargetId(null); focusComposer(); }; @@ -588,6 +619,21 @@ function InboxMessageDetailPane({ message.createdAt, ); + const canManageMessage = canManageMessageForCurrentUser( + { + id: message.id, + author: message.authorLabel, + body: message.content, + createdAt: message.createdAt, + depth: message.depth, + kind: message.kind, + pubkey: message.authorPubkey, + time: message.timeLabel ?? message.fullTimestampLabel, + }, + currentPubkey, + profiles, + ); + return ( setEditTargetId(null) : undefined + } onCancelReply={ composerReplyTarget ? () => setReplyTargetId(null) : undefined } + onEditSave={async (content, mediaTags, mentionPubkeys) => { + if (!composerEditTarget) { + return; + } + await onEditSave({ + content, + eventId: composerEditTarget.id, + mediaTags, + mentionPubkeys, + }); + setEditTargetId(null); + }} onSend={(content, mentionPubkeys, mediaTags) => onSendReply({ content, diff --git a/desktop/src/features/home/ui/InboxMessageRow.tsx b/desktop/src/features/home/ui/InboxMessageRow.tsx index 04deafb3ff..d57db1a5d5 100644 --- a/desktop/src/features/home/ui/InboxMessageRow.tsx +++ b/desktop/src/features/home/ui/InboxMessageRow.tsx @@ -31,6 +31,7 @@ type InboxMessageRowProps = { isFirst?: boolean; isFocusHighlightVisible: boolean; message: InboxDisplayMessage; + onEdit?: (message: InboxDisplayMessage) => void; onSelectReplyTarget: (message: InboxDisplayMessage) => void; onToggleReaction?: ( message: TimelineMessage, @@ -48,6 +49,7 @@ export function InboxMessageRow({ isFirst = false, isFocusHighlightVisible, message, + onEdit, onSelectReplyTarget, onToggleReaction, showUnreadBoundary = false, @@ -116,7 +118,7 @@ export function InboxMessageRow({ : "home-inbox-context-message" } > - {canReply || canToggleReactions ? ( + {canReply || canToggleReactions || onEdit ? (
onEdit(message) : undefined} onReactionSelect={ canToggleReactions ? handleReactionSelect : undefined } diff --git a/desktop/src/features/home/useHomeInboxContextMessages.ts b/desktop/src/features/home/useHomeInboxContextMessages.ts index b8693c6941..1997bcf27d 100644 --- a/desktop/src/features/home/useHomeInboxContextMessages.ts +++ b/desktop/src/features/home/useHomeInboxContextMessages.ts @@ -21,6 +21,7 @@ type UseHomeInboxContextMessagesOptions = { selectedChannel: Channel | null; selectedEventId: string | null; selectedItem: InboxItem | null; + structuralEvents?: RelayEvent[]; }; export function useHomeInboxContextMessages({ @@ -34,6 +35,7 @@ export function useHomeInboxContextMessages({ selectedChannel, selectedEventId, selectedItem, + structuralEvents = [], }: UseHomeInboxContextMessagesOptions): InboxContextMessage[] { return React.useMemo(() => { if (!selectedItem) return []; @@ -52,7 +54,7 @@ export function useHomeInboxContextMessages({ ? (profiles?.[currentPubkey.toLowerCase()]?.avatarUrl ?? null) : null; const timelineMessages = formatTimelineMessages( - [...events, ...contextReactions], + [...events, ...structuralEvents, ...contextReactions], selectedChannel, currentPubkey, currentUserAvatarUrl, @@ -83,5 +85,6 @@ export function useHomeInboxContextMessages({ selectedChannel, selectedEventId, selectedItem, + structuralEvents, ]); } diff --git a/desktop/src/features/home/useInboxEditMessage.ts b/desktop/src/features/home/useInboxEditMessage.ts new file mode 100644 index 0000000000..f0c5628343 --- /dev/null +++ b/desktop/src/features/home/useInboxEditMessage.ts @@ -0,0 +1,36 @@ +import * as React from "react"; + +import { useEditMessageMutation } from "@/features/messages/hooks"; +import type { Channel } from "@/shared/api/types"; + +export type InboxEditMessageInput = { + content: string; + eventId: string; + mediaTags?: string[][]; + mentionPubkeys?: string[]; +}; + +/** Publishes an Inbox edit, then refreshes its structural event overlay. */ +export function useInboxEditMessage( + channel: Channel | null, + refreshStructuralEvents: () => Promise, +) { + const editMessageMutation = useEditMessageMutation(channel); + const mutateRef = React.useRef(editMessageMutation.mutateAsync); + const refreshRef = React.useRef(refreshStructuralEvents); + mutateRef.current = editMessageMutation.mutateAsync; + refreshRef.current = refreshStructuralEvents; + + const editMessage = React.useCallback( + async (input: InboxEditMessageInput) => { + await mutateRef.current(input); + await refreshRef.current(); + }, + [], + ); + + return { + editMessage, + isEditingMessage: editMessageMutation.isPending, + } as const; +} diff --git a/desktop/src/features/home/useInboxThreadContext.ts b/desktop/src/features/home/useInboxThreadContext.ts index 67a46957f9..7438c4a4ab 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 { @@ -16,6 +19,10 @@ type InboxThreadContextResult = { events: RelayEvent[]; hasLoadError: boolean; isLoading: boolean; + /** Edits/deletions referencing context messages, fetched by `#e`. */ + structuralEvents: RelayEvent[]; + /** Re-fetch structural events after an Inbox edit is published. */ + refreshStructuralEvents: () => Promise; /** kind:7 events referencing the context messages, fetched by `#e`. */ reactionEvents: RelayEvent[]; /** Re-fetch reaction events (e.g. after a toggle) without reloading context. */ @@ -250,9 +257,10 @@ export function useInboxThreadContext( selectedThreadRootId, ]); - // Reactions carry only an `#e` reference, so the channel-window cache never - // has them for thread replies — fetch them for the rendered context messages. - const [reactionEvents, setReactionEvents] = React.useState([]); + // Auxiliary events carry only an `#e` reference, so they may be absent from + // both the selected feed item and the channel-window cache. Hydrate them by + // the context message ids so cold Inbox items receive edits, deletions, and + // reactions without requiring the full channel timeline to be open. const contextEventIdsKey = React.useMemo( () => events @@ -261,6 +269,57 @@ export function useInboxThreadContext( .join(","), [events], ); + const [structuralEvents, setStructuralEvents] = React.useState( + [], + ); + + const fetchStructuralEvents = 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 structural events for Inbox context messages", + selectedChannelId, + error, + ); + return null; + } + }, [contextEventIdsKey, selectedChannelId]); + + React.useEffect(() => { + let isCancelled = false; + setStructuralEvents([]); + + void fetchStructuralEvents().then((fetched) => { + if (!isCancelled && fetched !== null) { + setStructuralEvents(fetched); + } + }); + + return () => { + isCancelled = true; + }; + }, [fetchStructuralEvents]); + + const refreshStructuralEvents = React.useCallback(async () => { + const fetched = await fetchStructuralEvents(); + if (fetched !== null) { + setStructuralEvents(fetched); + } + }, [fetchStructuralEvents]); + + const [reactionEvents, setReactionEvents] = React.useState([]); const fetchReactions = React.useCallback(async (): Promise< RelayEvent[] | null @@ -288,6 +347,7 @@ export function useInboxThreadContext( React.useEffect(() => { let isCancelled = false; + setReactionEvents([]); void fetchReactions().then((fetched) => { if (!isCancelled && fetched !== null) { @@ -313,6 +373,8 @@ export function useInboxThreadContext( ? options.hasChannelLoadError === true : hasLoadError, isLoading: fullChannel ? options.isChannelLoading === true : isLoading, + structuralEvents, + refreshStructuralEvents, reactionEvents, refreshReactions, }; diff --git a/desktop/tests/e2e/inbox-edit.spec.ts b/desktop/tests/e2e/inbox-edit.spec.ts new file mode 100644 index 0000000000..4d8870f1c2 --- /dev/null +++ b/desktop/tests/e2e/inbox-edit.spec.ts @@ -0,0 +1,136 @@ +import { expect, test } from "@playwright/test"; + +import { waitForAnimations } from "../helpers/animations"; +import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; + +const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; +const CURRENT_PUBKEY = "deadbeef".repeat(8); +const OWN_MESSAGE_ID = "d1".repeat(32); +const FOREIGN_MESSAGE_ID = "e2".repeat(32); +const SHOTS = "test-results/inbox-edit"; + +type MockFeedItem = { + category: "activity"; + channel_id: string; + channel_name: string; + content: string; + created_at: number; + id: string; + kind: number; + pubkey: string; + tags: string[][]; +}; + +type MockWindow = Window & { + __BUZZ_E2E_PUSH_MOCK_FEED_ITEM__?: (item: MockFeedItem) => unknown; +}; + +async function openMoreActions( + page: import("@playwright/test").Page, + messageId: string, +) { + const row = page.locator(`[data-message-id="${messageId}"]`); + await row.hover(); + await page.getByTestId(`more-actions-${messageId}`).click(); + await expect(page.locator('[role="menuitem"]').first()).toBeVisible(); +} + +test("Inbox offers a working Edit action only for manageable messages", async ({ + page, +}) => { + await installMockBridge(page); + await page.goto("/"); + await expect(page.getByTestId("home-inbox-list")).toBeVisible(); + await page.waitForFunction( + () => + typeof (window as MockWindow).__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__ === + "function", + ); + + await page.evaluate( + ({ + channelId, + currentPubkey, + foreignPubkey, + foreignMessageId, + ownMessageId, + }) => { + const pushFeedItem = (window as MockWindow) + .__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__; + if (!pushFeedItem) { + throw new Error("Mock feed helper is not installed."); + } + + const createdAt = Math.floor(Date.now() / 1_000); + const messages = [ + { + content: "My Inbox message before editing.", + createdAt, + id: ownMessageId, + pubkey: currentPubkey, + }, + { + content: "Another person's Inbox message.", + createdAt: createdAt - 1, + id: foreignMessageId, + pubkey: foreignPubkey, + }, + ]; + + for (const message of messages) { + pushFeedItem({ + category: "activity", + channel_id: channelId, + channel_name: "general", + content: message.content, + created_at: message.createdAt, + id: message.id, + kind: 9, + pubkey: message.pubkey, + tags: [["h", channelId]], + }); + } + }, + { + channelId: GENERAL_CHANNEL_ID, + currentPubkey: CURRENT_PUBKEY, + foreignMessageId: FOREIGN_MESSAGE_ID, + foreignPubkey: TEST_IDENTITIES.alice.pubkey, + ownMessageId: OWN_MESSAGE_ID, + }, + ); + + await page.getByTestId(`home-inbox-item-${OWN_MESSAGE_ID}`).click(); + const detail = page.getByTestId("home-inbox-detail"); + await expect(detail).toContainText("My Inbox message before editing."); + + await openMoreActions(page, OWN_MESSAGE_ID); + await expect( + page.getByTestId(`edit-message-${OWN_MESSAGE_ID}`), + ).toBeVisible(); + await waitForAnimations(page); + await page.screenshot({ path: `${SHOTS}/01-edit-action.png` }); + await page.getByTestId(`edit-message-${OWN_MESSAGE_ID}`).click(); + await expect(detail.getByTestId("edit-target")).toBeVisible(); + + const input = detail.getByTestId("message-input"); + await expect(input).not.toBeEmpty(); + await input.click(); + await page.keyboard.press("ControlOrMeta+A"); + await page.keyboard.type("My Inbox message after editing."); + await page.keyboard.press("Enter"); + + await expect(detail.getByTestId("edit-target")).toBeHidden(); + await expect( + detail.locator(`[data-message-id="${OWN_MESSAGE_ID}"]`), + ).toContainText("My Inbox message after editing."); + await waitForAnimations(page); + await page.screenshot({ path: `${SHOTS}/02-edited-message.png` }); + + await page.getByTestId(`home-inbox-item-${FOREIGN_MESSAGE_ID}`).click(); + await expect(detail).toContainText("Another person's Inbox message."); + await openMoreActions(page, FOREIGN_MESSAGE_ID); + await expect( + page.getByTestId(`edit-message-${FOREIGN_MESSAGE_ID}`), + ).toHaveCount(0); +}); From 7e6b7956e8d8ce33c223636ea62643904c716a49 Mon Sep 17 00:00:00 2001 From: npub1ft62tztwwm2x9xamk25smmuaj4sfckdkldksruf2x2jwqalffkrq0g7arr <4af4a5896e76d4629bbbb2a90def9d95609c59b6fb6d01f12a32a4e077e94d86@sprout-oss.stage.blox.sqprod.co> Date: Mon, 20 Jul 2026 19:15:56 -0700 Subject: [PATCH 2/3] fix(desktop): hide Inbox edit for archived channels Co-authored-by: npub1ft62tztwwm2x9xamk25smmuaj4sfckdkldksruf2x2jwqalffkrq0g7arr <4af4a5896e76d4629bbbb2a90def9d95609c59b6fb6d01f12a32a4e077e94d86@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1ft62tztwwm2x9xamk25smmuaj4sfckdkldksruf2x2jwqalffkrq0g7arr <4af4a5896e76d4629bbbb2a90def9d95609c59b6fb6d01f12a32a4e077e94d86@sprout-oss.stage.blox.sqprod.co> --- .../src/features/home/ui/InboxDetailPane.tsx | 5 +++- desktop/tests/e2e/inbox-edit.spec.ts | 25 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/desktop/src/features/home/ui/InboxDetailPane.tsx b/desktop/src/features/home/ui/InboxDetailPane.tsx index 1571be532f..332fbbd5e2 100644 --- a/desktop/src/features/home/ui/InboxDetailPane.tsx +++ b/desktop/src/features/home/ui/InboxDetailPane.tsx @@ -634,6 +634,9 @@ function InboxMessageDetailPane({ profiles, ); + const canEditMessage = + channel?.archivedAt === null && canManageMessage; + return ( Promise; + __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: ( + command: string, + payload?: Record, + ) => Promise; __BUZZ_E2E_PUSH_MOCK_FEED_ITEM__?: (item: MockFeedItem) => unknown; }; @@ -133,4 +138,24 @@ test("Inbox offers a working Edit action only for manageable messages", async ({ await expect( page.getByTestId(`edit-message-${FOREIGN_MESSAGE_ID}`), ).toHaveCount(0); + await page.keyboard.press("Escape"); + + await page.evaluate(async (channelId) => { + const testWindow = window as MockWindow; + const invoke = testWindow.__BUZZ_E2E_INVOKE_MOCK_COMMAND__; + const invalidateChannels = testWindow.__BUZZ_E2E_INVALIDATE_CHANNELS__; + if (!invoke || !invalidateChannels) { + throw new Error("Mock channel helpers are not installed."); + } + + await invoke("archive_channel", { channelId }); + await invalidateChannels(); + }, GENERAL_CHANNEL_ID); + + await page.getByTestId(`home-inbox-item-${OWN_MESSAGE_ID}`).click(); + await expect(detail).toContainText("My Inbox message after editing."); + await openMoreActions(page, OWN_MESSAGE_ID); + await expect(page.getByTestId(`edit-message-${OWN_MESSAGE_ID}`)).toHaveCount( + 0, + ); }); From 3fea41db4668e2ac795f1c95089c5f13312bf5f1 Mon Sep 17 00:00:00 2001 From: npub1ft62tztwwm2x9xamk25smmuaj4sfckdkldksruf2x2jwqalffkrq0g7arr <4af4a5896e76d4629bbbb2a90def9d95609c59b6fb6d01f12a32a4e077e94d86@sprout-oss.stage.blox.sqprod.co> Date: Mon, 20 Jul 2026 20:21:35 -0700 Subject: [PATCH 3/3] fix(desktop): preserve Inbox reply attachments Co-authored-by: Tom Brow Signed-off-by: Tom Brow --- desktop/src/features/home/ui/HomeView.tsx | 2 +- desktop/src/testing/e2eBridge.ts | 39 +++++- desktop/tests/e2e/inbox-edit.spec.ts | 152 ++++++++++++++++++++++ desktop/tests/helpers/bridge.ts | 2 + 4 files changed, 190 insertions(+), 5 deletions(-) diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx index d4fe01cdcd..4cd404d012 100644 --- a/desktop/src/features/home/ui/HomeView.tsx +++ b/desktop/src/features/home/ui/HomeView.tsx @@ -847,7 +847,7 @@ export function HomeView({ id: result.eventId, parentId: result.parentEventId, rootId: result.rootEventId, - tags: emojiTags, + tags: [...imetaTags, ...emojiTags, ...mentionTags], timeLabel: formatTime(result.createdAt), }; setLocalRepliesByItemId((current) => ({ diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 355ccea9fc..4fea189855 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -296,6 +296,8 @@ type E2eConfig = { applyCommunityDelayMs?: number; openDmDelayMs?: number; sendMessageDelayMs?: number; + /** Hold mock send live echoes until the E2E release seam is invoked. */ + deferSendMessageLiveEcho?: boolean; /** Close the first channel-window live REQ; its retry is accepted. */ closeChannelLiveSubscriptionOnce?: boolean; /** Reject successive kind-9 sends with these messages, then resume. */ @@ -1044,6 +1046,8 @@ declare global { command: string; payload: unknown; }>; + /** Release mock send events that were stored but withheld from live subscribers. */ + __BUZZ_E2E_RELEASE_SEND_MESSAGE_LIVE_ECHO__?: () => number; __BUZZ_E2E_WEBVIEW_ZOOM__?: number; __BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: { channelName: string; @@ -2861,6 +2865,10 @@ const mockChannels: MockChannel[] = [ ]; const mockMessages = new Map(); +const deferredSendMessageLiveEchoes: Array<{ + channelId: string; + event: RelayEvent; +}> = []; const mockUserStatuses: RelayEvent[] = []; const mockReminderEvents: RelayEvent[] = []; const mockPersonaEvents: RelayEvent[] = []; @@ -4008,6 +4016,18 @@ function emitMockLiveEvent(channelId: string, event: RelayEvent) { } } +function emitOrDeferMockSendMessageLiveEcho( + channelId: string, + event: RelayEvent, + config: E2eConfig | undefined, +) { + if (config?.mock?.deferSendMessageLiveEcho) { + deferredSendMessageLiveEchoes.push({ channelId, event }); + return; + } + emitMockLiveEvent(channelId, event); +} + function emitMockGlobalEvent(event: RelayEvent) { if ( event.kind === KIND_PERSONA && @@ -8659,6 +8679,7 @@ async function handleSendChannelMessage( mentionPubkeys?: string[]; mediaTags?: string[][] | null; emojiTags?: string[][] | null; + mentionTags?: string[][] | null; }, config: E2eConfig | undefined, ): Promise { @@ -8678,8 +8699,10 @@ async function handleSendChannelMessage( // relay echoes them back on the stored event too, so mirror that here so the // emoji renderer keeps resolving `:shortcode:` after the round-trip. const emojiTags = args.emojiTags ?? []; - // Both kinds end up on the stored event's tag set, just like the real relay. - const extraTags = [...mediaTags, ...emojiTags]; + // Reference-only mentions are already part of the outbound event. Preserve + // them in the mock event too so local echoes match the complete sent tag set. + const mentionTags = args.mentionTags ?? []; + const extraTags = [...mediaTags, ...emojiTags, ...mentionTags]; const identity = getIdentity(config); if (!identity) { const createdAt = Math.floor(Date.now() / 1000); @@ -8695,7 +8718,7 @@ async function handleSendChannelMessage( ...extraTags, ]); recordMockMessage(args.channelId, event); - emitMockLiveEvent(args.channelId, event); + emitOrDeferMockSendMessageLiveEcho(args.channelId, event, config); return { event_id: event.id, @@ -8758,7 +8781,7 @@ async function handleSendChannelMessage( }; recordMockMessage(args.channelId, event); - emitMockLiveEvent(args.channelId, event); + emitOrDeferMockSendMessageLiveEcho(args.channelId, event, config); return { event_id: event.id, @@ -9608,6 +9631,7 @@ export function maybeInstallE2eTauriMocks() { mockClosedChannelLiveSubscription = false; mockWebsocketUnavailable = false; relayWebsocketConnectAttemptStarts.length = 0; + deferredSendMessageLiveEchoes.length = 0; mockGlobalAgentConfig = config.mock?.globalAgentConfig ? { ...config.mock.globalAgentConfig } : null; @@ -9858,6 +9882,13 @@ export function maybeInstallE2eTauriMocks() { window.__BUZZ_E2E_RESET_WEBSOCKET_CONNECT_ATTEMPTS__ = () => { relayWebsocketConnectAttemptStarts.length = 0; }; + window.__BUZZ_E2E_RELEASE_SEND_MESSAGE_LIVE_ECHO__ = () => { + const queued = deferredSendMessageLiveEchoes.splice(0); + for (const { channelId, event } of queued) { + emitMockLiveEvent(channelId, event); + } + return queued.length; + }; // Tests vary mesh admission and models to exercise provider discovery and // the managed-agent start preflight. window.__BUZZ_E2E_SET_MESH__ = (mesh) => { diff --git a/desktop/tests/e2e/inbox-edit.spec.ts b/desktop/tests/e2e/inbox-edit.spec.ts index ad81aea05e..27a6d458fa 100644 --- a/desktop/tests/e2e/inbox-edit.spec.ts +++ b/desktop/tests/e2e/inbox-edit.spec.ts @@ -7,6 +7,8 @@ const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; const CURRENT_PUBKEY = "deadbeef".repeat(8); const OWN_MESSAGE_ID = "d1".repeat(32); const FOREIGN_MESSAGE_ID = "e2".repeat(32); +const ATTACHMENT_URL = `https://mock.relay/media/${"a".repeat(64)}.pdf`; +const ATTACHMENT_FILENAME = "inbox-edit-proof.pdf"; const SHOTS = "test-results/inbox-edit"; type MockFeedItem = { @@ -22,7 +24,12 @@ type MockFeedItem = { }; type MockWindow = Window & { + __BUZZ_E2E_COMMAND_PAYLOADS__?: Array<{ + command: string; + payload: unknown; + }>; __BUZZ_E2E_INVALIDATE_CHANNELS__?: () => Promise; + __BUZZ_E2E_RELEASE_SEND_MESSAGE_LIVE_ECHO__?: () => number; __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: ( command: string, payload?: Record, @@ -40,6 +47,151 @@ async function openMoreActions( await expect(page.locator('[role="menuitem"]').first()).toBeVisible(); } +test("editing an immediate attachment reply preserves its media tags", async ({ + page, +}) => { + await installMockBridge(page, { + deferSendMessageLiveEcho: true, + uploadDescriptors: [ + { + url: ATTACHMENT_URL, + sha256: "a".repeat(64), + size: 12345, + type: "application/pdf", + uploaded: Math.floor(Date.now() / 1000), + filename: ATTACHMENT_FILENAME, + }, + ], + }); + await page.goto("/"); + await expect(page.getByTestId("home-inbox-list")).toBeVisible(); + await page.waitForFunction( + () => + typeof (window as MockWindow).__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__ === + "function", + ); + + await page.evaluate( + ({ channelId, messageId, pubkey }) => { + const pushFeedItem = (window as MockWindow) + .__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__; + if (!pushFeedItem) { + throw new Error("Mock feed helper is not installed."); + } + + pushFeedItem({ + category: "activity", + channel_id: channelId, + channel_name: "general", + content: "Inbox thread root.", + created_at: Math.floor(Date.now() / 1_000), + id: messageId, + kind: 9, + pubkey, + tags: [["h", channelId]], + }); + }, + { + channelId: GENERAL_CHANNEL_ID, + messageId: OWN_MESSAGE_ID, + pubkey: CURRENT_PUBKEY, + }, + ); + + await page.getByTestId(`home-inbox-item-${OWN_MESSAGE_ID}`).click(); + const detail = page.getByTestId("home-inbox-detail"); + await expect(detail).toContainText("Inbox thread root."); + + await detail.getByRole("button", { name: "Attach image" }).click(); + await expect(detail.getByTestId("message-composer")).toContainText( + ATTACHMENT_FILENAME, + ); + const input = detail.getByTestId("message-input"); + await input.fill("Attachment reply before editing."); + await detail.getByTestId("send-message").click(); + await expect(detail.getByText("Sending")).toHaveCount(0); + + const sendPayload = await page.evaluate(() => { + const payloads = (window as MockWindow).__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []; + return payloads.findLast( + (entry) => entry.command === "send_channel_message", + )?.payload; + }); + expect(sendPayload).toEqual( + expect.objectContaining({ + mediaTags: [ + [ + "imeta", + `url ${ATTACHMENT_URL}`, + "m application/pdf", + `x ${"a".repeat(64)}`, + "size 12345", + `filename ${ATTACHMENT_FILENAME}`, + ], + ], + parentEventId: OWN_MESSAGE_ID, + }), + ); + + const reply = detail + .locator('[data-testid="home-inbox-context-message"]') + .filter({ hasText: "Attachment reply before editing." }); + await expect(reply).toBeVisible(); + await expect( + reply.getByRole("link", { name: ATTACHMENT_FILENAME }), + ).toHaveAttribute("href", ATTACHMENT_URL); + const replyId = await reply.getAttribute("data-message-id"); + expect(replyId).not.toBeNull(); + const replyRow = detail.locator(`[data-message-id="${replyId}"]`); + + await openMoreActions(page, replyId as string); + await page.getByTestId(`edit-message-${replyId}`).click(); + await expect(detail.getByTestId("edit-target")).toBeVisible(); + await expect(detail.getByTestId("message-composer")).toContainText( + ATTACHMENT_FILENAME, + ); + + await input.fill("Attachment reply after editing."); + await page.keyboard.press("Enter"); + await expect(detail.getByTestId("edit-target")).toBeHidden(); + + const editPayload = await page.evaluate(() => { + const payloads = (window as MockWindow).__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []; + return payloads.findLast((entry) => entry.command === "edit_message") + ?.payload; + }); + expect(editPayload).toEqual( + expect.objectContaining({ + eventId: replyId, + mediaTags: [ + [ + "imeta", + `url ${ATTACHMENT_URL}`, + "m application/pdf", + `x ${"a".repeat(64)}`, + "size 12345", + `filename ${ATTACHMENT_FILENAME}`, + ], + ], + }), + ); + + const releasedEchoes = await page.evaluate(() => { + const release = (window as MockWindow) + .__BUZZ_E2E_RELEASE_SEND_MESSAGE_LIVE_ECHO__; + if (!release) { + throw new Error("Mock send-echo release helper is not installed."); + } + return release(); + }); + expect(releasedEchoes).toBe(1); + + await expect(replyRow).toContainText("Attachment reply after editing."); + await expect( + replyRow.getByRole("link", { name: ATTACHMENT_FILENAME }), + ).toHaveAttribute("href", ATTACHMENT_URL); +}); + test("Inbox offers a working Edit action only for manageable messages", async ({ page, }) => { diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 8a9ab2be11..83bea5419a 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -280,6 +280,8 @@ type MockBridgeOptions = { applyCommunityDelayMs?: number; openDmDelayMs?: number; sendMessageDelayMs?: number; + /** Hold mock send live echoes until the E2E release seam is invoked. */ + deferSendMessageLiveEcho?: boolean; /** Close the first channel-window live REQ; its retry is accepted. */ closeChannelLiveSubscriptionOnce?: boolean; /** Reject successive kind-9 sends with these messages, then resume. */