diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 1ec6cee95e..bebabb71df 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 760ef58073..ddbe943cd4 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 6254afd8c7..1d412f5fcb 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 f9c57f6656..09172612f9 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 8b457a7adf..04436c0384 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/backgroundMediaUploadStore.test.mjs b/desktop/src/features/messages/lib/backgroundMediaUploadStore.test.mjs index 2a4366be5a..da13cb70d3 100644 --- a/desktop/src/features/messages/lib/backgroundMediaUploadStore.test.mjs +++ b/desktop/src/features/messages/lib/backgroundMediaUploadStore.test.mjs @@ -4,6 +4,7 @@ import test from "node:test"; import { cancelStartedMediaUploads, dispatchTrackedMediaUpload, + prepareBackgroundMediaUpload, } from "./backgroundMediaUploadStore.ts"; const descriptor = { @@ -22,6 +23,27 @@ function deferred() { return { promise, resolve }; } +test("reports cancellation before a prepared upload starts", () => { + const prepared = prepareBackgroundMediaUpload([ + { + file: new File(["video"], "large-video.mp4", { type: "video/mp4" }), + id: 1, + spoilered: false, + }, + ]); + + assert.equal(prepared.isCanceled(), false); + prepared.cancel(); + assert.equal(prepared.isCanceled(), true); + assert.equal( + prepared.start({ + onComplete: async () => {}, + onError: () => {}, + }), + false, + ); +}); + test("cancels only uploads whose native commands were dispatched", async () => { const releaseUpload = deferred(); const startedProgressIds = new Map(); diff --git a/desktop/src/features/messages/lib/backgroundMediaUploadStore.ts b/desktop/src/features/messages/lib/backgroundMediaUploadStore.ts index 8066926067..3f7bd5a910 100644 --- a/desktop/src/features/messages/lib/backgroundMediaUploadStore.ts +++ b/desktop/src/features/messages/lib/backgroundMediaUploadStore.ts @@ -54,6 +54,7 @@ type StartBackgroundUploadOptions = Omit< export type PreparedBackgroundMediaUpload = { cancel: () => void; + isCanceled: () => boolean; start: (options: StartBackgroundUploadOptions) => boolean; }; @@ -243,6 +244,7 @@ export function prepareBackgroundMediaUpload( let started = false; return { cancel: () => undefined, + isCanceled: () => false, start: ({ onComplete, onError }) => { if (started) return false; started = true; @@ -274,6 +276,7 @@ export function prepareBackgroundMediaUpload( cancel: () => { cancelTask(task); }, + isCanceled: () => task.canceled, start: ({ onCancel, onComplete, onError }) => { if (started || task.canceled) return false; started = true; diff --git a/desktop/src/features/messages/lib/channelWindowStore.ts b/desktop/src/features/messages/lib/channelWindowStore.ts index 1bd921aabf..4929422ceb 100644 --- a/desktop/src/features/messages/lib/channelWindowStore.ts +++ b/desktop/src/features/messages/lib/channelWindowStore.ts @@ -1,4 +1,5 @@ import type { RelayEvent } from "@/shared/api/types"; +import { reconcileIncomingMessage } from "./messageMerge"; export type ChannelWindowCursor = { createdAt: number; eventId: string }; export type ChannelWindowThreadSummary = { @@ -214,10 +215,9 @@ export function mergeLiveChannelWindowEvent( } return { ...current, - liveOverlay: current.liveOverlay - .filter((candidate) => candidate.id !== event.id) - .concat(event) - .sort(compareRelayOrder), + liveOverlay: reconcileIncomingMessage(current.liveOverlay, event).sort( + compareRelayOrder, + ), }; } diff --git a/desktop/src/features/messages/lib/projectChannelWindow.test.mjs b/desktop/src/features/messages/lib/projectChannelWindow.test.mjs index 14ec110add..4209e6ec21 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, @@ -210,6 +213,31 @@ test("test_reconciliation_preserves_dense_second_window_order", () => { ); }); +test("test_live_echo_replaces_matching_staged_send", () => { + const harness = createHarness(); + const pending = { + ...event("pending", 110), + content: "hello", + pending: true, + }; + const accepted = { + ...event("accepted", 110), + content: "hello", + id: "c".repeat(64), + }; + appendLiveEvent(harness, pending); + appendLiveEvent(harness, accepted); + + assert.deepEqual( + harness.client.getQueryData(harness.messagesKey).map((item) => item.id), + [event("initial", 100).id, accepted.id], + ); + assert.equal( + harness.client.getQueryData(harness.messagesKey)[1]?.localKey, + pending.id, + ); +}); + test("test_reconciliation_retains_identical_pending_sends", () => { const harness = createHarness(); const first = { @@ -234,6 +262,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 31a40c86b6..11b92169fb 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 a24be0aeab..a29c106d3e 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 de496836c1..a93b2e8049 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 +138,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 +148,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 +168,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 +220,8 @@ export function MessageThreadPanel({ onScrollTargetSettled, onSelectReplyTarget, onSend, + onStagePendingSend, + onRemovePendingSend, onSendToChannel, onToggleReaction, onUnfollowThread, @@ -911,6 +912,8 @@ export function MessageThreadPanel({ onEditLastOwnMessage={onEditLastOwnMessage} onEditSave={onEditSave} onSend={onSend} + onStagePendingSend={onStagePendingSend} + onRemovePendingSend={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 0000000000..69e9f85f2d --- /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 e322f91987..e17ee66f32 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 && !preparedUpload?.isCanceled()) { + 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) => { @@ -623,9 +621,7 @@ export function useMentionSendFlow({ restoreComposerAfterFailure(); }, }); - if (!uploadStarted) { - return; - } + if (!uploadStarted) return removePendingSend(); } if ( draft.capturedChannelId === channelIdRef.current || @@ -665,6 +661,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 0000000000..4aeeb9c33e --- /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 d5680b6c90..b2ecef5a02 100644 --- a/desktop/tests/e2e/file-attachment.spec.ts +++ b/desktop/tests/e2e/file-attachment.spec.ts @@ -265,6 +265,12 @@ test("sends immediately and keeps upload progress across channels", async ({ await expect(queuedSpoiler).toHaveCSS("opacity", "1"); 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 expect(page.getByTestId("message-composer")).not.toContainText( "large-video.mp4", ); @@ -278,6 +284,9 @@ test("sends immediately and keeps upload progress across channels", async ({ }); await page.getByTestId("channel-general").click(); + await expect( + page.locator(`[data-message-id="${pendingMessageId}"]`), + ).toHaveCount(1); await expect(page.getByTestId("file-card").last()).toContainText( "quarterly-report.pdf", ); @@ -386,8 +395,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 f93ce0450b..f415a5a7d5 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");