diff --git a/playwright/e2e/spaces/threads-activity-centre/index.ts b/playwright/e2e/spaces/threads-activity-centre/index.ts index fadf079eca..7ad477541a 100644 --- a/playwright/e2e/spaces/threads-activity-centre/index.ts +++ b/playwright/e2e/spaces/threads-activity-centre/index.ts @@ -336,6 +336,15 @@ export class Helpers { return expect(this.page.locator(".mx_ThreadPanel")).toBeVisible(); } + /** + * Assert that the thread panel is focused (actually the 'close' button, specifically) + */ + assertThreadPanelFocused() { + return expect( + this.page.locator(".mx_ThreadPanel").locator(".mx_BaseCard_header").getByTitle("Close"), + ).toBeFocused(); + } + /** * Populate the rooms with messages and threads * @param room1 diff --git a/playwright/e2e/spaces/threads-activity-centre/threadsActivityCentre.spec.ts b/playwright/e2e/spaces/threads-activity-centre/threadsActivityCentre.spec.ts index 13361a70a2..7d0b694ef5 100644 --- a/playwright/e2e/spaces/threads-activity-centre/threadsActivityCentre.spec.ts +++ b/playwright/e2e/spaces/threads-activity-centre/threadsActivityCentre.spec.ts @@ -160,4 +160,18 @@ test.describe("Threads Activity Centre", () => { await util.assertNoTacIndicator(); }); + + test("should focus the thread panel close button when clicking an item in the TAC", async ({ + room1, + room2, + util, + msg, + }) => { + await util.receiveMessages(room1, ["Msg1", msg.threadedOff("Msg1", "Resp1")]); + + await util.openTac(); + await util.clickRoomInTac(room1.name); + + await util.assertThreadPanelFocused(); + }); }); diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 2cf41215a7..bda30a06a3 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -116,7 +116,7 @@ import { ButtonEvent } from "../views/elements/AccessibleButton"; import { ActionPayload } from "../../dispatcher/payloads"; import { SummarizedNotificationState } from "../../stores/notifications/SummarizedNotificationState"; import Views from "../../Views"; -import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"; +import { FocusNextType, ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"; import { ViewHomePagePayload } from "../../dispatcher/payloads/ViewHomePagePayload"; import { AfterLeaveRoomPayload } from "../../dispatcher/payloads/AfterLeaveRoomPayload"; import { DoAfterSyncPreparedPayload } from "../../dispatcher/payloads/DoAfterSyncPreparedPayload"; @@ -229,7 +229,8 @@ export default class MatrixChat extends React.PureComponent { private screenAfterLogin?: IScreen; private tokenLogin?: boolean; - private focusComposer: boolean; + // What to focus on next component update, if anything + private focusNext: FocusNextType; private subTitleStatus: string; private prevWindowWidth: number; private voiceBroadcastResumer?: VoiceBroadcastResumer; @@ -298,8 +299,6 @@ export default class MatrixChat extends React.PureComponent { this.themeWatcher.start(); this.fontWatcher.start(); - this.focusComposer = false; - // object field used for tracking the status info appended to the title tag. // we don't do it as react state as i'm scared about triggering needless react refreshes. this.subTitleStatus = ""; @@ -483,9 +482,11 @@ export default class MatrixChat extends React.PureComponent { PosthogTrackers.instance.trackPageChange(this.state.view, this.state.page_type, durationMs); } } - if (this.focusComposer) { + if (this.focusNext === "composer") { dis.fire(Action.FocusSendMessageComposer); - this.focusComposer = false; + this.focusNext = undefined; + } else if (this.focusNext === "threadsPanel") { + dis.fire(Action.FocusThreadsPanel); } } @@ -985,7 +986,7 @@ export default class MatrixChat extends React.PureComponent { // switch view to the given room private async viewRoom(roomInfo: ViewRoomPayload): Promise { - this.focusComposer = true; + this.focusNext = roomInfo.focusNext ?? "composer"; if (roomInfo.room_alias) { logger.log(`Switching to room alias ${roomInfo.room_alias} at event ${roomInfo.event_id}`); diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 8d625fffb4..f9d4604972 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -1268,7 +1268,7 @@ export class RoomView extends React.Component { case Action.FocusAComposer: { dis.dispatch({ ...(payload as FocusComposerPayload), - // re-dispatch to the correct composer + // re-dispatch to the correct composer (the send message will still be on screen even when editing a message) action: this.state.editState ? Action.FocusEditMessageComposer : Action.FocusSendMessageComposer, }); break; diff --git a/src/components/structures/ThreadPanel.tsx b/src/components/structures/ThreadPanel.tsx index e83eace484..d1e8360174 100644 --- a/src/components/structures/ThreadPanel.tsx +++ b/src/components/structures/ThreadPanel.tsx @@ -37,6 +37,9 @@ import { ButtonEvent } from "../views/elements/AccessibleButton"; import Spinner from "../views/elements/Spinner"; import Heading from "../views/typography/Heading"; import { clearRoomNotification } from "../../utils/notifications"; +import { useDispatcher } from "../../hooks/useDispatcher"; +import dis from "../../dispatcher/dispatcher"; +import { Action } from "../../dispatcher/actions"; interface IProps { roomId: string; @@ -229,6 +232,7 @@ const ThreadPanel: React.FC = ({ roomId, onClose, permalinkCreator }) => const roomContext = useContext(RoomContext); const timelinePanel = useRef(null); const card = useRef(null); + const closeButonRef = useRef(null); const [filterOption, setFilterOption] = useState(ThreadFilterType.All); const [room, setRoom] = useState(null); @@ -255,6 +259,14 @@ const ThreadPanel: React.FC = ({ roomId, onClose, permalinkCreator }) => } }, [timelineSet, timelinePanel]); + useDispatcher(dis, (payload) => { + // This actually foucses the close button on the threads panel, as its the only interactive element, + // but at least it puts the user in the right area of the app. + if (payload.action === Action.FocusThreadsPanel) { + closeButonRef.current?.focus(); + } + }); + return ( = ({ roomId, onClose, permalinkCreator }) => onClose={onClose} withoutScrollContainer={true} ref={card} + closeButtonRef={closeButonRef} > {card.current && } {timelineSet ? ( diff --git a/src/components/views/right_panel/BaseCard.tsx b/src/components/views/right_panel/BaseCard.tsx index 580db4621a..2afae0bc28 100644 --- a/src/components/views/right_panel/BaseCard.tsx +++ b/src/components/views/right_panel/BaseCard.tsx @@ -35,6 +35,8 @@ interface IProps { onKeyDown?(ev: KeyboardEvent): void; cardState?: any; ref?: Ref; + // Ref for the 'close' button the the card + closeButtonRef?: Ref; children: ReactNode; } @@ -54,7 +56,21 @@ export const Group: React.FC = ({ className, title, children }) => }; const BaseCard: React.FC = forwardRef( - ({ closeLabel, onClose, onBack, className, header, footer, withoutScrollContainer, children, onKeyDown }, ref) => { + ( + { + closeLabel, + onClose, + onBack, + className, + header, + footer, + withoutScrollContainer, + children, + onKeyDown, + closeButtonRef, + }, + ref, + ) => { let backButton; const cardHistory = RightPanelStore.instance.roomPhaseHistory; if (cardHistory.length > 1) { @@ -75,6 +91,7 @@ const BaseCard: React.FC = forwardRef( className="mx_BaseCard_close" onClick={onClose} title={closeLabel || _t("action|close")} + ref={closeButtonRef} /> ); } diff --git a/src/components/views/spaces/threads-activity-centre/ThreadsActivityCentre.tsx b/src/components/views/spaces/threads-activity-centre/ThreadsActivityCentre.tsx index 2f8607fb13..64888b3b28 100644 --- a/src/components/views/spaces/threads-activity-centre/ThreadsActivityCentre.tsx +++ b/src/components/views/spaces/threads-activity-centre/ThreadsActivityCentre.tsx @@ -159,6 +159,7 @@ function ThreadsActivityCentreRow({ room, onClick, notificationLevel }: ThreadsA show_room_tile: true, // make sure the room is visible in the list room_id: room.roomId, metricsTrigger: "WebThreadsActivityCentre", + focusNext: "threadsPanel", }); }} label={room.name} diff --git a/src/components/views/spaces/threads-activity-centre/ThreadsActivityCentreButton.tsx b/src/components/views/spaces/threads-activity-centre/ThreadsActivityCentreButton.tsx index 3f85de38fa..c2e35de7aa 100644 --- a/src/components/views/spaces/threads-activity-centre/ThreadsActivityCentreButton.tsx +++ b/src/components/views/spaces/threads-activity-centre/ThreadsActivityCentreButton.tsx @@ -27,7 +27,7 @@ import { notificationLevelToIndicator } from "../../../../utils/notifications"; interface ThreadsActivityCentreButtonProps extends ComponentProps { /** - * Display the `Treads` label next to the icon. + * Display the `Threads` label next to the icon. */ displayLabel?: boolean; /** diff --git a/src/dispatcher/actions.ts b/src/dispatcher/actions.ts index f774508f54..e577daa884 100644 --- a/src/dispatcher/actions.ts +++ b/src/dispatcher/actions.ts @@ -91,6 +91,11 @@ export enum Action { */ FocusAComposer = "focus_a_composer", + /** + * Focuses the threads panel. + */ + FocusThreadsPanel = "focus_threads_panel", + /** * Opens the user menu (previously known as the top left menu). No additional payload information required. */ diff --git a/src/dispatcher/payloads/ViewRoomPayload.ts b/src/dispatcher/payloads/ViewRoomPayload.ts index 69ae5910ee..b8211128e5 100644 --- a/src/dispatcher/payloads/ViewRoomPayload.ts +++ b/src/dispatcher/payloads/ViewRoomPayload.ts @@ -24,6 +24,8 @@ import { IOpts } from "../../createRoom"; import { JoinRoomPayload } from "./JoinRoomPayload"; import { AtLeastOne } from "../../@types/common"; +export type FocusNextType = "composer" | "threadsPanel" | undefined; + /* eslint-disable camelcase */ interface BaseViewRoomPayload extends Pick { action: Action.ViewRoom; @@ -61,5 +63,6 @@ export type ViewRoomPayload = BaseViewRoomPayload & // the number of API calls required. room_id?: string; room_alias?: string; + focusNext: FocusNextType; // wat to focus after room switch. Defaults to 'composer' if undefined. }>; /* eslint-enable camelcase */ diff --git a/test/components/structures/MatrixChat-test.tsx b/test/components/structures/MatrixChat-test.tsx index 63eec01ae3..9ee1907e69 100644 --- a/test/components/structures/MatrixChat-test.tsx +++ b/test/components/structures/MatrixChat-test.tsx @@ -15,7 +15,7 @@ limitations under the License. */ import React, { ComponentProps } from "react"; -import { fireEvent, render, RenderResult, screen, within } from "@testing-library/react"; +import { fireEvent, render, RenderResult, screen, waitFor, within } from "@testing-library/react"; import fetchMock from "fetch-mock-jest"; import { Mocked, mocked } from "jest-mock"; import { ClientEvent, MatrixClient, MatrixEvent, Room, SyncState } from "matrix-js-sdk/src/matrix"; @@ -59,6 +59,7 @@ import { SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY } from "../../../src/Base import SettingsStore from "../../../src/settings/SettingsStore"; import { SettingLevel } from "../../../src/settings/SettingLevel"; import { MatrixClientPeg as peg } from "../../../src/MatrixClientPeg"; +import DMRoomMap from "../../../src/utils/DMRoomMap"; jest.mock("matrix-js-sdk/src/oidc/authorize", () => ({ completeAuthorizationCodeGrant: jest.fn(), @@ -220,6 +221,9 @@ describe("", () => { jest.spyOn(StorageManager, "idbLoad").mockReset(); jest.spyOn(StorageManager, "idbSave").mockResolvedValue(undefined); jest.spyOn(defaultDispatcher, "dispatch").mockClear(); + jest.spyOn(defaultDispatcher, "fire").mockClear(); + + DMRoomMap.makeShared(mockClient); await clearAllModals(); }); @@ -227,6 +231,9 @@ describe("", () => { resetJsDomAfterEach(); afterEach(() => { + // @ts-ignore + DMRoomMap.setShared(null); + jest.restoreAllMocks(); // emit a loggedOut event so that all of the Store singletons forget about their references to the mock client @@ -239,6 +246,22 @@ describe("", () => { expect(container).toMatchSnapshot(); }); + it("should fire to focus the message composer", async () => { + getComponent(); + defaultDispatcher.dispatch({ action: Action.ViewRoom, room_id: "!room:server.org", focusNext: "composer" }); + await waitFor(() => { + expect(defaultDispatcher.fire).toHaveBeenCalledWith(Action.FocusSendMessageComposer); + }); + }); + + it("should fire to focus the threads panel", async () => { + getComponent(); + defaultDispatcher.dispatch({ action: Action.ViewRoom, room_id: "!room:server.org", focusNext: "threadsPanel" }); + await waitFor(() => { + expect(defaultDispatcher.fire).toHaveBeenCalledWith(Action.FocusThreadsPanel); + }); + }); + describe("when query params have a OIDC params", () => { const issuer = "https://auth.com/"; const homeserverUrl = "https://matrix.org"; diff --git a/test/components/structures/ThreadPanel-test.tsx b/test/components/structures/ThreadPanel-test.tsx index df939044fb..74a1d4023f 100644 --- a/test/components/structures/ThreadPanel-test.tsx +++ b/test/components/structures/ThreadPanel-test.tsx @@ -37,6 +37,8 @@ import ResizeNotifier from "../../../src/utils/ResizeNotifier"; import { createTestClient, getRoomContext, mkRoom, mockPlatformPeg, stubClient } from "../../test-utils"; import { mkThread } from "../../test-utils/threads"; import { IRoomState } from "../../../src/components/structures/RoomView"; +import defaultDispatcher from "../../../src/dispatcher/dispatcher"; +import { Action } from "../../../src/dispatcher/actions"; jest.mock("../../../src/utils/Feedback"); @@ -156,6 +158,43 @@ describe("ThreadPanel", () => { fireEvent.click(getByRole(container, "button", { name: "Mark all as read" })); await waitFor(() => expect(mockClient.sendReadReceipt).not.toHaveBeenCalled()); }); + + it("focuses the close button on FocusThreadsPanel dispatch", () => { + const ROOM_ID = "!roomId:example.org"; + + stubClient(); + mockPlatformPeg(); + const mockClient = mocked(MatrixClientPeg.safeGet()); + + const room = new Room(ROOM_ID, mockClient, mockClient.getUserId() ?? "", { + pendingEventOrdering: PendingEventOrdering.Detached, + }); + + render( + + + + + , + ); + + // Unfocus it first so we know it's not just focused by coincidence + screen.getByTestId("base-card-close-button").blur(); + expect(screen.getByTestId("base-card-close-button")).not.toHaveFocus(); + + defaultDispatcher.dispatch({ action: Action.FocusThreadsPanel }, true); + + expect(screen.getByTestId("base-card-close-button")).toHaveFocus(); + }); }); describe("Filtering", () => {