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

Accessibility: Add Keyboard Navigation for Messages #12328

Closed
wants to merge 16 commits into from
Closed
20 changes: 20 additions & 0 deletions src/accessibility/KeyboardShortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,10 @@ export enum KeyBindingAction {
SelectPrevUnreadRoom = "KeyBinding.previousUnreadRoom",
/** Select next room with unread messages */
SelectNextUnreadRoom = "KeyBinding.nextUnreadRoom",
/** Select prev message */
SelectPrevMessage = "KeyBinding.previousMessage",
/** Select next message */
SelectNextMessage = "KeyBinding.nextMessage",

/** Switches to a space by number */
SwitchToSpaceByNumber = "KeyBinding.switchToSpaceByNumber",
Expand Down Expand Up @@ -287,6 +291,8 @@ export const CATEGORIES: Record<CategoryName, ICategory> = {
KeyBindingAction.SelectPrevUnreadRoom,
KeyBindingAction.SelectNextRoom,
KeyBindingAction.SelectPrevRoom,
KeyBindingAction.SelectNextMessage,
KeyBindingAction.SelectPrevMessage,
KeyBindingAction.OpenUserSettings,
KeyBindingAction.SwitchToSpaceByNumber,
KeyBindingAction.PreviousVisitedRoomOrSpace,
Expand Down Expand Up @@ -559,6 +565,20 @@ export const KEYBOARD_SHORTCUTS: IKeyboardShortcuts = {
},
displayName: _td("keyboard|prev_room"),
},
[KeyBindingAction.SelectNextMessage]: {
default: {
ctrlOrCmdKey: true,
key: Key.ARROW_DOWN,
},
displayName: _td("keyboard|next_message"),
},
[KeyBindingAction.SelectPrevMessage]: {
default: {
ctrlOrCmdKey: true,
key: Key.ARROW_UP,
},
displayName: _td("keyboard|prev_message"),
},
[KeyBindingAction.CancelAutocomplete]: {
default: {
key: Key.ESCAPE,
Expand Down
9 changes: 9 additions & 0 deletions src/components/structures/LoggedInView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,8 @@ class LoggedInView extends React.Component<IProps, IState> {

const roomAction = getKeyBindingsManager().getRoomAction(ev);
switch (roomAction) {
case KeyBindingAction.SelectPrevMessage:
case KeyBindingAction.SelectNextMessage:
case KeyBindingAction.ScrollUp:
case KeyBindingAction.ScrollDown:
case KeyBindingAction.JumpToFirstMessage:
Expand All @@ -472,6 +474,13 @@ class LoggedInView extends React.Component<IProps, IState> {

const navAction = getKeyBindingsManager().getNavigationAction(ev);
switch (navAction) {
case KeyBindingAction.SelectPrevMessage:
case KeyBindingAction.SelectNextMessage:
// pass the event down to the scroll panel
this.onScrollKeyPressed(ev);
handled = true;
break;

case KeyBindingAction.FilterRooms:
dis.dispatch({
action: "focus_room_filter",
Expand Down
72 changes: 70 additions & 2 deletions src/components/structures/MessagePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ import { MainGrouper } from "./grouper/MainGrouper";
import { CreationGrouper } from "./grouper/CreationGrouper";
import { _t } from "../../languageHandler";
import { getLateEventInfo } from "./grouper/LateEventGrouper";
import { getKeyBindingsManager } from "../../KeyBindingsManager";
import { ActionPayload } from "../../dispatcher/payloads";
import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts";

const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes
const continuedTypes = [EventType.Sticker, EventType.RoomMessage];
Expand Down Expand Up @@ -205,6 +208,7 @@ interface IReadReceiptForUser {
*/
export default class MessagePanel extends React.Component<IProps, IState> {
public static contextType = RoomContext;
public focusedEventId?: string;
public context!: React.ContextType<typeof RoomContext>;

public static defaultProps = {
Expand Down Expand Up @@ -258,6 +262,8 @@ export default class MessagePanel extends React.Component<IProps, IState> {
private readonly showTypingNotificationsWatcherRef: string;
private eventTiles: Record<string, UnwrappedEventTile> = {};

private dispatcherRef: string;

// A map to allow groupers to maintain consistent keys even if their first event is uprooted due to back-pagination.
public grouperKeyMap = new WeakMap<MatrixEvent, string>();

Expand All @@ -282,15 +288,30 @@ export default class MessagePanel extends React.Component<IProps, IState> {
null,
this.onShowTypingNotificationsChange,
);
this.dispatcherRef = defaultDispatcher.register(this.onAction);
}

private onAction = (payload: ActionPayload): void => {
if (payload.action === Action.FocusLastTile) {
for (let i = this.props.events.length - 1; i >= 0; --i) {
const event = this.props.events[i];
if (this.shouldShowEvent(event)) {
const id = event.getId();
this.getTileForEventId(id)?.focus();
return;
}
}
}
};

public componentDidMount(): void {
this.calculateRoomMembersCount();
this.props.room?.currentState.on(RoomStateEvent.Update, this.calculateRoomMembersCount);
this.isMounted = true;
}

public componentWillUnmount(): void {
defaultDispatcher.unregister(this.dispatcherRef);
this.isMounted = false;
this.props.room?.currentState.off(RoomStateEvent.Update, this.calculateRoomMembersCount);
SettingsStore.unwatchSetting(this.showTypingNotificationsWatcherRef);
Expand Down Expand Up @@ -417,12 +438,59 @@ export default class MessagePanel extends React.Component<IProps, IState> {
}

/**
* Gives focus to next/previous tile in timeline depending of navAction
* @param navAction: KeyBindingAction that determines whether the next or previous message should be focused
*/
private handleNavigationAction(
navAction: KeyBindingAction.SelectPrevMessage | KeyBindingAction.SelectNextMessage,
): void {
// 1. We only care about events that are rendered in the timeline
const events = this.props.events.filter((event) => this.shouldShowEvent(event));
const lastEvent = events[events.length - 1];

// 2. Which event has the focus currently?
const currentEventId = this.focusedEventId ?? this.props.highlightedEventId ?? lastEvent?.getId();
const currentEventIndex = events.findIndex((e) => e.getId() === currentEventId);
if (currentEventIndex === -1) {
throw new Error(`Event with id ${currentEventId} not in list of events.`);
}

// 3. Next event to get focus is either to the left or right of the currently focused event
const nextIndex =
navAction === KeyBindingAction.SelectPrevMessage ? currentEventIndex - 1 : currentEventIndex + 1;

if (nextIndex >= 0 && nextIndex < events.length) {
// 4. Focus the next tile if it is within the array bounds
const id = events[nextIndex].getId();
this.getTileForEventId(id)?.focus();
this.focusedEventId = id;
} else if (navAction === KeyBindingAction.SelectNextMessage) {
// 5. If not within array bounds but action is next message, focus the composer
defaultDispatcher.dispatch(
{
action: Action.FocusSendMessageComposer,
context: TimelineRenderingType.Room,
},
true,
);
}
return;
}

/**
* Handle keyboard events:
* Scroll up/down in response to a scroll key
*
* Ctrl+UP/DOWN to move to previous/next tile
* @param {KeyboardEvent} ev: the keyboard event to handle
*/
public handleScrollKey(ev: React.KeyboardEvent | KeyboardEvent): void {
MidhunSureshR marked this conversation as resolved.
Show resolved Hide resolved
this.scrollPanel.current?.handleScrollKey(ev);
const navAction = getKeyBindingsManager().getNavigationAction(ev);
if (navAction === KeyBindingAction.SelectPrevMessage || navAction === KeyBindingAction.SelectNextMessage) {
ev.preventDefault();
this.handleNavigationAction(navAction);
} else {
this.scrollPanel.current?.handleScrollKey(ev);
}
}

/* jump to the given event id.
Expand Down
4 changes: 4 additions & 0 deletions src/components/views/rooms/EventTile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -910,6 +910,10 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
);
}

public focus(): void {
this.ref.current?.focus();
}

public render(): ReactNode {
const msgtype = this.props.mxEvent.getContent().msgtype;
const eventType = this.props.mxEvent.getType();
Expand Down
9 changes: 9 additions & 0 deletions src/components/views/rooms/SendMessageComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,15 @@ export class SendMessageComposer extends React.Component<ISendMessageComposerPro
return;
}
const replyingToThread = this.props.relation?.key === THREAD_RELATION_TYPE.name;
const navAction = getKeyBindingsManager().getNavigationAction(event);
switch (navAction) {
case KeyBindingAction.SelectPrevMessage:
dis.dispatch({ action: Action.FocusLastTile });
event.preventDefault();
event.stopPropagation();
return;
}

const action = getKeyBindingsManager().getMessageComposerAction(event);
switch (action) {
case KeyBindingAction.SendMessage:
Expand Down
5 changes: 5 additions & 0 deletions src/dispatcher/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,11 @@ export enum Action {
*/
FocusThreadsPanel = "focus_threads_panel",

/**
* Focuses last event tile in the timeline.
*/
FocusLastTile = "focus_last_tile",

/**
* Opens the user menu (previously known as the top left menu). No additional payload information required.
*/
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/strings/en_EN.json
Original file line number Diff line number Diff line change
Expand Up @@ -1359,12 +1359,14 @@
"navigate_next_message_edit": "Navigate to next message to edit",
"navigate_prev_history": "Previous recently visited room or space",
"navigate_prev_message_edit": "Navigate to previous message to edit",
"next_message": "Next message",
"next_room": "Next room or DM",
"next_unread_room": "Next unread room or DM",
"number": "[number]",
"open_user_settings": "Open user settings",
"page_down": "Page Down",
"page_up": "Page Up",
"prev_message": "Previous message",
"prev_room": "Previous room or DM",
"prev_unread_room": "Previous unread room or DM",
"room_list_collapse_section": "Collapse room list section",
Expand Down
47 changes: 47 additions & 0 deletions test/components/structures/MessagePanel-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ import {
import ResizeNotifier from "../../../src/utils/ResizeNotifier";
import { IRoomState } from "../../../src/components/structures/RoomView";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
import defaultDispatcher from "../../../src/dispatcher/dispatcher";
import { Action } from "../../../src/dispatcher/actions";

jest.mock("../../../src/utils/beacon", () => ({
useBeacon: jest.fn(),
Expand Down Expand Up @@ -375,6 +377,51 @@ describe("MessagePanel", function () {
expect(isReadMarkerVisible(rm)).toBeTruthy();
});

it("FocusLastTile action works", function () {
const events = mkEvents();
const ref = React.createRef<MessagePanel>();
render(
getComponent({
events,
readMarkerEventId: events[4].getId(),
readMarkerVisible: true,
ref,
}),
);
const lastTile = document.querySelector('.mx_EventTile[data-event-id="' + events[9].getId() + '"]');
expect(lastTile).not.toHaveFocus();
defaultDispatcher.fire(Action.FocusLastTile, true);
expect(lastTile).toHaveFocus();
});

it("Should be able to navigate between events using keyboard", function () {
const events = mkEvents();
const ref = React.createRef<MessagePanel>();
render(
getComponent({
events,
readMarkerEventId: events[4].getId(),
readMarkerVisible: true,
ref,
}),
);
const UpKeyEvent = new KeyboardEvent("keydown", { key: "ArrowUp", ctrlKey: true });
const DownKeyEvent = new KeyboardEvent("keydown", { key: "ArrowDown", ctrlKey: true });
// Ctrl + UP
ref.current?.handleScrollKey(UpKeyEvent);
// Second last tile should be focused because the last tile gets focus from the composer.
expect(document.querySelector('.mx_EventTile[data-event-id="' + events[8].getId() + '"]')).toHaveFocus();

ref.current?.handleScrollKey(UpKeyEvent);
expect(document.querySelector('.mx_EventTile[data-event-id="' + events[7].getId() + '"]')).toHaveFocus();

ref.current?.handleScrollKey(DownKeyEvent);
expect(document.querySelector('.mx_EventTile[data-event-id="' + events[8].getId() + '"]')).toHaveFocus();

ref.current?.handleScrollKey(DownKeyEvent);
expect(document.querySelector('.mx_EventTile[data-event-id="' + events[9].getId() + '"]')).toHaveFocus();
});

it("should hide the read-marker at the end of summarised events", function () {
const melsEvents = mkMelsEventsOnly();

Expand Down
15 changes: 15 additions & 0 deletions test/components/views/rooms/SendMessageComposer-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { RoomPermalinkCreator } from "../../../../src/utils/permalinks/Permalink
import { mockPlatformPeg } from "../../../test-utils/platform";
import { doMaybeLocalRoomAction } from "../../../../src/utils/local-room";
import { addTextToComposer } from "../../../test-utils/composer";
import { Action } from "../../../../src/dispatcher/actions";

jest.mock("../../../../src/utils/local-room", () => ({
doMaybeLocalRoomAction: jest.fn(),
Expand Down Expand Up @@ -576,6 +577,20 @@ describe("<SendMessageComposer/>", () => {
});
});

it("should dispatch focus tile action on keyboard shortcut", async () => {
const cli = stubClient();
const room = mkStubRoom("!roomId:server", "Room", cli);
const spyDispatcher = jest.spyOn(defaultDispatcher, "dispatch");
const { container } = render(
<MatrixClientContext.Provider value={cli}>
<SendMessageComposer room={room} toggleStickerPickerOpen={jest.fn()} />
</MatrixClientContext.Provider>,
);
const composer = container.querySelector<HTMLDivElement>(".mx_BasicMessageComposer_input")!;
await userEvent.type(composer, "[ControlLeft>][ArrowUp][/ControlLeft]");
expect(spyDispatcher).toHaveBeenCalledWith({ action: Action.FocusLastTile });
});

it("should call prepareToEncrypt when the user is typing", async () => {
const cli = stubClient();
cli.isCryptoEnabled = jest.fn().mockReturnValue(true);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -987,6 +987,46 @@ exports[`KeyboardUserSettingsTab renders list of keyboard shortcuts 1`] = `
</kbd>
</div>
</li>
<li
class="mx_KeyboardShortcut_shortcutRow"
>
Next message
<div
class="mx_KeyboardShortcut"
>
<kbd>

Ctrl

</kbd>
+
<kbd>


</kbd>
</div>
</li>
<li
class="mx_KeyboardShortcut_shortcutRow"
>
Previous message
<div
class="mx_KeyboardShortcut"
>
<kbd>

Ctrl

</kbd>
+
<kbd>


</kbd>
</div>
</li>
</ul>
</div>
</div>
Expand Down
Loading