From 0584d7604d006588d8f418569422371870d58a34 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Sat, 1 Aug 2026 17:17:20 +0200 Subject: [PATCH] fix(composer): serialize sends and snapshot drafts across the composer --- .../components/upload-board/UploadBoard.tsx | 29 +- .../room/AudioMessageRecorder.test.tsx | 107 ++ .../features/room/AudioMessageRecorder.tsx | 34 +- src/app/features/room/RoomInput.test.tsx | 1114 ++++++++++++++++ src/app/features/room/RoomInput.tsx | 1179 +++++++++++------ .../features/room/composerController.test.ts | 72 + src/app/features/room/composerController.ts | 40 + .../location-modal/LocationDialog.test.tsx | 101 ++ .../room/location-modal/LocationDialog.tsx | 49 +- .../persona-picker/PersonaPicker.test.tsx | 261 ++++ .../room/persona-picker/PersonaPicker.tsx | 124 +- .../room/poll-modals/PollDialog.test.tsx | 116 ++ .../features/room/poll-modals/PollDialog.tsx | 47 +- src/app/utils/common.ts | 6 - src/app/utils/delayedEvents.ts | 10 +- 15 files changed, 2769 insertions(+), 520 deletions(-) create mode 100644 src/app/features/room/AudioMessageRecorder.test.tsx create mode 100644 src/app/features/room/RoomInput.test.tsx create mode 100644 src/app/features/room/composerController.test.ts create mode 100644 src/app/features/room/composerController.ts create mode 100644 src/app/features/room/location-modal/LocationDialog.test.tsx create mode 100644 src/app/features/room/persona-picker/PersonaPicker.test.tsx create mode 100644 src/app/features/room/poll-modals/PollDialog.test.tsx diff --git a/src/app/components/upload-board/UploadBoard.tsx b/src/app/components/upload-board/UploadBoard.tsx index f0a26d386f..6d0d86e448 100644 --- a/src/app/components/upload-board/UploadBoard.tsx +++ b/src/app/components/upload-board/UploadBoard.tsx @@ -1,5 +1,5 @@ import type { MutableRefObject, ReactNode } from 'react'; -import { useEffect, useImperativeHandle, useRef } from 'react'; +import { useEffect, useImperativeHandle } from 'react'; import { Badge, Box, Chip, Header, Spinner, Text, as, percent } from 'folds'; import { CaretRight, CaretUp, X, sizedIcon } from '$components/icons/phosphor'; import classNames from 'classnames'; @@ -27,14 +27,15 @@ export const UploadBoard = as<'div', UploadBoardProps>(({ header, children, ...p )); -export type UploadBoardImperativeHandlers = { handleSend: () => Promise }; +// Progress ticks re-render this header, so the caller reads uploads on demand here +// instead of subscribing to them itself. +export type UploadBoardImperativeHandlers = { getSendableUploads: () => Upload[] }; type UploadBoardHeaderProps = { open: boolean; onToggle: () => void; uploadFamilyObserverAtom: TUploadFamilyObserverAtom; onCancel: (uploads: Upload[]) => void; - onSend: (uploads: Upload[]) => Promise; onBusyChange?: (busy: boolean) => void; imperativeHandlerRef: MutableRefObject; }; @@ -44,11 +45,9 @@ export function UploadBoardHeader({ onToggle, uploadFamilyObserverAtom, onCancel, - onSend, onBusyChange, imperativeHandlerRef, }: UploadBoardHeaderProps) { - const sendingRef = useRef(false); const uploads = useAtomValue(uploadFamilyObserverAtom); const isSuccess = uploads.every((upload) => upload.status === UploadStatus.Success); @@ -72,23 +71,11 @@ export function UploadBoardHeader({ { loaded: 0, total: 0 } ); - const handleSend = async () => { - if (sendingRef.current) return; - sendingRef.current = true; - try { - await onSend( - uploads.filter( - (upload) => - upload.status === UploadStatus.Success || upload.status === UploadStatus.Loading - ) - ); - } finally { - sendingRef.current = false; - } - }; - useImperativeHandle(imperativeHandlerRef, () => ({ - handleSend, + getSendableUploads: () => + uploads.filter( + (upload) => upload.status === UploadStatus.Success || upload.status === UploadStatus.Loading + ), })); const handleCancel = () => onCancel(uploads); diff --git a/src/app/features/room/AudioMessageRecorder.test.tsx b/src/app/features/room/AudioMessageRecorder.test.tsx new file mode 100644 index 0000000000..279649f4c7 --- /dev/null +++ b/src/app/features/room/AudioMessageRecorder.test.tsx @@ -0,0 +1,107 @@ +/* oxlint-disable typescript/no-explicit-any, vitest/require-mock-type-parameters */ + +import { act, render } from '@testing-library/react'; +import { createRef } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { AudioMessageRecorder, type AudioMessageRecorderHandle } from './AudioMessageRecorder'; + +const recorderState = vi.hoisted(() => ({ + handleStop: vi.fn(), + handleDelete: vi.fn(), + onStop: undefined as ((payload: any) => void) | undefined, + onDelete: undefined as (() => void) | undefined, +})); + +vi.mock('$plugins/voice-recorder-kit', () => ({ + useVoiceRecorder: ({ onStop, onDelete }: any) => { + recorderState.onStop = onStop; + recorderState.onDelete = onDelete; + return { + levels: [], + seconds: 0, + error: undefined, + handleStop: recorderState.handleStop, + handleDelete: recorderState.handleDelete, + }; + }, +})); +vi.mock('$hooks/useElementSizeObserver', () => ({ useElementSizeObserver: () => {} })); +vi.mock('folds', () => ({ + Box: ({ children }: any) =>
{children}
, + Text: ({ children }: any) => {children}, +})); + +const payload = { + audioFile: new Blob(['audio'], { type: 'audio/ogg' }), + waveform: [0.5], + audioLength: 1, + audioCodec: 'audio/ogg', +}; + +function renderRecorder() { + const ref = createRef(); + const onRecordingComplete = vi.fn(); + const result = render( + + ); + return { ref, onRecordingComplete, result }; +} + +beforeEach(() => { + vi.useFakeTimers(); + recorderState.handleStop.mockReset(); + recorderState.handleDelete.mockReset(); + recorderState.onStop = undefined; + recorderState.onDelete = undefined; +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('AudioMessageRecorder lifecycle', () => { + it('makes stop idempotent and surfaces one completion', () => { + const { ref, onRecordingComplete } = renderRecorder(); + + act(() => { + ref.current?.stop(); + ref.current?.stop(); + recorderState.onStop?.(payload); + recorderState.onStop?.(payload); + }); + + expect(recorderState.handleStop).toHaveBeenCalledOnce(); + expect(onRecordingComplete).toHaveBeenCalledOnce(); + }); + + it('makes delayed cancel idempotent and cancels its timer on unmount', () => { + const { ref, result } = renderRecorder(); + + act(() => { + ref.current?.cancel(); + ref.current?.cancel(); + vi.advanceTimersByTime(180); + }); + expect(recorderState.handleDelete).toHaveBeenCalledOnce(); + + result.unmount(); + act(() => vi.runOnlyPendingTimers()); + expect(recorderState.handleDelete).toHaveBeenCalledOnce(); + }); + + it('does not delete after cancel is followed by unmount before the delay', () => { + const { ref, result } = renderRecorder(); + + act(() => ref.current?.cancel()); + result.unmount(); + act(() => vi.advanceTimersByTime(180)); + + expect(recorderState.handleDelete).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/features/room/AudioMessageRecorder.tsx b/src/app/features/room/AudioMessageRecorder.tsx index f0db57b3dd..d73fe80561 100644 --- a/src/app/features/room/AudioMessageRecorder.tsx +++ b/src/app/features/room/AudioMessageRecorder.tsx @@ -48,8 +48,9 @@ export const AudioMessageRecorder = forwardRef< AudioMessageRecorderHandle, AudioMessageRecorderProps >(({ onRecordingComplete, onRequestClose, onWaveformUpdate, onAudioLengthUpdate }, ref) => { - const isDismissedRef = useRef(false); const userRequestedStopRef = useRef(false); + const actionRef = useRef<'active' | 'stopping' | 'canceling' | 'dismissed'>('active'); + const cancelTimerRef = useRef | null>(null); const containerRef = useRef(null); const [isCanceling, setIsCanceling] = useState(false); const [announcedTime, setAnnouncedTime] = useState(0); @@ -67,8 +68,8 @@ export const AudioMessageRecorder = forwardRef< const stableOnStop = useCallback((payload: VoiceRecorderStopPayload) => { // useVoiceRecorder also stops during cancel/teardown paths, so only surface a completed // recording after an explicit user stop. - if (!userRequestedStopRef.current) return; - if (isDismissedRef.current) return; + if (!userRequestedStopRef.current || actionRef.current !== 'stopping') return; + actionRef.current = 'dismissed'; onRecordingCompleteRef.current({ audioBlob: payload.audioFile, waveform: payload.waveform, @@ -80,7 +81,11 @@ export const AudioMessageRecorder = forwardRef< }, []); const stableOnDelete = useCallback(() => { - isDismissedRef.current = true; + if (cancelTimerRef.current !== null) { + clearTimeout(cancelTimerRef.current); + cancelTimerRef.current = null; + } + actionRef.current = 'dismissed'; onRequestCloseRef.current(); }, []); @@ -91,22 +96,35 @@ export const AudioMessageRecorder = forwardRef< }); const doStop = useCallback(() => { - if (isDismissedRef.current) return; + if (actionRef.current !== 'active') return; + actionRef.current = 'stopping'; userRequestedStopRef.current = true; handleStop(); }, [handleStop]); const doCancel = useCallback(() => { - if (isDismissedRef.current) return; + if (actionRef.current !== 'active') return; + actionRef.current = 'canceling'; setIsCanceling(true); - setTimeout(() => { - isDismissedRef.current = true; + cancelTimerRef.current = setTimeout(() => { + cancelTimerRef.current = null; + if (actionRef.current !== 'canceling') return; + actionRef.current = 'dismissed'; handleDelete(); }, 180); }, [handleDelete]); useImperativeHandle(ref, () => ({ stop: doStop, cancel: doCancel }), [doStop, doCancel]); + useEffect( + () => () => { + if (cancelTimerRef.current !== null) clearTimeout(cancelTimerRef.current); + cancelTimerRef.current = null; + actionRef.current = 'dismissed'; + }, + [] + ); + useEffect(() => { if (seconds > 0 && seconds % 30 === 0 && seconds !== announcedTime) { setAnnouncedTime(seconds); diff --git a/src/app/features/room/RoomInput.test.tsx b/src/app/features/room/RoomInput.test.tsx new file mode 100644 index 0000000000..cb950bc579 --- /dev/null +++ b/src/app/features/room/RoomInput.test.tsx @@ -0,0 +1,1114 @@ +/* oxlint-disable typescript/no-explicit-any, typescript/no-extraneous-class, unicorn/consistent-function-scoping, vitest/require-mock-type-parameters */ + +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { + forwardRef, + useEffect, + useImperativeHandle, + useMemo, + useState, + type ReactNode, +} from 'react'; +import { useAtom, useAtomValue } from 'jotai'; +import { createEditor, Transforms } from 'slate'; +import { M_POLL_START } from 'matrix-js-sdk'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { RoomInput } from './RoomInput'; +import { + roomIdToMsgDraftAtomFamily, + roomIdToReplyDraftAtomFamily, + roomIdToUploadItemsAtomFamily, +} from '$state/room/roomInputDrafts'; +import { + roomIdToEditingScheduledDelayIdAtomFamily, + roomIdToScheduledTimeAtomFamily, +} from '$state/scheduledMessages'; +import { roomScheduleCoordinator } from '$state/room/roomScheduleCoordinator'; + +const testState = vi.hoisted(() => ({ + isMobile: false, + matrix: { + sendMessage: vi.fn(), + sendEvent: vi.fn(), + getUserId: vi.fn(() => '@me:example.org'), + getSafeUserId: vi.fn(() => '@me:example.org'), + }, + cancelDelayedEvent: vi.fn(), + sendDelayedMessage: vi.fn(), + pendingUploads: [] as unknown[], + sendIndividualAttachmentAsCaption: false, + encrypted: false, + handleFiles: undefined as ((files: File[]) => Promise) | undefined, + safeUploadFile: vi.fn(), + encryptFile: vi.fn(), + editingEvent: undefined as + | { getId: () => string; getContent: () => Record } + | undefined, +})); + +vi.mock('$hooks/useMatrixClient', () => ({ + useMatrixClient: () => testState.matrix, +})); + +vi.mock(import('$utils/platform'), async (importOriginal) => ({ + ...(await importOriginal()), + isMobileOrTablet: () => testState.isMobile, + isMobileTauri: () => false, +})); + +vi.mock('$state/hooks/settings', () => ({ + useSetting: (_atom: unknown, key: string) => { + const values: Record = { + enterForNewline: false, + editorGifButton: false, + editorEmojiButton: false, + editorStickerButton: false, + editorMicButton: false, + editorButtonOrder: [], + shortcutOverrides: {}, + hideActivity: true, + mentionInReplies: true, + pkCompat: false, + pmpProxying: false, + pmpPicker: false, + hour24Clock: false, + enableMediaGalleries: false, + sendIndividualAttachmentAsCaption: testState.sendIndividualAttachmentAsCaption, + }; + return [values[key], vi.fn()]; + }, +})); + +vi.mock('$state/settings', () => ({ settingsAtom: {} })); + +vi.mock('$state/room/roomInputDrafts', async () => { + const { atom } = await import('jotai'); + const msgDraftAtom = atom([]); + const replyDraftAtom = atom(undefined); + const uploadItemsAtom = atom([], (get, set, action) => { + const update = action as any; + if (Array.isArray(update)) { + set(uploadItemsAtom, update); + return; + } + if (update?.type === 'PUT') set(uploadItemsAtom, [...get(uploadItemsAtom), ...update.item]); + if (update?.type === 'DELETE') { + const deleted = new Set(Array.isArray(update.item) ? update.item : [update.item]); + set( + uploadItemsAtom, + get(uploadItemsAtom).filter((item: any) => !deleted.has(item)) + ); + } + if (update?.type === 'REPLACE') { + set( + uploadItemsAtom, + get(uploadItemsAtom).map((item: any) => (item === update.item ? update.replacement : item)) + ); + } + }); + const scheduledTimeAtom = atom(null); + const editingScheduledDelayIdAtom = atom(null); + return { + roomIdToMsgDraftAtomFamily: () => msgDraftAtom, + roomIdToReplyDraftAtomFamily: () => replyDraftAtom, + roomIdToUploadItemsAtomFamily: () => uploadItemsAtom, + roomUploadAtomFamily: Object.assign(() => atom(undefined), { remove: vi.fn() }), + roomIdToScheduledTimeAtomFamily: () => scheduledTimeAtom, + roomIdToEditingScheduledDelayIdAtomFamily: () => editingScheduledDelayIdAtom, + }; +}); + +vi.mock('$state/upload', async () => { + const { atom } = await import('jotai'); + const observerAtom = atom([]); + return { + UploadStatus: { Loading: 'loading', Success: 'success' }, + createUploadFamilyObserverAtom: () => observerAtom, + }; +}); + +vi.mock('$components/editor', () => { + const textOf = (nodes: any[]) => + nodes + .map((node) => (node.children ?? []).map((child: any) => child.text ?? '').join('')) + .join('\n'); + const passthrough = ({ children }: { children?: unknown }) => children ?? null; + const CustomEditor = ({ + editableName, + editor, + onChange, + onKeyDown, + before, + top, + after, + bottom, + }: any) => ( +
+ {top} + {before} +
+ {after} + {bottom} +
+ ); + return { + AutocompletePrefix: { + RoomMention: 'room-mention', + UserMention: 'user-mention', + Emoticon: 'emoticon', + Reaction: 'reaction', + Command: 'command', + }, + ANYWHERE_AUTOCOMPLETE_PREFIXES: [], + BEGINNING_AUTOCOMPLETE_PREFIXES: [], + BlockType: { Paragraph: 'paragraph' }, + Command: {}, + CustomEditor, + EmoticonAutocomplete: passthrough, + MarkdownFormattingToolbarBottom: passthrough, + MarkdownFormattingToolbarToggle: passthrough, + RoomMentionAutocomplete: passthrough, + UserMentionAutocomplete: passthrough, + createEmoticonElement: () => ({ text: '' }), + customHtmlEqualsPlainText: (html: string, text: string) => html === text, + focusEditor: vi.fn(), + getAutocompleteQuery: vi.fn(), + getBeginCommand: () => undefined, + getLinks: () => [], + getMentions: () => ({ users: new Set(), room: undefined }), + getPrevWorldRange: () => undefined, + isEmptyEditor: (editor: any) => textOf(editor.children).trim() === '', + moveCursor: vi.fn(), + plainToEditorInput: (text: string) => [{ type: 'paragraph', children: [{ text }] }], + replaceWithElement: vi.fn(), + resetEditor: (editor: any) => { + editor.children = [{ type: 'paragraph', children: [{ text: '' }] }]; + editor.selection = null; + }, + resetEditorHistory: vi.fn(), + toMatrixCustomHTML: (nodes: any[]) => textOf(nodes), + toPlainText: textOf, + trimCommand: (_command: unknown, text: string) => text, + trimCustomHtml: (html: string) => html, + }; +}); + +vi.mock('$components/upload-board', async () => { + const UploadBoardHeader = ({ imperativeHandlerRef }: any) => { + useImperativeHandle(imperativeHandlerRef, () => ({ + getSendableUploads: () => testState.pendingUploads, + })); + return null; + }; + const UploadBoard = ({ header, children }: any) => ( + <> + {header} + {children} + + ); + return { + UploadBoard, + UploadBoardContent: ({ children }: any) => <>{children}, + UploadBoardHeader, + }; +}); + +vi.mock('$components/upload-card', () => ({ + UploadCardRenderer: ({ fileItem, setMetadata, setDesc }: any) => ( + <> + + + + ), +})); +vi.mock('$components/attachment-sheet/AttachmentSheet', () => ({ AttachmentSheet: () => null })); +vi.mock('$components/emoji-board', () => ({ + EmojiBoard: ({ onStickerSelect }: any) => ( + + ), + EmojiBoardTab: {}, +})); +vi.mock('$components/UseStateProvider', () => ({ + UseStateProvider: ({ children }: any) => (typeof children === 'function' ? children() : children), +})); +vi.mock('$components/message', () => ({ Reply: () => null, ThreadIndicator: () => null })); +vi.mock('./CommandAutocomplete', () => ({ CommandAutocomplete: () => null })); +vi.mock('./AudioMessageRecorder', () => ({ AudioMessageRecorder: () => null })); +vi.mock('./persona-picker/PersonaPicker', () => ({ PersonaPicker: () => null })); +vi.mock('./schedule-send', () => ({ SchedulePickerDialog: () => null })); +vi.mock('./poll-modals', () => ({ + PollDialog: ({ onSubmit }: any) => ( + + ), +})); +vi.mock('./location-modal', () => ({ + LocationDialog: ({ onSubmit, onCancel }: any) => { + const [failed, setFailed] = useState(false); + return ( + <> + {failed &&
location submit failed
} + + + ); + }, +})); +vi.mock('$components/icons/phosphor', () => { + const Icon = forwardRef(({ children }, ref) => ( + + )); + return { + Bell: Icon, + BellSlash: Icon, + CaretDown: Icon, + Clock: Icon, + File: Icon, + Gif: Icon, + Image: Icon, + ListBullets: Icon, + MapPinPlusIcon: Icon, + Microphone: Icon, + PaperPlaneTilt: Icon, + PencilSimple: Icon, + PlusCircle: Icon, + Smiley: Icon, + Sticker: Icon, + Stop: Icon, + X: Icon, + chipIcon: () => null, + composerIcon: () => null, + dropzoneIcon: () => null, + getPhosphorIconSize: () => 16, + menuIcon: () => null, + }; +}); + +vi.mock('folds', () => { + const Box = ({ children }: any) =>
{children}
; + const Button = forwardRef(({ children, ...props }, ref) => ( + + )); + const passthrough = ({ children }: any) => <>{children}; + return { + Box, + Dialog: passthrough, + IconButton: Button, + Menu: passthrough, + MenuItem: Button, + Overlay: passthrough, + OverlayBackdrop: () => null, + OverlayCenter: passthrough, + PopOut: ({ children, content }: any) => ( + <> + {children} + {content} + + ), + Scroll: passthrough, + Spinner: () => Sending, + Text: ({ children }: any) => {children}, + color: { Critical: { Main: 'red' } }, + config: { space: { S100: '1px', S200: '2px', S300: '3px' } }, + toRem: (value: number) => `${value / 16}rem`, + }; +}); + +vi.mock('$hooks/useTypingStatusUpdater', () => ({ useTypingStatusUpdater: () => vi.fn() })); +vi.mock('$hooks/useFilePicker', () => ({ + useFilePicker: (handleFiles: (files: File[]) => Promise) => { + testState.handleFiles = handleFiles; + return vi.fn(); + }, +})); +vi.mock('$hooks/useFilePasteHandler', () => ({ useFilePasteHandler: () => vi.fn() })); +vi.mock('$hooks/useFileDrop', () => ({ useFileDropZone: () => false })); +vi.mock('$hooks/useCommands', () => ({ + Command: {}, + SHRUG: '¯\\_(ツ)_/¯', + TABLEFLIP: '(╯°□°)╯︵ ┻━┻', + UNFLIP: '┬─┬ノ( º _ ºノ)', + useCommands: () => ({}), +})); +vi.mock('$hooks/useClientConfig', () => ({ useClientConfig: () => ({}) })); +vi.mock('$hooks/useMediaAuthentication', () => ({ useMediaAuthentication: () => false })); +vi.mock('$hooks/useImagePackRooms', () => ({ useImagePackRooms: () => [] })); +vi.mock('$hooks/useComposingCheck', () => ({ useComposingCheck: () => () => false })); +vi.mock('$hooks/usePerMessageProfile', () => ({ + convertPerMessageProfileToBeeperFormat: () => ({}), + getCurrentlyUsedPerMessageProfileForAccount: async () => undefined, + getCurrentlyUsedPerMessageProfileForRoom: async () => undefined, +})); +vi.mock('@tanstack/react-query', () => ({ + useQueryClient: () => ({ invalidateQueries: vi.fn() }), +})); +vi.mock('$hooks/usePowerLevels', () => ({ + usePowerLevelsContext: () => ({}), + useRoomPermissions: () => ({ event: () => true }), +})); +vi.mock('$hooks/useRoomPermissions', () => ({ useRoomPermissions: () => ({ event: () => true }) })); +vi.mock('$hooks/useRoomCreators', () => ({ useRoomCreators: () => [] })); +vi.mock('$features/settings/useSettingsLinkBaseUrl', () => ({ useSettingsLinkBaseUrl: () => '' })); +vi.mock('$state/room/roomToParents', async () => { + const { atom } = await import('jotai'); + return { roomToParentsAtom: atom({}) }; +}); +vi.mock('$state/nicknames', async () => { + const { atom } = await import('jotai'); + return { nicknamesAtom: atom({}) }; +}); +vi.mock('$state/scheduledMessages', async () => { + const { atom } = await import('jotai'); + const scheduledTimeAtom = atom(null); + const editingScheduledDelayIdAtom = atom(null); + return { + delayedEventsSupportedAtom: atom(false), + roomIdToScheduledTimeAtomFamily: () => scheduledTimeAtom, + roomIdToEditingScheduledDelayIdAtomFamily: () => editingScheduledDelayIdAtom, + serverMaxDelayMsAtom: atom(null), + }; +}); +vi.mock('$utils/matrix', () => ({ + cancelUploadContent: vi.fn(), + encryptFile: testState.encryptFile, + getImageInfo: vi.fn(), + mxcUrlToHttp: vi.fn(), + toggleReaction: vi.fn(), +})); +vi.mock('$utils/mimeTypes', () => ({ + FALLBACK_MIMETYPE: 'application/octet-stream', + TGS_MIMETYPE: 'application/x-tgsticker', + isImageMimeType: () => false, + safeUploadFile: testState.safeUploadFile, +})); +vi.mock('$utils/dom', () => ({ loadImageElementFromMediaUrl: vi.fn() })); +vi.mock('$plugins/custom-emoji/utils', () => ({ getPackImageInfo: () => undefined })); +vi.mock('$utils/msc4459helper', () => ({ + getImagePackReferencesForMxc: () => undefined, + getImagePackReferencesForMxcWrappedInMap: () => ({}), +})); +vi.mock('$utils/room/relations', () => ({ + getEditedEvent: vi.fn(), + getMentionContent: () => ({ user_ids: [] }), + getThreadReplyEvents: () => [], +})); +vi.mock('$plugins/markdown', () => ({ htmlToMarkdown: (html: string) => html })); +vi.mock('$utils/delayedEvents', () => ({ + cancelDelayedEvent: testState.cancelDelayedEvent, + computeDelayMs: vi.fn(), + sendDelayedMessage: testState.sendDelayedMessage, + sendDelayedMessageE2EE: vi.fn(), +})); +vi.mock('$utils/time', () => ({ + daysToMs: () => 1, + timeDayMonthYear: () => '', + timeHourMinute: () => '', +})); +vi.mock('$utils/keyboard', () => ({ stopPropagation: vi.fn() })); +vi.mock('$utils/debug', () => ({ createLogger: () => ({ error: vi.fn(), warn: vi.fn() }) })); +vi.mock('$utils/debugLogger', () => ({ + createDebugLogger: () => ({ info: vi.fn(), error: vi.fn() }), +})); +vi.mock('$plugins/pluralkit-handler/PKitCommandMessageHandler', () => ({ + PKitCommandMessageHandler: class { + static isPKCommand() { + return false; + } + }, +})); +vi.mock('$plugins/pluralkit-handler/PKitProxyMessageHandler', () => ({ + PKitProxyMessageHandler: class { + init() {} + async getPmpBasedOnMessage() { + return undefined; + } + }, +})); +vi.mock('$sentry/react', () => ({ + metrics: { count: vi.fn(), distribution: vi.fn() }, + startSpan: (_options: unknown, callback: () => unknown) => callback(), +})); + +const room = { + roomId: '!room:example.org', + name: 'Test room', + hasEncryptionStateEvent: () => testState.encrypted, + findEventById: (eventId: string) => + eventId === testState.editingEvent?.getId() ? testState.editingEvent : undefined, + getTimelineForEvent: () => undefined, + getMember: () => undefined, + getLiveTimeline: () => ({ getEvents: () => [] }), +} as any; + +function RoomInputHarness({ + editId, + onCancelEdit, + scheduled = false, + scheduledText = false, + initialDraft, + initialReply = false, + threadRootId, +}: { + editId?: string; + onCancelEdit?: () => void; + scheduled?: boolean; + /** Schedules a text-only send, with no attachments involved. */ + scheduledText?: boolean; + initialDraft?: string; + initialReply?: boolean; + threadRootId?: string; +}) { + const editor = useMemo(() => { + const nextEditor = createEditor(); + nextEditor.children = [{ type: 'paragraph' as any, children: [{ text: '' }] }]; + return nextEditor; + }, []); + const fileDropContainerRef = useMemo(() => ({ current: null }), []); + const [, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(room.roomId)); + const [, setSelectedFiles] = useAtom(roomIdToUploadItemsAtomFamily(room.roomId)); + const [, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(room.roomId)); + const [, setScheduledTime] = useAtom(roomIdToScheduledTimeAtomFamily(room.roomId)); + const [, setEditingScheduledDelayId] = useAtom( + roomIdToEditingScheduledDelayIdAtomFamily(room.roomId) + ); + useEffect(() => { + (setMsgDraft as any)( + initialDraft ? [{ type: 'paragraph', children: [{ text: initialDraft }] }] : [] + ); + (setSelectedFiles as any)([]); + setReplyDraft( + initialReply ? { userId: '@other:example.org', eventId: '$reply', body: 'reply' } : undefined + ); + setScheduledTime(scheduledText ? new Date('2026-07-28T12:00:00.000Z') : null); + setEditingScheduledDelayId(null); + }, [ + scheduledText, + initialDraft, + initialReply, + setEditingScheduledDelayId, + setMsgDraft, + setReplyDraft, + setScheduledTime, + setSelectedFiles, + ]); + const setText = (text = 'retry me') => { + editor.children = [{ type: 'paragraph' as any, children: [{ text: '' }] }]; + Transforms.select(editor, { path: [0, 0], offset: 0 }); + Transforms.insertText(editor, text); + fireEvent.input(screen.getByTestId('room-input-editor')); + }; + return ( + <> + + + + + + + + ); +} + +function DraftObserver() { + const draft = useAtomValue(roomIdToMsgDraftAtomFamily(room.roomId)); + return ( +
{((draft[0] as any)?.children?.[0] as any)?.text ?? ''}
+ ); +} + +function DraftSetter({ text }: { text: string }) { + const [, setDraft] = useAtom(roomIdToMsgDraftAtomFamily(room.roomId)); + useEffect(() => { + setDraft([{ type: 'paragraph' as any, children: [{ text }] }]); + }, [setDraft, text]); + return null; +} + +function ReplyObserver() { + const reply = useAtomValue(roomIdToReplyDraftAtomFamily(room.roomId)); + return
{reply?.eventId ?? ''}
; +} + +function UploadObserver() { + const uploads = useAtomValue(roomIdToUploadItemsAtomFamily(room.roomId)); + return ( +
+ {JSON.stringify( + uploads.map((upload) => ({ + encrypting: upload.encrypting, + metadata: upload.metadata, + body: upload.body, + })) + )} +
+ ); +} + +const sendButton = () => screen.getByRole('button', { name: 'Send your composed Message' }); + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +beforeEach(() => { + testState.isMobile = false; + testState.pendingUploads = []; + testState.sendIndividualAttachmentAsCaption = false; + testState.encrypted = false; + testState.handleFiles = undefined; + testState.safeUploadFile.mockReset().mockImplementation(async (file: File) => file); + testState.encryptFile.mockReset(); + testState.editingEvent = undefined; + testState.matrix.sendMessage.mockReset().mockResolvedValue({ event_id: '$event' }); + testState.matrix.sendEvent.mockReset().mockResolvedValue({}); + testState.cancelDelayedEvent.mockReset(); + testState.sendDelayedMessage.mockReset(); +}); + +describe('RoomInput submit regressions', () => { + it('sends only once when submitted twice before the first send resolves', async () => { + const send = deferred<{ event_id: string }>(); + testState.matrix.sendMessage.mockReturnValue(send.promise); + render(); + fireEvent.click(screen.getByRole('button', { name: 'Prepare attachment' })); + fireEvent.click(screen.getByRole('button', { name: 'Compose text' })); + + const submit = screen.getByRole('button', { name: 'Send your composed Message' }); + fireEvent.click(submit); + fireEvent.click(submit); + + await waitFor(() => expect(testState.matrix.sendMessage).toHaveBeenCalledOnce()); + send.resolve({ event_id: '$event' }); + await waitFor(() => + expect(screen.getByRole('button', { name: 'Send your composed Message' })).toBeEnabled() + ); + }); + + it('waits for an attachment transaction instead of sending text independently', async () => { + const upload = deferred<{ content_uri: string }>(); + const file = new File(['attachment'], 'attachment.txt', { type: 'text/plain' }); + testState.pendingUploads = [{ status: 'loading', file, promise: upload.promise }]; + + render(); + fireEvent.click(screen.getByRole('button', { name: 'Prepare attachment' })); + fireEvent.click(screen.getByRole('button', { name: 'Compose text' })); + + fireEvent.click(screen.getByRole('button', { name: 'Send your composed Message' })); + expect(testState.matrix.sendMessage).not.toHaveBeenCalled(); + upload.resolve({ content_uri: 'mxc://example/attachment' }); + await waitFor(() => expect(testState.matrix.sendMessage).toHaveBeenCalledTimes(2)); + expect(testState.matrix.sendMessage.mock.calls[0]?.[2]?.body).toBe('attachment.txt'); + expect(testState.matrix.sendMessage.mock.calls[1]?.[2]?.body).toBe('retry me'); + }); + + it('keeps attachment retry locked until every sibling send settles', async () => { + const delayedSecondSend = deferred<{ event_id: string }>(); + let sendNumber = 0; + testState.matrix.sendMessage.mockImplementation(() => { + sendNumber += 1; + return sendNumber === 1 + ? Promise.reject(new Error('first attachment failed')) + : delayedSecondSend.promise; + }); + + render(); + fireEvent.click(screen.getByRole('button', { name: 'Prepare two attachments' })); + fireEvent.keyDown(screen.getByTestId('room-input-editor'), { key: 'Enter', code: 'Enter' }); + + // Clearing the composer remounts the editor subtree, so re-query the button. + await waitFor(() => expect(testState.matrix.sendMessage).toHaveBeenCalledTimes(2)); + expect(sendButton()).toBeDisabled(); + fireEvent.click(sendButton()); + expect(testState.matrix.sendMessage).toHaveBeenCalledTimes(2); + + delayedSecondSend.resolve({ event_id: '$second' }); + await waitFor(() => expect(sendButton()).toBeEnabled()); + expect(testState.matrix.sendMessage).toHaveBeenCalledTimes(2); + }); + + it('waits for all scheduled siblings before unlocking and does not re-cancel on retry', async () => { + const delayedSecondSend = deferred(); + let sendNumber = 0; + testState.sendDelayedMessage.mockImplementation(() => { + sendNumber += 1; + if (sendNumber === 1) return Promise.reject(new Error('first scheduled send failed')); + if (sendNumber === 2) return delayedSecondSend.promise; + return Promise.resolve(); + }); + + render(); + fireEvent.click(screen.getByRole('button', { name: 'Prepare two attachments' })); + fireEvent.keyDown(screen.getByTestId('room-input-editor'), { key: 'Enter', code: 'Enter' }); + + await waitFor(() => expect(testState.sendDelayedMessage).toHaveBeenCalledTimes(2)); + expect(sendButton()).toBeDisabled(); + fireEvent.click(sendButton()); + expect(testState.sendDelayedMessage).toHaveBeenCalledTimes(2); + + delayedSecondSend.resolve(undefined); + await waitFor(() => expect(sendButton()).toBeEnabled()); + expect(testState.cancelDelayedEvent).toHaveBeenCalledOnce(); + + testState.pendingUploads = [testState.pendingUploads[0]]; + fireEvent.click(sendButton()); + await waitFor(() => expect(testState.sendDelayedMessage).toHaveBeenCalledTimes(3)); + expect(testState.cancelDelayedEvent).toHaveBeenCalledOnce(); + }); + + it('routes delayed replacement sends through the room schedule coordinator', async () => { + const runSpy = vi.spyOn(roomScheduleCoordinator, 'run'); + try { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Prepare two attachments' })); + fireEvent.keyDown(screen.getByTestId('room-input-editor'), { key: 'Enter', code: 'Enter' }); + + await waitFor(() => expect(testState.sendDelayedMessage).toHaveBeenCalledTimes(2)); + expect(runSpy).toHaveBeenCalledWith(room.roomId, expect.any(Function)); + } finally { + runSpy.mockRestore(); + } + }); + + it('does not consume main-room scheduling state from a thread composer', async () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Prepare two attachments' })); + fireEvent.keyDown(screen.getByTestId('room-input-editor'), { key: 'Enter', code: 'Enter' }); + + await waitFor(() => expect(testState.matrix.sendMessage).toHaveBeenCalledTimes(2)); + expect(testState.sendDelayedMessage).not.toHaveBeenCalled(); + expect(testState.cancelDelayedEvent).not.toHaveBeenCalled(); + }); + + it('queues poll content behind a picker and applies reply semantics', async () => { + const stickerSend = deferred(); + testState.matrix.sendEvent.mockReturnValue(stickerSend.promise); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Select sticker' })); + await waitFor(() => expect(testState.matrix.sendEvent).toHaveBeenCalledOnce()); + fireEvent.click(screen.getByRole('button', { name: 'Select newer reply' })); + fireEvent.click(screen.getByRole('button', { name: 'Create Poll' })); + fireEvent.click(screen.getByRole('button', { name: 'Submit poll' })); + + expect(testState.matrix.sendEvent).toHaveBeenCalledOnce(); + stickerSend.resolve({}); + await waitFor(() => expect(testState.matrix.sendEvent).toHaveBeenCalledTimes(2)); + + // A poll must go out as its own event type, not as an m.room.message. + const pollCall = testState.matrix.sendEvent.mock.calls[1]; + expect(pollCall?.[2]).toBe(M_POLL_START.name); + expect(pollCall?.[3]?.['m.relates_to']).toBeDefined(); + expect(testState.matrix.sendMessage).not.toHaveBeenCalled(); + }); + + it('keeps the location dialog open when the queued send rejects', async () => { + testState.matrix.sendMessage.mockRejectedValueOnce(new Error('send failed')); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Add Location' })); + await waitFor(() => + expect(screen.getByRole('button', { name: 'Submit location' })).toBeInTheDocument() + ); + fireEvent.click(screen.getByRole('button', { name: 'Submit location' })); + + await waitFor(() => expect(testState.matrix.sendMessage).toHaveBeenCalledOnce()); + expect(screen.getByTestId('location-submit-failed')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Submit location' })).toBeInTheDocument(); + }); + + it('clears the composer on a failed immediate send, leaving retry to the local echo', async () => { + // A rejected mx.sendMessage leaves a NOT_SENT local echo in the timeline with resend + // and delete affordances, so putting the text back would duplicate the message. + testState.matrix.sendMessage.mockRejectedValueOnce(new Error('send failed')); + render(); + fireEvent.click(screen.getByRole('button', { name: 'Compose text' })); + + fireEvent.click(sendButton()); + await waitFor(() => expect(testState.matrix.sendMessage).toHaveBeenCalledOnce()); + await waitFor(() => + expect(screen.getByTestId('room-input-editor')).toHaveAttribute('data-editor-text', '') + ); + }); + + it('restores composed text when a scheduled send fails', async () => { + // Delayed events produce no local echo, so the composer is the only way back. + testState.sendDelayedMessage.mockRejectedValueOnce(new Error('schedule failed')); + render(); + fireEvent.click(screen.getByRole('button', { name: 'Compose text' })); + + fireEvent.click(sendButton()); + await waitFor(() => expect(testState.sendDelayedMessage).toHaveBeenCalledOnce()); + await waitFor(() => + expect(screen.getByTestId('room-input-editor')).toHaveAttribute( + 'data-editor-text', + 'retry me' + ) + ); + }); + + it('keeps text typed while a send is in flight', async () => { + const send = deferred<{ event_id: string }>(); + testState.matrix.sendMessage.mockReturnValue(send.promise); + render(); + fireEvent.click(screen.getByRole('button', { name: 'Compose text' })); + const submit = screen.getByRole('button', { name: 'Send your composed Message' }); + + fireEvent.click(submit); + fireEvent.click(screen.getByRole('button', { name: 'Compose updated text' })); + send.resolve({ event_id: '$event' }); + + await waitFor(() => + expect(screen.getByTestId('room-input-editor')).toHaveAttribute( + 'data-editor-text', + 'updated while sending' + ) + ); + }); + + it('blocks submission while file preprocessing is pending', async () => { + const preprocessing = deferred(); + const file = new File(['attachment'], 'attachment.txt', { type: 'text/plain' }); + testState.safeUploadFile.mockReturnValue(preprocessing.promise); + render(); + + void testState.handleFiles?.([file]); + await waitFor(() => expect(testState.safeUploadFile).toHaveBeenCalledOnce()); + fireEvent.click(screen.getByRole('button', { name: 'Compose text' })); + const submit = screen.getByRole('button', { name: 'Send your composed Message' }); + + expect(submit).toBeDisabled(); + fireEvent.click(submit); + expect(testState.matrix.sendMessage).not.toHaveBeenCalled(); + + preprocessing.resolve(file); + }); + + it('uses the queued Enter snapshot after a slow sticker send', async () => { + const stickerSend = deferred(); + testState.matrix.sendEvent.mockReturnValue(stickerSend.promise); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Select sticker' })); + await waitFor(() => expect(testState.matrix.sendEvent).toHaveBeenCalledOnce()); + fireEvent.click(screen.getByRole('button', { name: 'Compose text' })); + fireEvent.keyDown(screen.getByTestId('room-input-editor'), { key: 'Enter', code: 'Enter' }); + fireEvent.click(screen.getByRole('button', { name: 'Compose updated text' })); + + stickerSend.resolve({}); + await waitFor(() => expect(testState.matrix.sendMessage).toHaveBeenCalledOnce()); + expect(testState.matrix.sendMessage.mock.calls[0]?.[2]?.body).toBe('retry me'); + }); + + it('gives a claimed reply only to the slow sticker, not queued Enter', async () => { + const stickerSend = deferred(); + testState.matrix.sendEvent.mockReturnValue(stickerSend.promise); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Select sticker' })); + await waitFor(() => expect(testState.matrix.sendEvent).toHaveBeenCalledOnce()); + fireEvent.click(screen.getByRole('button', { name: 'Compose text' })); + fireEvent.keyDown(screen.getByTestId('room-input-editor'), { key: 'Enter', code: 'Enter' }); + + stickerSend.resolve({}); + await waitFor(() => expect(testState.matrix.sendMessage).toHaveBeenCalledOnce()); + expect(testState.matrix.sendEvent.mock.calls[0]?.[2]?.['m.relates_to']).toBeDefined(); + expect(testState.matrix.sendMessage.mock.calls[0]?.[2]?.['m.relates_to']).toBeUndefined(); + }); + + it('preserves a newer reply selected during a slow sticker send', async () => { + const stickerSend = deferred(); + testState.matrix.sendEvent.mockReturnValue(stickerSend.promise); + render(); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Select sticker' })); + await waitFor(() => expect(testState.matrix.sendEvent).toHaveBeenCalledOnce()); + fireEvent.click(screen.getByRole('button', { name: 'Select newer reply' })); + await waitFor(() => + expect(screen.getByTestId('reply-observer')).toHaveTextContent('$new-reply') + ); + + stickerSend.resolve({}); + await waitFor(() => expect(testState.matrix.sendEvent).toHaveBeenCalledOnce()); + expect(testState.matrix.sendEvent.mock.calls[0]?.[2]?.['m.relates_to']).toBeDefined(); + expect(screen.getByTestId('reply-observer')).toHaveTextContent('$new-reply'); + }); + + it('resets after a selection-only editor change', async () => { + const send = deferred<{ event_id: string }>(); + testState.matrix.sendMessage.mockReturnValue(send.promise); + render(); + fireEvent.click(screen.getByRole('button', { name: 'Compose text' })); + const submit = screen.getByRole('button', { name: 'Send your composed Message' }); + + fireEvent.click(submit); + fireEvent.input(screen.getByTestId('room-input-editor')); + send.resolve({ event_id: '$event' }); + + await waitFor(() => + expect(screen.getByTestId('room-input-editor')).toHaveAttribute('data-editor-text', '') + ); + }); + + it('keeps an updated attachment caption after the upload completes', async () => { + const upload = deferred<{ content_uri: string }>(); + const file = new File(['attachment'], 'attachment.txt', { type: 'text/plain' }); + testState.sendIndividualAttachmentAsCaption = true; + testState.pendingUploads = [{ status: 'loading', file, promise: upload.promise }]; + testState.matrix.sendMessage.mockResolvedValue({ event_id: '$event' }); + + render(); + fireEvent.click(screen.getByRole('button', { name: 'Prepare attachment' })); + fireEvent.click(screen.getByRole('button', { name: 'Compose text' })); + fireEvent.click(screen.getByRole('button', { name: 'Send your composed Message' })); + fireEvent.click(screen.getByRole('button', { name: 'Compose updated text' })); + + upload.resolve({ content_uri: 'mxc://example/attachment' }); + await waitFor(() => expect(testState.matrix.sendMessage).toHaveBeenCalledOnce()); + expect(testState.matrix.sendMessage.mock.calls[0]?.[2]?.body).toBe('retry me'); + expect(screen.getByTestId('room-input-editor')).toHaveAttribute( + 'data-editor-text', + 'updated while sending' + ); + }); + + it('finishes encryption after metadata replacement using the stable original file', async () => { + const encryption = deferred<{ file: File; originalFile: File; encInfo: object }>(); + const file = new File(['attachment'], 'attachment.txt', { type: 'text/plain' }); + testState.encrypted = true; + testState.encryptFile.mockReturnValue(encryption.promise); + + render(); + render(); + await act(async () => { + void testState.handleFiles?.([file]); + await Promise.resolve(); + }); + + await waitFor(() => expect(screen.getByTestId('upload-observer')).toHaveTextContent('true')); + fireEvent.click(screen.getByRole('button', { name: 'Update attachment metadata' })); + await act(async () => { + encryption.resolve({ + file: new File(['encrypted'], 'attachment.txt', { type: 'text/plain' }), + originalFile: file, + encInfo: {}, + }); + await encryption.promise; + }); + + await waitFor(() => expect(screen.getByTestId('upload-observer')).toHaveTextContent('false')); + expect(screen.getByTestId('upload-observer')).toHaveTextContent('"markedAsSpoiler":true'); + }); + + it('removes the current replaced item when encryption fails', async () => { + const encryption = deferred(); + const file = new File(['attachment'], 'attachment.txt', { type: 'text/plain' }); + testState.encrypted = true; + testState.encryptFile.mockReturnValue(encryption.promise); + + render(); + render(); + await act(async () => { + void testState.handleFiles?.([file]); + await Promise.resolve(); + }); + + await waitFor(() => expect(screen.getByTestId('upload-observer')).toHaveTextContent('true')); + fireEvent.click(screen.getByRole('button', { name: 'Update attachment metadata' })); + fireEvent.click(screen.getByRole('button', { name: 'Update attachment description' })); + await waitFor(() => + expect(screen.getByTestId('upload-observer')).toHaveTextContent('updated description') + ); + await act(async () => { + encryption.reject(new Error('encryption failed')); + await encryption.promise.catch(() => undefined); + }); + + await waitFor(() => expect(screen.getByTestId('upload-observer')).toHaveTextContent('[]')); + expect(screen.getByTestId('upload-observer')).not.toHaveTextContent('encrypting'); + }); + + it('uses the submitted editor snapshot for an attachment reply relation', async () => { + const upload = deferred<{ content_uri: string }>(); + const file = new File(['attachment'], 'attachment.txt', { type: 'text/plain' }); + testState.pendingUploads = [{ status: 'loading', file, promise: upload.promise }]; + + const seed = render(); + seed.unmount(); + render(); + fireEvent.click(screen.getByRole('button', { name: 'Prepare attachment' })); + fireEvent.click(screen.getByRole('button', { name: 'Send your composed Message' })); + fireEvent.click(screen.getByRole('button', { name: 'Compose updated text' })); + upload.resolve({ content_uri: 'mxc://example/attachment' }); + + await waitFor(() => expect(testState.matrix.sendMessage).toHaveBeenCalledOnce()); + expect(testState.matrix.sendMessage.mock.calls[0]?.[2]?.['m.relates_to']).toEqual( + expect.objectContaining({ 'm.in_reply_to': expect.anything() }) + ); + }); + + it('preserves the normal draft when an edited message input unmounts', async () => { + testState.isMobile = true; + testState.editingEvent = { + getId: () => '$original', + getContent: () => ({ body: 'original', msgtype: 'm.text' }), + }; + const seed = render(); + seed.unmount(); + const input = render(); + render(); + + await waitFor(() => expect(screen.getByTestId('room-input-editor')).toBeInTheDocument()); + input.unmount(); + + expect(screen.getByTestId('draft-observer')).toHaveTextContent('keep this draft'); + }); + + it('does not submit mobile edits before initialization, then sends the replacement', async () => { + testState.isMobile = true; + testState.editingEvent = { + getId: () => '$original', + getContent: () => ({ body: 'original', msgtype: 'm.text' }), + }; + const onCancelEdit = vi.fn(); + render(); + + expect(testState.matrix.sendMessage).not.toHaveBeenCalled(); + await waitFor(() => expect(screen.getByTestId('room-input-editor')).toBeInTheDocument()); + fireEvent.input(screen.getByTestId('room-input-editor')); + fireEvent.click(screen.getByRole('button', { name: 'Send your composed Message' })); + + await waitFor(() => expect(testState.matrix.sendMessage).toHaveBeenCalledOnce()); + expect(testState.matrix.sendMessage).toHaveBeenCalledWith( + room.roomId, + expect.objectContaining({ + body: '* original', + 'm.new_content': expect.objectContaining({ body: 'original' }), + 'm.relates_to': expect.anything(), + }) + ); + expect(onCancelEdit).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index db002ed86d..fad6411c5b 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -20,9 +20,11 @@ import type { IEventRelation, RoomMessageEventContent, StickerEventContent, + TimelineEvents, } from '$types/matrix-sdk'; import { MatrixError } from '$types/matrix-sdk'; import { EventType, RelationType } from '$types/matrix-sdk'; +import { M_POLL_START } from 'matrix-js-sdk'; import { ReactEditor } from 'slate-react'; import { Editor, Point, Range, Transforms } from 'slate'; import type { RectCords } from 'folds'; @@ -101,7 +103,6 @@ import type { Upload, UploadSuccess } from '$state/upload'; import { UploadStatus, createUploadFamilyObserverAtom } from '$state/upload'; import { loadImageElementFromMediaUrl } from '$utils/dom'; import { isImageMimeType, safeUploadFile } from '$utils/mimeTypes'; -import { fulfilledPromiseSettledResult } from '$utils/common'; import { useSetting } from '$state/hooks/settings'; import type { EditorButtonId } from '$state/settings'; import { settingsAtom } from '$state/settings'; @@ -133,6 +134,7 @@ import { computeDelayMs, cancelDelayedEvent, } from '$utils/delayedEvents'; +import { roomScheduleCoordinator } from '$state/room/roomScheduleCoordinator'; import { timeHourMinute, timeDayMonthYear, daysToMs } from '$utils/time'; import { stopPropagation } from '$utils/keyboard'; @@ -198,7 +200,6 @@ import { buildGalleryContent, getGalleryItemContent, } from './msgContent'; -import { buildEditReplacement, buildOutgoingMessage } from './composerMessage'; import { getSendableKlipyMxcUrl } from '$utils/klipy'; import { CommandAutocomplete } from './CommandAutocomplete'; import type { @@ -210,6 +211,8 @@ import * as prefix from '$unstable/prefixes'; import { PollDialog } from './poll-modals'; import { useClientConfig } from '$hooks/useClientConfig'; import { PersonaPicker, type PersonaPickerTab } from './persona-picker/PersonaPicker.tsx'; +import { createComposerController, type ComposerController } from './composerController'; +import { buildEditReplacement, buildOutgoingMessage } from './composerMessage'; const LocationDialog = lazy(() => import('./location-modal').then((module) => ({ default: module.LocationDialog })) @@ -273,6 +276,28 @@ interface ReplyEventContent { const createUploadItemKey = () => globalThis.crypto.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(16).slice(2)}`; +interface ReplyClaim { + epoch: number; + snapshot: IReplyDraft; + silentReply: boolean; +} + +interface Submission { + children: Editor['children']; + epoch: number; + replyClaim: ReplyClaim | undefined; +} + +interface SendContentsOptions { + contents: IContent[]; + submission: Submission; + isLive: () => boolean; + includeReplyWithText?: boolean; + /** Defaults to `m.room.message`. Polls and other non-message events set this. */ + eventType?: keyof TimelineEvents; + onContentSent?: (index: number) => void | Promise; +} + interface RoomInputProps { editor: Editor; fileDropContainerRef: RefObject; @@ -358,13 +383,28 @@ export const RoomInput = forwardRef( const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(draftKey)); const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(draftKey)); + const replyDraftRef = useRef(replyDraft); + replyDraftRef.current = replyDraft; const [uploadBoard, setUploadBoard] = useState(true); const [uploadSending, setUploadSending] = useState(false); const [uploadBusy, setUploadBusy] = useState(false); + const [isSending, setIsSending] = useState(false); + const [ingestingFiles, setIngestingFiles] = useState(false); + const fileIngestionCountRef = useRef(0); + const composerControllerRef = useRef(); + composerControllerRef.current ??= createComposerController(); + // Bumped when this composer goes away, so async work started for a previous + // room/thread stops writing to the current one. + const draftEpochRef = useRef(0); + const mountedRef = useRef(false); const [selectedFiles, setSelectedFiles] = useAtom(roomIdToUploadItemsAtomFamily(draftKey)); + const selectedFilesRef = useRef(selectedFiles); + selectedFilesRef.current = selectedFiles; + const uploadItemOverridesRef = useRef(new Map>()); + const removedUploadFilesRef = useRef(new WeakSet()); const isEncrypting = selectedFiles.some((f) => f.encrypting); - const sendBusy = uploadSending || isEncrypting || uploadBusy; + const sendBusy = isSending || uploadSending || isEncrypting || uploadBusy || ingestingFiles; const uploadFamilyObserverAtom = createUploadFamilyObserverAtom( roomUploadAtomFamily, selectedFiles.map((f) => f.file) @@ -373,12 +413,27 @@ export const RoomInput = forwardRef( const longPressTimer = useRef | null>(null); const isLongPress = useRef(false); const suppressBlurRefocusRef = useRef(false); + const editorRafIdsRef = useRef(new Set()); + const scheduleEditorRaf = useCallback((callback: () => void) => { + const rafId = requestAnimationFrame(() => { + editorRafIdsRef.current.delete(rafId); + callback(); + }); + editorRafIdsRef.current.add(rafId); + }, []); + useEffect( + () => () => { + editorRafIdsRef.current.forEach((rafId) => cancelAnimationFrame(rafId)); + editorRafIdsRef.current.clear(); + }, + [] + ); const suppressEditorRefocus = useCallback(() => { suppressBlurRefocusRef.current = true; - requestAnimationFrame(() => { + scheduleEditorRaf(() => { suppressBlurRefocusRef.current = false; }); - }, []); + }, [scheduleEditorRaf]); const imagePackRooms: Room[] = useImagePackRooms(roomId, roomToParents); @@ -386,11 +441,26 @@ export const RoomInput = forwardRef( const audioRecorderRef = useRef(null); const micHoldStartRef = useRef(0); const micHoldReleaseRef = useRef<(() => void) | null>(null); + const recorderActionRef = useRef<'stop' | 'cancel'>(); + const recorderTimerRef = useRef>(); + const scheduleRecorderTimer = useCallback((callback: () => void) => { + recorderTimerRef.current = setTimeout(() => { + recorderTimerRef.current = undefined; + callback(); + }, 50); + }, []); + const requestRecorderStop = useCallback(() => { + if (recorderActionRef.current) return; + recorderActionRef.current = 'stop'; + audioRecorderRef.current?.stop(); + }, []); const HOLD_THRESHOLD_MS = 400; useEffect( () => () => { micHoldReleaseRef.current?.(); + clearTimeout(recorderTimerRef.current); + recorderActionRef.current = undefined; }, [] ); @@ -413,6 +483,17 @@ export const RoomInput = forwardRef( const sendTypingStatus = useTypingStatusUpdater(mx, roomId, { disabled: !!threadRootId }); + useEffect(() => { + mountedRef.current = true; + const controller = (composerControllerRef.current ??= createComposerController()); + return () => { + mountedRef.current = false; + draftEpochRef.current += 1; + controller.dispose(); + composerControllerRef.current = undefined; + }; + }, [draftKey]); + const [inputKey, setInputKey] = useState(0); const getUploadItemKey = useCallback((fileItem: TUploadItem): string => { const existingKey = uploadItemKeysRef.current.get(fileItem.originalFile); @@ -425,64 +506,98 @@ export const RoomInput = forwardRef( const handleFiles = useCallback( async (files: File[], audioMeta?: { waveform: number[]; audioDuration: number }) => { - setUploadBoard(true); - const safeFiles = await Promise.all(files.map(safeUploadFile)); - // Eager-read to avoid Android content URI expiry after SAF picker - const blobbedFiles = isMobileOrTablet() - ? await Promise.all( - safeFiles.map(async (f) => { + const epoch = draftEpochRef.current; + fileIngestionCountRef.current += 1; + setIngestingFiles(true); + try { + setUploadBoard(true); + const safeFiles = await Promise.all(files.map(safeUploadFile)); + if (epoch !== draftEpochRef.current || !mountedRef.current) return; + + // Eager-read to avoid Android content URI expiry after SAF picker + const blobbedFiles = isMobileOrTablet() + ? await Promise.all( + safeFiles.map(async (f) => { + try { + const buf = await f.arrayBuffer(); + return new File([buf], f.name, { type: f.type, lastModified: f.lastModified }); + } catch { + return f; + } + }) + ) + : safeFiles; + if (epoch !== draftEpochRef.current || !mountedRef.current) return; + blobbedFiles.forEach((file) => removedUploadFilesRef.current.delete(file)); + + const makeMetadata = () => ({ + markedAsSpoiler: false, + waveform: audioMeta?.waveform, + audioDuration: audioMeta?.audioDuration, + }); + + if (room.hasEncryptionStateEvent()) { + const placeholders: TUploadItem[] = blobbedFiles.map((f) => ({ + file: f, + originalFile: f, + encInfo: undefined, + encrypting: true, + metadata: makeMetadata(), + })); + setSelectedFiles({ type: 'PUT', item: placeholders }); + await Promise.all( + placeholders.map(async (placeholder) => { try { - const buf = await f.arrayBuffer(); - return new File([buf], f.name, { type: f.type, lastModified: f.lastModified }); - } catch { - return f; + const encryptedFile = await encryptFile(placeholder.originalFile); + if (epoch !== draftEpochRef.current || !mountedRef.current) return; + if (removedUploadFilesRef.current.has(placeholder.originalFile)) return; + const currentItem = selectedFilesRef.current.find( + (item) => item.originalFile === placeholder.originalFile + ); + if (!currentItem) return; + const overrides = uploadItemOverridesRef.current.get(placeholder.originalFile); + setSelectedFiles({ + type: 'REPLACE', + item: currentItem, + replacement: { + ...currentItem, + ...encryptedFile, + ...overrides, + encrypting: false, + metadata: overrides?.metadata ?? currentItem.metadata, + }, + }); + } catch (encryptError: unknown) { + log.warn('Failed to encrypt file for upload:', encryptError); + if (epoch === draftEpochRef.current && mountedRef.current) { + const currentItem = selectedFilesRef.current.find( + (item) => item.originalFile === placeholder.originalFile + ); + if (currentItem) setSelectedFiles({ type: 'DELETE', item: currentItem }); + } } }) - ) - : safeFiles; - const makeMetadata = () => ({ - markedAsSpoiler: false, - waveform: audioMeta?.waveform, - audioDuration: audioMeta?.audioDuration, - }); + ); + return; + } - if (room.hasEncryptionStateEvent()) { - const placeholders: TUploadItem[] = blobbedFiles.map((f) => ({ - file: f, - originalFile: f, - encInfo: undefined, - encrypting: true, - metadata: makeMetadata(), - })); - setSelectedFiles({ type: 'PUT', item: placeholders }); - placeholders.forEach((placeholder) => { - encryptFile(placeholder.originalFile) - .then((ef) => - setSelectedFiles({ - type: 'REPLACE', - item: placeholder, - replacement: { ...ef, encrypting: false, metadata: placeholder.metadata }, - }) - ) - .catch((encryptError: unknown) => { - log.warn('Failed to encrypt file for upload:', encryptError); - setSelectedFiles({ type: 'DELETE', item: placeholder }); - }); + setSelectedFiles({ + type: 'PUT', + item: blobbedFiles.map((f) => ({ + file: f, + originalFile: f, + encInfo: undefined, + metadata: makeMetadata(), + })), }); - return; + } catch (error: unknown) { + log.warn('Failed to prepare files for upload:', error); + } finally { + fileIngestionCountRef.current -= 1; + if (fileIngestionCountRef.current === 0 && mountedRef.current) setIngestingFiles(false); } - - setSelectedFiles({ - type: 'PUT', - item: blobbedFiles.map((f) => ({ - file: f, - originalFile: f, - encInfo: undefined, - metadata: makeMetadata(), - })), - }); }, - [setSelectedFiles, room] + [room, setSelectedFiles] ); const pickFile = useFilePicker(handleFiles, true); const handlePaste = useFilePasteHandler(handleFiles); @@ -525,6 +640,26 @@ export const RoomInput = forwardRef( const [scheduleMenuAnchor, setScheduleMenuAnchor] = useState(); const [showSchedulePicker, setShowSchedulePicker] = useState(false); const [silentReply, setSilentReply] = useState(!mentionInReplies); + // Clears the reply draft up front so it cannot be re-sent, keeping a snapshot to + // restore if the send never lands. + const claimReply = useCallback((): ReplyClaim | undefined => { + const currentReply = replyDraftRef.current; + if (!currentReply) return undefined; + + const epoch = draftEpochRef.current; + replyDraftRef.current = replyDraftBase; + setReplyDraft(replyDraftBase); + return { epoch, snapshot: structuredClone(currentReply), silentReply }; + }, [replyDraftBase, setReplyDraft, silentReply]); + const restoreReplyClaim = useCallback( + (claim: ReplyClaim | undefined) => { + if (!claim || claim.epoch !== draftEpochRef.current) return; + if (replyDraftRef.current !== replyDraftBase) return; + replyDraftRef.current = claim.snapshot; + setReplyDraft(claim.snapshot); + }, + [replyDraftBase, setReplyDraft] + ); const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); const setServerMaxDelayMs = useSetAtom(serverMaxDelayMsAtom); const [sendError, setSendError] = useState(); @@ -584,9 +719,13 @@ export const RoomInput = forwardRef( Transforms.insertFragment(editor, msgDraft); }, [editor, msgDraft]); + const editingStateRef = useRef(false); + const preEditDraftRef = useRef(); useEffect( () => () => { - if (isEmptyEditor(editor)) { + if (editingStateRef.current) { + setMsgDraft(structuredClone(preEditDraftRef.current ?? [])); + } else if (isEmptyEditor(editor)) { setMsgDraft([]); } else { const parsedDraft = structuredClone(editor.children); @@ -599,6 +738,9 @@ export const RoomInput = forwardRef( ); const editingEvent = editId ? room.findEventById(editId) : undefined; + const isMobile = isMobileOrTablet(); + const [initializedEditId, setInitializedEditId] = useState(); + const isEditInitializing = isMobile && editId !== undefined && initializedEditId !== editId; const getEditingContent = useCallback( (event: MatrixEvent): IContent => { const eventId = event.getId(); @@ -613,15 +755,23 @@ export const RoomInput = forwardRef( ); const prevEditingEventId = useRef(); - const preEditDraftRef = useRef(); useEffect(() => { - if (!isMobileOrTablet()) { + if (!isMobile) { + editingStateRef.current = false; prevEditingEventId.current = undefined; preEditDraftRef.current = undefined; + setInitializedEditId(undefined); + return; + } + + if (editId !== undefined && !editingEvent) { + setInitializedEditId(undefined); + onCancelEdit?.(); return; } if (editingEvent) { + editingStateRef.current = true; if (editingEvent.getId() !== prevEditingEventId.current) { if (!prevEditingEventId.current) { preEditDraftRef.current = structuredClone(editor.children); @@ -671,7 +821,7 @@ export const RoomInput = forwardRef( resetEditorHistory(editor); Transforms.insertFragment(editor, initialValue); - requestAnimationFrame(() => { + scheduleEditorRaf(() => { try { ReactEditor.focus(editor); moveCursor(editor); @@ -680,7 +830,9 @@ export const RoomInput = forwardRef( } }); } + setInitializedEditId(editId); } else { + editingStateRef.current = false; const previousDraft = preEditDraftRef.current; if (prevEditingEventId.current && previousDraft) { resetEditor(editor); @@ -692,7 +844,7 @@ export const RoomInput = forwardRef( prevEditingEventId.current && (!replyDraft?.eventId || replyDraft.eventId === threadRootId) ) { - requestAnimationFrame(() => { + scheduleEditorRaf(() => { try { const domNode = ReactEditor.toDOMNode(editor, editor); domNode.blur(); @@ -703,8 +855,10 @@ export const RoomInput = forwardRef( }); } prevEditingEventId.current = undefined; + setInitializedEditId(undefined); } }, [ + editId, editingEvent, editor, getEditingContent, @@ -713,6 +867,10 @@ export const RoomInput = forwardRef( room, replyDraft?.eventId, threadRootId, + isMobile, + setInitializedEditId, + onCancelEdit, + scheduleEditorRaf, ]); useEffect(() => { @@ -745,7 +903,7 @@ export const RoomInput = forwardRef( prevReplyEventId.current = newId; if (newId && newId !== threadRootId) { - requestAnimationFrame(() => { + scheduleEditorRaf(() => { try { ReactEditor.focus(editor); moveCursor(editor); @@ -754,7 +912,7 @@ export const RoomInput = forwardRef( } }); } else if (!newId && prevId && prevId !== threadRootId && !editId) { - requestAnimationFrame(() => { + scheduleEditorRaf(() => { try { const domNode = ReactEditor.toDOMNode(editor, editor); domNode.blur(); @@ -765,10 +923,14 @@ export const RoomInput = forwardRef( }); } } - }, [replyDraft?.eventId, threadRootId, editId, editor]); + }, [replyDraft?.eventId, threadRootId, editId, editor, scheduleEditorRaf]); const handleFileMetadata = useCallback( (fileItem: TUploadItem, metadata: TUploadMetadata) => { + uploadItemOverridesRef.current.set(fileItem.originalFile, { + ...uploadItemOverridesRef.current.get(fileItem.originalFile), + metadata, + }); setSelectedFiles({ type: 'REPLACE', item: fileItem, @@ -779,6 +941,11 @@ export const RoomInput = forwardRef( ); const setDesc = useCallback( (fileItem: TUploadItem, body: string, formatted_body: string) => { + uploadItemOverridesRef.current.set(fileItem.originalFile, { + ...uploadItemOverridesRef.current.get(fileItem.originalFile), + body, + formatted_body, + }); setSelectedFiles({ type: 'REPLACE', item: fileItem, @@ -794,13 +961,18 @@ export const RoomInput = forwardRef( type: 'DELETE', item: selectedFiles.filter((f) => uploads.find((u) => u === f.file)), }); - uploads.forEach((u) => roomUploadAtomFamily.remove(u)); + uploads.forEach((u) => { + removedUploadFilesRef.current.add(u); + roomUploadAtomFamily.remove(u); + uploadItemOverridesRef.current.delete(u); + }); }, [setSelectedFiles, selectedFiles] ); const handleAudioRecordingComplete = useCallback( (payload: AudioRecordingCompletePayload) => { + recorderActionRef.current = undefined; const extension = getSupportedAudioExtension(payload.audioCodec); const file = new File( [payload.audioBlob], @@ -821,7 +993,10 @@ export const RoomInput = forwardRef( const audioRecorder = showAudioRecorder ? ( setShowAudioRecorder(false)} + onRequestClose={() => { + recorderActionRef.current = undefined; + setShowAudioRecorder(false); + }} onRecordingComplete={handleAudioRecordingComplete} onAudioLengthUpdate={() => {}} onWaveformUpdate={() => {}} @@ -837,8 +1012,53 @@ export const RoomInput = forwardRef( handleRemoveUpload(uploads.map((upload) => upload.file)); }; - const handleSendContents = async (contents: IContent[]) => { - const plainText = toPlainText(editor.children).trim(); + // Clears the composer immediately so a slow send cannot be edited or submitted + // twice; anything that fails without a local echo is restored afterwards. + const takeSubmission = useCallback( + ({ clearEditor = true, claimReplyDraft = true } = {}): Submission => { + const children = structuredClone(editor.children); + const submission: Submission = { + children, + epoch: draftEpochRef.current, + replyClaim: claimReplyDraft ? claimReply() : undefined, + }; + if (clearEditor) { + resetEditor(editor); + resetEditorHistory(editor); + setInputKey((prev) => prev + 1); + imagePacksUsedRef.current.clear(); + sendTypingStatus(false); + } + return submission; + }, + [claimReply, editor, sendTypingStatus] + ); + const restoreSubmission = useCallback( + (submission: Submission) => { + restoreReplyClaim(submission.replyClaim); + if ( + !mountedRef.current || + submission.epoch !== draftEpochRef.current || + !isEmptyEditor(editor) + ) { + return; + } + Transforms.insertFragment(editor, submission.children); + }, + [editor, restoreReplyClaim] + ); + + const handleSendContents = async ({ + contents, + submission, + isLive, + includeReplyWithText = false, + eventType, + onContentSent, + }: SendContentsOptions) => { + const plainText = toPlainText(submission.children).trim(); + const submittedReplyDraft = submission.replyClaim?.snapshot; + const submittedSilentReply = submission.replyClaim?.silentReply ?? silentReply; /** * the currently with the room associated per-message profile, if any, so that it can be included in the message content when sending. @@ -859,39 +1079,61 @@ export const RoomInput = forwardRef( }); } - if (contents.length > 0) { - const replyContent = - plainText?.length === 0 ? getReplyContent(replyDraft, room) : undefined; - if (replyContent) { - contents[0]!['m.relates_to'] = replyContent; - if (!silentReply && replyDraft) - contents[0]!['m.mentions'] = { ['user_ids']: [replyDraft.userId] }; - setReplyDraft(replyDraftBase); - } + const replyContent = + submittedReplyDraft && (includeReplyWithText || plainText.length === 0) + ? getReplyContent(submittedReplyDraft, room) + : undefined; + if (replyContent && contents.length > 0) { + contents[0]!['m.relates_to'] = replyContent; + if (!submittedSilentReply && submittedReplyDraft) + contents[0]!['m.mentions'] = { ['user_ids']: [submittedReplyDraft.userId] }; } - const invalidate = () => - queryClient.invalidateQueries({ queryKey: ['delayedEvents', roomId] }); + const invalidate = () => { + if (isLive()) queryClient.invalidateQueries({ queryKey: ['delayedEvents', roomId] }); + }; + const handleContentSent = async (index: number) => { + if (onContentSent && isLive()) await onContentSent(index); + }; if (scheduledTime) { try { const delayMs = computeDelayMs(scheduledTime); - if (editingScheduledDelayId) { - await cancelDelayedEvent(mx, editingScheduledDelayId); - } + await roomScheduleCoordinator.run(roomId, async () => { + if (editingScheduledDelayId) { + await cancelDelayedEvent(mx, editingScheduledDelayId); + if (isLive()) setEditingScheduledDelayId(null); + } - await Promise.all( - contents.map((content) => { - if (isEncrypted) { - return sendDelayedMessageE2EE(mx, roomId, room, content, delayMs); - } - return sendDelayedMessage(mx, roomId, content, delayMs); - }) - ); + const sendResults = await Promise.allSettled( + contents.map(async (content, index) => { + const response = isEncrypted + ? await sendDelayedMessageE2EE( + mx, + roomId, + room, + content, + delayMs, + null, + eventType + ) + : await sendDelayedMessage(mx, roomId, content, delayMs, null, eventType); + await handleContentSent(index); + return response; + }) + ); + const failedSend = sendResults.find( + (result): result is PromiseRejectedResult => result.status === 'rejected' + ); + if (failedSend) throw failedSend.reason; + }); invalidate(); - setEditingScheduledDelayId(null); - setScheduledTime(null); + if (isLive()) { + setEditingScheduledDelayId(null); + setScheduledTime(null); + } + return contents.length > 0; } catch (error) { debugLog.error('message', 'Failed to schedule message', { roomId, @@ -901,40 +1143,65 @@ export const RoomInput = forwardRef( throw error; } } else { - if (editingScheduledDelayId) { - try { - await cancelDelayedEvent(mx, editingScheduledDelayId); - invalidate(); - setEditingScheduledDelayId(null); - } catch { - debugLog.error('message', 'Failed to cancel scheduled event before immediate send', { - roomId, - }); - } - } - - await Promise.all( - contents.map((content) => - mx - .sendMessage(roomId, threadRootId ?? null, content as RoomMessageEventContent) - .then((res: { event_id: string }) => { + const sendImmediateContents = async () => + Promise.allSettled( + contents.map(async (content, index) => { + try { + const res = eventType + ? await mx.sendEvent( + roomId, + threadRootId ?? null, + eventType, + content as TimelineEvents[keyof TimelineEvents] + ) + : await mx.sendMessage( + roomId, + threadRootId ?? null, + content as RoomMessageEventContent + ); + await handleContentSent(index); debugLog.info('message', 'Message sent', { roomId, eventId: res.event_id, msgtype: content.msgtype, }); return res; - }) - .catch((error: unknown) => { + } catch (error: unknown) { debugLog.error('message', 'Failed to send message', { roomId, error: error instanceof Error ? error.message : String(error), }); log.error('failed to send message', { roomId }, error); throw error; - }) - ) + } + }) + ); + let sendResults: PromiseSettledResult[] = []; + if (editingScheduledDelayId) { + let cancellationFailed = false; + await roomScheduleCoordinator.run(roomId, async () => { + try { + await cancelDelayedEvent(mx, editingScheduledDelayId); + invalidate(); + if (isLive()) setEditingScheduledDelayId(null); + } catch { + cancellationFailed = true; + debugLog.error('message', 'Failed to cancel scheduled event before immediate send', { + roomId, + }); + return; + } + sendResults = await sendImmediateContents(); + }); + if (cancellationFailed) sendResults = await sendImmediateContents(); + } else { + sendResults = await sendImmediateContents(); + } + const failedSend = sendResults.find( + (result): result is PromiseRejectedResult => result.status === 'rejected' ); + if (failedSend) throw failedSend.reason; + return contents.length > 0; } }; @@ -954,11 +1221,17 @@ export const RoomInput = forwardRef( return getFileMsgContent(fileItem, upload.mxc); }; - const handleSendUpload = async (uploads: Upload[]) => { - const plainText = toPlainText(editor.children).trim(); + // Resolves true when the composer text went out as an attachment caption, meaning + // the caller must not send it again. + const handleSendUpload = async ( + uploads: Upload[], + submission: Submission, + isLive: () => boolean + ): Promise => { + const plainText = toPlainText(submission.children).trim(); const caption = plainText.length > 0 ? plainText : undefined; let customHtml = trimCustomHtml( - toMatrixCustomHTML(editor.children, { + toMatrixCustomHTML(submission.children, { stripNickname: true, room, }) @@ -966,26 +1239,24 @@ export const RoomInput = forwardRef( const formattedCaption = caption && !customHtmlEqualsPlainText(customHtml, plainText) ? customHtml : undefined; - const resolved = fulfilledPromiseSettledResult( - await Promise.allSettled( - uploads.map(async (upload): Promise => { - if (upload.status === UploadStatus.Success) return upload; - if (upload.status === UploadStatus.Loading) { - const response = await upload.promise; - if (!response.content_uri) throw new Error('Upload failed'); - return { status: UploadStatus.Success, file: upload.file, mxc: response.content_uri }; - } - throw new Error('Upload not ready'); - }) - ) + if (uploads.length !== selectedFiles.length) throw new Error('Upload not ready'); + const resolved = await Promise.all( + uploads.map(async (upload): Promise => { + if (upload.status === UploadStatus.Success) return upload; + if (upload.status === UploadStatus.Loading) { + const response = await upload.promise; + if (!response.content_uri) throw new Error('Upload failed'); + return { status: UploadStatus.Success, file: upload.file, mxc: response.content_uri }; + } + throw new Error('Upload not ready'); + }) ); - if (resolved.length === 0) return; + if (resolved.length === 0) throw new Error('Upload not ready'); - if (resolved.length == 1 && sendIndividualAttachmentAsCaption) { + if (selectedFiles.length == 1 && sendIndividualAttachmentAsCaption) { const upload = resolved[0]; if (!upload) throw new Error('Broken upload'); let content = await uploadToContent(upload); - handleCancelUpload(resolved); content.body = caption ?? ''; content.formatted_body = undefined; @@ -995,30 +1266,70 @@ export const RoomInput = forwardRef( content.formatted_body = formattedCaption; } - await handleSendContents([content]); - return; + await handleSendContents({ + contents: [content], + submission, + isLive, + includeReplyWithText: true, + }); + if (isLive()) handleCancelUpload(resolved); + return true; } - if (resolved.length >= 2 && enableMediaGalleries) { + if (selectedFiles.length >= 2 && enableMediaGalleries) { const itemsPromises = resolved.map(async (upload) => { const fileItem = selectedFiles.find((f) => f.file === upload.file); if (!fileItem) throw new Error('Broken upload'); return getGalleryItemContent(mx, fileItem, upload.mxc); }); - handleCancelUpload(resolved); - const items = fulfilledPromiseSettledResult(await Promise.allSettled(itemsPromises)); - - if (items.length === 0) return; + const items = await Promise.all(itemsPromises); const galleryContent = buildGalleryContent(items, caption, formattedCaption); - await handleSendContents([galleryContent]); - return; + await handleSendContents({ + contents: [galleryContent], + submission, + isLive, + includeReplyWithText: true, + }); + if (isLive()) handleCancelUpload(resolved); + return true; } - const contentsPromises = resolved.map(uploadToContent); - handleCancelUpload(resolved); - const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises)); - - await handleSendContents(contents); + const contents = await Promise.all(resolved.map(uploadToContent)); + + await handleSendContents({ + contents, + submission, + isLive, + onContentSent: (index) => { + const upload = resolved[index]; + if (upload) handleCancelUpload([upload]); + }, + }); + return false; + }; + // `submit` is memoized but this closure is not. + const handleSendUploadRef = useRef(handleSendUpload); + handleSendUploadRef.current = handleSendUpload; + + const handleDialogSendContent = async ( + content: IContent, + eventType?: keyof TimelineEvents + ): Promise => { + const submission = takeSubmission({ clearEditor: false }); + await composerControllerRef.current?.enqueue(async (isLive) => { + try { + await handleSendContents({ + contents: [content], + submission, + isLive, + includeReplyWithText: true, + eventType, + }); + } catch (error) { + restoreReplyClaim(submission.replyClaim); + throw error; + } + }); }; const handleCloseAutocomplete = useCallback(() => { @@ -1060,232 +1371,276 @@ export const RoomInput = forwardRef( [editor, handleCloseAutocomplete, mx, room, sendTypingStatus] ); - const submit = useCallback(async () => { - if (editingEvent && isMobileOrTablet()) { - const content = buildEditReplacement(editor.children, { - mx, - room, - roomId, - editingEvent, - currentContent: getEditingContent(editingEvent), - }); - if (!content) { - onCancelEdit?.(); + const executeSubmit = useCallback( + async (submission: Submission, isLive: () => boolean) => { + if ( + fileIngestionCountRef.current > 0 || + isEditInitializing || + (isMobile && editId !== undefined && !editingEvent) + ) { + restoreSubmission(submission); return; } - await mx.sendMessage(roomId, content as RoomMessageEventContent); - onCancelEdit?.(); - sendTypingStatus(false); - return; - } + setIsSending(true); + const submittedReplyDraft = submission.replyClaim?.snapshot; + const submittedSilentReply = submission.replyClaim?.silentReply ?? silentReply; + try { + if (editingEvent && isMobile) { + const content = buildEditReplacement(submission.children, { + mx, + room, + roomId, + editingEvent, + currentContent: getEditingContent(editingEvent), + }); + if (!content) { + if (isLive()) onCancelEdit?.(); + return; + } + await mx.sendMessage(roomId, content as RoomMessageEventContent); + if (isLive()) { + onCancelEdit?.(); + sendTypingStatus(false); + } + return; + } - if (selectedFiles.some((f) => f.encrypting)) return; - uploadBoardHandlers.current?.handleSend(); - if ( - (selectedFiles.length >= 2 && enableMediaGalleries) || - (selectedFiles.length == 1 && sendIndividualAttachmentAsCaption) - ) { - resetEditor(editor); - resetEditorHistory(editor); - sendTypingStatus(false); - return; - } + if (selectedFiles.some((f) => f.encrypting)) { + restoreSubmission(submission); + return; + } + if (selectedFiles.length > 0) { + const uploads = uploadBoardHandlers.current?.getSendableUploads() ?? []; + const sendUpload = handleSendUploadRef.current; + setUploadSending(true); + try { + if (await sendUpload(uploads, submission, isLive)) return; + } catch (error: unknown) { + log.error('failed to send attachments', { roomId }, error); + if (isLive()) { + setSendError('Failed to send attachments. Please try again.'); + } + restoreSubmission(submission); + return; + } finally { + if (isLive()) setUploadSending(false); + } + } + + const outgoing = await buildOutgoingMessage(submission.children, { + mx, + room, + roomId, + nicknames, + replyEvent, + replyDraft: submittedReplyDraft, + silentReply: submittedSilentReply, + settingsLinkBaseUrl, + canSendReaction, + pkCompatEnable, + pmpProxyingEnable, + pmpLatchingEnable, + latchedPersona, + isPKCommand: (text) => PKitCommandMessageHandler.isPKCommand(text), + pluralkitProxyMessageHandler, + imagePacksUsed: imagePacksUsedRef.current, + }); - const outgoing = await buildOutgoingMessage(editor.children, { + if (outgoing.kind === 'empty') return; + if (outgoing.kind === 'quickReact') { + handleQuickReact(outgoing.key); + return; + } + if (outgoing.kind === 'pkCommand') { + await pluralkitCmdMessageHandler.handleMessage(outgoing.plainText); + return; + } + if (outgoing.kind === 'command') { + const { command, plainText, customHtml } = outgoing; + if (command === Command.Poll) setShowPollPicker(true); + else if (command === Command.Location && plainText.trim().length === 0) + setShowLocationPicker(true); + else commands[command as Command]?.exe(plainText, customHtml); + return; + } + + const { content } = outgoing; + if (outgoing.latchPersona) { + await setCurrentlyUsedPerMessageProfileIdForRoom(mx, roomId, outgoing.latchPersona.id); + if (isLive()) setLatchedPersona(outgoing.latchPersona); + } + if (submittedReplyDraft) { + content['m.relates_to'] = getReplyContent(submittedReplyDraft, room); + } + const invalidate = () => { + if (isLive()) { + queryClient.invalidateQueries({ queryKey: ['delayedEvents', roomId] }); + } + }; + + if (scheduledTime) { + try { + const delayMs = computeDelayMs(scheduledTime); + await roomScheduleCoordinator.run(roomId, async () => { + if (editingScheduledDelayId) { + await cancelDelayedEvent(mx, editingScheduledDelayId); + if (isLive()) setEditingScheduledDelayId(null); + } + if (isEncrypted) { + await sendDelayedMessageE2EE(mx, roomId, room, content, delayMs); + } else { + await sendDelayedMessage(mx, roomId, content as RoomMessageEventContent, delayMs); + } + }); + invalidate(); + if (isLive()) { + setSendError(undefined); + setEditingScheduledDelayId(null); + setScheduledTime(null); + } + } catch (e: unknown) { + // A scheduled send leaves no local echo, so hand the message back. + restoreSubmission(submission); + if (!isLive()) return; + if ( + e instanceof MatrixError && + (e.errcode === ErrorCode.M_MAX_DELAY_EXCEEDED || + e.data?.['org.matrix.msc4140.errcode'] === 'M_MAX_DELAY_EXCEEDED') + ) { + const maxDelay = + (e.data as { max_delay?: number })?.max_delay ?? + e.data?.['org.matrix.msc4140.max_delay']; + if (typeof maxDelay === 'number') setServerMaxDelayMs(maxDelay); + const maxDelayDays = maxDelay / daysToMs(1); + setSendError( + `Scheduled time exceeds the maximum delay allowed by this server. Please choose an earlier time. The Maximum Delay is of ${maxDelayDays} day${maxDelayDays > 1 ? 's' : ''}.` + ); + } else { + setSendError('Failed to schedule message. Please try again.'); + } + } + } else if (editingScheduledDelayId) { + const scheduledDelayId = editingScheduledDelayId; + try { + await roomScheduleCoordinator.run(roomId, async () => { + await cancelDelayedEvent(mx, scheduledDelayId); + if (isLive()) setEditingScheduledDelayId(null); + debugLog.info('message', 'Sending message after cancelling scheduled event', { + roomId, + scheduledDelayId, + }); + const res = await mx.sendMessage( + roomId, + threadRootId ?? null, + content as RoomMessageEventContent + ); + debugLog.info('message', 'Message sent successfully', { + roomId, + eventId: res.event_id, + }); + }); + invalidate(); + } catch (error) { + debugLog.error('message', 'Failed to send message after cancelling scheduled event', { + roomId, + error: error instanceof Error ? error.message : String(error), + }); + // The scheduled copy may still exist, so don't drop the user's text. + restoreSubmission(submission); + if (isLive()) setSendError('Failed to reschedule message. Please try again.'); + } + } else { + const msgSendStart = performance.now(); + debugLog.info('message', 'Sending message', { + roomId, + msgtype: content.msgtype, + }); + try { + const res = await Sentry.startSpan( + { + name: 'message.send', + op: 'matrix.message', + attributes: { encrypted: String(isEncrypted) }, + }, + () => + mx.sendMessage(roomId, threadRootId ?? null, content as RoomMessageEventContent) + ); + debugLog.info('message', 'Message sent successfully', { + roomId, + eventId: res.event_id, + }); + Sentry.metrics.distribution( + 'sable.message.send_latency_ms', + performance.now() - msgSendStart, + { attributes: { encrypted: String(isEncrypted) } } + ); + } catch (error: unknown) { + debugLog.error('message', 'Failed to send message', { + roomId, + error: error instanceof Error ? error.message : String(error), + }); + Sentry.metrics.count('sable.message.send_error', 1, { + attributes: { encrypted: String(isEncrypted) }, + }); + log.error('failed to send message', { roomId }, error); + // The failed send stays in the timeline as a local echo the user can retry, + // so the composer is intentionally left empty here. + } + } + } finally { + if (isLive()) setIsSending(false); + } + }, + [ + replyEvent, mx, - room, roomId, - nicknames, - replyEvent, - replyDraft, - silentReply, - settingsLinkBaseUrl, canSendReaction, pkCompatEnable, + silentReply, pmpProxyingEnable, + pluralkitProxyMessageHandler, + scheduledTime, + editingScheduledDelayId, + nicknames, + room, + handleQuickReact, + pluralkitCmdMessageHandler, + commands, + sendTypingStatus, + queryClient, + threadRootId, + settingsLinkBaseUrl, + isEncrypted, + setEditingScheduledDelayId, + setScheduledTime, + setServerMaxDelayMs, + selectedFiles, + editingEvent, + getEditingContent, + onCancelEdit, + restoreSubmission, + isMobile, + editId, + isEditInitializing, pmpLatchingEnable, latchedPersona, - isPKCommand: (text) => PKitCommandMessageHandler.isPKCommand(text), - pluralkitProxyMessageHandler, - imagePacksUsed: imagePacksUsedRef.current, - }); - - if (outgoing.kind === 'empty') return; - if (outgoing.kind === 'quickReact') { - handleQuickReact(outgoing.key); - return; - } - if (outgoing.kind === 'pkCommand') { - await pluralkitCmdMessageHandler.handleMessage(outgoing.plainText); - resetEditor(editor); - return; - } - if (outgoing.kind === 'command') { - const { command, plainText, customHtml } = outgoing; - if (command === Command.Poll) setShowPollPicker(true); - else if (command === Command.Location && plainText.trim().length === 0) - setShowLocationPicker(true); - else commands[command as Command]?.exe(plainText, customHtml); - resetEditor(editor); - resetEditorHistory(editor); - sendTypingStatus(false); - return; - } - - const { content } = outgoing; - if (outgoing.latchPersona) { - await setCurrentlyUsedPerMessageProfileIdForRoom(mx, roomId, outgoing.latchPersona.id); - setLatchedPersona(outgoing.latchPersona); - } - if (replyDraft) { - content['m.relates_to'] = getReplyContent(replyDraft, room); - } - const invalidate = () => - queryClient.invalidateQueries({ queryKey: ['delayedEvents', roomId] }); + ] + ); - const resetInput = () => { - resetEditor(editor); - resetEditorHistory(editor); - setInputKey((prev) => prev + 1); - imagePacksUsedRef.current.clear(); - setReplyDraft(replyDraftBase); - sendTypingStatus(false); - }; - if (scheduledTime) { - try { - const delayMs = computeDelayMs(scheduledTime); - if (editingScheduledDelayId) { - await cancelDelayedEvent(mx, editingScheduledDelayId); - } - if (isEncrypted) { - await sendDelayedMessageE2EE(mx, roomId, room, content, delayMs); - } else { - await sendDelayedMessage(mx, roomId, content as RoomMessageEventContent, delayMs); - } - setSendError(undefined); - invalidate(); - setEditingScheduledDelayId(null); - setScheduledTime(null); - resetInput(); - } catch (e: unknown) { - if ( - e instanceof MatrixError && - (e.errcode === ErrorCode.M_MAX_DELAY_EXCEEDED || - e.data?.['org.matrix.msc4140.errcode'] === 'M_MAX_DELAY_EXCEEDED') - ) { - const maxDelay = - (e.data as { max_delay?: number })?.max_delay ?? - e.data?.['org.matrix.msc4140.max_delay']; - if (typeof maxDelay === 'number') setServerMaxDelayMs(maxDelay); - const maxDelayDays = maxDelay / daysToMs(1); - setSendError( - `Scheduled time exceeds the maximum delay allowed by this server. Please choose an earlier time. The Maximum Delay is of ${maxDelayDays} day${maxDelayDays > 1 ? 's' : ''}.` - ); - } else { - setSendError('Failed to schedule message. Please try again.'); - } - } - } else if (editingScheduledDelayId) { - try { - await cancelDelayedEvent(mx, editingScheduledDelayId); - debugLog.info('message', 'Sending message after cancelling scheduled event', { - roomId, - scheduledDelayId: editingScheduledDelayId, - }); - const res = await mx.sendMessage( - roomId, - threadRootId ?? null, - content as RoomMessageEventContent - ); - debugLog.info('message', 'Message sent successfully', { - roomId, - eventId: res.event_id, - }); - invalidate(); - setEditingScheduledDelayId(null); - resetInput(); - } catch (error) { - debugLog.error('message', 'Failed to send message after cancelling scheduled event', { - roomId, - error: error instanceof Error ? error.message : String(error), - }); - // Cancel failed — leave state intact for retry - } - } else { - const msgSendStart = performance.now(); - resetInput(); - debugLog.info('message', 'Sending message', { - roomId, - msgtype: content.msgtype, - }); - Sentry.startSpan( - { - name: 'message.send', - op: 'matrix.message', - attributes: { encrypted: String(isEncrypted) }, - }, - () => mx.sendMessage(roomId, threadRootId ?? null, content as RoomMessageEventContent) - ) - .then((res: { event_id: string }) => { - debugLog.info('message', 'Message sent successfully', { - roomId, - eventId: res.event_id, - }); - Sentry.metrics.distribution( - 'sable.message.send_latency_ms', - performance.now() - msgSendStart, - { attributes: { encrypted: String(isEncrypted) } } - ); - }) - .catch((error: unknown) => { - debugLog.error('message', 'Failed to send message', { - roomId, - error: error instanceof Error ? error.message : String(error), - }); - Sentry.metrics.count('sable.message.send_error', 1, { - attributes: { encrypted: String(isEncrypted) }, - }); - log.error('failed to send message', { roomId }, error); - }); - } - }, [ - editor, - replyEvent, - mx, - roomId, - canSendReaction, - pkCompatEnable, - replyDraft, - silentReply, - pmpProxyingEnable, - pmpLatchingEnable, - pluralkitProxyMessageHandler, - scheduledTime, - editingScheduledDelayId, - nicknames, - room, - handleQuickReact, - pluralkitCmdMessageHandler, - commands, - sendTypingStatus, - queryClient, - threadRootId, - setReplyDraft, - settingsLinkBaseUrl, - isEncrypted, - setEditingScheduledDelayId, - setScheduledTime, - setServerMaxDelayMs, - replyDraftBase, - selectedFiles, - enableMediaGalleries, - sendIndividualAttachmentAsCaption, - editingEvent, - getEditingContent, - onCancelEdit, - latchedPersona, - ]); + const submit = useCallback(() => { + // A mobile edit replaces an existing event, so it owns neither the draft nor the reply. + const isMobileEdit = Boolean(editingEvent && isMobile); + const submission = takeSubmission({ + clearEditor: !isMobileEdit, + claimReplyDraft: !isMobileEdit, + }); + return ( + composerControllerRef.current?.enqueue((isLive) => executeSubmit(submission, isLive)) ?? + Promise.resolve(undefined) + ); + }, [editingEvent, executeSubmit, isMobile, takeSubmission]); const handleKeyDown: KeyboardEventHandler = useCallback( (evt) => { @@ -1441,7 +1796,9 @@ export const RoomInput = forwardRef( handleCloseAutocomplete(); }; - const handleStickerSelect = async (mxc: string, shortcode: string, label: string) => { + const executeStickerSelect = async (mxc: string, label: string, submission: Submission) => { + const replySnapshot = submission.replyClaim?.snapshot; + const silentReplySnapshot = submission.replyClaim?.silentReply ?? silentReply; // Packs declare their own info, so sending does not need the file. Measuring it instead made // the send fail outright whenever the media fetch did. let info = getPackImageInfo(mx, room, ImageUsage.Sticker, mxc); @@ -1484,29 +1841,46 @@ export const RoomInput = forwardRef( content[prefix.MATRIX_UNSTABLE_IMAGE_SOURCE_PACK_PROPERTY_NAME] = getImagePackReferencesForMxcWrappedInMap(mxc, mx, ImageUsage.Sticker, room); - if (replyDraft) { - content['m.relates_to'] = getReplyContent(replyDraft, room); - if (!silentReply && replyDraft) - content['m.mentions'] = { ['user_ids']: [replyDraft.userId] }; - setReplyDraft(replyDraftBase); - } - try { - await mx.sendEvent(roomId, EventType.Sticker, content); - } catch (error) { - log.error('failed to send sticker', { roomId }, error); + if (replySnapshot) { + content['m.relates_to'] = getReplyContent(replySnapshot, room); + if (!silentReplySnapshot) content['m.mentions'] = { ['user_ids']: [replySnapshot.userId] }; } + await mx.sendEvent(roomId, EventType.Sticker, content); }; - const handleGifSelect = async (gif: GifData, spoiler?: boolean) => { - const url = getSendableKlipyMxcUrl(gif.url, clientConfig.gifs?.proxyUrl); - if (!url) return; + const handleStickerSelect = (mxc: string, _shortcode: string, label: string) => { + const submission = takeSubmission({ clearEditor: false }); + return composerControllerRef.current?.enqueue(async () => { + try { + await executeStickerSelect(mxc, label, submission); + } catch (error) { + log.error('failed to send sticker', { roomId }, error); + restoreReplyClaim(submission.replyClaim); + } + }); + }; + + const handleGifSelect = (gif: GifData, spoiler?: boolean) => { + const submission = takeSubmission({ clearEditor: false }); + return composerControllerRef.current?.enqueue(async (isLive) => { + try { + const url = getSendableKlipyMxcUrl(gif.url, clientConfig.gifs?.proxyUrl); + if (!url) throw new Error('Unsendable GIF url'); - const content = await getGifMsgContent(mx, gif, url, spoiler); - if (!content) return; + const content = await getGifMsgContent(mx, gif, url, spoiler); + if (!content) throw new Error('Unsendable GIF content'); - await handleSendContents([content]); + return await handleSendContents({ contents: [content], submission, isLive }); + } catch (error) { + log.error('failed to send gif', { roomId }, error); + restoreReplyClaim(submission.replyClaim); + return false; + } + }); }; + if (isEditInitializing) return
; + return (
( open={uploadBoard} onToggle={() => setUploadBoard(!uploadBoard)} uploadFamilyObserverAtom={uploadFamilyObserverAtom} - onSend={async (uploads) => { - setUploadSending(true); - try { - await handleSendUpload(uploads); - } finally { - setUploadSending(false); - } - }} onBusyChange={setUploadBusy} imperativeHandlerRef={uploadBoardHandlers} onCancel={handleCancelUpload} @@ -2114,7 +2480,7 @@ export const RoomInput = forwardRef( aria-pressed={!hasContent && editorMicButton ? showAudioRecorder : undefined} onClick={() => { if (showAudioRecorder) { - audioRecorderRef.current?.stop(); + requestRecorderStop(); return; } if (hasContent) { @@ -2127,6 +2493,7 @@ export const RoomInput = forwardRef( } if (!editorMicButton) return; if (isMobileOrTablet()) return; + recorderActionRef.current = undefined; setShowAudioRecorder(true); }} onMouseDown={(e: MouseEvent) => { @@ -2136,7 +2503,7 @@ export const RoomInput = forwardRef( if (showAudioRecorder) return; if (hasContent) { isLongPress.current = false; - if (isMobileOrTablet() && delayedEventsSupported) { + if (isMobileOrTablet() && delayedEventsSupported && !threadRootId) { longPressTimer.current = setTimeout(() => { isLongPress.current = true; setShowSchedulePicker(true); @@ -2146,22 +2513,27 @@ export const RoomInput = forwardRef( } if (!editorMicButton) return; if (!isMobileOrTablet()) return; + recorderActionRef.current = undefined; micHoldStartRef.current = Date.now(); setShowAudioRecorder(true); function discardRecording() { + if (recorderActionRef.current) return; + recorderActionRef.current = 'cancel'; releaseListeners(); - setTimeout(() => { + scheduleRecorderTimer(() => { audioRecorderRef.current?.cancel(); - }, 50); + }); } function onUp() { + if (recorderActionRef.current) return; const held = Date.now() - micHoldStartRef.current; if (held >= HOLD_THRESHOLD_MS) { + recorderActionRef.current = 'stop'; releaseListeners(); - setTimeout(() => { + scheduleRecorderTimer(() => { audioRecorderRef.current?.stop(); - }, 50); + }); } else { discardRecording(); } @@ -2187,8 +2559,12 @@ export const RoomInput = forwardRef( longPressTimer.current = null; } }} - disabled={hasContent && sendBusy && !showAudioRecorder} - className={hasContent && delayedEventsSupported ? css.SplitSendButton : undefined} + disabled={sendBusy && !showAudioRecorder} + className={ + hasContent && delayedEventsSupported && !threadRootId + ? css.SplitSendButton + : undefined + } > {showAudioRecorder ? ( ( weight="fill" style={{ color: color.Critical.Main }} /> + ) : sendBusy ? ( + ) : hasContent || !editorMicButton ? ( - sendBusy ? ( - - ) : scheduledTime ? ( + scheduledTime ? ( composerIcon(Clock) ) : ( composerIcon(PaperPlaneTilt) @@ -2251,7 +2627,7 @@ export const RoomInput = forwardRef( } /> - {delayedEventsSupported && !isMobileOrTablet() && ( + {delayedEventsSupported && !isMobileOrTablet() && !threadRootId && ( ) => { setScheduleMenuAnchor(evt.currentTarget.getBoundingClientRect()); @@ -2271,7 +2647,7 @@ export const RoomInput = forwardRef( } bottom={} /> - {showSchedulePicker && ( + {showSchedulePicker && !threadRootId && ( ( {showPollPicker && ( setShowPollPicker(false)} - mx={mx} - room={room} - replyDraft={replyDraft} - clearReplyDraft={() => setReplyDraft(replyDraftBase)} + onSubmit={(content) => + handleDialogSendContent(content, M_POLL_START.name as keyof TimelineEvents) + } /> )} {showLocationPicker && ( setShowLocationPicker(false)} - mx={mx} room={room} - replyDraft={replyDraft} - clearReplyDraft={() => setReplyDraft(replyDraftBase)} + onSubmit={handleDialogSendContent} /> )} diff --git a/src/app/features/room/composerController.test.ts b/src/app/features/room/composerController.test.ts new file mode 100644 index 0000000000..3ef5f1f494 --- /dev/null +++ b/src/app/features/room/composerController.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; +import { createComposerController } from './composerController'; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +describe('composer controller', () => { + it('runs a later send only after a slow one finishes', async () => { + const controller = createComposerController(); + const slow = deferred(); + const order: string[] = []; + + const slowSend = controller.enqueue(async () => { + order.push('slow:start'); + await slow.promise; + order.push('slow:end'); + }); + const nextSend = controller.enqueue(() => { + order.push('next'); + }); + + await Promise.resolve(); + expect(order).toEqual(['slow:start']); + slow.resolve(); + await Promise.all([slowSend, nextSend]); + expect(order).toEqual(['slow:start', 'slow:end', 'next']); + }); + + it('reports the composer as gone after disposal', async () => { + const controller = createComposerController(); + const completion = deferred(); + let cleanupCount = 0; + + const send = controller.enqueue(async (isLive) => { + await completion.promise; + if (isLive()) cleanupCount += 1; + }); + + await Promise.resolve(); + controller.dispose(); + completion.resolve(); + await send; + + expect(cleanupCount).toBe(0); + }); + + it('skips operations enqueued after disposal', async () => { + const controller = createComposerController(); + let ran = false; + + controller.dispose(); + + await expect(controller.enqueue(() => (ran = true))).resolves.toBeUndefined(); + expect(ran).toBe(false); + }); + + it('advances the tail after a rejected operation', async () => { + const controller = createComposerController(); + + await expect( + controller.enqueue(() => { + throw new Error('send failed'); + }) + ).rejects.toThrow('send failed'); + await expect(controller.enqueue(() => 'completed')).resolves.toBe('completed'); + }); +}); diff --git a/src/app/features/room/composerController.ts b/src/app/features/room/composerController.ts new file mode 100644 index 0000000000..8fb7b986d9 --- /dev/null +++ b/src/app/features/room/composerController.ts @@ -0,0 +1,40 @@ +export type ComposerOperation = (isLive: () => boolean) => T | Promise; + +export interface ComposerController { + enqueue(operation: ComposerOperation): Promise; + dispose(): void; +} + +/** Serializes composer sends so a slow send cannot interleave with the next one. */ +export const createComposerController = (): ComposerController => { + let queueTail: Promise = Promise.resolve(); + let disposed = false; + const isLive = () => !disposed; + + const enqueue = (operation: ComposerOperation): Promise => + new Promise((resolve, reject) => { + const run = async () => { + if (disposed) { + resolve(undefined); + return; + } + try { + resolve(await operation(isLive)); + } catch (error) { + reject(error); + } + }; + + queueTail = queueTail.then(run, run).then( + () => undefined, + () => undefined + ); + }); + + return { + enqueue, + dispose: () => { + disposed = true; + }, + }; +}; diff --git a/src/app/features/room/location-modal/LocationDialog.test.tsx b/src/app/features/room/location-modal/LocationDialog.test.tsx new file mode 100644 index 0000000000..4ca1edbb46 --- /dev/null +++ b/src/app/features/room/location-modal/LocationDialog.test.tsx @@ -0,0 +1,101 @@ +/* oxlint-disable typescript/no-explicit-any, react/void-dom-elements-no-children, vitest/require-mock-type-parameters, unicorn/consistent-function-scoping, typescript/no-extraneous-class */ + +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { LocationDialog } from './LocationDialog'; + +vi.mock('folds', () => { + const div = ({ children, ...props }: any) =>
{children}
; + const button = ({ children, ...props }: any) => ; + return { + Dialog: div, + Header: div, + Box: div, + Text: div, + IconButton: button, + Button: button, + Input: (props: any) => , + Chip: button, + color: { Critical: { OnContainer: 'red' } }, + }; +}); + +vi.mock('@phosphor-icons/react', () => ({ + ClipboardIcon: () => null, + MapPinAreaIcon: () => null, + MapPinLineIcon: () => null, +})); + +vi.mock('$components/icons/phosphor', () => ({ + chipIcon: () => null, + composerIcon: () => null, + Warning: 'Warning', + X: 'X', +})); + +vi.mock('$components/modal-overlay/ModalOverlay', () => ({ + ModalOverlay: ({ children }: any) =>
{children}
, +})); + +vi.mock('$state/settings', () => ({ settingsAtom: {} })); +vi.mock('$state/hooks/settings', () => ({ + useSetting: (_atom: unknown, key: string) => [key === 'showInteractiveMap', vi.fn()], +})); +vi.mock('$utils/dom', () => ({ readClipboardText: vi.fn() })); +vi.mock('react-leaflet', () => ({ + MapContainer: ({ children }: any) =>
{children}
, + Marker: () => null, + TileLayer: () => null, + useMapEvents: () => null, +})); + +vi.mock('leaflet', () => { + class Icon {} + return { + default: { Icon, Marker: class Marker {}, marker: () => ({ addTo: () => ({}) }) }, + Icon, + }; +}); + +describe('LocationDialog', () => { + it('awaits the content callback and only closes after success', async () => { + let resolveSubmit!: () => void; + const onSubmit = vi.fn( + () => + new Promise((resolve) => { + resolveSubmit = resolve; + }) + ); + const onCancel = vi.fn(); + const room = { hasEncryptionStateEvent: () => false } as any; + + render(); + const submit = screen.getByRole('button', { name: 'Share Location' }); + fireEvent.click(submit); + fireEvent.click(submit); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onCancel).not.toHaveBeenCalled(); + expect(submit).toBeDisabled(); + expect((onSubmit as any).mock.calls[0]?.[0]).toMatchObject({ + msgtype: 'm.location', + geo_uri: expect.stringMatching(/^geo:/), + }); + + resolveSubmit(); + await waitFor(() => expect(onCancel).toHaveBeenCalledTimes(1)); + }); + + it('shows a retryable error when submission fails', async () => { + const onSubmit = vi.fn().mockRejectedValueOnce(new Error('Send failed')); + const onCancel = vi.fn(); + const room = { hasEncryptionStateEvent: () => false } as any; + + render(); + fireEvent.click(screen.getByRole('button', { name: 'Share Location' })); + + await waitFor(() => expect(screen.getByText('Send failed')).toBeInTheDocument()); + expect(onCancel).not.toHaveBeenCalled(); + expect(screen.getByRole('button', { name: 'Share Location' })).not.toBeDisabled(); + }); +}); diff --git a/src/app/features/room/location-modal/LocationDialog.tsx b/src/app/features/room/location-modal/LocationDialog.tsx index d35c07281c..338489d1fd 100644 --- a/src/app/features/room/location-modal/LocationDialog.tsx +++ b/src/app/features/room/location-modal/LocationDialog.tsx @@ -1,17 +1,14 @@ -import { Dialog, Header, Box, Text, IconButton, Button, Input, Chip } from 'folds'; +import { Dialog, Header, Box, Text, IconButton, Button, Input, Chip, color } from 'folds'; import { ClipboardIcon, MapPinAreaIcon, MapPinLineIcon } from '@phosphor-icons/react'; import { chipIcon, composerIcon, Warning, X } from '$components/icons/phosphor'; import { readClipboardText } from '$utils/dom'; -import type { IContent, MatrixClient, Room } from 'matrix-js-sdk'; +import type { IContent, Room } from 'matrix-js-sdk'; import * as css from './LocationDialog.css'; -import type { IReplyDraft } from '$state/room/roomInputDrafts'; import { MapContainer, Marker, TileLayer, useMapEvents } from 'react-leaflet'; import 'leaflet/dist/leaflet.css'; import { useCallback, useEffect, useRef, useState, type ChangeEventHandler } from 'react'; import type { LatLngLiteral } from 'leaflet'; import L from 'leaflet'; -import { getReplyContent } from '../RoomInput'; -import type { RoomMessageEventContent } from '$types/matrix-sdk'; import { MsgType } from '$types/matrix-sdk'; import { settingsAtom } from '$state/settings'; import { useSetting } from '$state/hooks/settings'; @@ -85,10 +82,8 @@ export function filterLocationString(result: string) { type LocationDialogProps = { onCancel: () => void; - mx: MatrixClient; + onSubmit: (content: IContent) => Promise; room: Room; - replyDraft?: IReplyDraft; - clearReplyDraft?: () => void; }; export enum LocationErrors { @@ -106,13 +101,7 @@ export type LocationPoint = { lon?: number; }; -export function LocationDialog({ - onCancel, - mx, - room, - replyDraft, - clearReplyDraft, -}: LocationDialogProps) { +export function LocationDialog({ onCancel, onSubmit, room }: LocationDialogProps) { const [showInteractiveMap] = useSetting(settingsAtom, 'showInteractiveMap'); const [showEncInteractiveMap] = useSetting(settingsAtom, 'showEncInteractiveMap'); const showMaps = room.hasEncryptionStateEvent() ? showEncInteractiveMap : showInteractiveMap; @@ -125,6 +114,8 @@ export function LocationDialog({ const [pinPosition, setPinPosition] = useState(initCoords); const zoom = useRef(2); const [locationError, setLocationError] = useState(LocationErrors.none); + const [submitError, setSubmitError] = useState(undefined); + const [isSubmitting, setIsSubmitting] = useState(false); const [map, setMap] = useState(null); @@ -235,7 +226,8 @@ export function LocationDialog({ } }; - const handleSubmit = () => { + const handleSubmit = async () => { + if (isSubmitting) return; const mlat = pinPosition.lat.toFixed(6); const mlon = pinPosition.lng.toFixed(6); const content: IContent = { @@ -243,13 +235,18 @@ export function LocationDialog({ geo_uri: `geo:${mlat},${mlon};u=0`, body: `https://www.openstreetmap.org/?mlat=${mlat}&mlon=${mlon}#map=16/${mlat}/${mlon}"`, }; - if (replyDraft && clearReplyDraft) { - content['m.relates_to'] = getReplyContent(replyDraft, room); - clearReplyDraft(); - } - mx.sendMessage(room.roomId, content as RoomMessageEventContent).then(() => { + setSubmitError(undefined); + setIsSubmitting(true); + try { + await onSubmit(content); onCancel(); - }); + } catch (err) { + setSubmitError( + err instanceof Error ? err.message : 'Failed to share location. Please try again.' + ); + } finally { + setIsSubmitting(false); + } }; return ( @@ -258,7 +255,7 @@ export function LocationDialog({
- {`Share Location ${replyDraft ? '(reply / thread)' : ''}`} + Share Location Share Location + {!!submitError && ( + + {submitError} + + )} diff --git a/src/app/features/room/persona-picker/PersonaPicker.test.tsx b/src/app/features/room/persona-picker/PersonaPicker.test.tsx new file mode 100644 index 0000000000..1b5f556fe6 --- /dev/null +++ b/src/app/features/room/persona-picker/PersonaPicker.test.tsx @@ -0,0 +1,261 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { MatrixClient } from 'matrix-js-sdk'; +import type { PerMessageProfile } from '$hooks/usePerMessageProfile'; +import { PersonaPicker, PersonaPickerTab } from './PersonaPicker'; + +const mocked = vi.hoisted(() => ({ + getAll: vi.fn<(mx: MatrixClient) => Promise>(), + getRoom: vi.fn<(mx: MatrixClient, roomId: string) => Promise>(), + getAccount: vi.fn<(mx: MatrixClient) => Promise>(), + setRoom: vi.fn<(...args: unknown[]) => Promise>(), + setAccount: vi.fn<(...args: unknown[]) => Promise>(), +})); + +vi.mock('$hooks/usePerMessageProfile', () => ({ + getAllPerMessageProfiles: mocked.getAll, + getCurrentlyUsedPerMessageProfileForRoom: mocked.getRoom, + getCurrentlyUsedPerMessageProfileForAccount: mocked.getAccount, + setCurrentlyUsedPerMessageProfileIdForRoom: mocked.setRoom, + setCurrentlyUsedPerMessageProfileIdForAccount: mocked.setAccount, +})); +vi.mock('$hooks/useMediaAuthentication.ts', () => ({ useMediaAuthentication: () => false })); +// useActiveTheme reaches for window.matchMedia, which jsdom does not provide. +vi.mock('$hooks/useTheme.ts', () => ({ + ThemeKind: { Light: 'light', Dark: 'dark' }, + useActiveTheme: () => ({ kind: 'light' }), +})); +vi.mock('$components/icons/phosphor', () => ({ + MagnifyingGlass: {}, + User: {}, + composerIcon: () => null, + menuIcon: () => null, +})); +vi.mock('$components/ResponsiveMenu', () => ({ + ResponsiveMenu: ({ children, menu }: { children: ReactNode; menu: ReactNode }) => ( +
+ {children} + {menu} +
+ ), +})); +vi.mock('$components/user-avatar/UserAvatar.tsx', () => ({ + UserAvatar: ({ renderFallback }: { renderFallback: () => ReactNode }) => <>{renderFallback()}, +})); +vi.mock('$components/info-card/InfoCard.tsx', () => ({ + InfoCard: ({ description }: { description: ReactNode }) =>
{description}
, +})); +vi.mock('@phosphor-icons/react', () => ({ InfoIcon: {} })); +vi.mock('$utils/matrix.ts', () => ({ mxcUrlToHttp: () => undefined })); +vi.mock('$utils/platform', () => ({ isMobileOrTablet: () => false })); +vi.mock('$utils/common', () => ({ nameInitials: (name: string) => name.slice(0, 1) })); +vi.mock('./PersonaPicker.css.ts', () => ({ + PersonaPickerMenuItem: '', + PersonaPickerButtonAvatar: '', + SelectedPersonaPickerButtonAvatar: '', + PersonaPickerButtonAvatarImage: '', +})); + +vi.mock('folds', async () => { + const React = await import('react'); + const Input = React.forwardRef>((props, ref) => ( + + )); + const TestButton = ({ children, ...props }: React.ComponentProps<'button'>) => + React.createElement('button', props, children); + const TestContainer = ({ children }: { children?: ReactNode }) => + React.createElement('div', null, children); + + return { + Avatar: TestContainer, + Badge: TestButton, + Box: TestContainer, + IconButton: TestButton, + Input, + Menu: TestContainer, + MenuItem: TestButton, + Scroll: TestContainer, + Text: TestContainer, + config: { space: { S200: 0 } }, + toRem: (value: number) => `${value}rem`, + }; +}); + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +const profiles: PerMessageProfile[] = [ + { id: 'first', name: 'First' }, + { id: 'second', name: 'Second' }, +]; + +function renderPicker(mx = {} as MatrixClient, tab = PersonaPickerTab.Global) { + return render( + void>()} + onTabChange={vi.fn<(tab: PersonaPickerTab) => void>()} + latchedPersona={undefined} + /> + ); +} + +describe('PersonaPicker async flows', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocked.getAll.mockResolvedValue(profiles); + mocked.getRoom.mockResolvedValue(undefined); + mocked.getAccount.mockResolvedValue(undefined); + mocked.setRoom.mockResolvedValue(undefined); + mocked.setAccount.mockResolvedValue(undefined); + }); + + it('ignores a stale profile fetch after the client changes', async () => { + const firstClient = {} as MatrixClient; + const secondClient = {} as MatrixClient; + const firstFetch = deferred(); + const secondFetch = deferred(); + mocked.getAll.mockImplementation((client: MatrixClient) => + client === firstClient ? firstFetch.promise : secondFetch.promise + ); + + const view = renderPicker(firstClient); + view.rerender( + void>()} + onTabChange={vi.fn<(tab: PersonaPickerTab) => void>()} + latchedPersona={undefined} + /> + ); + + secondFetch.resolve([{ id: 'new', name: 'New' }]); + expect(await screen.findByText('New')).toBeInTheDocument(); + firstFetch.resolve([{ id: 'old', name: 'Old' }]); + + await waitFor(() => expect(screen.queryByText('Old')).not.toBeInTheDocument()); + }); + + it('does not update state when an in-flight profile fetch resolves after unmount', async () => { + const fetch = deferred(); + mocked.getAll.mockReturnValue(fetch.promise); + const view = renderPicker(); + view.unmount(); + + fetch.resolve(profiles); + await Promise.resolve(); + expect(screen.queryByText('First')).not.toBeInTheDocument(); + }); + + it('commits an independent global sync when the room sync is still pending', async () => { + const roomSync = deferred(); + mocked.getRoom.mockReturnValue(roomSync.promise); + mocked.getAccount.mockResolvedValue(profiles[0]); + renderPicker(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'First' })).toHaveAttribute( + 'aria-selected', + 'true' + ); + }); + + roomSync.reject(new Error('room sync failed')); + }); + + it('keeps the latest optimistic selection when an earlier write fails', async () => { + const firstWrite = deferred(); + const secondWrite = deferred(); + mocked.setAccount.mockImplementationOnce(() => firstWrite.promise); + mocked.setAccount.mockImplementationOnce(() => secondWrite.promise); + renderPicker(); + await screen.findByText('First'); + + fireEvent.click(screen.getByRole('button', { name: 'First' })); + fireEvent.click(screen.getByRole('button', { name: 'Second' })); + + firstWrite.reject(new Error('first write failed')); + secondWrite.resolve(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Second' })).toHaveAttribute( + 'aria-selected', + 'true' + ); + }); + }); + + it('rolls back a failed global selection without suppressing a room selection', async () => { + const mx = {} as MatrixClient; + const globalWrite = deferred(); + const roomWrite = deferred(); + mocked.setAccount.mockImplementationOnce(() => globalWrite.promise); + mocked.setRoom.mockImplementationOnce(() => roomWrite.promise); + const view = renderPicker(mx); + await screen.findByText('First'); + + fireEvent.click(screen.getByRole('button', { name: 'First' })); + view.rerender( + void>()} + onTabChange={vi.fn<(tab: PersonaPickerTab) => void>()} + latchedPersona={undefined} + /> + ); + fireEvent.click(screen.getByRole('button', { name: 'Second' })); + + globalWrite.reject(new Error('global write failed')); + roomWrite.resolve(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Second' })).toHaveAttribute( + 'aria-selected', + 'true' + ); + }); + + view.rerender( + void>()} + onTabChange={vi.fn<(tab: PersonaPickerTab) => void>()} + latchedPersona={undefined} + /> + ); + expect(screen.getByRole('button', { name: 'First' })).not.toHaveAttribute( + 'aria-selected', + 'true' + ); + }); + + it('reconciles an optimistic selection when its write is rejected', async () => { + mocked.setAccount.mockRejectedValueOnce(new Error('write failed')); + renderPicker(); + await screen.findByText('First'); + + fireEvent.click(screen.getByRole('button', { name: 'First' })); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'First' })).not.toHaveAttribute( + 'aria-selected', + 'true' + ); + }); + }); +}); diff --git a/src/app/features/room/persona-picker/PersonaPicker.tsx b/src/app/features/room/persona-picker/PersonaPicker.tsx index 5f098bdf33..b943cd4902 100644 --- a/src/app/features/room/persona-picker/PersonaPicker.tsx +++ b/src/app/features/room/persona-picker/PersonaPicker.tsx @@ -33,7 +33,14 @@ import { Badge, } from 'folds'; import type { MatrixClient } from 'matrix-js-sdk'; -import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react'; +import { + useCallback, + useEffect, + useRef, + useState, + type FormEvent, + type MutableRefObject, +} from 'react'; import * as css from './PersonaPicker.css.ts'; import { InfoCard } from '$components/info-card/InfoCard.tsx'; import { InfoIcon } from '@phosphor-icons/react'; @@ -75,6 +82,12 @@ export function PersonaPicker({ const [selectedRoomPersona, setSelectedRoomPersona] = useState( latchedPersona ?? null ); + const mountedRef = useRef(false); + const profileFetchGenerationRef = useRef(0); + // Bumped on each click so an in-flight sync cannot undo a fresher choice. Global and + // per-room selections are independent, so one must not invalidate the other's rollback. + const globalSelectionRef = useRef(0); + const roomSelectionRef = useRef(0); const isPickerMenuItemSelected = (persona: PerMessageProfile) => { const selectedPersona = tab === PersonaPickerTab.Global ? selectedGlobalPersona : selectedRoomPersona; @@ -96,6 +109,14 @@ export function PersonaPicker({ undefined ); + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + profileFetchGenerationRef.current += 1; + }; + }, []); + const clearFilterInput = () => { if (searchInputRef.current) { searchInputRef.current.value = ''; @@ -104,25 +125,61 @@ export function PersonaPicker({ }; useEffect(() => { - const syncProfile = async () => { - const syncedRoomProfile = await getCurrentlyUsedPerMessageProfileForRoom(mx, roomId); - if (!selectedRoomPersona) setSelectedRoomPersona(syncedRoomProfile ?? null); + let cancelled = false; + + const syncProfile = async ( + generationRef: MutableRefObject, + load: () => Promise, + apply: (profile: PerMessageProfile | null) => void + ) => { + const generation = generationRef.current; + try { + const synced = await load(); + if (!cancelled && generation === generationRef.current) apply(synced ?? null); + } catch { + // Profile synchronization is best effort; retain the current selection on failure. + } + }; - const syncedGlobalProfile = await getCurrentlyUsedPerMessageProfileForAccount(mx); - setSelectedGlobalPersona(syncedGlobalProfile ?? null); + void syncProfile( + roomSelectionRef, + () => getCurrentlyUsedPerMessageProfileForRoom(mx, roomId), + // A latched persona already reflects the user's intent, so don't overwrite it. + (profile) => { + if (!selectedRoomPersona) setSelectedRoomPersona(profile); + } + ); + void syncProfile( + globalSelectionRef, + () => getCurrentlyUsedPerMessageProfileForAccount(mx), + setSelectedGlobalPersona + ); + + return () => { + cancelled = true; }; - syncProfile(); - }, [mx, roomId, profiles, latchedPersona, selectedRoomPersona]); + }, [mx, roomId, latchedPersona, selectedRoomPersona]); - const fetchProfiles = async (mx_: MatrixClient) => { - const fetchedProfiles = await getAllPerMessageProfiles(mx_); - setProfiles(fetchedProfiles); - setFilteredProfiles(fetchedProfiles); - }; + const fetchProfiles = useCallback(async (mx_: MatrixClient) => { + const fetchGeneration = ++profileFetchGenerationRef.current; + try { + const fetchedProfiles = await getAllPerMessageProfiles(mx_); + if (!mountedRef.current || fetchGeneration !== profileFetchGenerationRef.current) { + return; + } + setProfiles(fetchedProfiles); + setFilteredProfiles(fetchedProfiles); + } catch { + // Profile loading is best effort; keep the existing list when it fails. + } + }, []); useEffect(() => { - fetchProfiles(mx); - }, [mx]); + void fetchProfiles(mx); + return () => { + profileFetchGenerationRef.current += 1; + }; + }, [fetchProfiles, mx]); const filter = useCallback( (e: FormEvent) => { @@ -220,38 +277,39 @@ export function PersonaPicker({ aria-selected={isPickerMenuItemSelected(profile)} onClick={async () => { const isGlobal = tab === PersonaPickerTab.Global; - const selectedPersona = isGlobal + const previousPersona = isGlobal ? selectedGlobalPersona : selectedRoomPersona; - const disabling = profile.id === selectedPersona?.id; + const disabling = profile.id === previousPersona?.id; + const setPersona = isGlobal + ? setSelectedGlobalPersona + : setSelectedRoomPersona; + const generationRef = isGlobal ? globalSelectionRef : roomSelectionRef; + const selectionGeneration = ++generationRef.current; - if (!disabling) { - if (isGlobal) { - setSelectedGlobalPersona(profile); - await setCurrentlyUsedPerMessageProfileIdForAccount(mx, profile.id); - } else { - setSelectedRoomPersona(profile); - await setCurrentlyUsedPerMessageProfileIdForRoom(mx, roomId, profile.id); - } - } else { + setPersona(disabling ? null : profile); + + try { if (isGlobal) { - setSelectedGlobalPersona(null); await setCurrentlyUsedPerMessageProfileIdForAccount( mx, + disabling ? undefined : profile.id, undefined, - undefined, - true + disabling ); } else { - setSelectedRoomPersona(null); await setCurrentlyUsedPerMessageProfileIdForRoom( mx, roomId, + disabling ? undefined : profile.id, undefined, - undefined, - true + disabling ); } + } catch { + if (mountedRef.current && selectionGeneration === generationRef.current) { + setPersona(previousPersona); + } } }} before={ @@ -315,7 +373,7 @@ export function PersonaPicker({ onClick={(evt) => { // getAllPerMessageProfiles can return an empty list during initial startup. if (profiles?.length === 0) { - fetchProfiles(mx); + void fetchProfiles(mx); } setAddPersonaMenuAnchor(evt.currentTarget.getBoundingClientRect()); }} diff --git a/src/app/features/room/poll-modals/PollDialog.test.tsx b/src/app/features/room/poll-modals/PollDialog.test.tsx new file mode 100644 index 0000000000..62e7e74fbf --- /dev/null +++ b/src/app/features/room/poll-modals/PollDialog.test.tsx @@ -0,0 +1,116 @@ +/* oxlint-disable typescript/no-explicit-any, react/void-dom-elements-no-children, vitest/require-mock-type-parameters, unicorn/consistent-function-scoping */ + +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { PollDialog } from './PollDialog'; + +vi.mock('folds', () => { + const div = ({ children, ...props }: any) =>
{children}
; + const button = ({ children, ...props }: any) => ; + const input = (props: any) => ; + return { + Dialog: div, + Header: div, + Box: div, + Text: div, + IconButton: button, + Button: button, + Input: input, + Chip: button, + Switch: ({ value, onChange }: any) => ( + onChange(event.target.checked)} /> + ), + Scroll: div, + color: { Critical: { OnContainer: 'red' } }, + }; +}); + +vi.mock('$components/icons/phosphor', () => ({ + chipIcon: () => null, + composerIcon: () => null, + ListBullets: 'ListBullets', + Minus: 'Minus', + X: 'X', +})); + +vi.mock('$components/setting-tile', () => ({ + SettingTile: ({ children, after }: any) => ( +
+ {children} + {after} +
+ ), +})); + +vi.mock('$components/sequence-card', () => ({ + SequenceCard: ({ children }: any) =>
{children}
, + SequenceCardStyle: '', +})); + +vi.mock('$components/modal-overlay/ModalOverlay', () => ({ + ModalOverlay: ({ children }: any) =>
{children}
, +})); + +describe('PollDialog', () => { + beforeEach(() => vi.clearAllMocks()); + + it('awaits submission, prevents duplicates, and closes after success', async () => { + let resolveSubmit!: () => void; + const onSubmit = vi.fn( + () => + new Promise((resolve) => { + resolveSubmit = resolve; + }) + ); + const onCancel = vi.fn(); + + render(); + fireEvent.change(screen.getByLabelText('Insert Title'), { + target: { value: 'Dinner' }, + }); + fireEvent.change(screen.getByLabelText('Type Option 1'), { + target: { value: 'Pizza' }, + }); + fireEvent.change(screen.getByLabelText('Type Option 2'), { + target: { value: 'Pasta' }, + }); + + const submit = screen.getByRole('button', { name: 'Create Poll' }); + fireEvent.click(submit); + fireEvent.click(submit); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onCancel).not.toHaveBeenCalled(); + expect(submit).toBeDisabled(); + expect((onSubmit as any).mock.calls[0]?.[0]).toMatchObject({ + 'org.matrix.msc3381.poll.start': expect.objectContaining({ + question: expect.objectContaining({ body: 'Dinner' }), + }), + }); + + resolveSubmit(); + await waitFor(() => expect(onCancel).toHaveBeenCalledTimes(1)); + }); + + it('keeps the dialog open and shows a retryable error when submission fails', async () => { + const onSubmit = vi.fn().mockRejectedValueOnce(new Error('Send failed')); + const onCancel = vi.fn(); + + render(); + fireEvent.change(screen.getByLabelText('Insert Title'), { + target: { value: 'Dinner' }, + }); + fireEvent.change(screen.getByLabelText('Type Option 1'), { + target: { value: 'Pizza' }, + }); + fireEvent.change(screen.getByLabelText('Type Option 2'), { + target: { value: 'Pasta' }, + }); + + fireEvent.click(screen.getByRole('button', { name: 'Create Poll' })); + + await waitFor(() => expect(screen.getByText('Send failed')).toBeInTheDocument()); + expect(onCancel).not.toHaveBeenCalled(); + expect(screen.getByRole('button', { name: 'Create Poll' })).not.toBeDisabled(); + }); +}); diff --git a/src/app/features/room/poll-modals/PollDialog.tsx b/src/app/features/room/poll-modals/PollDialog.tsx index a0e15b8f60..b65389b673 100644 --- a/src/app/features/room/poll-modals/PollDialog.tsx +++ b/src/app/features/room/poll-modals/PollDialog.tsx @@ -18,7 +18,7 @@ import type { PollAnswerItem } from '$components/message/PollEvent'; import { randomStr } from '$utils/common'; import { SettingTile } from '$components/setting-tile'; import { SequenceCard, SequenceCardStyle } from '$components/sequence-card'; -import type { IContent, MatrixClient, Room, TimelineEvents } from 'matrix-js-sdk'; +import type { IContent } from 'matrix-js-sdk'; import { M_POLL_KIND_DISCLOSED, M_POLL_KIND_UNDISCLOSED, @@ -28,16 +28,11 @@ import { import { MsgType } from '$types/matrix-sdk'; import { isKeyHotkey } from 'is-hotkey'; import * as css from './PollDialog.css'; -import type { IReplyDraft } from '$state/room/roomInputDrafts'; -import { getReplyContent } from '../RoomInput'; import { ModalOverlay } from '$components/modal-overlay/ModalOverlay'; type PollDialogProps = { onCancel: () => void; - mx: MatrixClient; - room: Room; - replyDraft?: IReplyDraft; - clearReplyDraft?: () => void; + onSubmit: (content: IContent) => Promise; }; type ErrorProps = { @@ -45,13 +40,14 @@ type ErrorProps = { errorString: string; }; -export function PollDialog({ onCancel, mx, room, replyDraft, clearReplyDraft }: PollDialogProps) { - const roomId = room.roomId; +export function PollDialog({ onCancel, onSubmit }: PollDialogProps) { const [isDisclosed, setIsDisclosed] = useState(true); const [maxSelections, setMaxSelections] = useState(1); const [inputValue, setInputValue] = useState(1); const title = useRef(''); const [error, setError] = useState(undefined); + const [submitError, setSubmitError] = useState(undefined); + const [isSubmitting, setIsSubmitting] = useState(false); const [answers, setAnswers] = useState([ { id: randomStr(), @@ -88,7 +84,8 @@ export function PollDialog({ onCancel, mx, room, replyDraft, clearReplyDraft }: [answers, setAnswers, maxSelections, setMaxSelections] ); - const handleSubmit = () => { + const handleSubmit = async () => { + if (isSubmitting) return; if (title.current.length === 0) { setError({ errorcode: 'title', errorString: 'Missing Title' }); return; @@ -122,18 +119,18 @@ export function PollDialog({ onCancel, mx, room, replyDraft, clearReplyDraft }: }, [M_TEXT.name]: `New poll\n Question: ${title.current}\nAnswers:\n ${answers.map((item) => item[M_TEXT.name]).join('\n')}`, }; - if (replyDraft && clearReplyDraft) { - pollContent['m.relates_to'] = getReplyContent(replyDraft, room); - clearReplyDraft(); + setSubmitError(undefined); + setIsSubmitting(true); + try { + await onSubmit(pollContent); + onCancel(); + } catch (err) { + setSubmitError( + err instanceof Error ? err.message : 'Failed to create poll. Please try again.' + ); + } finally { + setIsSubmitting(false); } - - mx.sendEvent( - roomId, - M_POLL_START.name as keyof TimelineEvents, - pollContent as TimelineEvents[keyof TimelineEvents] - ); - - onCancel(); }; const handleMaxOptions: ChangeEventHandler = (evt) => { @@ -155,7 +152,7 @@ export function PollDialog({ onCancel, mx, room, replyDraft, clearReplyDraft }:
{composerIcon(ListBullets)} - {`New Poll ${replyDraft ? '(reply / thread)' : ''}`} + New Poll )} + {!!submitError && ( + + {submitError} + + )} diff --git a/src/app/utils/common.ts b/src/app/utils/common.ts index 3f590320a1..887a368fe1 100644 --- a/src/app/utils/common.ts +++ b/src/app/utils/common.ts @@ -45,12 +45,6 @@ export const getFileTypeIconComponent = (fileType: string): PhosphorIcon => { return File; }; -export const fulfilledPromiseSettledResult = (prs: PromiseSettledResult[]): T[] => - prs.reduce((values, pr) => { - if (pr.status === 'fulfilled') values.push(pr.value); - return values; - }, []); - export const promiseFulfilledResult = ( settledResult: PromiseSettledResult ): T | undefined => { diff --git a/src/app/utils/delayedEvents.ts b/src/app/utils/delayedEvents.ts index e63deaf776..2ce5c089d3 100644 --- a/src/app/utils/delayedEvents.ts +++ b/src/app/utils/delayedEvents.ts @@ -36,13 +36,14 @@ export async function sendDelayedMessage( roomId: string, content: IContent, delayMs: number, - threadId?: string | null + threadId?: string | null, + eventType: keyof TimelineEvents = EventType.RoomMessage ): Promise { return mx._unstable_sendDelayedEvent( roomId, { delay: delayMs }, threadId ?? null, - EventType.RoomMessage as Parameters[3], + eventType as Parameters[3], content as RoomMessageEventContent ); } @@ -59,7 +60,8 @@ export async function sendDelayedMessageE2EE( room: Room, content: IContent, delayMs: number, - threadId?: string | null + threadId?: string | null, + eventType: keyof TimelineEvents = EventType.RoomMessage ): Promise { const crypto = mx.getCrypto(); if (!crypto || !('encryptEvent' in crypto)) { @@ -68,7 +70,7 @@ export async function sendDelayedMessageE2EE( // Create a temporary MatrixEvent to encrypt in-place. const event = new MatrixEvent({ - type: EventType.RoomMessage, + type: eventType, content, room_id: roomId, sender: mx.getUserId() ?? '',