diff --git a/src/app/components/editor/utils.test.ts b/src/app/components/editor/utils.test.ts new file mode 100644 index 0000000000..a45fedf7a0 --- /dev/null +++ b/src/app/components/editor/utils.test.ts @@ -0,0 +1,40 @@ +import { createEditor, Editor, Transforms } from 'slate'; +import { describe, expect, it } from 'vitest'; +import { getPrevWorldRange } from './utils'; +import { BlockType } from './types'; + +const createTestEditor = (text: string) => { + const editor = createEditor(); + editor.children = [{ type: BlockType.Paragraph, children: [{ text }] }]; + return editor; +}; + +const selectAt = (editor: Editor, offset: number) => { + Transforms.select(editor, { path: [0, 0], offset }); +}; + +describe('getPrevWorldRange', () => { + it('returns the word before the cursor', () => { + const editor = createTestEditor('hello :smile world'); + selectAt(editor, 12); + + const range = getPrevWorldRange(editor); + expect(range && Editor.string(editor, range)).toBe(':smile'); + }); + + it('returns the whole word when the cursor sits inside it', () => { + const editor = createTestEditor('hello :smile world'); + selectAt(editor, 9); + + const range = getPrevWorldRange(editor); + expect(range && Editor.string(editor, range)).toBe(':smile'); + }); + + it('returns the whole word when the cursor sits inside the last word', () => { + const editor = createTestEditor('hello :smile'); + selectAt(editor, 9); + + const range = getPrevWorldRange(editor); + expect(range && Editor.string(editor, range)).toBe(':smile'); + }); +}); diff --git a/src/app/components/editor/utils.ts b/src/app/components/editor/utils.ts index 1776f9fb08..bb34f5d7d8 100644 --- a/src/app/components/editor/utils.ts +++ b/src/app/components/editor/utils.ts @@ -181,11 +181,11 @@ const getPointUntilChar = ( let prevPoint: BasePoint | undefined; let char: string | undefined; + const startPoint = Editor.point(editor, cursorPoint, { edge: 'start' }); const pointItr = Editor.positions(editor, { - at: { - anchor: Editor.start(editor, []), - focus: Editor.point(editor, cursorPoint, { edge: 'start' }), - }, + at: options.reverse + ? { anchor: Editor.start(editor, []), focus: startPoint } + : { anchor: startPoint, focus: Editor.end(editor, []) }, unit: 'character', reverse: options.reverse, }); @@ -202,16 +202,21 @@ const getPointUntilChar = ( return targetPoint; }; +// line breaks produce empty chars, not \n +const isWorldBoundary = (char: string) => /\s|^$/.test(char); + export const getPrevWorldRange = (editor: Editor): BaseRange | undefined => { const { selection } = editor; if (!selection || !Range.isCollapsed(selection)) return undefined; const [cursorPoint] = Range.edges(selection); const worldStartPoint = getPointUntilChar(editor, cursorPoint, { reverse: true, - // line breaks produce empty chars, not \n - match: (char) => /\s|^$/.test(char), + match: isWorldBoundary, }); - return worldStartPoint && Editor.range(editor, worldStartPoint, cursorPoint); + if (!worldStartPoint) return undefined; + const worldEndPoint = + getPointUntilChar(editor, cursorPoint, { match: isWorldBoundary }) ?? cursorPoint; + return Editor.range(editor, worldStartPoint, worldEndPoint); }; export const isEmptyEditor = (editor: Editor): boolean => { diff --git a/src/app/features/room/RoomInput.test.tsx b/src/app/features/room/RoomInput.test.tsx index 10186cc3b8..f61c9674c9 100644 --- a/src/app/features/room/RoomInput.test.tsx +++ b/src/app/features/room/RoomInput.test.tsx @@ -781,6 +781,51 @@ describe('RoomInput submit regressions', () => { ); }); + it('sends on touch pointerup when the tap never produces a click', async () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Compose text' })); + + fireEvent.pointerDown(sendButton(), { pointerType: 'touch' }); + fireEvent.pointerUp(sendButton(), { pointerType: 'touch' }); + + await waitFor(() => expect(testState.matrix.sendMessage).toHaveBeenCalledOnce()); + }); + + it('sends once when a touch tap produces both a pointerup and a click', async () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Compose text' })); + + const submit = sendButton(); + fireEvent.pointerDown(submit, { pointerType: 'touch' }); + fireEvent.pointerUp(submit, { pointerType: 'touch' }); + fireEvent.click(submit); + + await waitFor(() => expect(testState.matrix.sendMessage).toHaveBeenCalledOnce()); + }); + + it('cancels a touch send released outside the button', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Compose text' })); + + fireEvent.pointerDown(sendButton(), { pointerType: 'touch' }); + fireEvent.pointerUp(sendButton(), { pointerType: 'touch', clientX: 200, clientY: 200 }); + + expect(testState.matrix.sendMessage).not.toHaveBeenCalled(); + }); + + it('leaves mouse taps on the click path', async () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Compose text' })); + + const submit = sendButton(); + fireEvent.pointerDown(submit, { pointerType: 'mouse' }); + fireEvent.pointerUp(submit, { pointerType: 'mouse' }); + expect(testState.matrix.sendMessage).not.toHaveBeenCalled(); + + fireEvent.click(submit); + await waitFor(() => expect(testState.matrix.sendMessage).toHaveBeenCalledOnce()); + }); + 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' }); diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index d3436d030d..7367b9d425 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -1,4 +1,10 @@ -import type { KeyboardEventHandler, MouseEvent, ReactElement, RefObject } from 'react'; +import type { + KeyboardEventHandler, + MouseEvent, + PointerEvent, + ReactElement, + RefObject, +} from 'react'; import { forwardRef, Fragment, @@ -408,6 +414,7 @@ export const RoomInput = forwardRef( const uploadBoardHandlers = useRef(); const longPressTimer = useRef | null>(null); const isLongPress = useRef(false); + const sentOnPointerUpRef = useRef(false); const suppressBlurRefocusRef = useRef(false); const editorRafIdsRef = useRef(new Set()); const scheduleEditorRaf = useCallback((callback: () => void) => { @@ -2518,6 +2525,7 @@ export const RoomInput = forwardRef( isLongPress.current = false; return; } + if (sentOnPointerUpRef.current) return; submit(); return; } @@ -2530,6 +2538,7 @@ export const RoomInput = forwardRef( if (hasContent) e.preventDefault(); }} onPointerDown={() => { + sentOnPointerUpRef.current = false; if (showAudioRecorder) return; if (hasContent) { isLongPress.current = false; @@ -2577,11 +2586,26 @@ export const RoomInput = forwardRef( window.addEventListener('pointerup', onUp); window.addEventListener('pointercancel', discardRecording); }} - onPointerUp={() => { + onPointerUp={(evt: PointerEvent) => { if (longPressTimer.current !== null) { clearTimeout(longPressTimer.current); longPressTimer.current = null; } + // iOS drops the synthesized click when the page mutates during a tap. + if (evt.pointerType === 'mouse') return; + if (showAudioRecorder || !hasContent || isLongPress.current) return; + // Touch implicitly captures the pointer, so a release off the button lands here too. + const rect = evt.currentTarget.getBoundingClientRect(); + if ( + evt.clientX < rect.left || + evt.clientX > rect.right || + evt.clientY < rect.top || + evt.clientY > rect.bottom + ) { + return; + } + sentOnPointerUpRef.current = true; + submit(); }} onPointerCancel={() => { if (longPressTimer.current !== null) { diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index 7e910c4034..05734821ea 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -13,6 +13,7 @@ import { SyncState, } from '$types/matrix-sdk'; import { fetch } from '$utils/fetch'; +import { matrixFetch } from './matrixFetch'; import { clearMediaCache } from '$utils/mediaCache'; import { clearNavToActivePathStore } from '$state/navToActivePath'; @@ -250,7 +251,7 @@ const buildClient = async (session: Session): Promise => { const mx = createClient({ baseUrl: session.baseUrl, - fetchFn: fetch, + fetchFn: matrixFetch, accessToken: session.accessToken, refreshToken: session.refreshToken, userId: session.userId, diff --git a/src/client/matrixFetch.test.ts b/src/client/matrixFetch.test.ts new file mode 100644 index 0000000000..1354e81bff --- /dev/null +++ b/src/client/matrixFetch.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createMatrixFetch } from './matrixFetch'; + +describe('createMatrixFetch', () => { + it('adds a timeout abort only to Matrix event send PUTs', async () => { + const baseFetch = vi.fn(() => Promise.resolve(new Response())); + const matrixFetch = createMatrixFetch(baseFetch); + + await matrixFetch('https://matrix.example/_matrix/client/v3/sync'); + await matrixFetch( + 'https://matrix.example/_matrix/client/v3/rooms/!r:ex/send/m.room.message/m1', + { method: 'PUT' } + ); + await matrixFetch( + 'https://matrix.example/_matrix/client/v3/rooms/!r:ex/send/m.room.encrypted/m2', + { method: 'PUT' } + ); + await matrixFetch('https://matrix.example/_matrix/client/v3/rooms/!r:ex/redact/$e/m3', { + method: 'PUT', + }); + + expect(baseFetch.mock.calls[0]?.[1]?.signal).toBeUndefined(); + expect(baseFetch.mock.calls[1]?.[1]?.signal).toBeInstanceOf(AbortSignal); + expect(baseFetch.mock.calls[2]?.[1]?.signal).toBeInstanceOf(AbortSignal); + expect(baseFetch.mock.calls[3]?.[1]?.signal).toBeUndefined(); + }); + + it('merges with an existing abort signal on the request', async () => { + const baseFetch = vi.fn(() => Promise.resolve(new Response())); + const matrixFetch = createMatrixFetch(baseFetch); + const controller = new AbortController(); + + await matrixFetch( + new Request('https://matrix.example/_matrix/client/v3/rooms/!r:ex/send/m.room.message/m1', { + method: 'PUT', + signal: controller.signal, + }) + ); + + const signal = baseFetch.mock.calls[0]?.[1]?.signal; + expect(signal).toBeInstanceOf(AbortSignal); + expect(signal).not.toBe(controller.signal); + }); +}); diff --git a/src/client/matrixFetch.ts b/src/client/matrixFetch.ts new file mode 100644 index 0000000000..cb903b9670 --- /dev/null +++ b/src/client/matrixFetch.ts @@ -0,0 +1,55 @@ +import { fetch } from '$utils/fetch'; + +/** Timeline event sends use `PUT /_matrix/client/{ver}/rooms/{roomId}/send/{type}/{txnId}`. */ +const MATRIX_EVENT_SEND_PATH = /\/_matrix\/client\/[^/]+\/rooms\/[^/]+\/send\//; + +const EVENT_SEND_TIMEOUT_MS = 30_000; + +const requestUrl = (input: RequestInfo | URL): string => + typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + +const requestMethod = (input: RequestInfo | URL, init?: RequestInit): string => { + if (init?.method) return init.method.toUpperCase(); + if (input instanceof Request) return input.method.toUpperCase(); + return 'GET'; +}; + +const isMatrixEventSend = (input: RequestInfo | URL, init?: RequestInit): boolean => { + const method = requestMethod(input, init); + if (method !== 'PUT') return false; + return MATRIX_EVENT_SEND_PATH.test(new URL(requestUrl(input)).pathname); +}; + +const mergeAbortSignals = (signals: AbortSignal[]): AbortSignal => { + const controller = new AbortController(); + for (const signal of signals) { + if (signal.aborted) { + controller.abort(signal.reason); + return controller.signal; + } + signal.addEventListener('abort', () => controller.abort(signal.reason), { once: true }); + } + return controller.signal; +}; + +/** + * Wraps the app fetch so stalled timeline sends abort after 30s. That makes the + * SDK mark the local echo `NOT_SENT`, which unlocks retry without a global client timeout. + */ +export const createMatrixFetch = (baseFetch: typeof fetch = fetch): typeof fetch => { + const matrixFetch: typeof fetch = (input, init) => { + if (!isMatrixEventSend(input, init)) { + return baseFetch(input, init); + } + + const timeout = AbortSignal.timeout(EVENT_SEND_TIMEOUT_MS); + const existingSignal = init?.signal ?? (input instanceof Request ? input.signal : undefined); + const signal = existingSignal ? mergeAbortSignals([existingSignal, timeout]) : timeout; + + return baseFetch(input, { ...init, signal }); + }; + + return matrixFetch; +}; + +export const matrixFetch = createMatrixFetch();