@@ -4,6 +4,11 @@ import { createAndLoginTestUser } from './fixtures/testUser';
44import { withLoggedInServerWindow } from './fixtures/serverUser' ;
55import { waitForRoomReady } from './fixtures/realtimeSync' ;
66import { TIMEOUTS } from './constants' ;
7+ import {
8+ getRoomIdByNameViaConnect ,
9+ postMessageViaConnect ,
10+ postThreadReplyWithEchoViaConnect
11+ } from './fixtures/connectHelpers' ;
712import * as routes from './routes' ;
813
914test . 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+ }
0 commit comments