diff --git a/src/app/components/emoji-board/EmojiBoard.tsx b/src/app/components/emoji-board/EmojiBoard.tsx index 6c8d78a044..95fcb23763 100644 --- a/src/app/components/emoji-board/EmojiBoard.tsx +++ b/src/app/components/emoji-board/EmojiBoard.tsx @@ -1,5 +1,5 @@ import type { - ChangeEventHandler, + ChangeEvent, FocusEventHandler, MouseEventHandler, ReactNode, @@ -525,6 +525,7 @@ export function EmojiBoard({ loading: gifsLoading, error: gifsError, searchGifs, + cancelSearch: cancelGifSearch, } = useGifSearch(favoriteGifs, showGifPicker, gifSearch); const [emojiGroupItems, stickerGroupItems, gifGroupItems] = useGroups(tab, imagePacks, gifs); const [showFavoritesOnly, setShowFavoritesOnly] = useState(true); @@ -553,9 +554,9 @@ export function EmojiBoard({ const groups = groupsByTab[tab]; const renderItem = useItemRenderer(tab, saveStickerEmojiBandwidth); - const handleOnChange: ChangeEventHandler = useDebounce( + const handleOnChange = useDebounce( useCallback( - (evt) => { + (evt: ChangeEvent) => { const term = evt.target.value; if (tab === EmojiBoardTab.Gif) { if (term) { @@ -563,6 +564,7 @@ export function EmojiBoard({ searchGifs(term); } else { setShowFavoritesOnly(true); + cancelGifSearch(); resetGifSearch(); } } else if (term) { @@ -571,11 +573,18 @@ export function EmojiBoard({ resetEmojiSearch(); } }, - [emojiSearch, resetEmojiSearch, searchGifs, resetGifSearch, tab] + [cancelGifSearch, emojiSearch, resetEmojiSearch, searchGifs, resetGifSearch, tab] ), { wait: 200 } ); + useEffect(() => { + if (!showGifPicker) { + handleOnChange.cancel(); + cancelGifSearch(); + } + }, [cancelGifSearch, handleOnChange, showGifPicker]); + const contentScrollRef = useRef(null); const virtualBaseRef = useRef(null); const virtualizer = useVirtualizer({ diff --git a/src/app/components/emoji-board/useGifSearch.test.tsx b/src/app/components/emoji-board/useGifSearch.test.tsx new file mode 100644 index 0000000000..26d37a29c0 --- /dev/null +++ b/src/app/components/emoji-board/useGifSearch.test.tsx @@ -0,0 +1,134 @@ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { useGifSearch } from './useGifSearch'; + +const { fetchMock } = vi.hoisted(() => ({ + fetchMock: vi.fn<(input: string, init?: RequestInit) => Promise>(), +})); + +vi.mock('$utils/fetch', () => ({ fetch: fetchMock })); +vi.mock('$hooks/useClientConfig', () => ({ + useClientConfig: () => ({ gifs: { klipyApiKey: 'test-key' } }), +})); + +type Deferred = { + promise: Promise; + resolve: (value: T) => void; + reject: (reason?: unknown) => void; +}; + +const deferred = (): Deferred => { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + return { promise, resolve, reject }; +}; + +const responseFor = (id: string): Response => + new Response( + JSON.stringify({ + data: { + data: [ + { + id, + title: id, + file: { xs: { gif: { url: `https://${id}.preview` } } }, + }, + ], + }, + }), + { status: 200 } + ); + +const flushPromises = async () => { + await Promise.resolve(); + await Promise.resolve(); +}; + +afterEach(() => { + fetchMock.mockReset(); +}); + +describe('useGifSearch', () => { + it('keeps stale success, error, and finally handlers from changing the latest request', async () => { + const first = deferred(); + const second = deferred(); + fetchMock.mockImplementation((url: string) => + url.includes('q=first') ? first.promise : second.promise + ); + + const { result } = renderHook(() => useGifSearch([], true, vi.fn<() => void>())); + + act(() => { + void result.current.searchGifs('first'); + void result.current.searchGifs('second'); + }); + + const firstSignal = fetchMock.mock.calls[0]?.[1]?.signal as AbortSignal; + expect(firstSignal.aborted).toBe(true); + + first.reject(new Error('stale failure')); + await act(async () => { + await flushPromises(); + }); + expect(result.current.loading).toBe(true); + expect(result.current.error).toBeNull(); + + second.resolve(responseFor('second')); + await act(async () => { + await flushPromises(); + }); + expect(result.current.gifs.gifs[0]?.id).toBe('second'); + expect(result.current.loading).toBe(false); + + first.resolve(responseFor('first')); + await act(async () => { + await flushPromises(); + }); + expect(result.current.gifs.gifs[0]?.id).toBe('second'); + }); + + it('aborts and invalidates a request when cancelled or closed', async () => { + const request = deferred(); + fetchMock.mockReturnValue(request.promise); + + const { result, rerender } = renderHook( + ({ open }) => useGifSearch([], open, vi.fn<() => void>()), + { initialProps: { open: true } } + ); + + act(() => { + void result.current.searchGifs('query'); + }); + const signal = fetchMock.mock.calls[0]?.[1]?.signal as AbortSignal; + + rerender({ open: false }); + + expect(signal.aborted).toBe(true); + expect(result.current.loading).toBe(false); + + request.resolve(responseFor('stale')); + await act(async () => { + await flushPromises(); + }); + expect(result.current.gifs.gifs).toEqual([]); + }); + + it('aborts the active request on unmount', () => { + const request = deferred(); + fetchMock.mockReturnValue(request.promise); + const { result, unmount } = renderHook(() => useGifSearch([], true, vi.fn<() => void>())); + + act(() => { + void result.current.searchGifs('query'); + }); + const signal = fetchMock.mock.calls[0]?.[1]?.signal as AbortSignal; + + unmount(); + + expect(signal.aborted).toBe(true); + }); +}); diff --git a/src/app/components/emoji-board/useGifSearch.ts b/src/app/components/emoji-board/useGifSearch.ts index dc7402a666..0e2cf14213 100644 --- a/src/app/components/emoji-board/useGifSearch.ts +++ b/src/app/components/emoji-board/useGifSearch.ts @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { AsyncSearchHandler } from '$utils/AsyncSearch'; import { fetch } from '$utils/fetch'; import { useClientConfig } from '$hooks/useClientConfig'; @@ -57,14 +57,34 @@ export function useGifSearch( const [error, setError] = useState(null); const clientConfig = useClientConfig(); const klipyApiKey = clientConfig.gifs?.klipyApiKey ?? ''; + const requestGenerationRef = useRef(0); + const abortControllerRef = useRef(); + const mountedRef = useRef(true); + const showGifPickerRef = useRef(showGifPicker); + showGifPickerRef.current = showGifPicker; + + const cancelRequest = useCallback(() => { + requestGenerationRef.current += 1; + abortControllerRef.current?.abort(); + abortControllerRef.current = undefined; + }, []); + + const cancelSearch = useCallback(() => { + cancelRequest(); + if (mountedRef.current) setLoading(false); + }, [cancelRequest]); const searchGifs = useCallback( async (query: string) => { - if (!showGifPicker) { + if (!mountedRef.current || !showGifPickerRef.current) { return; } const trimmedQuery = query.trim(); + cancelRequest(); + const generation = requestGenerationRef.current; + const controller = new AbortController(); + abortControllerRef.current = controller; setLoading(true); setError(null); @@ -77,30 +97,50 @@ export function useGifSearch( url.searchParams.set('q', trimmedQuery); url.searchParams.set('per_page', '50'); // TODO: infinite scroll? - const response = await fetch(url.toString()); + const response = await fetch(url.toString(), { signal: controller.signal }); if (response.status === 200) { const data = (await response.json()) as KlipySearchResponse; const results = data.data?.data; - setSearchResults(results ? results.map(parseKlipyResult) : []); + if (generation === requestGenerationRef.current && mountedRef.current) { + setSearchResults(results ? results.map(parseKlipyResult) : []); + } } else { throw new Error(`HTTP ${response.status}`); } } catch { - setError('Failed to search GIFs'); - setSearchResults([]); + if (generation === requestGenerationRef.current && mountedRef.current) { + setError('Failed to search GIFs'); + setSearchResults([]); + } } finally { - setLoading(false); + if (generation === requestGenerationRef.current && mountedRef.current) { + abortControllerRef.current = undefined; + setLoading(false); + } } }, - [klipyApiKey, showGifPicker, gifSearch] + [cancelRequest, klipyApiKey, gifSearch] ); + useEffect(() => { + mountedRef.current = true; + + return () => { + mountedRef.current = false; + cancelRequest(); + }; + }, [cancelRequest]); + + useEffect(() => { + if (!showGifPicker) cancelSearch(); + }, [cancelSearch, showGifPicker]); + const gifs = useMemo( () => ({ gifs: searchResults, favorites: favoriteGifs }), [searchResults, favoriteGifs] ); - return { gifs, loading, error, searchGifs }; + return { gifs, loading, error, searchGifs, cancelSearch }; } 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 d081c7e229..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, MsgType, RelationType } 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'; @@ -63,17 +65,12 @@ import { moveCursor, resetEditorHistory, isEmptyEditor, - getBeginCommand, - trimCommand, - getMentions, ANYWHERE_AUTOCOMPLETE_PREFIXES, BEGINNING_AUTOCOMPLETE_PREFIXES, - getLinks, MarkdownFormattingToolbarBottom, MarkdownFormattingToolbarToggle, focusEditor, replaceWithElement, - BlockType, } from '$components/editor'; import { stripMarkdownEscapesForHiddenPreviews } from './message/hiddenLinkPreviews'; import { plainToEditorInput } from '$components/editor/input'; @@ -106,15 +103,13 @@ 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'; import { matchesShortcut } from '../../keyboard/shortcuts'; -import { getEditedEvent, getMentionContent, getThreadReplyEvents } from '$utils/room/relations'; -import { buildReplacementContent } from './buildReplacementContent'; +import { getEditedEvent, getThreadReplyEvents } from '$utils/room/relations'; import { htmlToMarkdown } from '$plugins/markdown'; -import { Command, SHRUG, TABLEFLIP, UNFLIP, useCommands } from '$hooks/useCommands'; +import { Command, useCommands } from '$hooks/useCommands'; import { isMobileOrTablet } from '$utils/platform'; import { Reply, ThreadIndicator } from '$components/message'; import { roomToParentsAtom } from '$state/room/roomToParents'; @@ -139,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'; @@ -179,7 +175,6 @@ import { } from '$components/icons/phosphor'; import { getSupportedAudioExtension } from '$plugins/voice-recorder-kit/supportedCodec'; import { ErrorCode } from '../../cs-errorcode'; -import { sanitizeText } from '$utils/sanitize'; import { PKitCommandMessageHandler } from '$plugins/pluralkit-handler/PKitCommandMessageHandler'; import { PKitProxyMessageHandler } from '$plugins/pluralkit-handler/PKitProxyMessageHandler'; import type { IGenericMSC4459, MSC4459ImagePackReference } from '$types/matrix/common'; @@ -205,7 +200,6 @@ import { buildGalleryContent, getGalleryItemContent, } from './msgContent'; -import { outgoingMessageTransforms } from './outgoingMessageTransforms'; import { getSendableKlipyMxcUrl } from '$utils/klipy'; import { CommandAutocomplete } from './CommandAutocomplete'; import type { @@ -217,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 })) @@ -280,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; @@ -365,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) @@ -380,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); @@ -393,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; }, [] ); @@ -420,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); @@ -432,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); @@ -504,10 +612,26 @@ export const RoomInput = forwardRef( const queryClient = useQueryClient(); const delayedEventsSupported = useAtomValue(delayedEventsSupportedAtom); - const [scheduledTime, setScheduledTime] = useAtom(roomIdToScheduledTimeAtomFamily(roomId)); - const [editingScheduledDelayId, setEditingScheduledDelayId] = useAtom( + const [roomScheduledTime, setRoomScheduledTime] = useAtom( + roomIdToScheduledTimeAtomFamily(roomId) + ); + const [roomEditingScheduledDelayId, setRoomEditingScheduledDelayId] = useAtom( roomIdToEditingScheduledDelayIdAtomFamily(roomId) ); + const scheduledTime = threadRootId ? null : roomScheduledTime; + const editingScheduledDelayId = threadRootId ? null : roomEditingScheduledDelayId; + const setScheduledTime = useCallback( + (value: Date | null) => { + if (!threadRootId) setRoomScheduledTime(value); + }, + [setRoomScheduledTime, threadRootId] + ); + const setEditingScheduledDelayId = useCallback( + (value: string | null) => { + if (!threadRootId) setRoomEditingScheduledDelayId(value); + }, + [setRoomEditingScheduledDelayId, threadRootId] + ); const [AddMenuAnchor, setAddMenuAnchor] = useState(); const [showAttachmentSheet, setShowAttachmentSheet] = useState(false); const attachmentSkipReturnFocusRef = useRef(false); @@ -516,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(); @@ -575,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); @@ -590,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(); @@ -604,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); @@ -662,7 +821,7 @@ export const RoomInput = forwardRef( resetEditorHistory(editor); Transforms.insertFragment(editor, initialValue); - requestAnimationFrame(() => { + scheduleEditorRaf(() => { try { ReactEditor.focus(editor); moveCursor(editor); @@ -671,7 +830,9 @@ export const RoomInput = forwardRef( } }); } + setInitializedEditId(editId); } else { + editingStateRef.current = false; const previousDraft = preEditDraftRef.current; if (prevEditingEventId.current && previousDraft) { resetEditor(editor); @@ -683,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(); @@ -694,8 +855,10 @@ export const RoomInput = forwardRef( }); } prevEditingEventId.current = undefined; + setInitializedEditId(undefined); } }, [ + editId, editingEvent, editor, getEditingContent, @@ -704,6 +867,10 @@ export const RoomInput = forwardRef( room, replyDraft?.eventId, threadRootId, + isMobile, + setInitializedEditId, + onCancelEdit, + scheduleEditorRaf, ]); useEffect(() => { @@ -736,7 +903,7 @@ export const RoomInput = forwardRef( prevReplyEventId.current = newId; if (newId && newId !== threadRootId) { - requestAnimationFrame(() => { + scheduleEditorRaf(() => { try { ReactEditor.focus(editor); moveCursor(editor); @@ -745,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(); @@ -756,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, @@ -770,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, @@ -785,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], @@ -812,7 +993,10 @@ export const RoomInput = forwardRef( const audioRecorder = showAudioRecorder ? ( setShowAudioRecorder(false)} + onRequestClose={() => { + recorderActionRef.current = undefined; + setShowAudioRecorder(false); + }} onRecordingComplete={handleAudioRecordingComplete} onAudioLengthUpdate={() => {}} onWaveformUpdate={() => {}} @@ -828,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. @@ -850,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, @@ -892,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; } }; @@ -945,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, }) @@ -957,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; @@ -986,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(() => { @@ -1051,494 +1371,276 @@ export const RoomInput = forwardRef( [editor, handleCloseAutocomplete, mx, room, sendTypingStatus] ); - const submit = useCallback(async () => { - if (editingEvent && isMobileOrTablet()) { - let plainText = toPlainText(editor.children).trim(); - if (!plainText) { - onCancelEdit?.(); - return; - } - - let customHtml = trimCustomHtml( - toMatrixCustomHTML(editor.children, { - forEmote: editingEvent.getContent().msgtype === MsgType.Emote, - room, - }) - ); - const oldContent = editingEvent.getContent(); - const currentContent = getEditingContent(editingEvent); - const eventId = editingEvent.getId(); - if (!eventId) return; - - const rawPmp = - currentContent['com.beeper.per_message_profile'] ?? - oldContent['com.beeper.per_message_profile']; - - const mentionData = getMentions(mx, roomId, editor); - const previousMentions = currentContent['m.mentions']; + const executeSubmit = useCallback( + async (submission: Submission, isLive: () => boolean) => { if ( - previousMentions && - typeof previousMentions === 'object' && - 'user_ids' in previousMentions && - Array.isArray(previousMentions.user_ids) + fileIngestionCountRef.current > 0 || + isEditInitializing || + (isMobile && editId !== undefined && !editingEvent) ) { - previousMentions.user_ids.forEach((userId) => { - if (typeof userId === 'string') mentionData.users.add(userId); - }); + restoreSubmission(submission); + return; } - const mMentions = getMentionContent(Array.from(mentionData.users), mentionData.room); - - const linkPreviews = - getLinks(editor.children)?.map((matchedUrl) => ({ - matched_url: matchedUrl, - })) ?? []; - - const content = buildReplacementContent( - oldContent, - plainText, - customHtml, - eventId, - mMentions, - linkPreviews, - rawPmp - ); - - await mx.sendMessage(roomId, content as RoomMessageEventContent); - 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; - } + 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; + } - const commandName = getBeginCommand(editor); - /** - * a map of regex patterns to replace nicknames with, - * used when stripNickname is true in toMatrixCustomHTML - * during HTML generation for the message content. - * This is necessary because the HTML generation needs to know - * which nicknames to strip in order to generate the correct formatted_body, - * and the plain text generation needs to replace those same nicknames with - * the original user IDs so that the message content remains consistent and - * mentions are correctly processed by the server and clients. - */ - const nicknameReplacement = new Map(); - if (replyEvent) { - /** - * the id of the user being replied to, - * whose nickname (if any) should be stripped - * from the message content and replaced with their - * user ID for correct mention processing - */ - const senderId = replyEvent.getSender(); - if (senderId) { - const nick = nicknames[senderId]; - if (typeof nick === 'string' && nick.length > 0) { - nicknameReplacement.set( - new RegExp(`@?${nick}`, 'g'), - room.getMember(senderId)?.rawDisplayName ?? senderId - ); + if (selectedFiles.some((f) => f.encrypting)) { + restoreSubmission(submission); + return; } - } - } - /** - * any other users mentioned in the message being replied to, - * whose nicknames should also be stripped and replaced with user IDs - */ - const mentions = getMentions(mx, roomId, editor); - if (mentions?.users) { - mentions.users.forEach((id) => { - const nick = nicknames[id]; - if (typeof nick === 'string' && nick.length > 0) { - nicknameReplacement.set( - new RegExp(`@?${nick}`, 'g'), - room.getMember(id)?.rawDisplayName ?? id - ); + 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); + } } - }); - } - /** - * the plain text we will send - */ - let serializedChildren = editor.children; - if (commandName) { - // Strip the empty text node and command node from the beginning of the first paragraph - const firstPara = serializedChildren[0]; - if ( - firstPara && - 'type' in firstPara && - firstPara.type === BlockType.Paragraph && - firstPara.children.length >= 2 - ) { - serializedChildren = [ - { - ...firstPara, - children: firstPara.children.slice(2), - }, - ...serializedChildren.slice(1), - ]; - } - } - const outgoingTransformContext = { - isMarkdown: true, - settingsLinkBaseUrl, - }; - - outgoingMessageTransforms.forEach((transform) => { - if (!transform.shouldApply(serializedChildren, outgoingTransformContext)) return; - serializedChildren = transform.apply(serializedChildren, outgoingTransformContext); - }); - let plainText = toPlainText(serializedChildren, true, true, nicknameReplacement).trim(); - - /** - * the html we will send - */ - let customHtml = trimCustomHtml( - toMatrixCustomHTML(serializedChildren, { - stripNickname: true, - nickNameReplacement: nicknameReplacement, - forEmote: commandName === Command.Me || commandName === Command.RainbowMe, - room, - }) - ); - - let msgType = MsgType.Text; - - // quick text react - if (canSendReaction && plainText.startsWith('+#')) { - handleQuickReact(plainText.substring(2)); - return; - } - - // check if its a pk command - if (pkCompatEnable && PKitCommandMessageHandler.isPKCommand(plainText)) { - await pluralkitCmdMessageHandler.handleMessage(plainText); - resetEditor(editor); // clear the editor - return; // don't do anything besides handling the command - } + 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, + }); - if (commandName) { - plainText = trimCommand(commandName, plainText); - customHtml = trimCommand(commandName, customHtml); - } - if (commandName === Command.Me) { - msgType = MsgType.Emote; - } else if (commandName === Command.Notice) { - msgType = MsgType.Notice; - } else if (commandName === Command.Shrug) { - plainText = `${SHRUG} ${plainText}`; - customHtml = `${SHRUG} ${customHtml}`; - } else if (commandName === Command.TableFlip) { - plainText = `${TABLEFLIP} ${plainText}`; - customHtml = `${TABLEFLIP} ${customHtml}`; - } else if (commandName === Command.UnFlip) { - plainText = `${UNFLIP} ${plainText}`; - customHtml = `${UNFLIP} ${customHtml}`; - } else if (commandName) { - if ((commandName as Command) === Command.Poll) setShowPollPicker(true); - else if ((commandName as Command) === Command.Location && plainText.trim().length === 0) - setShowLocationPicker(true); - else { - const commandContent = commands[commandName as Command]; - if (commandContent) { - commandContent.exe(plainText, customHtml); + 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; } - } - resetEditor(editor); - resetEditorHistory(editor); - sendTypingStatus(false); - 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 (plainText === '') return; - - // PluralKit-style proxy wrappers (per-message profile proxies) must be stripped - // *before* building `content`, otherwise we end up sending the wrapper verbatim. - let proxiedPerMessageProfile: - | Awaited> - | undefined; - if (pmpProxyingEnable) { - proxiedPerMessageProfile = - await pluralkitProxyMessageHandler.getPmpBasedOnMessage(plainText); - if (proxiedPerMessageProfile) { - // normal plainText has spoilers stripped, but this breaks spoilers with a proxy tag. - // here we get a new 'unsanitized' plainText without spoiler stripping - let unsanitizedPlainText = toPlainText( - serializedChildren, - true, - false, - nicknameReplacement - ).trim(); - - const stripped = pluralkitProxyMessageHandler.stripProxyFromMessage(unsanitizedPlainText); - if (stripped !== undefined) { - // Re-run the normal outgoing pipeline on the stripped content so the message - // goes through the same transforms/parsers as any other message. - serializedChildren = plainToEditorInput(stripped); - - outgoingMessageTransforms.forEach((transform) => { - if (!transform.shouldApply(serializedChildren, outgoingTransformContext)) return; - serializedChildren = transform.apply(serializedChildren, outgoingTransformContext); + 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, }); - - plainText = toPlainText(serializedChildren, true, true, nicknameReplacement).trim(); - customHtml = trimCustomHtml( - toMatrixCustomHTML(serializedChildren, { - stripNickname: true, - nickNameReplacement: nicknameReplacement, - forEmote: commandName === Command.Me || commandName === Command.RainbowMe, - room, - }) - ); - - if (pmpLatchingEnable) { - await setCurrentlyUsedPerMessageProfileIdForRoom( - mx, + 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, - proxiedPerMessageProfile.id + eventId: res.event_id, + }); + Sentry.metrics.distribution( + 'sable.message.send_latency_ms', + performance.now() - msgSendStart, + { attributes: { encrypted: String(isEncrypted) } } ); - setLatchedPersona(proxiedPerMessageProfile); + } 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); } - } - - const body = plainText; - const formattedBody = customHtml; - const mentionData = getMentions(mx, roomId, editor); - - const content: IContent & Pick = { - msgtype: msgType, - body, - }; - - if (replyDraft && !silentReply) { - mentionData.users.add(replyDraft.userId); - } - - content['m.mentions'] = getMentionContent(Array.from(mentionData.users), mentionData.room); - content[prefix.MATRIX_UNSTABLE_IMAGE_SOURCE_PACK_PROPERTY_NAME] = - imagePacksUsedRef.current.toJSON(); + }, + [ + replyEvent, + mx, + roomId, + 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, + ] + ); - const links = getLinks(serializedChildren); - content[prefix.MATRIX_UNSTABLE_EMBEDDED_LINK_PREVIEW_PROPERTY_NAME] = []; - links?.forEach((link) => - content[prefix.MATRIX_UNSTABLE_EMBEDDED_LINK_PREVIEW_PROPERTY_NAME].push({ - matched_url: link, - }) + 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) ); - - if (replyDraft || !customHtmlEqualsPlainText(formattedBody, body)) { - content.format = 'org.matrix.custom.html'; - content.formatted_body = formattedBody; - } - - /** - * the currently with the room associated per-message profile, if any, so that it can be included in the message content when sending. - * This allows the server to apply the correct profile-based transformations (e.g. font size adjustments) when processing the message, - * and also allows clients to display an accurate preview of how the message will look with the profile applied while it's being composed. - */ - const globalPerMessageProfile = await getCurrentlyUsedPerMessageProfileForAccount(mx); - const roomPerMessageProfile = await getCurrentlyUsedPerMessageProfileForRoom(mx, roomId); - let perMessageProfile = latchedPersona ?? roomPerMessageProfile ?? globalPerMessageProfile; - - if (pmpProxyingEnable) { - if (proxiedPerMessageProfile) perMessageProfile = proxiedPerMessageProfile; - } - if (perMessageProfile) { - content[prefix.MATRIX_UNSTABLE_PER_MESSAGE_PROFILE_PROPERTY_NAME] = - convertPerMessageProfileToBeeperFormat( - perMessageProfile, - perMessageProfile.name.trim() !== '' - ); - - if (perMessageProfile.name.trim() !== '') { - // if a per-message profile is used, it must per spec include a fallback - const pmpPrefix = `${perMessageProfile.name}: `; - - if (!content.body.startsWith(pmpPrefix)) { - // to prevent double-prefixing when the fallback is already present - content.body = pmpPrefix + content.body; - } - - /** - * html escaped version of the display name - */ - const escapedName = sanitizeText(perMessageProfile.name); - - const htmlPrefix = `${escapedName}: `; - - if (content.formatted_body && !content.formatted_body.startsWith(htmlPrefix)) { - content.formatted_body = htmlPrefix + content.formatted_body; - } else { - // we don't have a formatted body, but we need one - content.format = 'org.matrix.custom.html'; - const escapedBody = sanitizeText(plainText).replaceAll('\n', '
'); - content.formatted_body = `${htmlPrefix}${escapedBody}`; - } - } - } - - 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, - ]); + }, [editingEvent, executeSubmit, isMobile, takeSubmission]); const handleKeyDown: KeyboardEventHandler = useCallback( (evt) => { @@ -1694,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); @@ -1737,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 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 = async (gif: GifData, spoiler?: boolean) => { - const url = getSendableKlipyMxcUrl(gif.url, clientConfig.gifs?.proxyUrl); - if (!url) return; + 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} @@ -2367,7 +2480,7 @@ export const RoomInput = forwardRef( aria-pressed={!hasContent && editorMicButton ? showAudioRecorder : undefined} onClick={() => { if (showAudioRecorder) { - audioRecorderRef.current?.stop(); + requestRecorderStop(); return; } if (hasContent) { @@ -2380,6 +2493,7 @@ export const RoomInput = forwardRef( } if (!editorMicButton) return; if (isMobileOrTablet()) return; + recorderActionRef.current = undefined; setShowAudioRecorder(true); }} onMouseDown={(e: MouseEvent) => { @@ -2389,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); @@ -2399,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(); } @@ -2440,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) @@ -2504,7 +2627,7 @@ export const RoomInput = forwardRef( } /> - {delayedEventsSupported && !isMobileOrTablet() && ( + {delayedEventsSupported && !isMobileOrTablet() && !threadRootId && ( ) => { setScheduleMenuAnchor(evt.currentTarget.getBoundingClientRect()); @@ -2524,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/composerMessage.test.ts b/src/app/features/room/composerMessage.test.ts new file mode 100644 index 0000000000..4e1a324071 --- /dev/null +++ b/src/app/features/room/composerMessage.test.ts @@ -0,0 +1,200 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { BlockType, plainToEditorInput } from '$components/editor'; +import { Command, SHRUG } from '$hooks/useCommands'; +import type { MatrixClient, Room } from '$types/matrix-sdk'; +import { SerializableMap } from '$types/wrapper/SerializableMap'; +import type { MSC4459ImagePackReference } from '$types/matrix/common'; +import type { PKitProxyMessageHandler } from '$plugins/pluralkit-handler/PKitProxyMessageHandler'; +import type * as PerMessageProfileModule from '$hooks/usePerMessageProfile'; +import type { PerMessageProfile } from '$hooks/usePerMessageProfile'; + +const { profiles } = vi.hoisted(() => ({ + profiles: { + account: undefined as PerMessageProfile | undefined, + room: undefined as PerMessageProfile | undefined, + }, +})); + +vi.mock('$hooks/usePerMessageProfile', async (importOriginal) => ({ + ...(await importOriginal()), + getCurrentlyUsedPerMessageProfileForAccount: () => Promise.resolve(profiles.account), + getCurrentlyUsedPerMessageProfileForRoom: () => Promise.resolve(profiles.room), +})); + +const { buildOutgoingMessage } = await import('./composerMessage'); + +const ROOM_ID = '!room:example.org'; + +const room = { + roomId: ROOM_ID, + getMember: (userId: string) => ({ rawDisplayName: `Display ${userId}` }), +} as unknown as Room; + +const mx = { + getUserId: () => '@me:example.org', + getSafeUserId: () => '@me:example.org', + getRoom: () => room, +} as unknown as MatrixClient; + +const noProxyHandler = { + getPmpBasedOnMessage: () => Promise.resolve(undefined), + stripProxyFromMessage: () => undefined, +} as unknown as PKitProxyMessageHandler; + +/** Mirrors how the editor represents a typed command: empty text node, then a command node. */ +const commandInput = (command: Command, rest = '') => [ + { + type: BlockType.Paragraph as const, + children: [ + { text: '' }, + { type: BlockType.Command as const, command, children: [{ text: '' }] }, + { text: rest }, + ], + }, +]; + +const build = ( + input: string | ReturnType, + overrides: Partial[1]> = {} +) => + buildOutgoingMessage(typeof input === 'string' ? plainToEditorInput(input) : input, { + mx, + room, + roomId: ROOM_ID, + nicknames: {}, + replyEvent: undefined, + replyDraft: undefined, + silentReply: false, + settingsLinkBaseUrl: 'https://app.example', + canSendReaction: true, + pkCompatEnable: false, + pmpProxyingEnable: false, + pmpLatchingEnable: false, + latchedPersona: undefined, + isPKCommand: () => false, + pluralkitProxyMessageHandler: noProxyHandler, + imagePacksUsed: new SerializableMap(), + ...overrides, + }); + +beforeEach(() => { + profiles.account = undefined; + profiles.room = undefined; +}); + +describe('buildOutgoingMessage', () => { + it('builds a plain text message', async () => { + const result = await build('hello world'); + expect(result).toMatchObject({ kind: 'message' }); + if (result.kind !== 'message') throw new Error('expected a message'); + expect(result.content.body).toBe('hello world'); + expect(result.content.msgtype).toBe('m.text'); + }); + + it('reports empty input instead of sending a blank message', async () => { + await expect(build(' ')).resolves.toEqual({ kind: 'empty' }); + }); + + it('returns a quick-react descriptor rather than reacting itself', async () => { + await expect(build('+#tada')).resolves.toEqual({ kind: 'quickReact', key: 'tada' }); + }); + + it('ignores quick-react syntax without permission to react', async () => { + const result = await build('+#tada', { canSendReaction: false }); + expect(result.kind).toBe('message'); + }); + + it('returns a pk-command descriptor only when pk compat is on', async () => { + await expect( + build('pk;switch', { pkCompatEnable: false, isPKCommand: () => true }) + ).resolves.toMatchObject({ kind: 'message' }); + await expect( + build('pk;switch', { pkCompatEnable: true, isPKCommand: () => true }) + ).resolves.toEqual({ kind: 'pkCommand', plainText: 'pk;switch' }); + }); + + it('prefixes shrug and emits an emote for /me', async () => { + const shrug = await build(commandInput(Command.Shrug, ' take it')); + if (shrug.kind !== 'message') throw new Error('expected a message'); + expect(shrug.content.body.startsWith(SHRUG)).toBe(true); + + const emote = await build(commandInput(Command.Me, ' waves')); + if (emote.kind !== 'message') throw new Error('expected a message'); + expect(emote.content.msgtype).toBe('m.emote'); + expect(emote.content.body).toBe('waves'); + }); + + it('hands unhandled commands back to the caller to execute', async () => { + const result = await build(commandInput(Command.Poll)); + expect(result).toMatchObject({ kind: 'command', command: Command.Poll }); + }); + + it('mentions the replied-to user unless the reply is silent', async () => { + const replyDraft = { userId: '@other:example.org', eventId: '$reply', body: 'hi' }; + + const loud = await build('answer', { replyDraft }); + if (loud.kind !== 'message') throw new Error('expected a message'); + expect(loud.content['m.mentions']?.user_ids).toContain('@other:example.org'); + + const silent = await build('answer', { replyDraft, silentReply: true }); + if (silent.kind !== 'message') throw new Error('expected a message'); + expect(silent.content['m.mentions']?.user_ids ?? []).not.toContain('@other:example.org'); + }); + + it('adds the spec-required fallback prefix for a named per-message profile', async () => { + profiles.room = { id: 'p1', name: 'Alter' }; + const result = await build('hello'); + if (result.kind !== 'message') throw new Error('expected a message'); + expect(result.content.body).toBe('Alter: hello'); + expect(result.content.formatted_body).toContain('data-mx-profile-fallback'); + expect(result.content.formatted_body).toContain('Alter: '); + }); + + it('does not double-prefix a body that already carries the fallback', async () => { + profiles.room = { id: 'p1', name: 'Alter' }; + const result = await build('Alter: hello'); + if (result.kind !== 'message') throw new Error('expected a message'); + expect(result.content.body).toBe('Alter: hello'); + }); + + it('prefers the room profile over the account profile', async () => { + profiles.account = { id: 'global', name: 'Global' }; + profiles.room = { id: 'scoped', name: 'Scoped' }; + const result = await build('hello'); + if (result.kind !== 'message') throw new Error('expected a message'); + expect(result.content.body).toBe('Scoped: hello'); + }); + + it('omits an unnamed profile fallback but still tags the profile', async () => { + profiles.account = { id: 'p1', name: '' }; + const result = await build('hello'); + if (result.kind !== 'message') throw new Error('expected a message'); + expect(result.content.body).toBe('hello'); + }); + + it('strips a pluralkit proxy wrapper and lets its profile win', async () => { + const proxied: PerMessageProfile = { id: 'proxy', name: 'Proxied' }; + const handler = { + getPmpBasedOnMessage: () => Promise.resolve(proxied), + stripProxyFromMessage: (text: string) => text.replace(/^A:\s*/, ''), + } as unknown as PKitProxyMessageHandler; + profiles.account = { id: 'global', name: 'Global' }; + + const result = await build('A: hello there', { + pmpProxyingEnable: true, + pluralkitProxyMessageHandler: handler, + }); + if (result.kind !== 'message') throw new Error('expected a message'); + // The wrapper must never reach the wire, and the proxy's profile wins. + expect(result.content.body).toBe('Proxied: hello there'); + }); + + it('records embedded link previews for urls in the body', async () => { + const result = await build('see https://example.com/page'); + if (result.kind !== 'message') throw new Error('expected a message'); + const previews = result.content['com.beeper.linkpreviews'] as + | { matched_url: string }[] + | undefined; + expect(previews?.map((preview) => preview.matched_url)).toContain('https://example.com/page'); + }); +}); diff --git a/src/app/features/room/composerMessage.ts b/src/app/features/room/composerMessage.ts new file mode 100644 index 0000000000..f4178975c0 --- /dev/null +++ b/src/app/features/room/composerMessage.ts @@ -0,0 +1,316 @@ +import type { Editor } from 'slate'; +import type { IContent, MatrixEvent, Room } from '$types/matrix-sdk'; +import { MsgType } from '$types/matrix-sdk'; +import type { MatrixClient, RoomMessageEventContent } from '$types/matrix-sdk'; +import { + BlockType, + customHtmlEqualsPlainText, + getBeginCommand, + getLinks, + getMentions, + plainToEditorInput, + toMatrixCustomHTML, + toPlainText, + trimCommand, + trimCustomHtml, +} from '$components/editor'; +import { sanitizeText } from '$utils/sanitize'; +import { getMentionContent } from '$utils/room/relations'; +import type { IReplyDraft } from '$state/room/roomInputDrafts'; +import { + convertPerMessageProfileToBeeperFormat, + getCurrentlyUsedPerMessageProfileForAccount, + getCurrentlyUsedPerMessageProfileForRoom, + type PerMessageProfile, +} from '$hooks/usePerMessageProfile'; +import * as prefix from '$unstable/prefixes'; +import { outgoingMessageTransforms } from './outgoingMessageTransforms'; +import { buildReplacementContent } from './buildReplacementContent'; +import { Command, SHRUG, TABLEFLIP, UNFLIP } from '$hooks/useCommands'; +import type { PKitProxyMessageHandler } from '$plugins/pluralkit-handler/PKitProxyMessageHandler'; +import type { MSC4459ImagePackReference } from '$types/matrix/common'; +import type { SerializableMap } from '$types/wrapper/SerializableMap'; + +export type MessageContent = IContent & Pick; + +/** + * Branches needing React state (opening a picker, running a command, reacting) are + * returned as descriptors rather than executed, keeping this module pure. + */ +export type OutgoingMessage = + | { kind: 'message'; content: MessageContent; latchPersona?: PerMessageProfile } + | { kind: 'quickReact'; key: string } + | { kind: 'pkCommand'; plainText: string } + | { kind: 'command'; command: Command; plainText: string; customHtml: string } + | { kind: 'empty' }; + +export interface BuildOutgoingMessageDeps { + mx: MatrixClient; + room: Room; + roomId: string; + /** userId -> nickname, stripped from the body and replaced with the real display name. */ + nicknames: Record; + replyEvent: MatrixEvent | undefined; + replyDraft: IReplyDraft | undefined; + silentReply: boolean; + settingsLinkBaseUrl: string; + canSendReaction: boolean; + pkCompatEnable: boolean; + pmpProxyingEnable: boolean; + pmpLatchingEnable: boolean; + /** Persona latched by an earlier proxied message in this room. */ + latchedPersona: PerMessageProfile | undefined; + isPKCommand: (plainText: string) => boolean; + pluralkitProxyMessageHandler: PKitProxyMessageHandler; + imagePacksUsed: SerializableMap; +} + +const resolveNickname = ( + room: Room, + nicknames: Record, + userId: string +): string | undefined => { + const nick = nicknames[userId]; + if (typeof nick !== 'string' || nick.length === 0) return undefined; + return room.getMember(userId)?.rawDisplayName ?? userId; +}; + +// Nicknames are local-only, so they are swapped back to real display names before the +// body goes out, keeping server-side mention processing correct. +const buildNicknameReplacement = ( + children: Editor['children'], + { mx, room, roomId, nicknames, replyEvent }: BuildOutgoingMessageDeps +): Map => { + const replacement = new Map(); + const add = (userId: string) => { + const displayName = resolveNickname(room, nicknames, userId); + if (displayName) + replacement.set(new RegExp(`@?${nicknames[userId] as string}`, 'g'), displayName); + }; + + const senderId = replyEvent?.getSender(); + if (senderId) add(senderId); + getMentions(mx, roomId, { children } as Editor)?.users?.forEach(add); + + return replacement; +}; + +const stripCommandNode = (children: Editor['children']): Editor['children'] => { + const firstPara = children[0]; + if ( + firstPara && + 'type' in firstPara && + firstPara.type === BlockType.Paragraph && + firstPara.children.length >= 2 + ) { + return [{ ...firstPara, children: firstPara.children.slice(2) }, ...children.slice(1)]; + } + return children; +}; + +const applyPerMessageProfileFallback = (content: MessageContent, profile: PerMessageProfile) => { + content[prefix.MATRIX_UNSTABLE_PER_MESSAGE_PROFILE_PROPERTY_NAME] = + convertPerMessageProfileToBeeperFormat(profile, profile.name.trim() !== ''); + if (profile.name.trim() === '') return; + + // Per spec a per-message profile must ship a fallback prefix for clients that ignore it. + const pmpPrefix = `${profile.name}: `; + // guard against double-prefixing when the fallback is already present + if (!content.body.startsWith(pmpPrefix)) content.body = pmpPrefix + content.body; + + const htmlPrefix = `${sanitizeText(profile.name)}: `; + if (content.formatted_body && !content.formatted_body.startsWith(htmlPrefix)) { + content.formatted_body = htmlPrefix + content.formatted_body; + } else { + // we don't have a formatted body, but the fallback needs one + content.format = 'org.matrix.custom.html'; + const escapedBody = sanitizeText(content.body).replaceAll('\n', '
'); + content.formatted_body = `${htmlPrefix}${escapedBody}`; + } +}; + +export async function buildOutgoingMessage( + children: Editor['children'], + deps: BuildOutgoingMessageDeps +): Promise { + const { + mx, + room, + roomId, + replyDraft, + silentReply, + settingsLinkBaseUrl, + canSendReaction, + pkCompatEnable, + pmpProxyingEnable, + pmpLatchingEnable, + latchedPersona, + isPKCommand, + pluralkitProxyMessageHandler, + imagePacksUsed, + } = deps; + + const commandName = getBeginCommand({ children } as Editor); + const nicknameReplacement = buildNicknameReplacement(children, deps); + const transformContext = { isMarkdown: true, settingsLinkBaseUrl }; + + const runTransforms = (input: Editor['children']): Editor['children'] => { + let output = input; + outgoingMessageTransforms.forEach((transform) => { + if (!transform.shouldApply(output, transformContext)) return; + output = transform.apply(output, transformContext); + }); + return output; + }; + const forEmote = commandName === Command.Me || commandName === Command.RainbowMe; + const serializeHtml = (input: Editor['children']) => + trimCustomHtml( + toMatrixCustomHTML(input, { + stripNickname: true, + nickNameReplacement: nicknameReplacement, + forEmote, + room, + }) + ); + + let serializedChildren = runTransforms(commandName ? stripCommandNode(children) : children); + let plainText = toPlainText(serializedChildren, true, true, nicknameReplacement).trim(); + let customHtml = serializeHtml(serializedChildren); + let msgType = MsgType.Text; + + if (canSendReaction && plainText.startsWith('+#')) { + return { kind: 'quickReact', key: plainText.substring(2) }; + } + if (pkCompatEnable && isPKCommand(plainText)) { + return { kind: 'pkCommand', plainText }; + } + + if (commandName) { + plainText = trimCommand(commandName, plainText); + customHtml = trimCommand(commandName, customHtml); + } + if (commandName === Command.Me) { + msgType = MsgType.Emote; + } else if (commandName === Command.Notice) { + msgType = MsgType.Notice; + } else if (commandName === Command.Shrug) { + plainText = `${SHRUG} ${plainText}`; + customHtml = `${SHRUG} ${customHtml}`; + } else if (commandName === Command.TableFlip) { + plainText = `${TABLEFLIP} ${plainText}`; + customHtml = `${TABLEFLIP} ${customHtml}`; + } else if (commandName === Command.UnFlip) { + plainText = `${UNFLIP} ${plainText}`; + customHtml = `${UNFLIP} ${customHtml}`; + } else if (commandName) { + return { kind: 'command', command: commandName as Command, plainText, customHtml }; + } + + if (plainText === '') return { kind: 'empty' }; + + // PluralKit-style proxy wrappers must be stripped before building `content`, otherwise + // the wrapper itself gets sent verbatim. + let proxiedPerMessageProfile: PerMessageProfile | undefined; + if (pmpProxyingEnable) { + proxiedPerMessageProfile = await pluralkitProxyMessageHandler.getPmpBasedOnMessage(plainText); + if (proxiedPerMessageProfile) { + // plainText has spoilers stripped, which breaks spoilers carrying a proxy tag, so + // match the proxy against an unsanitized copy instead. + const unsanitizedPlainText = toPlainText( + serializedChildren, + true, + false, + nicknameReplacement + ).trim(); + const stripped = pluralkitProxyMessageHandler.stripProxyFromMessage(unsanitizedPlainText); + if (stripped !== undefined) { + // Re-run the normal pipeline so a proxied message is parsed like any other. + serializedChildren = runTransforms(plainToEditorInput(stripped)); + plainText = toPlainText(serializedChildren, true, true, nicknameReplacement).trim(); + customHtml = serializeHtml(serializedChildren); + } + } + } + + const mentionData = getMentions(mx, roomId, { children } as Editor); + if (replyDraft && !silentReply) mentionData.users.add(replyDraft.userId); + + const content: MessageContent = { msgtype: msgType, body: plainText }; + content['m.mentions'] = getMentionContent(Array.from(mentionData.users), mentionData.room); + content[prefix.MATRIX_UNSTABLE_IMAGE_SOURCE_PACK_PROPERTY_NAME] = imagePacksUsed.toJSON(); + content[prefix.MATRIX_UNSTABLE_EMBEDDED_LINK_PREVIEW_PROPERTY_NAME] = ( + getLinks(serializedChildren) ?? [] + ).map((matched_url) => ({ matched_url })); + + if (replyDraft || !customHtmlEqualsPlainText(customHtml, plainText)) { + content.format = 'org.matrix.custom.html'; + content.formatted_body = customHtml; + } + + const [globalProfile, roomProfile] = await Promise.all([ + getCurrentlyUsedPerMessageProfileForAccount(mx), + getCurrentlyUsedPerMessageProfileForRoom(mx, roomId), + ]); + const perMessageProfile = + proxiedPerMessageProfile ?? latchedPersona ?? roomProfile ?? globalProfile; + if (perMessageProfile) applyPerMessageProfileFallback(content, perMessageProfile); + + return { + kind: 'message', + content, + latchPersona: + pmpLatchingEnable && proxiedPerMessageProfile ? proxiedPerMessageProfile : undefined, + }; +} + +export interface BuildEditReplacementDeps { + mx: MatrixClient; + room: Room; + roomId: string; + editingEvent: MatrixEvent; + /** Content of the latest edit, so a re-edit builds on the current body. */ + currentContent: IContent; +} + +/** Returns undefined when there is nothing to send, which cancels the edit. */ +export function buildEditReplacement( + children: Editor['children'], + { mx, room, roomId, editingEvent, currentContent }: BuildEditReplacementDeps +): IContent | undefined { + const plainText = toPlainText(children).trim(); + if (!plainText) return undefined; + + const eventId = editingEvent.getId(); + if (!eventId) return undefined; + + const oldContent = editingEvent.getContent(); + const customHtml = trimCustomHtml( + toMatrixCustomHTML(children, { + forEmote: oldContent.msgtype === MsgType.Emote, + room, + }) + ); + + const mentionData = getMentions(mx, roomId, { children } as Editor); + const previousMentions = currentContent['m.mentions']; + if ( + previousMentions && + typeof previousMentions === 'object' && + 'user_ids' in previousMentions && + Array.isArray(previousMentions.user_ids) + ) { + previousMentions.user_ids.forEach((userId) => { + if (typeof userId === 'string') mentionData.users.add(userId); + }); + } + + return buildReplacementContent( + oldContent, + plainText, + customHtml, + eventId, + getMentionContent(Array.from(mentionData.users), mentionData.room), + (getLinks(children) ?? []).map((matched_url) => ({ matched_url })), + currentContent['com.beeper.per_message_profile'] ?? oldContent['com.beeper.per_message_profile'] + ); +} 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/features/room/schedule-send/ScheduledMessagesList.test.tsx b/src/app/features/room/schedule-send/ScheduledMessagesList.test.tsx new file mode 100644 index 0000000000..2137f9248c --- /dev/null +++ b/src/app/features/room/schedule-send/ScheduledMessagesList.test.tsx @@ -0,0 +1,197 @@ +/* oxlint-disable typescript/no-explicit-any, typescript/no-extraneous-class, unicorn/consistent-function-scoping, vitest/require-mock-type-parameters */ + +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ScheduledMessagesList } from './ScheduledMessagesList'; +import type * as MatrixSdkModule from '$types/matrix-sdk'; + +const testState = vi.hoisted(() => ({ + cancelDelayedEvent: vi.fn(), + coordinatorRun: vi.fn(), + invalidateQueries: vi.fn(), + matrix: { + getSafeUserId: vi.fn(() => '@me:example.org'), + }, +})); + +vi.mock('$hooks/useMatrixClient', () => ({ + useMatrixClient: () => testState.matrix, +})); + +vi.mock('$utils/delayedEvents', () => ({ + cancelDelayedEvent: testState.cancelDelayedEvent, + getDelayedEvents: vi.fn(), +})); + +vi.mock('$state/room/roomScheduleCoordinator', () => ({ + roomScheduleCoordinator: { + run: testState.coordinatorRun, + }, +})); + +vi.mock('@tanstack/react-query', () => ({ + useQuery: () => ({ + data: { + delayed_events: [ + { + delay_id: 'delay-1', + room_id: '!room:example.org', + type: 'm.room.message', + content: { body: 'Scheduled message' }, + running_since: 1_000, + delay: 60_000, + }, + ], + }, + }), + useQueryClient: () => ({ invalidateQueries: testState.invalidateQueries }), +})); + +vi.mock('$state/scheduledMessages', async () => { + const { atom } = await import('jotai'); + const scheduledTimeAtom = atom(null); + const editingScheduledDelayIdAtom = atom(null); + + return { + delayedEventsSupportedAtom: atom(true), + roomIdToScheduledTimeAtomFamily: () => scheduledTimeAtom, + roomIdToEditingScheduledDelayIdAtomFamily: () => editingScheduledDelayIdAtom, + }; +}); + +vi.mock('$state/hooks/settings', () => ({ + useSetting: (_atom: unknown, key: string) => [ + key === 'hour24Clock' ? false : 'YYYY-MM-DD', + vi.fn(), + ], +})); + +vi.mock('$state/settings', () => ({ settingsAtom: {} })); + +vi.mock('$types/matrix-sdk', async (importOriginal) => ({ + ...(await importOriginal()), + MatrixEvent: class MatrixEvent {}, +})); + +vi.mock('$utils/time', () => ({ + timeDayMonthYear: () => '2026-07-28', + timeHourMinute: () => '00:01', +})); + +vi.mock('$components/message-preview', () => ({ + MessagePreview: ({ actions, event }: any) => ( +
+ {event.getContent?.().body ?? 'Scheduled message'} + {actions} +
+ ), + useRoomMessagePreviewRenderer: () => vi.fn(), +})); + +vi.mock('$components/icons/phosphor', () => ({ + CaretDown: 'CaretDown', + CaretUp: 'CaretUp', + Clock: 'Clock', + Lock: 'Lock', + PencilSimple: 'PencilSimple', + X: 'X', + chipIcon: () => null, +})); + +vi.mock('folds', () => { + const Box = ({ children, ...props }: any) =>
{children}
; + const Text = ({ children, ...props }: any) => {children}; + const Button = ({ children, ...props }: any) => ; + + return { + Box, + Chip: Button, + IconButton: Button, + Spinner: () => Cancelling, + Text, + config: { + borderWidth: { B300: '1px' }, + space: { S100: '1px', S200: '2px', S400: '4px' }, + }, + toRem: (value: number) => `${value / 16}rem`, + }; +}); + +vi.mock('./SchedulePickerDialog', () => ({ + SchedulePickerDialog: () => null, +})); + +const room = { roomId: '!room:example.org' } as any; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + return { promise, resolve, reject }; +} + +function renderExpandedList() { + render(); + fireEvent.click(screen.getByRole('button', { name: /1 scheduled message/i })); + return screen.getByRole('button', { name: 'Cancel scheduled message' }); +} + +describe('ScheduledMessagesList cancellation', () => { + beforeEach(() => { + testState.cancelDelayedEvent.mockReset(); + testState.coordinatorRun.mockReset(); + testState.invalidateQueries.mockReset(); + testState.coordinatorRun.mockImplementation((_roomId: string, operation: () => unknown) => + operation() + ); + }); + + it('blocks duplicate cancellation clicks and shows pending state', () => { + const pending = deferred(); + testState.cancelDelayedEvent.mockReturnValue(pending.promise); + + const cancel = renderExpandedList(); + fireEvent.click(cancel); + fireEvent.click(cancel); + + expect(testState.coordinatorRun).toHaveBeenCalledWith(room.roomId, expect.any(Function)); + expect(testState.cancelDelayedEvent).toHaveBeenCalledOnce(); + expect(cancel).toBeDisabled(); + expect(cancel).toHaveAttribute('aria-busy', 'true'); + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + }); + + it('shows a retryable alert when cancellation fails', async () => { + testState.cancelDelayedEvent.mockRejectedValueOnce(new Error('Cancel failed')); + const cancel = renderExpandedList(); + + fireEvent.click(cancel); + + await waitFor(() => + expect(screen.getByRole('alert')).toHaveTextContent( + 'Failed to cancel scheduled message. Try again.' + ) + ); + expect(cancel).not.toBeDisabled(); + expect(cancel).not.toHaveAttribute('aria-busy', 'true'); + }); + + it('successfully retries cancellation after a failure', async () => { + testState.cancelDelayedEvent + .mockRejectedValueOnce(new Error('Cancel failed')) + .mockResolvedValueOnce(undefined); + const cancel = renderExpandedList(); + + fireEvent.click(cancel); + await waitFor(() => expect(screen.getByRole('alert')).toBeInTheDocument()); + + fireEvent.click(cancel); + + await waitFor(() => expect(testState.cancelDelayedEvent).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(testState.invalidateQueries).toHaveBeenCalledOnce()); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); +}); diff --git a/src/app/features/room/schedule-send/ScheduledMessagesList.tsx b/src/app/features/room/schedule-send/ScheduledMessagesList.tsx index 16583b392f..c9aafa05a8 100644 --- a/src/app/features/room/schedule-send/ScheduledMessagesList.tsx +++ b/src/app/features/room/schedule-send/ScheduledMessagesList.tsx @@ -1,6 +1,6 @@ -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useMemo, useRef, useState } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { Box, Text, Chip, IconButton } from 'folds'; +import { Box, Text, Chip, IconButton, Spinner } from 'folds'; import { CaretDown, CaretUp, @@ -27,6 +27,7 @@ import { import { timeHourMinute, timeDayMonthYear } from '$utils/time'; import { useSetting } from '$state/hooks/settings'; import { settingsAtom } from '$state/settings'; +import { roomScheduleCoordinator } from '$state/room/roomScheduleCoordinator'; import { MessagePreview, useRoomMessagePreviewRenderer } from '$components/message-preview'; import { SchedulePickerDialog } from './SchedulePickerDialog'; import * as css from './ScheduledMessagesList.css'; @@ -44,6 +45,12 @@ type ScheduledMessageRowProps = { hour24Clock: boolean; onEdit: (delayId: string, body: string, formattedBody?: string, scheduledTs?: number) => void; onCancel: (delayId: string) => void; + cancellationState: CancellationState; +}; + +type CancellationState = { + status: 'idle' | 'pending' | 'error'; + error?: string; }; function ScheduledMessageRow({ @@ -52,6 +59,7 @@ function ScheduledMessageRow({ hour24Clock, onEdit, onCancel, + cancellationState, }: ScheduledMessageRowProps) { const mx = useMatrixClient(); const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString'); @@ -63,6 +71,8 @@ function ScheduledMessageRow({ !isEncrypted && typeof event.content.formatted_body === 'string' ? event.content.formatted_body : undefined; + const isCancelling = cancellationState.status === 'pending'; + const cancelIcon = isCancelling ? : chipIcon(X); const matrixEvent = useMemo( () => new MatrixEvent({ @@ -106,9 +116,11 @@ function ScheduledMessageRow({ variant="Critical" radii="300" onClick={() => onCancel(event.delay_id)} + disabled={isCancelling} + aria-busy={isCancelling} aria-label="Cancel scheduled message" > - {chipIcon(X)} + {cancelIcon}
} @@ -126,12 +138,19 @@ function ScheduledMessageRow({ variant="Critical" radii="300" onClick={() => onCancel(event.delay_id)} + disabled={isCancelling} + aria-busy={isCancelling} aria-label="Cancel scheduled message" > - {chipIcon(X)} + {cancelIcon} )} + {cancellationState.status === 'error' && ( + + {cancellationState.error} + + )} ); } @@ -146,6 +165,10 @@ export function ScheduledMessagesList({ room, onEditMessage }: ScheduledMessages const [editingDelayId, setEditingDelayId] = useAtom( roomIdToEditingScheduledDelayIdAtomFamily(room.roomId) ); + const [cancellationStates, setCancellationStates] = useState>( + {} + ); + const pendingCancellations = useRef(new Set()); const { data } = useQuery({ queryKey: ['delayedEvents', room.roomId], @@ -167,10 +190,35 @@ export function ScheduledMessagesList({ room, onEditMessage }: ScheduledMessages const handleCancel = useCallback( async (delayId: string) => { - await cancelDelayedEvent(mx, delayId); - invalidateEvents(); + if (pendingCancellations.current.has(delayId)) return; + + pendingCancellations.current.add(delayId); + setCancellationStates((states) => ({ + ...states, + [delayId]: { status: 'pending' }, + })); + + try { + await roomScheduleCoordinator.run(room.roomId, () => cancelDelayedEvent(mx, delayId)); + invalidateEvents(); + setCancellationStates((states) => { + const next = { ...states }; + delete next[delayId]; + return next; + }); + } catch { + setCancellationStates((states) => ({ + ...states, + [delayId]: { + status: 'error', + error: 'Failed to cancel scheduled message. Try again.', + }, + })); + } finally { + pendingCancellations.current.delete(delayId); + } }, - [mx, invalidateEvents] + [mx, room.roomId, invalidateEvents] ); const handleEdit = useCallback( @@ -218,6 +266,7 @@ export function ScheduledMessagesList({ room, onEditMessage }: ScheduledMessages hour24Clock={hour24Clock} onEdit={handleEdit} onCancel={handleCancel} + cancellationState={cancellationStates[event.delay_id] ?? { status: 'idle' }} /> ))} diff --git a/src/app/hooks/useDebounce.test.tsx b/src/app/hooks/useDebounce.test.tsx index 0c28edea5f..1de88db558 100644 --- a/src/app/hooks/useDebounce.test.tsx +++ b/src/app/hooks/useDebounce.test.tsx @@ -97,4 +97,33 @@ describe('useDebounce', () => { expect(fn).toHaveBeenCalledOnce(); expect(fn).toHaveBeenCalledWith('go'); }); + + it('cancels a pending callback explicitly', () => { + const fn = vi.fn<(arg: string) => void>(); + const { result } = renderHook(() => useDebounce(fn, { wait: 200 })); + + act(() => { + result.current('cancelled'); + result.current.cancel(); + vi.advanceTimersByTime(200); + }); + + expect(fn).not.toHaveBeenCalled(); + }); + + it('cancels a pending callback when unmounted', () => { + const fn = vi.fn<(arg: string) => void>(); + const { result, unmount } = renderHook(() => useDebounce(fn, { wait: 200 })); + + act(() => { + result.current('cancelled'); + }); + unmount(); + + act(() => { + vi.advanceTimersByTime(200); + }); + + expect(fn).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/hooks/useDebounce.ts b/src/app/hooks/useDebounce.ts index 5f33976a3e..018defe79d 100644 --- a/src/app/hooks/useDebounce.ts +++ b/src/app/hooks/useDebounce.ts @@ -1,23 +1,32 @@ -import { useCallback, useRef } from 'react'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; export interface DebounceOptions { wait?: number; immediate?: boolean; } export type DebounceCallback = (...args: T) => void; +export type DebouncedCallback = DebounceCallback & { + cancel: () => void; +}; export function useDebounce( callback: DebounceCallback, options?: DebounceOptions -): DebounceCallback { +): DebouncedCallback { const timeoutIdRef = useRef(); const { wait, immediate } = options ?? {}; + const cancel = useCallback(() => { + if (timeoutIdRef.current !== undefined) { + clearTimeout(timeoutIdRef.current); + timeoutIdRef.current = undefined; + } + }, []); + const debounceCallback = useCallback( (...cbArgs: T) => { - if (timeoutIdRef.current) { - clearTimeout(timeoutIdRef.current); - timeoutIdRef.current = undefined; + if (timeoutIdRef.current !== undefined) { + cancel(); } else if (immediate) { callback(...cbArgs); } @@ -27,8 +36,10 @@ export function useDebounce( timeoutIdRef.current = undefined; }, wait); }, - [callback, wait, immediate] + [callback, cancel, wait, immediate] ); - return debounceCallback; + useEffect(() => cancel, [cancel]); + + return useMemo(() => Object.assign(debounceCallback, { cancel }), [debounceCallback, cancel]); } diff --git a/src/app/hooks/usePerMessageProfile.proxy.test.ts b/src/app/hooks/usePerMessageProfile.proxy.test.ts index 2f5fca6e28..ac910a9eaf 100644 --- a/src/app/hooks/usePerMessageProfile.proxy.test.ts +++ b/src/app/hooks/usePerMessageProfile.proxy.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from 'vitest'; +import type { MatrixClient } from '$types/matrix-sdk'; +import { describe, expect, it, vi } from 'vitest'; import { extractCircumfixProxyTagsFromKey, @@ -8,6 +9,8 @@ import { type PerMessageProfileProxyAssociationV1, proxyNeedsMigration, createProxyKey, + setCurrentlyUsedPerMessageProfileIdForAccount, + setCurrentlyUsedPerMessageProfileIdForRoom, } from './usePerMessageProfile'; describe('migratePerMessageProfileProxyAssociation', () => { @@ -132,3 +135,60 @@ describe('parsePerMessageProfileProxyAssociation', () => { expect(parsed.regex.test('[no] trailing')).toBe(false); }); }); + +describe('per-message profile persistence', () => { + it('serializes room writes and reads the latest account data snapshot', async () => { + const associations: Record = {}; + const writes: unknown[] = []; + let releaseFirstWrite!: () => void; + const firstWrite = new Promise((resolve) => { + releaseFirstWrite = resolve; + }); + + const mx = { + getAccountData: vi.fn<() => { getContent: () => { associations: typeof associations } }>( + () => ({ getContent: () => ({ associations }) }) + ), + setAccountData: vi.fn< + (_event: unknown, content: { associations: typeof associations }) => Promise + >(async (_event, content) => { + writes.push(content); + if (writes.length === 1) await firstWrite; + Object.assign(associations, content.associations); + }), + } as unknown as MatrixClient; + + const first = setCurrentlyUsedPerMessageProfileIdForRoom(mx, '!room:example.org', 'first'); + const second = setCurrentlyUsedPerMessageProfileIdForRoom(mx, '!room:example.org', 'second'); + + await vi.waitFor(() => expect(writes).toHaveLength(1)); + + releaseFirstWrite(); + await Promise.all([first, second]); + + expect(writes).toHaveLength(2); + expect((writes[1] as { associations: typeof associations }).associations).toEqual({ + '!room:example.org': { profileId: 'second' }, + }); + }); + + it('continues queued account writes after a rejected write', async () => { + const writes: string[] = []; + const mx = { + setAccountData: vi.fn< + (_event: unknown, content: { association: { profileId: string } }) => Promise + >(async (_event, content) => { + writes.push(content.association.profileId); + if (writes.length === 1) throw new Error('write failed'); + }), + deleteAccountData: vi.fn<(...args: unknown[]) => void>(), + } as unknown as MatrixClient; + + const first = setCurrentlyUsedPerMessageProfileIdForAccount(mx, 'first'); + const second = setCurrentlyUsedPerMessageProfileIdForAccount(mx, 'second'); + + await expect(first).rejects.toThrow('write failed'); + await expect(second).resolves.toBeUndefined(); + expect(writes).toEqual(['first', 'second']); + }); +}); diff --git a/src/app/hooks/usePerMessageProfile.ts b/src/app/hooks/usePerMessageProfile.ts index 3b18de2375..bfc540f374 100644 --- a/src/app/hooks/usePerMessageProfile.ts +++ b/src/app/hooks/usePerMessageProfile.ts @@ -6,9 +6,13 @@ import { CustomAccountDataEvent } from '$types/matrix/accountData'; import { MATRIX_UNSTABLE_COLORS } from '$unstable/prefixes'; import { MATRIX_UNSTABLE_PROFILE_PRONOUNS_PROPERTY_NAME } from '$unstable/prefixes'; import type { ColorSet } from './useUserProfile'; +import { createKeyedQueue } from '$utils/keyedQueue'; const ACCOUNT_DATA_PREFIX = CustomAccountDataEvent.SablePerProfileMessageProfiles; +/** Account data is read-modify-written, so writes to the same key must not interleave. */ +const enqueueProfilePersistence = createKeyedQueue(); + /** * a per message profile */ @@ -453,32 +457,34 @@ export async function setCurrentlyUsedPerMessageProfileIdForRoom( validUntil?: number, reset?: boolean ) { - const accountData = mx.getAccountData( - `${ACCOUNT_DATA_PREFIX}.roomassociation` as Parameters[0] - ); - const content: PerMessageProfileRoomAssociationWrapper | undefined = accountData?.getContent(); - const associations = getAssociationsMap(content); - - if (reset) { - associations.delete(roomId); - mx.setAccountData( + return enqueueProfilePersistence('roomassociation', async () => { + const accountData = mx.getAccountData( + `${ACCOUNT_DATA_PREFIX}.roomassociation` as Parameters[0] + ); + const content: PerMessageProfileRoomAssociationWrapper | undefined = accountData?.getContent(); + const associations = getAssociationsMap(content); + + if (reset) { + associations.delete(roomId); + await mx.setAccountData( + `${ACCOUNT_DATA_PREFIX}.roomassociation` as Parameters[0], + { associations: associationsMapToObject(associations) } as Parameters< + typeof mx.setAccountData + >[1] + ); + return; + } + if (!profileId) { + throw new Error("profile Id is empty, yet it isn't a reset"); + } + associations.set(roomId, { profileId, validUntil }); + await mx.setAccountData( `${ACCOUNT_DATA_PREFIX}.roomassociation` as Parameters[0], { associations: associationsMapToObject(associations) } as Parameters< typeof mx.setAccountData >[1] ); - return; - } - if (!profileId) { - throw new Error("profile Id is empty, yet it isn't a reset"); - } - associations.set(roomId, { profileId, validUntil }); - mx.setAccountData( - `${ACCOUNT_DATA_PREFIX}.roomassociation` as Parameters[0], - { associations: associationsMapToObject(associations) } as Parameters< - typeof mx.setAccountData - >[1] - ); + }); } /** @@ -490,22 +496,24 @@ export async function setCurrentlyUsedPerMessageProfileIdForAccount( validUntil?: number, reset?: boolean ) { - if (reset) { - mx.deleteAccountData( - `${ACCOUNT_DATA_PREFIX}.globalassociation` as Parameters[0] - ); - return; - } - if (!profileId) { - throw new Error("profile Id is empty, yet it isn't a reset"); - } + return enqueueProfilePersistence('globalassociation', async () => { + if (reset) { + await mx.deleteAccountData( + `${ACCOUNT_DATA_PREFIX}.globalassociation` as Parameters[0] + ); + return; + } + if (!profileId) { + throw new Error("profile Id is empty, yet it isn't a reset"); + } - const association: PerMessageProfileRoomAssociation = { profileId, validUntil }; + const association: PerMessageProfileRoomAssociation = { profileId, validUntil }; - mx.setAccountData( - `${ACCOUNT_DATA_PREFIX}.globalassociation` as Parameters[0], - { association: association } as Parameters[1] - ); + await mx.setAccountData( + `${ACCOUNT_DATA_PREFIX}.globalassociation` as Parameters[0], + { association: association } as Parameters[1] + ); + }); } /** diff --git a/src/app/state/room/roomScheduleCoordinator.test.ts b/src/app/state/room/roomScheduleCoordinator.test.ts new file mode 100644 index 0000000000..98f67e5483 --- /dev/null +++ b/src/app/state/room/roomScheduleCoordinator.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it, vi } from 'vitest'; +import { roomScheduleCoordinator } from './roomScheduleCoordinator'; + +function deferred() { + let resolve!: () => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +describe('roomScheduleCoordinator', () => { + it('runs operations in FIFO order for a room', async () => { + const first = deferred(); + const order: string[] = []; + + const firstOperation = roomScheduleCoordinator.run('!room:example.org', async () => { + order.push('first-start'); + await first.promise; + order.push('first-end'); + }); + const secondOperation = roomScheduleCoordinator.run('!room:example.org', async () => { + order.push('second'); + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(order).toEqual(['first-start']); + + first.resolve(); + await Promise.all([firstOperation, secondOperation]); + expect(order).toEqual(['first-start', 'first-end', 'second']); + }); + + it('continues the room queue after an operation fails', async () => { + const second = vi.fn<() => Promise>(async () => 'completed'); + + await expect( + roomScheduleCoordinator.run('!room:example.org', async () => { + throw new Error('cancel failed'); + }) + ).rejects.toThrow('cancel failed'); + await expect(roomScheduleCoordinator.run('!room:example.org', second)).resolves.toBe( + 'completed' + ); + + expect(second).toHaveBeenCalledOnce(); + }); + + it('does not serialize operations for different rooms', async () => { + const first = deferred(); + const second = vi.fn<() => Promise>(async () => 'completed'); + + const firstOperation = roomScheduleCoordinator.run('!first:example.org', () => first.promise); + const secondOperation = roomScheduleCoordinator.run('!second:example.org', second); + + await expect(secondOperation).resolves.toBe('completed'); + expect(second).toHaveBeenCalledOnce(); + + first.resolve(); + await firstOperation; + }); +}); diff --git a/src/app/state/room/roomScheduleCoordinator.ts b/src/app/state/room/roomScheduleCoordinator.ts new file mode 100644 index 0000000000..ddfa595ed6 --- /dev/null +++ b/src/app/state/room/roomScheduleCoordinator.ts @@ -0,0 +1,4 @@ +import { createKeyedQueue } from '$utils/keyedQueue'; + +/** Serializes delayed-event mutations for each room without blocking other rooms. */ +export const roomScheduleCoordinator = { run: createKeyedQueue() }; 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() ?? '', diff --git a/src/app/utils/keyedQueue.ts b/src/app/utils/keyedQueue.ts new file mode 100644 index 0000000000..1ce68997af --- /dev/null +++ b/src/app/utils/keyedQueue.ts @@ -0,0 +1,20 @@ +/** Serializes operations per key so read-modify-write sequences cannot interleave. */ +export function createKeyedQueue() { + const tails = new Map>(); + + return function run(key: string, operation: () => T | PromiseLike): Promise { + const previous = tails.get(key) ?? Promise.resolve(); + const current = previous.catch(() => undefined).then(operation); + const tail = current.then( + () => undefined, + () => undefined + ); + + tails.set(key, tail); + void tail.then(() => { + if (tails.get(key) === tail) tails.delete(key); + }); + + return current; + }; +}