Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions src/app/components/editor/utils.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
19 changes: 12 additions & 7 deletions src/app/components/editor/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand All @@ -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 => {
Expand Down
45 changes: 45 additions & 0 deletions src/app/features/room/RoomInput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -781,6 +781,51 @@ describe('RoomInput submit regressions', () => {
);
});

it('sends on touch pointerup when the tap never produces a click', async () => {
render(<RoomInputHarness />);
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(<RoomInputHarness />);
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(<RoomInputHarness />);
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(<RoomInputHarness />);
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' });
Expand Down
28 changes: 26 additions & 2 deletions src/app/features/room/RoomInput.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import type { KeyboardEventHandler, MouseEvent, ReactElement, RefObject } from 'react';
import type {
KeyboardEventHandler,
MouseEvent,
PointerEvent,
ReactElement,
RefObject,
} from 'react';
import {
forwardRef,
Fragment,
Expand Down Expand Up @@ -408,6 +414,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const uploadBoardHandlers = useRef<UploadBoardImperativeHandlers>();
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const isLongPress = useRef(false);
const sentOnPointerUpRef = useRef(false);
const suppressBlurRefocusRef = useRef(false);
const editorRafIdsRef = useRef(new Set<number>());
const scheduleEditorRaf = useCallback((callback: () => void) => {
Expand Down Expand Up @@ -2518,6 +2525,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
isLongPress.current = false;
return;
}
if (sentOnPointerUpRef.current) return;
submit();
return;
}
Expand All @@ -2530,6 +2538,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
if (hasContent) e.preventDefault();
}}
onPointerDown={() => {
sentOnPointerUpRef.current = false;
if (showAudioRecorder) return;
if (hasContent) {
isLongPress.current = false;
Expand Down Expand Up @@ -2577,11 +2586,26 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
window.addEventListener('pointerup', onUp);
window.addEventListener('pointercancel', discardRecording);
}}
onPointerUp={() => {
onPointerUp={(evt: PointerEvent<HTMLButtonElement>) => {
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) {
Expand Down
3 changes: 2 additions & 1 deletion src/client/initMatrix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -250,7 +251,7 @@ const buildClient = async (session: Session): Promise<BuiltClient> => {

const mx = createClient({
baseUrl: session.baseUrl,
fetchFn: fetch,
fetchFn: matrixFetch,
accessToken: session.accessToken,
refreshToken: session.refreshToken,
userId: session.userId,
Expand Down
44 changes: 44 additions & 0 deletions src/client/matrixFetch.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof fetch>(() => 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<typeof fetch>(() => 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);
});
});
55 changes: 55 additions & 0 deletions src/client/matrixFetch.ts
Original file line number Diff line number Diff line change
@@ -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();
Loading