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();