Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.

Commit 59395ab

Browse files
authored
Focus the thread panel when clicking on an item in the TAC (#12410)
* Focus the thread panel when clicking on an item in the TAC actually the 'close' button in the threads panel as it's the only interactive element: we can improve this later when we use landmarks & generally have better a11y. * Undo minor refactoring as none of it is test3ed, it's not worth it. * add unit test * Add matrixchat tests * Needs awaits * ts-ignore * Fix test (I think...) * Remove unnecessary value set * Not how assignments work
1 parent 0daf0cf commit 59395ab

File tree

12 files changed

+136
-11
lines changed

12 files changed

+136
-11
lines changed

playwright/e2e/spaces/threads-activity-centre/index.ts

+9
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,15 @@ export class Helpers {
336336
return expect(this.page.locator(".mx_ThreadPanel")).toBeVisible();
337337
}
338338

339+
/**
340+
* Assert that the thread panel is focused (actually the 'close' button, specifically)
341+
*/
342+
assertThreadPanelFocused() {
343+
return expect(
344+
this.page.locator(".mx_ThreadPanel").locator(".mx_BaseCard_header").getByTitle("Close"),
345+
).toBeFocused();
346+
}
347+
339348
/**
340349
* Populate the rooms with messages and threads
341350
* @param room1

playwright/e2e/spaces/threads-activity-centre/threadsActivityCentre.spec.ts

+14
Original file line numberDiff line numberDiff line change
@@ -160,4 +160,18 @@ test.describe("Threads Activity Centre", () => {
160160

161161
await util.assertNoTacIndicator();
162162
});
163+
164+
test("should focus the thread panel close button when clicking an item in the TAC", async ({
165+
room1,
166+
room2,
167+
util,
168+
msg,
169+
}) => {
170+
await util.receiveMessages(room1, ["Msg1", msg.threadedOff("Msg1", "Resp1")]);
171+
172+
await util.openTac();
173+
await util.clickRoomInTac(room1.name);
174+
175+
await util.assertThreadPanelFocused();
176+
});
163177
});

src/components/structures/MatrixChat.tsx

+8-7
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ import { ButtonEvent } from "../views/elements/AccessibleButton";
116116
import { ActionPayload } from "../../dispatcher/payloads";
117117
import { SummarizedNotificationState } from "../../stores/notifications/SummarizedNotificationState";
118118
import Views from "../../Views";
119-
import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
119+
import { FocusNextType, ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
120120
import { ViewHomePagePayload } from "../../dispatcher/payloads/ViewHomePagePayload";
121121
import { AfterLeaveRoomPayload } from "../../dispatcher/payloads/AfterLeaveRoomPayload";
122122
import { DoAfterSyncPreparedPayload } from "../../dispatcher/payloads/DoAfterSyncPreparedPayload";
@@ -229,7 +229,8 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
229229

230230
private screenAfterLogin?: IScreen;
231231
private tokenLogin?: boolean;
232-
private focusComposer: boolean;
232+
// What to focus on next component update, if anything
233+
private focusNext: FocusNextType;
233234
private subTitleStatus: string;
234235
private prevWindowWidth: number;
235236
private voiceBroadcastResumer?: VoiceBroadcastResumer;
@@ -298,8 +299,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
298299
this.themeWatcher.start();
299300
this.fontWatcher.start();
300301

301-
this.focusComposer = false;
302-
303302
// object field used for tracking the status info appended to the title tag.
304303
// we don't do it as react state as i'm scared about triggering needless react refreshes.
305304
this.subTitleStatus = "";
@@ -483,9 +482,11 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
483482
PosthogTrackers.instance.trackPageChange(this.state.view, this.state.page_type, durationMs);
484483
}
485484
}
486-
if (this.focusComposer) {
485+
if (this.focusNext === "composer") {
487486
dis.fire(Action.FocusSendMessageComposer);
488-
this.focusComposer = false;
487+
this.focusNext = undefined;
488+
} else if (this.focusNext === "threadsPanel") {
489+
dis.fire(Action.FocusThreadsPanel);
489490
}
490491
}
491492

@@ -985,7 +986,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
985986

986987
// switch view to the given room
987988
private async viewRoom(roomInfo: ViewRoomPayload): Promise<void> {
988-
this.focusComposer = true;
989+
this.focusNext = roomInfo.focusNext ?? "composer";
989990

990991
if (roomInfo.room_alias) {
991992
logger.log(`Switching to room alias ${roomInfo.room_alias} at event ${roomInfo.event_id}`);

src/components/structures/RoomView.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -1268,7 +1268,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
12681268
case Action.FocusAComposer: {
12691269
dis.dispatch<FocusComposerPayload>({
12701270
...(payload as FocusComposerPayload),
1271-
// re-dispatch to the correct composer
1271+
// re-dispatch to the correct composer (the send message will still be on screen even when editing a message)
12721272
action: this.state.editState ? Action.FocusEditMessageComposer : Action.FocusSendMessageComposer,
12731273
});
12741274
break;

src/components/structures/ThreadPanel.tsx

+13
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ import { ButtonEvent } from "../views/elements/AccessibleButton";
3737
import Spinner from "../views/elements/Spinner";
3838
import Heading from "../views/typography/Heading";
3939
import { clearRoomNotification } from "../../utils/notifications";
40+
import { useDispatcher } from "../../hooks/useDispatcher";
41+
import dis from "../../dispatcher/dispatcher";
42+
import { Action } from "../../dispatcher/actions";
4043

4144
interface IProps {
4245
roomId: string;
@@ -229,6 +232,7 @@ const ThreadPanel: React.FC<IProps> = ({ roomId, onClose, permalinkCreator }) =>
229232
const roomContext = useContext(RoomContext);
230233
const timelinePanel = useRef<TimelinePanel | null>(null);
231234
const card = useRef<HTMLDivElement | null>(null);
235+
const closeButonRef = useRef<HTMLDivElement | null>(null);
232236

233237
const [filterOption, setFilterOption] = useState<ThreadFilterType>(ThreadFilterType.All);
234238
const [room, setRoom] = useState<Room | null>(null);
@@ -255,6 +259,14 @@ const ThreadPanel: React.FC<IProps> = ({ roomId, onClose, permalinkCreator }) =>
255259
}
256260
}, [timelineSet, timelinePanel]);
257261

262+
useDispatcher(dis, (payload) => {
263+
// This actually foucses the close button on the threads panel, as its the only interactive element,
264+
// but at least it puts the user in the right area of the app.
265+
if (payload.action === Action.FocusThreadsPanel) {
266+
closeButonRef.current?.focus();
267+
}
268+
});
269+
258270
return (
259271
<RoomContext.Provider
260272
value={{
@@ -276,6 +288,7 @@ const ThreadPanel: React.FC<IProps> = ({ roomId, onClose, permalinkCreator }) =>
276288
onClose={onClose}
277289
withoutScrollContainer={true}
278290
ref={card}
291+
closeButtonRef={closeButonRef}
279292
>
280293
{card.current && <Measured sensor={card.current} onMeasurement={setNarrow} />}
281294
{timelineSet ? (

src/components/views/right_panel/BaseCard.tsx

+18-1
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ interface IProps {
3535
onKeyDown?(ev: KeyboardEvent): void;
3636
cardState?: any;
3737
ref?: Ref<HTMLDivElement>;
38+
// Ref for the 'close' button the the card
39+
closeButtonRef?: Ref<HTMLDivElement>;
3840
children: ReactNode;
3941
}
4042

@@ -54,7 +56,21 @@ export const Group: React.FC<IGroupProps> = ({ className, title, children }) =>
5456
};
5557

5658
const BaseCard: React.FC<IProps> = forwardRef<HTMLDivElement, IProps>(
57-
({ closeLabel, onClose, onBack, className, header, footer, withoutScrollContainer, children, onKeyDown }, ref) => {
59+
(
60+
{
61+
closeLabel,
62+
onClose,
63+
onBack,
64+
className,
65+
header,
66+
footer,
67+
withoutScrollContainer,
68+
children,
69+
onKeyDown,
70+
closeButtonRef,
71+
},
72+
ref,
73+
) => {
5874
let backButton;
5975
const cardHistory = RightPanelStore.instance.roomPhaseHistory;
6076
if (cardHistory.length > 1) {
@@ -75,6 +91,7 @@ const BaseCard: React.FC<IProps> = forwardRef<HTMLDivElement, IProps>(
7591
className="mx_BaseCard_close"
7692
onClick={onClose}
7793
title={closeLabel || _t("action|close")}
94+
ref={closeButtonRef}
7895
/>
7996
);
8097
}

src/components/views/spaces/threads-activity-centre/ThreadsActivityCentre.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ function ThreadsActivityCentreRow({ room, onClick, notificationLevel }: ThreadsA
159159
show_room_tile: true, // make sure the room is visible in the list
160160
room_id: room.roomId,
161161
metricsTrigger: "WebThreadsActivityCentre",
162+
focusNext: "threadsPanel",
162163
});
163164
}}
164165
label={room.name}

src/components/views/spaces/threads-activity-centre/ThreadsActivityCentreButton.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import { notificationLevelToIndicator } from "../../../../utils/notifications";
2727

2828
interface ThreadsActivityCentreButtonProps extends ComponentProps<typeof IconButton> {
2929
/**
30-
* Display the `Treads` label next to the icon.
30+
* Display the `Threads` label next to the icon.
3131
*/
3232
displayLabel?: boolean;
3333
/**

src/dispatcher/actions.ts

+5
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,11 @@ export enum Action {
9191
*/
9292
FocusAComposer = "focus_a_composer",
9393

94+
/**
95+
* Focuses the threads panel.
96+
*/
97+
FocusThreadsPanel = "focus_threads_panel",
98+
9499
/**
95100
* Opens the user menu (previously known as the top left menu). No additional payload information required.
96101
*/

src/dispatcher/payloads/ViewRoomPayload.ts

+3
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ import { IOpts } from "../../createRoom";
2424
import { JoinRoomPayload } from "./JoinRoomPayload";
2525
import { AtLeastOne } from "../../@types/common";
2626

27+
export type FocusNextType = "composer" | "threadsPanel" | undefined;
28+
2729
/* eslint-disable camelcase */
2830
interface BaseViewRoomPayload extends Pick<ActionPayload, "action"> {
2931
action: Action.ViewRoom;
@@ -61,5 +63,6 @@ export type ViewRoomPayload = BaseViewRoomPayload &
6163
// the number of API calls required.
6264
room_id?: string;
6365
room_alias?: string;
66+
focusNext: FocusNextType; // wat to focus after room switch. Defaults to 'composer' if undefined.
6467
}>;
6568
/* eslint-enable camelcase */

test/components/structures/MatrixChat-test.tsx

+24-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ limitations under the License.
1515
*/
1616

1717
import React, { ComponentProps } from "react";
18-
import { fireEvent, render, RenderResult, screen, within } from "@testing-library/react";
18+
import { fireEvent, render, RenderResult, screen, waitFor, within } from "@testing-library/react";
1919
import fetchMock from "fetch-mock-jest";
2020
import { Mocked, mocked } from "jest-mock";
2121
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
5959
import SettingsStore from "../../../src/settings/SettingsStore";
6060
import { SettingLevel } from "../../../src/settings/SettingLevel";
6161
import { MatrixClientPeg as peg } from "../../../src/MatrixClientPeg";
62+
import DMRoomMap from "../../../src/utils/DMRoomMap";
6263

6364
jest.mock("matrix-js-sdk/src/oidc/authorize", () => ({
6465
completeAuthorizationCodeGrant: jest.fn(),
@@ -220,13 +221,19 @@ describe("<MatrixChat />", () => {
220221
jest.spyOn(StorageManager, "idbLoad").mockReset();
221222
jest.spyOn(StorageManager, "idbSave").mockResolvedValue(undefined);
222223
jest.spyOn(defaultDispatcher, "dispatch").mockClear();
224+
jest.spyOn(defaultDispatcher, "fire").mockClear();
225+
226+
DMRoomMap.makeShared(mockClient);
223227

224228
await clearAllModals();
225229
});
226230

227231
resetJsDomAfterEach();
228232

229233
afterEach(() => {
234+
// @ts-ignore
235+
DMRoomMap.setShared(null);
236+
230237
jest.restoreAllMocks();
231238

232239
// emit a loggedOut event so that all of the Store singletons forget about their references to the mock client
@@ -239,6 +246,22 @@ describe("<MatrixChat />", () => {
239246
expect(container).toMatchSnapshot();
240247
});
241248

249+
it("should fire to focus the message composer", async () => {
250+
getComponent();
251+
defaultDispatcher.dispatch({ action: Action.ViewRoom, room_id: "!room:server.org", focusNext: "composer" });
252+
await waitFor(() => {
253+
expect(defaultDispatcher.fire).toHaveBeenCalledWith(Action.FocusSendMessageComposer);
254+
});
255+
});
256+
257+
it("should fire to focus the threads panel", async () => {
258+
getComponent();
259+
defaultDispatcher.dispatch({ action: Action.ViewRoom, room_id: "!room:server.org", focusNext: "threadsPanel" });
260+
await waitFor(() => {
261+
expect(defaultDispatcher.fire).toHaveBeenCalledWith(Action.FocusThreadsPanel);
262+
});
263+
});
264+
242265
describe("when query params have a OIDC params", () => {
243266
const issuer = "https://auth.com/";
244267
const homeserverUrl = "https://matrix.org";

test/components/structures/ThreadPanel-test.tsx

+39
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ import ResizeNotifier from "../../../src/utils/ResizeNotifier";
3737
import { createTestClient, getRoomContext, mkRoom, mockPlatformPeg, stubClient } from "../../test-utils";
3838
import { mkThread } from "../../test-utils/threads";
3939
import { IRoomState } from "../../../src/components/structures/RoomView";
40+
import defaultDispatcher from "../../../src/dispatcher/dispatcher";
41+
import { Action } from "../../../src/dispatcher/actions";
4042

4143
jest.mock("../../../src/utils/Feedback");
4244

@@ -156,6 +158,43 @@ describe("ThreadPanel", () => {
156158
fireEvent.click(getByRole(container, "button", { name: "Mark all as read" }));
157159
await waitFor(() => expect(mockClient.sendReadReceipt).not.toHaveBeenCalled());
158160
});
161+
162+
it("focuses the close button on FocusThreadsPanel dispatch", () => {
163+
const ROOM_ID = "!roomId:example.org";
164+
165+
stubClient();
166+
mockPlatformPeg();
167+
const mockClient = mocked(MatrixClientPeg.safeGet());
168+
169+
const room = new Room(ROOM_ID, mockClient, mockClient.getUserId() ?? "", {
170+
pendingEventOrdering: PendingEventOrdering.Detached,
171+
});
172+
173+
render(
174+
<MatrixClientContext.Provider value={mockClient}>
175+
<RoomContext.Provider
176+
value={getRoomContext(room, {
177+
canSendMessages: true,
178+
})}
179+
>
180+
<ThreadPanel
181+
roomId={ROOM_ID}
182+
onClose={jest.fn()}
183+
resizeNotifier={new ResizeNotifier()}
184+
permalinkCreator={new RoomPermalinkCreator(room)}
185+
/>
186+
</RoomContext.Provider>
187+
</MatrixClientContext.Provider>,
188+
);
189+
190+
// Unfocus it first so we know it's not just focused by coincidence
191+
screen.getByTestId("base-card-close-button").blur();
192+
expect(screen.getByTestId("base-card-close-button")).not.toHaveFocus();
193+
194+
defaultDispatcher.dispatch({ action: Action.FocusThreadsPanel }, true);
195+
196+
expect(screen.getByTestId("base-card-close-button")).toHaveFocus();
197+
});
159198
});
160199

161200
describe("Filtering", () => {

0 commit comments

Comments
 (0)