diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 9c35447e84..588979f01c 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -92,6 +92,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-pr-review.spec.ts", diff --git a/desktop/src/features/home/lib/inboxViewHelpers.test.mjs b/desktop/src/features/home/lib/inboxViewHelpers.test.mjs index e76b551239..15caf77f2f 100644 --- a/desktop/src/features/home/lib/inboxViewHelpers.test.mjs +++ b/desktop/src/features/home/lib/inboxViewHelpers.test.mjs @@ -4,6 +4,7 @@ import test from "node:test"; import { formatTimelineMessages } from "../../messages/lib/formatTimelineMessages.ts"; import { getConfigNudgeAuthorPubkey } from "../../messages/ui/configNudgeAuthPubkey.ts"; import { + findInboxItemByEventId, getContextMessageDepth, getReactionTargetId, isInboxThreadContextEvent, @@ -12,6 +13,18 @@ import { toTimelineMessage, } from "./inboxViewHelpers.ts"; +// --- findInboxItemByEventId --- + +test("findInboxItemByEventId finds representative and grouped event ids", () => { + const representative = { id: "latest", groupItems: [{ id: "older" }] }; + const other = { id: "other", groupItems: [] }; + const items = [representative, other]; + + assert.equal(findInboxItemByEventId(items, "latest"), representative); + assert.equal(findInboxItemByEventId(items, "older"), representative); + assert.equal(findInboxItemByEventId(items, "missing"), null); +}); + // --- matchesInboxFilter --- test("matchesInboxFilter returns true for the 'all' filter regardless of categories", () => { diff --git a/desktop/src/features/home/lib/inboxViewHelpers.ts b/desktop/src/features/home/lib/inboxViewHelpers.ts index e87671b9fa..7aba069b4a 100644 --- a/desktop/src/features/home/lib/inboxViewHelpers.ts +++ b/desktop/src/features/home/lib/inboxViewHelpers.ts @@ -2,6 +2,7 @@ import { formatInboxFullTimestamp, type InboxContextMessage, type InboxFilter, + type InboxItem, } from "@/features/home/lib/inbox"; import { getChannelIdFromTags, @@ -21,6 +22,19 @@ function hasThreadReplyTags(tags: string[][]) { return thread.parentId !== null && !isBroadcastReply(tags); } +export function findInboxItemByEventId( + items: readonly InboxItem[], + eventId: string, +): InboxItem | null { + const direct = items.find((item) => item.id === eventId); + if (direct) return direct; + return ( + items.find((item) => + item.groupItems.some((groupItem) => groupItem.id === eventId), + ) ?? null + ); +} + export function matchesInboxFilter( item: { categories: readonly string[]; diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx index 992702f431..e6c668b439 100644 --- a/desktop/src/features/home/ui/HomeView.tsx +++ b/desktop/src/features/home/ui/HomeView.tsx @@ -10,14 +10,15 @@ import { ChannelManagementSheet } from "@/features/channels/ui/ChannelManagement import { type InboxFilter, type InboxContextMessage, - type InboxItem, type InboxReply, buildInboxItems, formatInboxFullTimestamp, getInboxConversationId, } from "@/features/home/lib/inbox"; import { useInboxSelectionAnchor } from "@/features/home/useInboxSelectionAnchor"; +import { useInboxEditMessage } from "@/features/home/useInboxEditMessage"; import { + findInboxItemByEventId, getReactionTargetId, matchesInboxFilter, toInboxContextMessage, @@ -82,27 +83,6 @@ const INBOX_SEARCH_KEYS = [ "profileView", ] as const; -/** - * Finds the InboxItem whose stable conversation contains the given event ID. - * Checks `item.id` (the current representative/latest event) first, then - * falls back to `item.groupItems` so that a deep-linked or URL-anchored event - * that is no longer the representative still resolves to its row. - */ -function findItemByEventId( - items: readonly InboxItem[], - eventId: string, -): InboxItem | null { - // Fast path: representative event matches (the common case). - const direct = items.find((item) => item.id === eventId); - if (direct) return direct; - // Slow path: event is a non-representative group member (e.g. original - // mention that was later superseded by a newer reply as the representative). - return ( - items.find((item) => item.groupItems.some((gi) => gi.id === eventId)) ?? - null - ); -} - type HomeViewProps = { feed?: HomeFeedResponse; isLoading?: boolean; @@ -190,6 +170,7 @@ export function HomeView({ ); const { goChannel } = useAppNavigation(); const openDmMutation = useOpenDmMutation(); + const openDm = openDmMutation.mutateAsync; // handleUserSelectItem: explicit selection — only patches the URL. // No local setSelectedEventId call; the URL patch triggers a TanStack Router // navigation which updates urlSelectedItemId, which becomes selectedEventId @@ -240,10 +221,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< @@ -322,6 +303,10 @@ export function HomeView({ threadContextFeedItem, channelMessages, ); + const { editMessage, isEditingMessage } = useInboxEditMessage( + selectedChannel, + threadContext.refreshStructuralEvents, + ); const feedProfilePubkeys = React.useMemo( () => [ @@ -403,7 +388,7 @@ export function HomeView({ const selectedItemFromAll = React.useMemo( () => selectedEventId - ? (findItemByEventId(inboxItems, selectedEventId) ?? null) + ? (findInboxItemByEventId(inboxItems, selectedEventId) ?? null) : null, [inboxItems, selectedEventId], ); @@ -440,7 +425,7 @@ export function HomeView({ const selectedItem = React.useMemo(() => { if (!selectedEventId) return null; // Primary: find by event anchor in the filtered view. - const fromFiltered = findItemByEventId(filteredItems, selectedEventId); + const fromFiltered = findInboxItemByEventId(filteredItems, selectedEventId); if (fromFiltered) return fromFiltered; // Secondary: event anchor is in an unfiltered row (e.g., dismissed item). if (selectedItemFromAll) return selectedItemFromAll; @@ -491,7 +476,11 @@ export function HomeView({ ? (feedProfiles?.[currentPubkey.toLowerCase()]?.avatarUrl ?? null) : null; const timelineMessages = formatTimelineMessages( - [...threadContext.events, ...reactionEvents], + [ + ...threadContext.events, + ...threadContext.structuralEvents, + ...reactionEvents, + ], selectedChannel, currentPubkey, currentUserAvatarUrl, @@ -522,6 +511,7 @@ export function HomeView({ selectedItem, threadContext.events, threadContext.reactionEvents, + threadContext.structuralEvents, ]); const selectedItemReplies = React.useMemo(() => { if (!selectedItem) return []; @@ -808,6 +798,7 @@ export function HomeView({ currentPubkey={currentPubkey} disabledReplyReason={disabledReplyReason} isDeletingMessage={isDeletingMessage} + isEditingMessage={isEditingMessage} isSendingReply={isSendingReply} isSinglePanelView={isSinglePanelDetailView} isThreadContextLoading={threadContext.isLoading} @@ -845,6 +836,7 @@ export function HomeView({ handleCloseProfilePanel(); setManagedChannelId(channelId); }} + onEditSave={editMessage} onOpenContext={onOpenContext} onSendReply={async ({ content, @@ -899,7 +891,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/features/home/ui/InboxDetailPane.tsx b/desktop/src/features/home/ui/InboxDetailPane.tsx index 611adbf559..98accf3052 100644 --- a/desktop/src/features/home/ui/InboxDetailPane.tsx +++ b/desktop/src/features/home/ui/InboxDetailPane.tsx @@ -19,6 +19,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"; @@ -55,6 +57,7 @@ type InboxDetailPaneProps = { canReply: boolean; disabledReplyReason?: string | null; isDeletingMessage?: boolean; + isEditingMessage?: boolean; isSendingReply?: boolean; isSinglePanelView?: boolean; isThreadContextLoading?: boolean; @@ -82,6 +85,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, @@ -108,6 +117,7 @@ export function InboxDetailPane({ canReply, disabledReplyReason, isDeletingMessage = false, + isEditingMessage = false, isSendingReply = false, isSinglePanelView = false, isThreadContextLoading = false, @@ -122,6 +132,7 @@ export function InboxDetailPane({ latchedDefaultParentId = null, onBack, onDelete, + onEditSave, onManageChannel, onOpenContext, onSendReply, @@ -133,6 +144,7 @@ export function InboxDetailPane({ 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); @@ -230,6 +242,7 @@ export function InboxDetailPane({ React.useEffect(() => { void conversationId; setReplyTargetId(null); + setEditTargetId(null); }, [conversationId]); React.useEffect(() => { @@ -348,6 +361,16 @@ export function InboxDetailPane({ 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. @@ -377,6 +400,14 @@ export function InboxDetailPane({ 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(); }; @@ -496,6 +527,24 @@ export function InboxDetailPane({ 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, + ); + + const canEditMessage = + channel?.archivedAt === null && canManageMessage; + return ( @@ -550,12 +600,28 @@ export function InboxDetailPane({ channelName={item.channelLabel ?? "channel"} channelType={composerChannelType} containerClassName="px-4 pb-4 sm:px-4" - disabled={!canReply} + disabled={!canReply && !composerEditTarget} draftKey={`thread:${item.conversationId}`} - isSending={isSendingReply} + editTarget={composerEditTarget} + isSending={isSendingReply || isEditingMessage} + onCancelEdit={ + composerEditTarget ? () => 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 aaad2bcbd9..303d545182 100644 --- a/desktop/src/features/home/ui/InboxMessageRow.tsx +++ b/desktop/src/features/home/ui/InboxMessageRow.tsx @@ -30,6 +30,7 @@ type InboxMessageRowProps = { isFirst?: boolean; isFocusHighlightVisible: boolean; message: InboxDisplayMessage; + onEdit?: (message: InboxDisplayMessage) => void; onSelectReplyTarget: (message: InboxDisplayMessage) => void; onToggleReaction?: ( message: TimelineMessage, @@ -46,6 +47,7 @@ export function InboxMessageRow({ isFirst = false, isFocusHighlightVisible, message, + onEdit, onSelectReplyTarget, onToggleReaction, }: InboxMessageRowProps) { @@ -112,7 +114,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/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 b3c1da9485..41bb589873 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"; @@ -12,6 +15,10 @@ import { HOME_MENTION_EVENT_KINDS } from "@/shared/constants/kinds"; type InboxThreadContextResult = { events: RelayEvent[]; 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. */ @@ -181,9 +188,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 @@ -192,6 +200,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 @@ -219,6 +278,7 @@ export function useInboxThreadContext( React.useEffect(() => { let isCancelled = false; + setReactionEvents([]); void fetchReactions().then((fetched) => { if (!isCancelled && fetched !== null) { @@ -241,6 +301,8 @@ export function useInboxThreadContext( return { events, isLoading, + structuralEvents, + refreshStructuralEvents, reactionEvents, refreshReactions, }; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 5d0505a308..b3e2ee5e09 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -201,6 +201,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. */ @@ -888,6 +890,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; @@ -2645,6 +2649,10 @@ const mockChannels: MockChannel[] = [ ]; const mockMessages = new Map(); +const deferredSendMessageLiveEchoes: Array<{ + channelId: string; + event: RelayEvent; +}> = []; const mockUserStatuses: RelayEvent[] = []; const mockReminderEvents: RelayEvent[] = []; let mockRelayMembers: RawRelayMember[] = []; @@ -3677,6 +3685,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) { for (const socket of mockSockets.values()) { for (const [subId, subscription] of socket.subscriptions) { @@ -7784,6 +7804,7 @@ async function handleSendChannelMessage( mentionPubkeys?: string[]; mediaTags?: string[][] | null; emojiTags?: string[][] | null; + mentionTags?: string[][] | null; }, config: E2eConfig | undefined, ): Promise { @@ -7803,8 +7824,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); @@ -7820,7 +7843,7 @@ async function handleSendChannelMessage( ...extraTags, ]); recordMockMessage(args.channelId, event); - emitMockLiveEvent(args.channelId, event); + emitOrDeferMockSendMessageLiveEcho(args.channelId, event, config); return { event_id: event.id, @@ -7883,7 +7906,7 @@ async function handleSendChannelMessage( }; recordMockMessage(args.channelId, event); - emitMockLiveEvent(args.channelId, event); + emitOrDeferMockSendMessageLiveEcho(args.channelId, event, config); return { event_id: event.id, @@ -8659,6 +8682,7 @@ export function maybeInstallE2eTauriMocks() { } mockClosedChannelLiveSubscription = false; + deferredSendMessageLiveEchoes.length = 0; mockGlobalAgentConfig = config.mock?.globalAgentConfig ? { ...config.mock.globalAgentConfig } : null; @@ -8861,6 +8885,13 @@ export function maybeInstallE2eTauriMocks() { for (const socketId of socketIds) disconnectMockSocket(socketId); return socketIds.length; }; + 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 new file mode 100644 index 0000000000..27a6d458fa --- /dev/null +++ b/desktop/tests/e2e/inbox-edit.spec.ts @@ -0,0 +1,313 @@ +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 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 = { + 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_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, + ) => Promise; + __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("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, +}) => { + 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); + 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, + ); +}); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 25a93820f8..200359b614 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -211,6 +211,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. */