fix(desktop): enable message editing in Inbox (#2198)

### What changed?

Inbox detail now gives the current user's messages the same
ownership-gated Edit action as channel view. Editing reuses the existing
composer and mutation flow, preserves attachment metadata, and refreshes
structural overlays so the edited content appears immediately.

Foreign authors' messages remain non-editable, including grouped Inbox
conversations whose selected event is not the representative item.

| Own Inbox message exposes **Edit message**. | Saving the edit updates
the Inbox detail immediately. |
| --- | --- |
| ![Before: Edit message action in Inbox
detail](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/2198/inbox-edit-before.png)
| ![After: edited Inbox message
content](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/2198/inbox-edit-after.png)
|

### Why?

Inbox rows did not pass an edit handler into the shared message action
bar, so a user's own messages could be edited from channel view but not
from Inbox detail.

### How is it tested?

Desktop checks, unit tests, and the full local CI gate passed. The
focused Inbox Playwright regression passed 3 consecutive runs and covers
current-user edit/save, foreign and archived-channel denial, and
attachment preservation when a just-sent reply is edited before its
relay echo arrives.

Added tests:

-
[`inbox-edit.spec.ts`](https://github.com/block/buzz/blob/inbox-message-edit-action/desktop/tests/e2e/inbox-edit.spec.ts)
-
[`inboxViewHelpers.test.mjs`](https://github.com/block/buzz/blob/inbox-message-edit-action/desktop/src/features/home/lib/inboxViewHelpers.test.mjs)

*🤖 This PR was authored [with an
agent](buzz://message?channel=7f2d7e02-f4d5-4fb0-a426-0ca60ed3a1c3&id=c09ee04d18399b90296c3f932d22ab0377fa05f7e690ec7b08c36483ee633fbb).*

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
Signed-off-by: npub1ft62tztwwm2x9xamk25smmuaj4sfckdkldksruf2x2jwqalffkrq0g7arr <4af4a5896e76d4629bbbb2a90def9d95609c59b6fb6d01f12a32a4e077e94d86@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1ft62tztwwm2x9xamk25smmuaj4sfckdkldksruf2x2jwqalffkrq0g7arr <4af4a5896e76d4629bbbb2a90def9d95609c59b6fb6d01f12a32a4e077e94d86@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Tom Brow
2026-08-05 17:00:23 -06:00
committed by GitHub
co-authored by npub1ft62tztwwm2x9xamk25smmuaj4sfckdkldksruf2x2jwqalffkrq0g7arr
parent e14fff74d0
commit eb6a37569d
10 changed files with 953 additions and 37 deletions
+1
View File
@@ -102,6 +102,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",
+54 -19
View File
@@ -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,
@@ -52,6 +53,7 @@ import {
} from "@/features/messages/hooks";
import { collectMessageMentionPubkeys } from "@/features/messages/lib/formatTimelineMessages";
import { formatTime } from "@/features/messages/lib/dateFormatters";
import { DeleteMessageConfirmDialog } from "@/features/messages/ui/DeleteMessageConfirmDialog";
import { splitOutgoingTags } from "@/features/messages/lib/imetaMediaMarkdown";
import { getThreadReference } from "@/features/messages/lib/threading";
import { useUsersBatchQuery } from "@/features/profile/hooks";
@@ -167,6 +169,7 @@ export function HomeView({
);
const { goChannel } = useAppNavigation();
const openDmMutation = useOpenDmMutation();
const openDm = openDmMutation.mutateAsync;
const handleUserSelectItem = React.useCallback(
(itemId: string | null) => {
setAutoSelectedEventId(null);
@@ -209,13 +212,15 @@ export function HomeView({
[applyInboxSearchPatch],
);
const [isDeletingMessage, setIsDeletingMessage] = React.useState(false);
const [emptyDeleteId, setEmptyDeleteId] = React.useState<string | null>(null);
const [editTargetId, setEditTargetId] = React.useState<string | null>(null);
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<
@@ -304,6 +309,10 @@ export function HomeView({
isChannelLoading: channelMessagesQuery.isPending,
},
);
const { editMessage, isEditingMessage } = useInboxEditMessage(
selectedChannel,
threadContext.refreshStructuralEvents,
);
const feedProfilePubkeys = React.useMemo(
() => [
@@ -447,6 +456,25 @@ export function HomeView({
}
return null;
}, [filteredItems, selectedConversationId, selectedEventId]);
const deleteInboxMessage = React.useCallback(
async (eventId: string) => {
const channelId = selectedItem?.item.channelId;
if (!channelId) return;
setIsDeletingMessage(true);
try {
await deleteMessage(channelId, eventId);
await threadContext.refreshStructuralEvents();
onRefresh();
} finally {
setIsDeletingMessage(false);
}
},
[
onRefresh,
selectedItem?.item.channelId,
threadContext.refreshStructuralEvents,
],
);
const unreadBoundaryEventId = React.useMemo(() => {
if (!selectedItem) return null;
if (unreadBoundary?.conversationId === selectedItem.conversationId) {
@@ -465,6 +493,7 @@ export function HomeView({
selectedChannel,
selectedEventId,
selectedItem,
structuralEvents: threadContext.structuralEvents,
});
const selectedItemReplies = React.useMemo<InboxReply[]>(() => {
if (!selectedItem) return [];
@@ -490,6 +519,8 @@ export function HomeView({
React.useEffect(() => {
void selectedConversationId;
setEmptyDeleteId(null);
setEditTargetId(null);
setIsDeletingMessage(false);
setIsSendingReply(false);
}, [selectedConversationId]);
@@ -607,6 +638,19 @@ export function HomeView({
return (
<ProfilePanelProvider onOpenProfilePanel={handleOpenProfilePanel}>
<DeleteMessageConfirmDialog
onConfirm={() => {
if (emptyDeleteId) {
setEditTargetId(null);
void deleteInboxMessage(emptyDeleteId);
}
setEmptyDeleteId(null);
}}
onOpenChange={(open) => {
if (!open) setEmptyDeleteId(null);
}}
open={emptyDeleteId !== null}
/>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div
className={cn(
@@ -750,6 +794,7 @@ export function HomeView({
currentPubkey={currentPubkey}
disabledReplyReason={disabledReplyReason}
isDeletingMessage={isDeletingMessage}
isEditingMessage={isEditingMessage}
isSendingReply={isSendingReply}
isSinglePanelView={isSinglePanelDetailView}
hasThreadContextLoadError={threadContext.hasLoadError}
@@ -760,6 +805,8 @@ export function HomeView({
profiles={feedProfiles}
selectedEventId={selectedEventId}
unreadBoundaryEventId={unreadBoundaryEventId}
editTargetId={editTargetId}
onEditTargetChange={setEditTargetId}
onBack={
isSinglePanelDetailView
? () => {
@@ -768,27 +815,15 @@ export function HomeView({
: undefined
}
onDelete={() => {
if (!selectedItem || !canDelete) {
return;
}
const channelId = selectedItem.item.channelId;
if (!channelId) {
return;
}
setIsDeletingMessage(true);
void deleteMessage(channelId, selectedItem.id)
.then(() => {
onRefresh();
})
.finally(() => {
setIsDeletingMessage(false);
});
if (!selectedItem || !canDelete) return;
void deleteInboxMessage(selectedItem.id);
}}
onManageChannel={(channelId) => {
handleCloseProfilePanel();
setManagedChannelId(channelId);
}}
onEditSave={editMessage}
onRequestEmptyEditDelete={setEmptyDeleteId}
onOpenContext={onOpenContext}
onSendReply={async ({
content,
@@ -843,7 +878,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) => ({
@@ -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,7 +69,9 @@ type InboxDetailPaneProps = {
canReply: boolean;
disabledReplyReason?: string | null;
isDeletingMessage?: boolean;
isEditingMessage?: boolean;
isSendingReply?: boolean;
editTargetId: string | null;
isSinglePanelView?: boolean;
hasThreadContextLoadError?: boolean;
isThreadContextLoading?: boolean;
@@ -96,6 +100,14 @@ type InboxDetailPaneProps = {
latchedDefaultParentId?: string | null;
onBack?: () => void;
onDelete: () => void;
onEditTargetChange: React.Dispatch<React.SetStateAction<string | null>>;
onEditSave: (input: {
content: string;
eventId: string;
mediaTags?: string[][];
mentionPubkeys?: string[];
}) => Promise<void>;
onRequestEmptyEditDelete: (eventId: string) => void;
onManageChannel: (channelId: string) => void;
onOpenContext: (
channelId: string,
@@ -137,7 +149,9 @@ function InboxMessageDetailPane({
canOpenChannel,
canReply,
disabledReplyReason,
editTargetId,
isDeletingMessage = false,
isEditingMessage = false,
isSendingReply = false,
isSinglePanelView = false,
hasThreadContextLoadError = false,
@@ -154,6 +168,9 @@ function InboxMessageDetailPane({
latchedDefaultParentId = null,
onBack,
onDelete,
onEditTargetChange,
onEditSave,
onRequestEmptyEditDelete,
onManageChannel,
onOpenContext,
onSendReply,
@@ -382,6 +399,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 +456,14 @@ function InboxMessageDetailPane({
setReplyTargetId((currentReplyTargetId) =>
currentReplyTargetId === message.id ? null : message.id,
);
onEditTargetChange(null);
focusComposer();
};
const handleSelectEditTarget = (message: InboxDisplayMessage) => {
onEditTargetChange((currentEditTargetId) =>
currentEditTargetId === message.id ? null : message.id,
);
setReplyTargetId(null);
focusComposer();
};
@@ -588,6 +623,24 @@ 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,
);
const canEditMessage =
channel?.archivedAt === null && canManageMessage;
return (
<InboxMessageRow
agentPubkeys={agentPubkeys}
@@ -598,6 +651,7 @@ function InboxMessageDetailPane({
isFocusHighlightVisible={isFocusHighlightVisible}
key={message.id}
message={message}
onEdit={canEditMessage ? handleSelectEditTarget : undefined}
onSelectReplyTarget={handleSelectReplyTarget}
onToggleReaction={onToggleReaction}
showUnreadBoundary={hasUnreadBoundary}
@@ -647,16 +701,41 @@ function InboxMessageDetailPane({
channelName={item.channelLabel ?? "channel"}
channelType={composerChannelType}
containerClassName="px-4 pb-4 sm:px-4"
disabled={!canReply}
disabled={!canReply && !composerEditTarget}
draftKey={
isDirectMessage
? (item.item.channelId ?? item.conversationId)
: `thread:${item.conversationId}`
}
isSending={isSendingReply}
editTarget={composerEditTarget}
isSending={isSendingReply || isEditingMessage}
onCancelEdit={
composerEditTarget ? () => onEditTargetChange(null) : undefined
}
onCancelReply={
composerReplyTarget ? () => setReplyTargetId(null) : undefined
}
onEditSave={async (content, mediaTags, mentionPubkeys) => {
if (!composerEditTarget) {
return;
}
// Empty edits are delete shorthand. Keep edit mode active while
// confirmation is open so Cancel returns to the editor.
const isEmptyDeletion =
content.trim().length === 0 &&
(mediaTags === undefined || mediaTags.length === 0);
if (isEmptyDeletion) {
onRequestEmptyEditDelete(composerEditTarget.id);
return;
}
await onEditSave({
content,
eventId: composerEditTarget.id,
mediaTags,
mentionPubkeys,
});
onEditTargetChange(null);
}}
onSend={(content, mentionPubkeys, mediaTags) =>
onSendReply({
content,
@@ -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 ? (
<div
className={cn(
"absolute right-2 top-1 z-10",
@@ -126,6 +128,7 @@ export function InboxMessageRow({
<MessageActionBar
channelId={channelId}
message={timelineMessage}
onEdit={onEdit ? () => onEdit(message) : undefined}
onReactionSelect={
canToggleReactions ? handleReactionSelect : undefined
}
@@ -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,
]);
}
@@ -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<void>,
) {
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;
}
@@ -2,6 +2,7 @@ import * as React from "react";
import { isInboxThreadContextEvent } from "@/features/home/lib/inboxViewHelpers";
import { relayEventFromFeedItem } from "@/features/home/lib/inbox";
import { fetchStructuralAuxForMessages } from "@/features/messages/lib/auxBackfill";
import { getThreadReference } from "@/features/messages/lib/threading";
import { relayClient } from "@/shared/api/relayClient";
import { buildChannelReactionAuxFilter } from "@/shared/api/relayChannelFilters";
@@ -16,6 +17,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<void>;
/** 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 +255,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<RelayEvent[]>([]);
// 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 +267,58 @@ export function useInboxThreadContext(
.join(","),
[events],
);
const [structuralEvents, setStructuralEvents] = React.useState<RelayEvent[]>(
[],
);
const fetchStructuralEvents = React.useCallback(async (): Promise<
RelayEvent[] | null
> => {
const eventIds = contextEventIdsKey ? contextEventIdsKey.split(",") : [];
if (!selectedChannelId || eventIds.length === 0) {
return [];
}
try {
// Two hops, not one. A deletion can target an edit event rather than the
// original message, and `formatTimelineMessages` drops an edit only when
// the edit's own id is in the deletion set. A one-hop fetch therefore
// re-applies retracted content on a cold Inbox open. The channel and
// thread paths already resolve this closure with the same helper.
return await fetchStructuralAuxForMessages(selectedChannelId, eventIds);
} 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<RelayEvent[]>([]);
const fetchReactions = React.useCallback(async (): Promise<
RelayEvent[] | null
@@ -288,6 +346,7 @@ export function useInboxThreadContext(
React.useEffect(() => {
let isCancelled = false;
setReactionEvents([]);
void fetchReactions().then((fetched) => {
if (!isCancelled && fetched !== null) {
@@ -313,6 +372,8 @@ export function useInboxThreadContext(
? options.hasChannelLoadError === true
: hasLoadError,
isLoading: fullChannel ? options.isChannelLoading === true : isLoading,
structuralEvents,
refreshStructuralEvents,
reactionEvents,
refreshReactions,
};
+57 -11
View File
@@ -314,6 +314,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. */
@@ -1058,6 +1060,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_EMIT_MEDIA_UPLOAD_PHASE__?: (input: {
id: string;
phase: string;
@@ -2951,6 +2955,10 @@ const mockChannels: MockChannel[] = [
];
const mockMessages = new Map<string, RelayEvent[]>();
const deferredSendMessageLiveEchoes: Array<{
channelId: string;
event: RelayEvent;
}> = [];
const mockUserStatuses: RelayEvent[] = [];
const mockReminderEvents: RelayEvent[] = [];
const mockPersonaEvents: RelayEvent[] = [];
@@ -4184,6 +4192,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 &&
@@ -8907,6 +8927,7 @@ async function handleSendChannelMessage(
mentionPubkeys?: string[];
mediaTags?: string[][] | null;
emojiTags?: string[][] | null;
mentionTags?: string[][] | null;
},
config: E2eConfig | undefined,
): Promise<RawSendChannelMessageResponse> {
@@ -8926,8 +8947,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);
@@ -8943,7 +8966,7 @@ async function handleSendChannelMessage(
...extraTags,
]);
recordMockMessage(args.channelId, event);
emitMockLiveEvent(args.channelId, event);
emitOrDeferMockSendMessageLiveEcho(args.channelId, event, config);
return {
event_id: event.id,
@@ -9006,7 +9029,7 @@ async function handleSendChannelMessage(
};
recordMockMessage(args.channelId, event);
emitMockLiveEvent(args.channelId, event);
emitOrDeferMockSendMessageLiveEcho(args.channelId, event, config);
return {
event_id: event.id,
@@ -9117,19 +9140,33 @@ async function handleSendManagedAgentChannelMessage(
/**
* Mock the `delete_message` Tauri command. Removes the event from the
* in-memory mock store so the query-cache invalidation in
* `useDeleteMessageMutation.onSuccess` (which filters by eventId) finds
* nothing to keep, and the row disappears from the timeline.
* in-memory mock store and records the kind:5 structural event used by Inbox
* refreshes. Do not emit it live: mock target IDs may fail the production
* 64-hex deletion filter, letting a live merge restore the flattened row.
*/
function handleDeleteMessage(args: {
channelId: string;
eventId: string;
}): void {
function handleDeleteMessage(
args: {
channelId: string;
eventId: string;
},
config: E2eConfig | undefined,
): void {
const history = mockMessages.get(args.channelId);
if (history) {
const index = history.findIndex((ev) => ev.id === args.eventId);
if (index !== -1) history.splice(index, 1);
}
const deletion = createMockEvent(
KIND_DELETION,
"",
[
["e", args.eventId],
["h", args.channelId],
],
getMockMemberPubkey(config),
);
recordMockMessage(args.channelId, deletion);
}
/**
@@ -9907,6 +9944,7 @@ export function maybeInstallE2eTauriMocks() {
mockWebsocketUnavailable = false;
mockAuthResponses.length = 0;
relayWebsocketConnectAttemptStarts.length = 0;
deferredSendMessageLiveEchoes.length = 0;
mockGlobalAgentConfig = config.mock?.globalAgentConfig
? { ...config.mock.globalAgentConfig }
: null;
@@ -10199,6 +10237,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) => {
@@ -12434,6 +12479,7 @@ export function maybeInstallE2eTauriMocks() {
case "delete_message":
handleDeleteMessage(
payload as Parameters<typeof handleDeleteMessage>[0],
activeConfig,
);
return null;
case "edit_message":
+650
View File
@@ -0,0 +1,650 @@
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 EMPTY_DELETE_ROOT_ID = "a1".repeat(32);
const EMPTY_DELETE_REPLY_ID = "b2".repeat(32);
const EMPTY_DELETE_ROOT_CONTENT = "Inbox empty-edit root message.";
const EMPTY_DELETE_REPLY_CONTENT =
"Selected Inbox reply remains after root deletion.";
const COLD_ROOT_ID = "c3".repeat(32);
const COLD_SELECTED_REPLY_ID = "f4".repeat(32);
const COLD_DELETED_EDIT_ID = "0a".repeat(32);
const COLD_SIBLING_REPLY_ID = "1b".repeat(32);
const COLD_SIBLING_EDIT_ID = "2c".repeat(32);
const COLD_ROOT_CONTENT = "Cold Inbox thread root.";
const COLD_SELECTED_ORIGINAL_CONTENT = "Cold Inbox reply original text.";
const COLD_SELECTED_RETRACTED_CONTENT = "Cold Inbox reply retracted edit text.";
const COLD_SIBLING_ORIGINAL_CONTENT = "Cold Inbox sibling reply original text.";
const COLD_SIBLING_EDITED_CONTENT = "Cold Inbox sibling reply edited text.";
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: "mention";
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_EMIT_MOCK_MESSAGE__?: (input: {
channelName: string;
content: string;
parentEventId?: string | null;
pubkey?: string;
mentionPubkeys?: string[];
id?: string;
kind?: number;
extraTags?: string[][];
}) => {
content: string;
created_at: number;
id: string;
kind: number;
pubkey: string;
tags: string[][];
};
__BUZZ_E2E_INVALIDATE_CHANNELS__?: () => Promise<void>;
__BUZZ_E2E_RELEASE_SEND_MESSAGE_LIVE_ECHO__?: () => number;
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: (
command: string,
payload?: Record<string, unknown>,
) => Promise<unknown>;
__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();
}
async function seedEmptyDeleteThread(page: import("@playwright/test").Page) {
await page.waitForFunction(() => {
const win = window as MockWindow;
return (
typeof win.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" &&
typeof win.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__ === "function"
);
});
await page.evaluate(
({
channelId,
currentPubkey,
replyContent,
replyId,
rootContent,
rootId,
}) => {
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("Mock bridge helpers are unavailable.");
const root = emit({
channelName: "general",
content: rootContent,
id: rootId,
pubkey: currentPubkey,
});
const reply = emit({
channelName: "general",
content: replyContent,
id: replyId,
mentionPubkeys: [currentPubkey],
parentEventId: root.id,
pubkey: currentPubkey,
});
push({
category: "mention",
channel_id: channelId,
channel_name: "general",
content: reply.content,
created_at: reply.created_at,
id: reply.id,
kind: reply.kind,
pubkey: reply.pubkey,
tags: reply.tags,
});
},
{
channelId: GENERAL_CHANNEL_ID,
currentPubkey: CURRENT_PUBKEY,
replyContent: EMPTY_DELETE_REPLY_CONTENT,
replyId: EMPTY_DELETE_REPLY_ID,
rootContent: EMPTY_DELETE_ROOT_CONTENT,
rootId: EMPTY_DELETE_ROOT_ID,
},
);
await page.getByTestId(`home-inbox-item-${EMPTY_DELETE_REPLY_ID}`).click();
const detail = page.getByTestId("home-inbox-detail");
const rootRow = detail.locator(`[data-message-id="${EMPTY_DELETE_ROOT_ID}"]`);
await expect(rootRow).toContainText(EMPTY_DELETE_ROOT_CONTENT);
await expect(
detail.locator(`[data-message-id="${EMPTY_DELETE_REPLY_ID}"]`),
).toContainText(EMPTY_DELETE_REPLY_CONTENT);
return { detail, rootRow };
}
async function submitEmptyEdit(
page: import("@playwright/test").Page,
detail: import("@playwright/test").Locator,
messageId: string,
) {
await openMoreActions(page, messageId);
const editAction = page.getByTestId(`edit-message-${messageId}`);
await expect(editAction).toBeVisible();
await editAction.click();
await expect(detail.getByTestId("edit-target")).toBeVisible();
const input = detail.getByTestId("message-input");
await expect(input).not.toBeEmpty();
await input.fill("");
await expect(input).toBeEmpty();
await page.keyboard.press("Enter");
}
function editCommandCount(page: import("@playwright/test").Page) {
return page.evaluate(
() =>
((window as MockWindow).__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).filter(
(entry) => entry.command === "edit_message",
).length,
);
}
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: "mention",
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],
["p", pubkey],
],
});
},
{
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: "mention",
channel_id: channelId,
channel_name: "general",
content: message.content,
created_at: message.createdAt,
id: message.id,
kind: 9,
pubkey: message.pubkey,
tags: [
["h", channelId],
["p", currentPubkey],
],
});
}
},
{
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,
);
});
test("empty edit confirms deletion of the edited non-selected Inbox row", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await expect(page.getByTestId("home-inbox-list")).toBeVisible();
const { detail, rootRow } = await seedEmptyDeleteThread(page);
const selectedReplyRow = detail.locator(
`[data-message-id="${EMPTY_DELETE_REPLY_ID}"]`,
);
const editsBefore = await editCommandCount(page);
await submitEmptyEdit(page, detail, EMPTY_DELETE_ROOT_ID);
const dialog = page.getByRole("alertdialog");
await expect(dialog).toContainText("Delete message?");
await expect(detail.getByTestId("edit-target")).toBeVisible();
expect(await editCommandCount(page)).toBe(editsBefore);
await dialog.getByRole("button", { name: "Delete" }).click();
await expect(dialog).toBeHidden();
await expect(detail.getByTestId("edit-target")).toBeHidden();
await expect(rootRow).toBeHidden();
await expect(selectedReplyRow).toContainText(EMPTY_DELETE_REPLY_CONTENT);
expect(await editCommandCount(page)).toBe(editsBefore);
const deletePayload = await page.evaluate(() => {
const payloads = (window as MockWindow).__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [];
return payloads.findLast((entry) => entry.command === "delete_message")
?.payload;
});
expect(deletePayload).toEqual(
expect.objectContaining({ eventId: EMPTY_DELETE_ROOT_ID }),
);
});
test("cancelling an empty Inbox edit deletion preserves content and edit mode", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await expect(page.getByTestId("home-inbox-list")).toBeVisible();
const { detail, rootRow } = await seedEmptyDeleteThread(page);
const editsBefore = await editCommandCount(page);
await submitEmptyEdit(page, detail, EMPTY_DELETE_ROOT_ID);
const dialog = page.getByRole("alertdialog");
await expect(dialog).toContainText("Delete message?");
await expect(detail.getByTestId("edit-target")).toBeVisible();
expect(await editCommandCount(page)).toBe(editsBefore);
await dialog.getByRole("button", { name: "Cancel" }).click();
await expect(dialog).toBeHidden();
await expect(detail.getByTestId("edit-target")).toBeVisible();
await expect(rootRow).toContainText(EMPTY_DELETE_ROOT_CONTENT);
await expect(
detail.locator(`[data-message-id="${EMPTY_DELETE_REPLY_ID}"]`),
).toContainText(EMPTY_DELETE_REPLY_CONTENT);
expect(await editCommandCount(page)).toBe(editsBefore);
expect(
await page.evaluate(
() =>
((window as MockWindow).__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).filter(
(entry) => entry.command === "delete_message",
).length,
),
).toBe(0);
});
// Regression test for the cold Inbox hydration closure. A deletion can target
// an edit event instead of the original message, and `formatTimelineMessages`
// drops an edit only when the edit's own id is in the deletion set. A one-hop
// `#e` fetch over the context message ids returns the edit but not the deletion
// of that edit, so a cold Inbox open re-applies retracted content. The sibling
// reply is the positive control: its live edit is NOT deleted, so it must still
// render as edited after the same cold fetch.
test("cold Inbox open drops an edit that was itself deleted", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await expect(page.getByTestId("home-inbox-list")).toBeVisible();
await page.waitForFunction(() => {
const win = window as MockWindow;
return (
typeof win.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ === "function" &&
typeof win.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__ === "function"
);
});
await page.evaluate(
({
channelId,
currentPubkey,
deletedEditId,
rootContent,
rootId,
selectedOriginalContent,
selectedReplyId,
selectedRetractedContent,
siblingEditId,
siblingEditedContent,
siblingOriginalContent,
siblingReplyId,
}) => {
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("Mock bridge helpers are unavailable.");
}
const root = emit({
channelName: "general",
content: rootContent,
id: rootId,
pubkey: currentPubkey,
});
const selectedReply = emit({
channelName: "general",
content: selectedOriginalContent,
id: selectedReplyId,
mentionPubkeys: [currentPubkey],
parentEventId: root.id,
pubkey: currentPubkey,
});
const siblingReply = emit({
channelName: "general",
content: siblingOriginalContent,
id: siblingReplyId,
parentEventId: root.id,
pubkey: currentPubkey,
});
// The retracted edit: published, then deleted by a kind:5 that targets
// the EDIT event, never the reply itself.
const retractedEdit = emit({
channelName: "general",
content: selectedRetractedContent,
extraTags: [["e", selectedReply.id]],
id: deletedEditId,
kind: 40003,
pubkey: currentPubkey,
});
emit({
channelName: "general",
content: "",
extraTags: [["e", retractedEdit.id]],
kind: 5,
pubkey: currentPubkey,
});
// Positive control: a live edit with no deletion of its own.
emit({
channelName: "general",
content: siblingEditedContent,
extraTags: [["e", siblingReply.id]],
id: siblingEditId,
kind: 40003,
pubkey: currentPubkey,
});
push({
category: "mention",
channel_id: channelId,
channel_name: "general",
content: selectedReply.content,
created_at: selectedReply.created_at,
id: selectedReply.id,
kind: selectedReply.kind,
pubkey: selectedReply.pubkey,
tags: selectedReply.tags,
});
},
{
channelId: GENERAL_CHANNEL_ID,
currentPubkey: CURRENT_PUBKEY,
deletedEditId: COLD_DELETED_EDIT_ID,
rootContent: COLD_ROOT_CONTENT,
rootId: COLD_ROOT_ID,
selectedOriginalContent: COLD_SELECTED_ORIGINAL_CONTENT,
selectedReplyId: COLD_SELECTED_REPLY_ID,
selectedRetractedContent: COLD_SELECTED_RETRACTED_CONTENT,
siblingEditId: COLD_SIBLING_EDIT_ID,
siblingEditedContent: COLD_SIBLING_EDITED_CONTENT,
siblingOriginalContent: COLD_SIBLING_ORIGINAL_CONTENT,
siblingReplyId: COLD_SIBLING_REPLY_ID,
},
);
// First selection of this item: every edit and deletion must come from the
// hydration fetch, not from an optimistic local write.
await page.getByTestId(`home-inbox-item-${COLD_SELECTED_REPLY_ID}`).click();
const detail = page.getByTestId("home-inbox-detail");
const siblingRow = detail.locator(
`[data-message-id="${COLD_SIBLING_REPLY_ID}"]`,
);
await expect(siblingRow).toContainText(COLD_SIBLING_EDITED_CONTENT);
const selectedRow = detail.locator(
`[data-message-id="${COLD_SELECTED_REPLY_ID}"]`,
);
await expect(selectedRow).toContainText(COLD_SELECTED_ORIGINAL_CONTENT);
await expect(selectedRow).not.toContainText(COLD_SELECTED_RETRACTED_CONTENT);
await expect(detail).not.toContainText(COLD_SELECTED_RETRACTED_CONTENT);
});
+2
View File
@@ -275,6 +275,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. */