From ab0e75a9cd1ca694d13852813da89f6835a16b01 Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 14 Aug 2026 14:05:32 -0600 Subject: [PATCH] feat(desktop): show sends while attachments prepare **Category:** feat **User Impact:** Messages with delayed link previews or deferred attachments appear in the timeline immediately with an inline preparation status. Stage a send-scoped optimistic row only after destination and audience preflight, then let relay publication adopt the same stable local key. Failure and cancellation remove only that row before composer or draft recovery, preserving unrelated concurrent timeline updates. Co-authored-by: Carl Signed-off-by: Wes --- .../src/features/channels/ui/ChannelPane.tsx | 6 + .../features/channels/ui/ChannelPane.types.ts | 4 + .../features/channels/ui/ChannelScreen.tsx | 6 + .../channels/useChannelPaneHandlers.ts | 4 + desktop/src/features/messages/hooks.ts | 122 +++++++++++++++++- .../lib/projectChannelWindow.test.mjs | 38 +++++- .../features/messages/ui/MessageComposer.tsx | 8 +- .../messages/ui/MessageComposer.types.ts | 10 ++ .../src/features/messages/ui/MessageRow.tsx | 2 + .../messages/ui/MessageThreadPanel.tsx | 20 +-- .../messages/ui/PendingMessagePreparation.tsx | 43 ++++++ .../messages/ui/useMentionSendFlow.ts | 112 ++++++++-------- .../messages/ui/useMentionSendFlow.types.ts | 65 ++++++++++ desktop/tests/e2e/file-attachment.spec.ts | 10 +- desktop/tests/e2e/messaging.spec.ts | 7 +- 15 files changed, 379 insertions(+), 78 deletions(-) create mode 100644 desktop/src/features/messages/ui/PendingMessagePreparation.tsx create mode 100644 desktop/src/features/messages/ui/useMentionSendFlow.types.ts diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 1ec6cee95..bebabb71d 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -125,6 +125,8 @@ export const ChannelPane = React.memo(function ChannelPane({ onResetThreadPanelWidth, onSelectThreadReplyTarget, onSendMessage, + onStagePendingSend, + onRemovePendingSend, onSendToChannel, onSendVideoReviewComment, onSendThreadReply, @@ -712,6 +714,8 @@ export const ChannelPane = React.memo(function ChannelPane({ : undefined } onSend={handleSendMessage} + onStagePendingSend={onStagePendingSend} + onRemovePendingSend={onRemovePendingSend} profiles={profiles} showBackgroundUploadProgress={false} placeholder={ @@ -814,6 +818,8 @@ export const ChannelPane = React.memo(function ChannelPane({ onExpandReplies={onExpandThreadReplies} onSelectReplyTarget={onSelectThreadReplyTarget} onSend={onSendThreadReply} + onStagePendingSend={onStagePendingSend} + onRemovePendingSend={onRemovePendingSend} onSendToChannel={ isComposerDisabled ? undefined : onSendToChannel } diff --git a/desktop/src/features/channels/ui/ChannelPane.types.ts b/desktop/src/features/channels/ui/ChannelPane.types.ts index 760ef5807..ddbe943cd 100644 --- a/desktop/src/features/channels/ui/ChannelPane.types.ts +++ b/desktop/src/features/channels/ui/ChannelPane.types.ts @@ -14,6 +14,7 @@ import type { } from "@/features/profile/ui/UserProfilePanel"; import type { ProfilePanelOpenOptions } from "@/shared/context/ProfilePanelContext"; import type { Channel } from "@/shared/api/types"; +import type { MessageComposerProps } from "@/features/messages/ui/MessageComposer.types"; export type ChannelPaneProps = { activeChannel: Channel | null; activityAgents?: BotActivityAgent[]; @@ -115,7 +116,10 @@ export type ChannelPaneProps = { threadHeadId: string | null; } | null, forceRest?: boolean, + optimisticId?: string, ) => Promise; + onStagePendingSend?: MessageComposerProps["onStagePendingSend"]; + onRemovePendingSend?: MessageComposerProps["onRemovePendingSend"]; onSendToChannel: ( message: TimelineMessage, threadRoot: TimelineMessage, diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 6254afd8c..1d412f5fc 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -919,6 +919,12 @@ export function ChannelScreen({ onOpenThread={handleOpenThreadAndCloseAgentSession} onSelectThreadReplyTarget={handleSelectThreadReplyTarget} onSendMessage={handleSendMessage} + onStagePendingSend={ + sendMessageMutation.stageOptimisticMessage + } + onRemovePendingSend={ + sendMessageMutation.removeOptimisticMessage + } onSendToChannel={handleSendToChannel} onSendVideoReviewComment={effectiveSendVideoReviewComment} onSendThreadReply={handleSendThreadReply} diff --git a/desktop/src/features/channels/useChannelPaneHandlers.ts b/desktop/src/features/channels/useChannelPaneHandlers.ts index f9c57f665..09172612f 100644 --- a/desktop/src/features/channels/useChannelPaneHandlers.ts +++ b/desktop/src/features/channels/useChannelPaneHandlers.ts @@ -289,6 +289,7 @@ export function useChannelPaneHandlers({ threadHeadId: string | null; } | null, forceRest?: boolean, + optimisticId?: string, ) => { await sendMutateRef.current({ content, @@ -296,6 +297,7 @@ export function useChannelPaneHandlers({ mediaTags, channelId: channelId ?? undefined, forceRest, + optimisticId, }); }, [], @@ -334,6 +336,7 @@ export function useChannelPaneHandlers({ threadHeadId: string | null; } | null, forceRest?: boolean, + optimisticId?: string, ) => { // Resolve target using captured submit-time context (race-free) or live // refs (legacy path). When threadContext is supplied, no live-ref reads @@ -367,6 +370,7 @@ export function useChannelPaneHandlers({ mediaTags, channelId: channelId ?? undefined, forceRest, + optimisticId, }); // Only update thread UI state if the user is still viewing the same diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index 8b457a7ad..04436c038 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -1,4 +1,4 @@ -import { useEffect, useEffectEvent } from "react"; +import { useCallback, useEffect, useEffectEvent } from "react"; import { type QueryClient, useMutation, @@ -76,10 +76,11 @@ import { type MessageQueryContext = { optimisticId: string; - previousMessages: RelayEvent[]; - previousWindow: ChannelWindowStore | undefined; + previousMessages?: RelayEvent[]; + previousWindow?: ChannelWindowStore; channelId: string; queryKey: ReturnType; + adopted: boolean; }; const CHANNEL_TIMELINE_KINDS = new Set(CHANNEL_TIMELINE_CONTENT_KINDS); @@ -434,13 +435,38 @@ export function useChannelSubscription(channel: Channel | null) { }, [channelId, channelType]); } +export function removeOptimisticChannelWindowMessage( + queryClient: QueryClient, + channelId: string, + optimisticId: string, +) { + const windowKey = channelWindowKey(channelId); + const current = + queryClient.getQueryData(windowKey) ?? + emptyChannelWindowStore(); + queryClient.setQueryData( + channelMessagesKey(channelId), + (messages = []) => + messages.filter( + (event) => event.id !== optimisticId && event.localKey !== optimisticId, + ), + ); + queryClient.setQueryData(windowKey, { + ...current, + liveOverlay: current.liveOverlay.filter( + (event) => event.id !== optimisticId, + ), + }); + projectChannelWindowMessages(queryClient, channelId); +} + export function useSendMessageMutation( channel: Channel | null, identity: Identity | undefined, ) { const queryClient = useQueryClient(); - return useMutation< + const mutation = useMutation< RelayEvent, Error, { @@ -454,6 +480,8 @@ export function useSendMessageMutation( sentFromThreadRootId?: string | null; sentFromThreadRootExcerpt?: string | null; transport?: "auto" | "http"; + /** Adopt a send-scoped pending row that was inserted before preparation. */ + optimisticId?: string; }, MessageQueryContext | undefined >({ @@ -610,6 +638,7 @@ export function useSendMessageMutation( mediaTags, sentFromThreadRootId, sentFromThreadRootExcerpt, + optimisticId, }) => { // Mirror mutationFn's target resolution so the optimistic message lands // in the cache for the same channel as the real send. A caller-supplied @@ -632,6 +661,15 @@ export function useSendMessageMutation( const queryKey = channelMessagesKey(effectiveChannel.id); await queryClient.cancelQueries({ queryKey }); + if (optimisticId) { + return { + optimisticId, + channelId: effectiveChannel.id, + queryKey, + adopted: true, + }; + } + const previousMessages = queryClient.getQueryData(queryKey) ?? []; const windowKey = channelWindowKey(effectiveChannel.id); @@ -662,6 +700,7 @@ export function useSendMessageMutation( previousWindow, channelId: effectiveChannel.id, queryKey, + adopted: false, }; }, onError: (error, _variables, context) => { @@ -673,7 +712,19 @@ export function useSendMessageMutation( return; } - queryClient.setQueryData(context.queryKey, context.previousMessages); + if (context.adopted) { + removeOptimisticChannelWindowMessage( + queryClient, + context.channelId, + context.optimisticId, + ); + return; + } + + queryClient.setQueryData( + context.queryKey, + context.previousMessages ?? [], + ); queryClient.setQueryData( channelWindowKey(context.channelId), context.previousWindow, @@ -705,6 +756,67 @@ export function useSendMessageMutation( projectChannelWindowMessages(queryClient, context.channelId); }, }); + + const stageOptimisticMessage = useCallback( + ({ + channelId: capturedChannelId, + content, + mentionPubkeys = [], + parentEventId = null, + mediaTags = [], + }: { + channelId?: string | null; + content: string; + mentionPubkeys?: string[]; + parentEventId?: string | null; + mediaTags?: string[][]; + }): string | null => { + const effectiveChannel = resolveSendChannel( + undefined, + capturedChannelId, + queryClient.getQueryData(channelsQueryKey), + channel, + ); + if (!effectiveChannel || !identity) return null; + + const queryKey = channelMessagesKey(effectiveChannel.id); + const currentMessages = + queryClient.getQueryData(queryKey) ?? []; + const optimisticMessage = createOptimisticMessage( + effectiveChannel.id, + content.trim(), + identity, + currentMessages, + mentionPubkeys, + parentEventId, + mediaTags, + ); + const windowKey = channelWindowKey(effectiveChannel.id); + const currentWindow = + queryClient.getQueryData(windowKey) ?? + emptyChannelWindowStore(); + queryClient.setQueryData( + windowKey, + mergeLiveChannelWindowEvent(currentWindow, optimisticMessage), + ); + projectChannelWindowMessages(queryClient, effectiveChannel.id); + return optimisticMessage.id; + }, + [channel, identity, queryClient], + ); + + const removeOptimisticMessage = useCallback( + (channelId: string, optimisticId: string) => { + removeOptimisticChannelWindowMessage( + queryClient, + channelId, + optimisticId, + ); + }, + [queryClient], + ); + + return { ...mutation, removeOptimisticMessage, stageOptimisticMessage }; } export function useToggleReactionMutation() { diff --git a/desktop/src/features/messages/lib/projectChannelWindow.test.mjs b/desktop/src/features/messages/lib/projectChannelWindow.test.mjs index 14ec110ad..f207777bd 100644 --- a/desktop/src/features/messages/lib/projectChannelWindow.test.mjs +++ b/desktop/src/features/messages/lib/projectChannelWindow.test.mjs @@ -2,7 +2,10 @@ import assert from "node:assert/strict"; import test from "node:test"; import { QueryClient, QueryObserver } from "@tanstack/react-query"; -import { reconcileFetchedChannelWindow } from "../hooks.ts"; +import { + reconcileFetchedChannelWindow, + removeOptimisticChannelWindowMessage, +} from "../hooks.ts"; import { channelMessagesKey, channelWindowKey } from "./messageQueryKeys.ts"; import { appendOlderChannelWindow, @@ -234,6 +237,39 @@ test("test_reconciliation_retains_identical_pending_sends", () => { ); }); +test("test_failed_identical_pending_send_removes_only_its_stable_key", () => { + const harness = createHarness(); + const older = { + ...event("older-pending", 110), + content: "hello", + pending: true, + }; + const newer = { + ...event("newer-pending", 111), + content: "hello", + pending: true, + }; + appendLiveEvent(harness, older); + appendLiveEvent(harness, newer); + + removeOptimisticChannelWindowMessage( + harness.client, + harness.channelId, + older.id, + ); + + assert.deepEqual( + harness.client.getQueryData(harness.messagesKey).map((item) => item.id), + [event("initial", 100), newer].map((item) => item.id), + ); + assert.deepEqual( + harness.client + .getQueryData(harness.windowKey) + .liveOverlay.map((item) => item.id), + [newer.id], + ); +}); + test("test_reconciliation_acknowledges_only_one_identical_pending_send", () => { const harness = createHarness(); const first = { diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 31a40c86b..11b92169f 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -83,6 +83,8 @@ function MessageComposerImpl({ onPrepareSendChannel, onPreparingMentionSendChange, onSend, + onStagePendingSend, + onRemovePendingSend, placeholder, profiles, replyTarget = null, @@ -312,6 +314,8 @@ function MessageComposerImpl({ mentions, onPrepareSendChannel, onSendRef, + onStagePendingSend, + onRemovePendingSend, richText, setContent: setComposerContent, setIsEmojiPickerOpen, @@ -808,11 +812,9 @@ function MessageComposerImpl({ media.pendingImeta.length === 0 && media.queuedAttachments.length === 0); const handleCaptureSelection = React.useCallback(() => {}, []); - const handlePaperclipClick = React.useCallback(() => { void media.handlePaperclip(); }, [media.handlePaperclip]); - const handleRemoveAttachment = React.useCallback( (url: string) => { setSpoileredAttachmentUrls((current) => { @@ -825,14 +827,12 @@ function MessageComposerImpl({ }, [media.removeAttachment], ); - const { handleAttachmentEditSave, handleAttachmentRevert } = useAttachmentEditing({ revertAttachment: media.revertAttachment, setSpoileredAttachmentUrls, uploadEditedAttachment: media.uploadEditedAttachment, }); - const handleToggleAttachmentSpoiler = React.useCallback((url: string) => { setSpoileredAttachmentUrls((current) => { const next = new Set(current); diff --git a/desktop/src/features/messages/ui/MessageComposer.types.ts b/desktop/src/features/messages/ui/MessageComposer.types.ts index a24be0aea..a29c106d3 100644 --- a/desktop/src/features/messages/ui/MessageComposer.types.ts +++ b/desktop/src/features/messages/ui/MessageComposer.types.ts @@ -93,7 +93,17 @@ export type MessageComposerProps = { } | null, /** Route through the REST publisher even when best-effort enrichment settled empty. */ forceRest?: boolean, + /** Stable key of a pending row inserted before preparation. */ + optimisticId?: string, ) => Promise; + onStagePendingSend?: (input: { + channelId: string; + content: string; + mentionPubkeys: string[]; + parentEventId: string | null; + mediaTags: string[][]; + }) => string | null; + onRemovePendingSend?: (channelId: string, optimisticId: string) => void; placeholder?: string; profiles?: UserProfileLookup; replyTarget?: { diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index de496836c..a93b2e804 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -56,6 +56,7 @@ import { } from "./MessageHeader"; import { MessageTimestamp } from "./MessageTimestamp"; import { SentFromThreadLine } from "./SentFromThreadLine"; +import { PendingMessagePreparation } from "./PendingMessagePreparation"; import { WaveMessageAttachment } from "./WaveMessageAttachment"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; @@ -662,6 +663,7 @@ export const MessageRow = React.memo( <> {renderBody()} + {continuationMetadataNode} Promise; + onStagePendingSend?: MessageComposerProps["onStagePendingSend"]; + onRemovePendingSend?: MessageComposerProps["onRemovePendingSend"]; onSendToChannel?: ( message: TimelineMessage, threadRoot: TimelineMessage, @@ -132,10 +136,8 @@ type MessageThreadPanelProps = ThreadPanelLayoutProps & { /** Called when the thread-composer auto-submit fires so the parent can clear the trigger. */ onAutoSubmitComplete?: () => void; }; - const EMPTY_THREAD_REPLIES: MainTimelineEntry[] = []; const THREAD_PANEL_SUMMARY_INDENT_OFFSET_REM = 0; - function hasLaterVisibleSibling( entries: readonly MainTimelineEntry[], entryIndex: number, @@ -144,17 +146,14 @@ function hasLaterVisibleSibling( if (depth == null) { return false; } - for (let index = entryIndex + 1; index < entries.length; index += 1) { const nextDepth = entries[index].message.depth; if (nextDepth <= depth) { return nextDepth === depth; } } - return false; } - function getActiveContinuationDepths({ ancestors, entries, @@ -167,12 +166,10 @@ function getActiveContinuationDepths({ message: TimelineMessage; }): number[] { const depths: number[] = []; - for (const ancestor of ancestors) { if (ancestor.message.depth === 0) { continue; } - const childDepth = ancestor.message.depth + 1; const pathChild = message.depth === childDepth @@ -221,6 +218,8 @@ export function MessageThreadPanel({ onScrollTargetSettled, onSelectReplyTarget, onSend, + onStagePendingSend, + onRemovePendingSend, onSendToChannel, onToggleReaction, onUnfollowThread, @@ -911,6 +910,7 @@ export function MessageThreadPanel({ onEditLastOwnMessage={onEditLastOwnMessage} onEditSave={onEditSave} onSend={onSend} + {...{ onStagePendingSend, onRemovePendingSend }} placeholder={ isHuddleTranscript ? "Message the huddle" diff --git a/desktop/src/features/messages/ui/PendingMessagePreparation.tsx b/desktop/src/features/messages/ui/PendingMessagePreparation.tsx new file mode 100644 index 000000000..69e9f85f2 --- /dev/null +++ b/desktop/src/features/messages/ui/PendingMessagePreparation.tsx @@ -0,0 +1,43 @@ +import * as React from "react"; + +import type { TimelineMessage } from "@/features/messages/types"; + +type PendingMessagePreparationProps = { + message: TimelineMessage; +}; + +export const PendingMessagePreparation = React.memo( + function PendingMessagePreparation({ + message, + }: PendingMessagePreparationProps) { + const pending = message.pending + ? (message.tags ?? []).filter((tag) => tag[0] === "client-pending") + : []; + if (pending.length === 0) return null; + + return ( +
+ {pending.map((tag, index) => ( +
+
+ ))} +
+ ); + }, +); diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index e322f9198..5842ad42c 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -20,21 +20,14 @@ import { filterEffectiveExplicitAgentPubkeys } from "@/features/messages/lib/eff import { prepareBackgroundMediaUpload, saveQueuedAttachmentsForDraft, - type QueuedMediaAttachment, } from "@/features/messages/lib/backgroundMediaUploadStore"; -import type { UseChannelLinksResult } from "@/features/messages/lib/useChannelLinks"; -import type { UseEmojiAutocompleteResult } from "@/features/messages/lib/useEmojiAutocomplete"; import { buildOutgoingMessage, type ImetaMedia, } from "@/features/messages/lib/imetaMediaMarkdown"; -import type { UseMentionsResult } from "@/features/messages/lib/useMentions"; -import type { UseRichTextEditorResult } from "@/features/messages/lib/useRichTextEditor"; -import type { UseDraftsResult } from "@/features/messages/lib/useDrafts"; import { useActivePreparedLinkPreviews } from "./useActivePreparedLinkPreviews"; import { invokeTauri } from "@/shared/api/tauri"; -import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji"; -import type { AcpRuntime, ChannelType, ManagedAgent } from "@/shared/api/types"; +import type { AcpRuntime, ManagedAgent } from "@/shared/api/types"; import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; import { buildCustomEmojiTags } from "@/shared/lib/customEmojiTags"; import { @@ -48,50 +41,7 @@ import { resolvePreviewTags, uniqueNormalizedPubkeys, } from "./useMentionSendFlow.helpers"; -type UseMentionSendFlowOptions = { - channelId: string | null; - channelLinks: Pick; - channelType: ChannelType | null; - contentRef: React.MutableRefObject; - customEmoji: CustomEmoji[]; - drafts: Pick; - emojiAutocomplete: Pick; - mentions: UseMentionsResult; - onPrepareSendChannel?: (pubkeys?: string[]) => Promise; - onSendRef: React.MutableRefObject< - ( - content: string, - mentionPubkeys: string[], - mediaTags?: string[][], - channelId?: string | null, - threadContext?: { - parentEventId: string | null; - threadHeadId: string | null; - } | null, - forceRest?: boolean, - ) => Promise - >; - richText: Pick< - UseRichTextEditorResult, - "clearContent" | "setContent" | "restorePlainTextAndFocusEnd" - >; - setContent: (content: string) => void; - setIsEmojiPickerOpen: React.Dispatch>; - setPendingImeta: (pendingImeta: ImetaMedia[]) => void; - hasUnsavedMedia: () => boolean; - clearQueuedAttachments: () => void; - restoreQueuedAttachments: (attachments: QueuedMediaAttachment[]) => void; - setSpoileredAttachmentUrls?: React.Dispatch< - React.SetStateAction> - >; - onSuccessfulExplicitAgentAudience?: (audience: { - channelId: string; - expectedGeneration: number; - expectedRevision: number | null; - explicitAgentPubkeys: string[]; - }) => void; - resolvePostSendContent?: (effectiveExplicitAgentPubkeys: string[]) => string; -}; +import type { UseMentionSendFlowOptions } from "./useMentionSendFlow.types"; export function useMentionSendFlow({ channelId, channelLinks, @@ -103,6 +53,8 @@ export function useMentionSendFlow({ mentions, onPrepareSendChannel, onSendRef, + onStagePendingSend, + onRemovePendingSend, richText, setContent, setIsEmojiPickerOpen, @@ -319,7 +271,6 @@ export function useMentionSendFlow({ provisionPersonaAgentMutation, ], ); - const clearComposer = React.useCallback( (postSendContent = "") => { setPendingNonMemberSend(null); @@ -494,6 +445,18 @@ export function useMentionSendFlow({ mentionPubkeys, ); const send = onSendRef.current; + let optimisticId: string | null = null; + const removePendingSend = () => { + if (optimisticId && sendChannelId) { + onRemovePendingSend?.(sendChannelId, optimisticId); + optimisticId = null; + } + }; + const stopCancelledSend = () => { + if (!isSendCancelled()) return false; + removePendingSend(); + return true; + }; const persistCanceledDraft = () => { if (isSendCancelled() || !draft.recoveryDraftKey) return; const existing = drafts.loadDraft(draft.recoveryDraftKey); @@ -519,6 +482,7 @@ export function useMentionSendFlow({ ); }; const restoreComposerAfterFailure = () => { + removePendingSend(); if (isSendCancelled()) return; persistCanceledDraft(); const canRestoreCurrentComposer = @@ -567,11 +531,14 @@ export function useMentionSendFlow({ mediaTags, outgoingTags, ); - if (!finalOutgoingTags || signal?.aborted || isSendCancelled()) + if (!finalOutgoingTags) { + removePendingSend(); return; + } + if (signal?.aborted || stopCancelledSend()) return; const revalidatedMentionPubkeys = await mentions.revalidateMentionPubkeys(mentionPubkeys); - if (signal?.aborted || isSendCancelled()) return; + if (signal?.aborted || stopCancelledSend()) return; const revalidatedExplicitAgentPubkeys = filterEffectiveExplicitAgentPubkeys( draft.explicitAgentPubkeys, @@ -584,8 +551,9 @@ export function useMentionSendFlow({ sendChannelId, draft.capturedThreadContext, draft.preparedLinkPreviews != null, + optimisticId ?? undefined, ); - if (signal?.aborted || isSendCancelled()) return; + if (signal?.aborted || stopCancelledSend()) return; if (revalidatedExplicitAgentPubkeys.length > 0) { onSuccessfulExplicitAgentAudience?.({ channelId: sendChannelId ?? draft.capturedChannelId ?? "", @@ -604,6 +572,36 @@ export function useMentionSendFlow({ ); } }; + if (!optimisticId && sendChannelId) { + const initialMessage = buildOutgoingMessage( + draft.trimmed, + draft.savedImeta, + draft.savedSpoileredAttachmentUrls, + ); + const pendingPreparationTags = [ + ...(draft.preparedLinkPreviews + ? [["client-pending", "link-preview"]] + : []), + ...draft.queuedAttachments.map((attachment) => [ + "client-pending", + "media", + attachment.file.name, + attachment.file.type, + ]), + ]; + optimisticId = + onStagePendingSend?.({ + channelId: sendChannelId, + content: initialMessage.content, + mentionPubkeys, + parentEventId: draft.capturedThreadContext?.parentEventId ?? null, + mediaTags: [ + ...(initialMessage.mediaTags ?? []), + ...(outgoingTags ?? []), + ...pendingPreparationTags, + ], + }) ?? null; + } if (preparedUpload) { uploadStarted = preparedUpload.start({ onComplete: async (uploaded, signal) => { @@ -665,6 +663,8 @@ export function useMentionSendFlow({ mentions.revalidateMentionPubkeys, onPrepareSendChannel, onSendRef, + onStagePendingSend, + onRemovePendingSend, onSuccessfulExplicitAgentAudience, resolvePostSendContent, richText.setContent, diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.types.ts b/desktop/src/features/messages/ui/useMentionSendFlow.types.ts new file mode 100644 index 000000000..4aeeb9c33 --- /dev/null +++ b/desktop/src/features/messages/ui/useMentionSendFlow.types.ts @@ -0,0 +1,65 @@ +import type * as React from "react"; + +import type { QueuedMediaAttachment } from "@/features/messages/lib/backgroundMediaUploadStore"; +import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; +import type { UseChannelLinksResult } from "@/features/messages/lib/useChannelLinks"; +import type { UseDraftsResult } from "@/features/messages/lib/useDrafts"; +import type { UseEmojiAutocompleteResult } from "@/features/messages/lib/useEmojiAutocomplete"; +import type { UseMentionsResult } from "@/features/messages/lib/useMentions"; +import type { UseRichTextEditorResult } from "@/features/messages/lib/useRichTextEditor"; +import type { ChannelType } from "@/shared/api/types"; +import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji"; + +export type UseMentionSendFlowOptions = { + channelId: string | null; + channelLinks: Pick; + channelType: ChannelType | null; + contentRef: React.MutableRefObject; + customEmoji: CustomEmoji[]; + drafts: Pick; + emojiAutocomplete: Pick; + mentions: UseMentionsResult; + onPrepareSendChannel?: (pubkeys?: string[]) => Promise; + onSendRef: React.MutableRefObject< + ( + content: string, + mentionPubkeys: string[], + mediaTags?: string[][], + channelId?: string | null, + threadContext?: { + parentEventId: string | null; + threadHeadId: string | null; + } | null, + forceRest?: boolean, + optimisticId?: string, + ) => Promise + >; + onStagePendingSend?: (input: { + channelId: string; + content: string; + mentionPubkeys: string[]; + parentEventId: string | null; + mediaTags: string[][]; + }) => string | null; + onRemovePendingSend?: (channelId: string, optimisticId: string) => void; + richText: Pick< + UseRichTextEditorResult, + "clearContent" | "setContent" | "restorePlainTextAndFocusEnd" + >; + setContent: (content: string) => void; + setIsEmojiPickerOpen: React.Dispatch>; + setPendingImeta: (pendingImeta: ImetaMedia[]) => void; + hasUnsavedMedia: () => boolean; + clearQueuedAttachments: () => void; + restoreQueuedAttachments: (attachments: QueuedMediaAttachment[]) => void; + setSpoileredAttachmentUrls?: React.Dispatch< + React.SetStateAction> + >; + onSuccessfulExplicitAgentAudience?: (audience: { + channelId: string; + expectedGeneration: number; + expectedRevision: number | null; + explicitAgentPubkeys: string[]; + }) => void; + resolvePostSendContent?: (effectiveExplicitAgentPubkeys: string[]) => string; +}; diff --git a/desktop/tests/e2e/file-attachment.spec.ts b/desktop/tests/e2e/file-attachment.spec.ts index d5680b6c9..93ae28054 100644 --- a/desktop/tests/e2e/file-attachment.spec.ts +++ b/desktop/tests/e2e/file-attachment.spec.ts @@ -386,8 +386,16 @@ test("canceling a background upload prevents the message from publishing", async await chooseLargeVideo(page); await page.getByTestId("send-message").click(); + const pendingRow = page.getByTestId("message-row").last(); + await expect( + pendingRow.getByTestId("message-preparation-status"), + ).toContainText("Preparing large-video.mp4…"); + const pendingMessageId = await pendingRow.getAttribute("data-message-id"); + expect(pendingMessageId).not.toBeNull(); await page.getByTestId("composer-upload-cancel").click(); - await expect(page.getByTestId("composer-upload-progress")).toHaveCount(0); + await expect( + page.locator(`[data-message-id="${pendingMessageId}"]`), + ).toHaveCount(0); await page.waitForTimeout(1_100); await expect(page.getByTestId("file-card")).toHaveCount(0); }); diff --git a/desktop/tests/e2e/messaging.spec.ts b/desktop/tests/e2e/messaging.spec.ts index f93ce0450..f415a5a7d 100644 --- a/desktop/tests/e2e/messaging.spec.ts +++ b/desktop/tests/e2e/messaging.spec.ts @@ -979,7 +979,12 @@ test("Enter during an in-flight snapshot upload hands off and sends once", async await expect(card).toHaveAttribute("data-snapshot-tag-ready", "false"); await input.press("Enter"); - await expect(input).toHaveText(""); + const pendingRow = page.getByTestId("message-row").last(); + await expect(pendingRow).toContainText(previewUrl); + await expect( + pendingRow.getByTestId("message-preparation-status"), + ).toContainText("Preparing link preview…"); + const progress = page.getByTestId("composer-upload-progress"); await expect(progress).toHaveAccessibleName("Preparing link preview"); await expect(page.getByTestId("composer-upload-cancel")).toHaveText("Skip");