diff --git a/e2e-tests/cypress/tests/support/chai.d.ts b/e2e-tests/cypress/tests/support/chai.d.ts
new file mode 100644
index 0000000000..c9f939fe3f
--- /dev/null
+++ b/e2e-tests/cypress/tests/support/chai.d.ts
@@ -0,0 +1,12 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+///
+
+declare namespace Cypress {
+ interface Chainer {
+ (chainer: 'be.focusVisible', {exactStyles}: {exactStyles?: boolean}): Chainable;
+
+ (chainer: 'not.be.focusVisible', {exactStyles}: {exactStyles?: boolean}): Chainable;
+ }
+}
diff --git a/e2e-tests/cypress/tests/support/chai.ts b/e2e-tests/cypress/tests/support/chai.ts
new file mode 100644
index 0000000000..6c63a30b25
--- /dev/null
+++ b/e2e-tests/cypress/tests/support/chai.ts
@@ -0,0 +1,36 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+chai.use((chai: Chai.ChaiStatic) => {
+ function assertIsFoo({exactStyles = true} = {}) {
+ // eslint-disable-next-line no-underscore-dangle
+ const obj = this._obj as JQuery;
+
+ this.assert(
+ obj.hasClass('a11y--active'),
+ 'expected #{this} to have a11y--active class',
+ 'expected #{this} to not have a11y--active class',
+ obj,
+ );
+
+ // These should match the styles set on :focus-visible in sass/utils/_modifiers.scss
+ this.assert(
+ exactStyles ? obj.css('box-shadow').includes('0px 0px 1px 3px') : Boolean(obj.css('box-shadow')),
+ 'expected #{this} to have focused element style (box-shadow)',
+ 'expected #{this} to not have focused element style (box-shadow)',
+ obj.css('box-shadow'),
+ );
+
+ this.assert(
+ exactStyles ? obj.css('border-radius') === '4px' : Boolean(obj.css('border-radius')),
+ 'expected #{this} to have focused element style (border-radius)',
+ 'expected #{this} to not have focused element style (border-radius)',
+ obj.css('border-radius'),
+ );
+
+ return this;
+ }
+ /* eslint-enable no-underscore-dangle */
+
+ chai.Assertion.addMethod('a11yVisible', assertIsFoo);
+});
diff --git a/e2e-tests/playwright/lib/src/browser_context.ts b/e2e-tests/playwright/lib/src/browser_context.ts
index 2c1e9fddb2..450bdda201 100644
--- a/e2e-tests/playwright/lib/src/browser_context.ts
+++ b/e2e-tests/playwright/lib/src/browser_context.ts
@@ -34,10 +34,11 @@ export class TestBrowser {
const systemConsolePage = new pages.SystemConsolePage(page);
const scheduledPostsPage = new pages.ScheduledPostsPage(page);
const draftsPage = new pages.DraftsPage(page);
+ const threadsPage = new pages.ThreadsPage(page);
this.context = context;
- return {context, page, channelsPage, systemConsolePage, scheduledPostsPage, draftsPage};
+ return {context, page, channelsPage, systemConsolePage, scheduledPostsPage, draftsPage, threadsPage};
}
async close() {
diff --git a/e2e-tests/playwright/lib/src/ui/pages/index.ts b/e2e-tests/playwright/lib/src/ui/pages/index.ts
index a36a892dfa..624199b77c 100644
--- a/e2e-tests/playwright/lib/src/ui/pages/index.ts
+++ b/e2e-tests/playwright/lib/src/ui/pages/index.ts
@@ -9,6 +9,7 @@ import SignupPage from './signup';
import SystemConsolePage from './system_console';
import ScheduledPostsPage from './scheduled_posts';
import DraftsPage from './drafts';
+import ThreadsPage from './threads';
const pages = {
ChannelsPage,
@@ -19,6 +20,7 @@ const pages = {
ScheduledPostsPage,
SystemConsolePage,
DraftsPage,
+ ThreadsPage,
};
export {
diff --git a/e2e-tests/playwright/lib/src/ui/pages/threads.ts b/e2e-tests/playwright/lib/src/ui/pages/threads.ts
new file mode 100644
index 0000000000..5736ea4728
--- /dev/null
+++ b/e2e-tests/playwright/lib/src/ui/pages/threads.ts
@@ -0,0 +1,46 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {Page, expect} from '@playwright/test';
+
+import {ChannelsPost} from '@/ui/components';
+
+export default class ThreadsPage {
+ readonly page: Page;
+
+ readonly threadsList;
+
+ readonly noThreadSelected;
+
+ constructor(page: Page) {
+ this.page = page;
+
+ this.threadsList = page.locator('#threads-list');
+
+ this.noThreadSelected = page.locator('.no-results__title', {
+ hasText: /Looks like you’re all caught up|Catch up on your threads/,
+ });
+ }
+
+ async goto(teamName: string) {
+ await this.page.goto(`/${teamName}/threads`);
+ }
+
+ async toBeVisible() {
+ await expect(this.threadsList).toBeVisible();
+ }
+
+ async toHaveThreadSelected() {
+ await expect(this.noThreadSelected).not.toBeAttached();
+ }
+
+ async toNotHaveThreadSelected() {
+ await expect(this.noThreadSelected).toBeVisible();
+ }
+
+ async getLastPost() {
+ const lastPost = this.page.getByTestId('rhsPostView').last();
+ await lastPost.waitFor();
+ return new ChannelsPost(lastPost);
+ }
+}
diff --git a/e2e-tests/playwright/specs/functional/channels/threads/threads_list.spec.ts b/e2e-tests/playwright/specs/functional/channels/threads/threads_list.spec.ts
new file mode 100644
index 0000000000..70cc1cb261
--- /dev/null
+++ b/e2e-tests/playwright/specs/functional/channels/threads/threads_list.spec.ts
@@ -0,0 +1,76 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {test} from '@mattermost/playwright-lib';
+
+test('Should be able to change threads with arrow keys', async ({pw}, testInfo) => {
+ test.skip(testInfo.project.name === 'ipad');
+
+ const {team, user} = await pw.initSetup();
+
+ const {channelsPage, page, threadsPage} = await pw.testBrowser.login(user);
+
+ await channelsPage.goto();
+ await channelsPage.toBeVisible();
+
+ // # Start some threads, and leave a draft in one of them
+ await channelsPage.centerView.postCreate.postMessage('aaa');
+ await (await channelsPage.getLastPost()).openAThread();
+ await channelsPage.sidebarRight.postMessage('aaa reply');
+
+ await channelsPage.centerView.postCreate.postMessage('bbb');
+ await (await channelsPage.getLastPost()).openAThread();
+ await channelsPage.sidebarRight.postMessage('bbb reply');
+ await channelsPage.sidebarRight.postCreate.writeMessage('bbb second reply');
+
+ await channelsPage.centerView.postCreate.postMessage('ccc');
+ await (await channelsPage.getLastPost()).openAThread();
+ await channelsPage.sidebarRight.postMessage('ccc reply');
+
+ // * Ensure that there's a draft
+ await channelsPage.sidebarLeft.draftsVisible();
+
+ // # Switch to the threads list
+ await threadsPage.goto(team.name);
+ await threadsPage.toBeVisible();
+
+ // * Ensure no thread starts selected
+ await threadsPage.toNotHaveThreadSelected();
+
+ // # Press the down arrow to select a thread
+ await page.keyboard.press('ArrowDown');
+
+ // * Ensure the latest thread was selected
+ await threadsPage.toHaveThreadSelected();
+ (await threadsPage.getLastPost()).toContainText('ccc reply');
+
+ // # Press the down arrow again
+ await page.keyboard.press('ArrowDown');
+
+ // * Ensure the latest thread was selected
+ await threadsPage.toHaveThreadSelected();
+ (await threadsPage.getLastPost()).toContainText('bbb reply');
+
+ await threadsPage.threadsList.focus();
+
+ // # Press the down arrow again
+ await page.keyboard.press('ArrowDown');
+
+ // * Ensure the latest thread was selected
+ await threadsPage.toHaveThreadSelected();
+ (await threadsPage.getLastPost()).toContainText('aaa reply');
+
+ // # Press the up arrow
+ await page.keyboard.press('ArrowUp');
+
+ // * Ensure the latest thread was selected
+ await threadsPage.toHaveThreadSelected();
+ (await threadsPage.getLastPost()).toContainText('bbb reply');
+
+ // # Press the up arrow
+ await page.keyboard.press('ArrowUp');
+
+ // * Ensure the latest thread was selected
+ await threadsPage.toHaveThreadSelected();
+ (await threadsPage.getLastPost()).toContainText('ccc reply');
+});
diff --git a/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.tsx b/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.tsx
index 4bcfdd51d4..87d35e3abf 100644
--- a/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.tsx
+++ b/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.tsx
@@ -523,7 +523,7 @@ const AdvancedTextEditor = ({
// Update the caret position in the input box when changed by a side effect
useEffect(() => {
const textbox: HTMLInputElement | HTMLTextAreaElement | undefined = textboxRef.current?.getInputBox();
- if (textbox && textbox.selectionStart !== caretPosition) {
+ if (textbox && textbox === document.activeElement && textbox.selectionStart !== caretPosition) {
Utils.setCaretPosition(textbox, caretPosition);
}
}, [caretPosition]);