Skip to content

Commit 79e2703

Browse files
authored
fix(frontend): prevent thread autofocus on touch devices (#2478)
1 parent ddd69bd commit 79e2703

3 files changed

Lines changed: 101 additions & 2 deletions

File tree

apps/frontend/e2e/composer.test.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ import { createAndLoginTestUser } from './fixtures/testUser';
44
import { withLoggedInServerWindow } from './fixtures/serverUser';
55
import { waitForRoomReady } from './fixtures/realtimeSync';
66
import { TIMEOUTS } from './constants';
7+
import {
8+
getRoomIdByNameViaConnect,
9+
postMessageViaConnect,
10+
postThreadReplyWithEchoViaConnect
11+
} from './fixtures/connectHelpers';
712
import * as routes from './routes';
813

914
test.describe('Composer drafts', () => {
@@ -506,3 +511,97 @@ test.describe('Composer auto-focus on navigation (touch device)', () => {
506511
await expect(roomPage.messageInput).not.toBeFocused();
507512
});
508513
});
514+
515+
for (const editor of ['visual', 'markdown'] as const) {
516+
for (const device of ['phone', 'wide touch', 'desktop'] as const) {
517+
test.describe(`Thread composer focus (${editor}, ${device})`, () => {
518+
test.use({
519+
hasTouch: device !== 'desktop',
520+
isMobile: device !== 'desktop',
521+
viewport: { width: device === 'phone' ? 390 : 1280, height: 844 }
522+
});
523+
524+
test('navigation respects input capabilities and authoring still focuses', async ({
525+
page,
526+
chatPage,
527+
roomPage
528+
}) => {
529+
const pageErrors: string[] = [];
530+
page.on('pageerror', (error) => pageErrors.push(error.message));
531+
await createAndLoginTestUser(page);
532+
await chatPage.goto();
533+
await page.evaluate((composerEditor) => {
534+
const preferences = JSON.parse(localStorage.getItem('chatto:preferences') ?? '{}');
535+
localStorage.setItem(
536+
'chatto:preferences',
537+
JSON.stringify({ ...preferences, composerEditor })
538+
);
539+
}, editor);
540+
const roomId = await getRoomIdByNameViaConnect(page, 'general');
541+
await page.goto(routes.room(roomId));
542+
await waitForRoomReady(page, 'general');
543+
for (const body of ['First focus thread', 'Second focus thread']) {
544+
const rootId = await postMessageViaConnect(page, roomId, body);
545+
await postThreadReplyWithEchoViaConnect(page, roomId, `${body} reply`, rootId, rootId);
546+
}
547+
548+
// Reopen the first thread, then switch to another thread. Use navigation
549+
// links rather than Reply, which deliberately starts an authoring action.
550+
for (const body of ['First focus thread', 'First focus thread', 'Second focus thread']) {
551+
await roomPage.getMessage(body).locator.getByRole('link', { name: '1 reply' }).click();
552+
const input = roomPage.threadReplyInput;
553+
await expect(input).toHaveAttribute('contenteditable', 'true');
554+
// TipTap schedules focus on an animation frame. Let both editor kinds
555+
// finish pending focus work before making the negative assertion.
556+
await page.evaluate(
557+
() =>
558+
new Promise<void>((resolve) =>
559+
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
560+
)
561+
);
562+
if (device === 'desktop') await expect(input).toBeFocused();
563+
else await expect(input).not.toBeFocused();
564+
565+
if (body === 'Second focus thread') {
566+
if (device === 'desktop') await input.click();
567+
else await input.tap();
568+
await expect(input).toBeFocused();
569+
await input.fill('An intentional reply');
570+
await roomPage.threadPane
571+
.getByRole('button', { name: 'Send message', exact: true })
572+
.click();
573+
await roomPage.expectTextInThreadPane('An intentional reply');
574+
await expect(input).not.toContainText('An intentional reply');
575+
await expect(input).toBeFocused();
576+
await roomPage.getThreadMessage('An intentional reply').startEdit();
577+
await expect(input).toBeFocused();
578+
} else if (device === 'phone') {
579+
await roomPage.closeThreadWithBackButton();
580+
} else {
581+
await roomPage.closeThreadWithCloseButton();
582+
}
583+
}
584+
if (device === 'phone') await roomPage.closeThreadWithBackButton();
585+
else await roomPage.closeThreadWithCloseButton();
586+
587+
await roomPage.getMessage('Second focus thread reply').replyToEchoInThread();
588+
await expect(roomPage.threadReplyInput).toBeFocused();
589+
590+
const quotedMessage = roomPage.getThreadMessage('An intentional reply');
591+
await quotedMessage.locator
592+
.getByText('An intentional reply', { exact: true })
593+
.evaluate((element) => {
594+
const range = document.createRange();
595+
range.selectNodeContents(element);
596+
window.getSelection()?.removeAllRanges();
597+
window.getSelection()?.addRange(range);
598+
element.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
599+
});
600+
await quotedMessage.replyInRoom();
601+
await expect(roomPage.threadReplyInput).toContainText('An intentional reply');
602+
await expect(roomPage.threadReplyInput).toBeFocused();
603+
expect(pageErrors).toEqual([]);
604+
});
605+
});
606+
}
607+
}

apps/frontend/src/lib/components/composer/messageComposerState.svelte.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -521,7 +521,8 @@ export class MessageComposerState {
521521
const api = this.editorApi;
522522
if (!request || !api || request.id === this.#insertedQuoteRequestId) return;
523523
this.#insertedQuoteRequestId = request.id;
524-
api.insertQuote(request.text);
524+
// Let the message action sheet close before the editor takes focus.
525+
this.insertQuote(request.text);
525526
});
526527
}
527528

apps/frontend/src/routes/chat/[serverId]/[roomId]/ThreadPane.svelte

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -372,7 +372,6 @@
372372
onEscape={onClose}
373373
onReady={(api: MessageComposerApi) => {
374374
composerApi = api;
375-
api.focus();
376375
}}
377376
onTyping={canPost ? () => typingIndicator?.sendTypingIndicator() : undefined}
378377
onMessageSent={(event) => {

0 commit comments

Comments
 (0)