From 483b53c148ef2a1886b2b2a0830b06c89a05ee4a Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 20 Apr 2023 13:18:13 +0100 Subject: [PATCH 01/12] Translate credits in help about section (#10676) --- .../tabs/user/HelpUserSettingsTab.tsx | 134 ++++++++++++------ src/i18n/strings/en_EN.json | 3 + 2 files changed, 90 insertions(+), 47 deletions(-) diff --git a/src/components/views/settings/tabs/user/HelpUserSettingsTab.tsx b/src/components/views/settings/tabs/user/HelpUserSettingsTab.tsx index b5c1e8086934..f939cba64cc8 100644 --- a/src/components/views/settings/tabs/user/HelpUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/HelpUserSettingsTab.tsx @@ -137,57 +137,97 @@ export default class HelpUserSettingsTab extends React.Component {_t("Credits")} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index c64a49abd149..a391253494a0 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1568,6 +1568,9 @@ "Olm version:": "Olm version:", "Legal": "Legal", "Credits": "Credits", + "The default cover photo is © Jesús Roncero used under the terms of CC-BY-SA 4.0.": "The default cover photo is © Jesús Roncero used under the terms of CC-BY-SA 4.0.", + "The twemoji-colr font is © Mozilla Foundation used under the terms of Apache 2.0.": "The twemoji-colr font is © Mozilla Foundation used under the terms of Apache 2.0.", + "The Twemoji emoji art is © Twitter, Inc and other contributors used under the terms of CC-BY 4.0.": "The Twemoji emoji art is © Twitter, Inc and other contributors used under the terms of CC-BY 4.0.", "For help with using %(brand)s, click here.": "For help with using %(brand)s, click here.", "For help with using %(brand)s, click here or start a chat with our bot using the button below.": "For help with using %(brand)s, click here or start a chat with our bot using the button below.", "Chat with %(brand)s Bot": "Chat with %(brand)s Bot", From 467c52a2ae0e47534f0c477d1568618fd4fe54b4 Mon Sep 17 00:00:00 2001 From: Suguru Hirahara Date: Thu, 20 Apr 2023 13:18:19 +0000 Subject: [PATCH 02/12] Add E2E test - `general-user-settings-tab.spec.ts` (#10658) * Add E2E test: `general-user-settings-tab.spec.ts` Initial implementation Signed-off-by: Suguru Hirahara * lint Signed-off-by: Suguru Hirahara * Check an input area for a new email address too Signed-off-by: Suguru Hirahara --------- Signed-off-by: Suguru Hirahara --- .../general-user-settings-tab.spec.ts | 224 ++++++++++++++++++ .../tabs/user/GeneralUserSettingsTab.tsx | 8 +- 2 files changed, 229 insertions(+), 3 deletions(-) create mode 100644 cypress/e2e/settings/general-user-settings-tab.spec.ts diff --git a/cypress/e2e/settings/general-user-settings-tab.spec.ts b/cypress/e2e/settings/general-user-settings-tab.spec.ts new file mode 100644 index 000000000000..837ae5aaaa54 --- /dev/null +++ b/cypress/e2e/settings/general-user-settings-tab.spec.ts @@ -0,0 +1,224 @@ +/* +Copyright 2023 Suguru Hirahara + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/// + +import { HomeserverInstance } from "../../plugins/utils/homeserver"; + +const USER_NAME = "Bob"; +const USER_NAME_NEW = "Alice"; +const IntegrationManager = "scalar.vector.im"; + +describe("General user settings tab", () => { + let homeserver: HomeserverInstance; + let userId: string; + + beforeEach(() => { + cy.startHomeserver("default").then((data) => { + homeserver = data; + cy.initTestUser(homeserver, USER_NAME).then((user) => (userId = user.userId)); + cy.tweakConfig({ default_country_code: "US" }); // For checking the international country calling code + }); + cy.openUserSettings("General"); + }); + + afterEach(() => { + cy.stopHomeserver(homeserver); + }); + + it("should be rendered properly", () => { + // Exclude userId from snapshots + const percyCSS = ".mx_ProfileSettings_profile_controls_userId { visibility: hidden !important; }"; + + cy.get(".mx_SettingsTab.mx_GeneralUserSettingsTab").percySnapshotElement("User settings tab - General", { + percyCSS, + }); + + cy.get(".mx_SettingsTab.mx_GeneralUserSettingsTab").within(() => { + // Assert that the top heading is rendered + cy.findByTestId("general").should("have.text", "General").should("be.visible"); + + cy.get(".mx_ProfileSettings_profile") + .scrollIntoView() + .within(() => { + // Assert USER_NAME is rendered + cy.findByRole("textbox", { name: "Display Name" }) + .get(`input[value='${USER_NAME}']`) + .should("be.visible"); + + // Assert that a userId is rendered + cy.get(".mx_ProfileSettings_profile_controls_userId").within(() => { + cy.findByText(userId).should("exist"); + }); + + // Check avatar setting + cy.get(".mx_AvatarSetting_avatar") + .should("exist") + .realHover() + .get(".mx_AvatarSetting_avatar_hovering") + .within(() => { + // Hover effect + cy.get(".mx_AvatarSetting_hoverBg").should("exist"); + cy.get(".mx_AvatarSetting_hover span").within(() => { + cy.findByText("Upload").should("exist"); + }); + }); + }); + + // Wait until spinners disappear + cy.get(".mx_GeneralUserSettingsTab_accountSection .mx_Spinner").should("not.exist"); + cy.get(".mx_GeneralUserSettingsTab_discovery .mx_Spinner").should("not.exist"); + + cy.get(".mx_GeneralUserSettingsTab_accountSection").within(() => { + // Assert that input areas for changing a password exists + cy.get("form.mx_GeneralUserSettingsTab_changePassword") + .scrollIntoView() + .within(() => { + cy.findByLabelText("Current password").should("be.visible"); + cy.findByLabelText("New Password").should("be.visible"); + cy.findByLabelText("Confirm password").should("be.visible"); + }); + + // Check email addresses area + cy.get(".mx_EmailAddresses") + .scrollIntoView() + .within(() => { + // Assert that an input area for a new email address is rendered + cy.findByRole("textbox", { name: "Email Address" }).should("be.visible"); + + // Assert the add button is visible + cy.findByRole("button", { name: "Add" }).should("be.visible"); + }); + + // Check phone numbers area + cy.get(".mx_PhoneNumbers") + .scrollIntoView() + .within(() => { + // Assert that an input area for a new phone number is rendered + cy.findByRole("textbox", { name: "Phone Number" }).should("be.visible"); + + // Assert that the add button is rendered + cy.findByRole("button", { name: "Add" }).should("be.visible"); + }); + }); + + // Check language and region setting dropdown + cy.get(".mx_GeneralUserSettingsTab_languageInput") + .scrollIntoView() + .within(() => { + // Check the default value + cy.findByText("English").should("be.visible"); + + // Click the button to display the dropdown menu + cy.findByRole("button", { name: "Language Dropdown" }).click(); + + // Assert that the default option is rendered and highlighted + cy.findByRole("option", { name: /Bahasa Indonesia/ }) + .should("be.visible") + .should("have.class", "mx_Dropdown_option_highlight"); + + // Click again to close the dropdown + cy.findByRole("button", { name: "Language Dropdown" }).click(); + + // Assert that the default value is rendered again + cy.findByText("English").should("be.visible"); + }); + + cy.get("form.mx_SetIdServer") + .scrollIntoView() + .within(() => { + // Assert that an input area for identity server exists + cy.findByRole("textbox", { name: "Enter a new identity server" }).should("be.visible"); + }); + + cy.get(".mx_SetIntegrationManager") + .scrollIntoView() + .within(() => { + cy.contains(".mx_SetIntegrationManager_heading_manager", IntegrationManager).should("be.visible"); + + // Make sure integration manager's toggle switch is enabled + cy.get(".mx_ToggleSwitch_enabled").should("be.visible"); + }); + + // Assert the account deactivation button is displayed + cy.findByTestId("account-management-section") + .scrollIntoView() + .findByRole("button", { name: "Deactivate Account" }) + .should("be.visible") + .should("have.class", "mx_AccessibleButton_kind_danger"); + }); + }); + + it("should support adding and removing a profile picture", () => { + cy.get(".mx_SettingsTab.mx_GeneralUserSettingsTab .mx_ProfileSettings").within(() => { + // Upload a picture + cy.get(".mx_ProfileSettings_avatarUpload").selectFile("cypress/fixtures/riot.png", { force: true }); + + // Find and click "Remove" link button + cy.get(".mx_ProfileSettings_profile").within(() => { + cy.findByRole("button", { name: "Remove" }).click(); + }); + + // Assert that the link button disappeared + cy.get(".mx_AvatarSetting_avatar .mx_AccessibleButton_kind_link_sm").should("not.exist"); + }); + }); + + it("should set a country calling code based on default_country_code", () => { + // Check phone numbers area + cy.get(".mx_PhoneNumbers") + .scrollIntoView() + .within(() => { + // Assert that an input area for a new phone number is rendered + cy.findByRole("textbox", { name: "Phone Number" }).should("be.visible"); + + // Check a new phone number dropdown menu + cy.get(".mx_PhoneNumbers_country") + .scrollIntoView() + .within(() => { + // Assert that the country calling code of United States is visible + cy.findByText(/\+1/).should("be.visible"); + + // Click the button to display the dropdown menu + cy.findByRole("button", { name: "Country Dropdown" }).click(); + + // Assert that the option for calling code of United Kingdom is visible + cy.findByRole("option", { name: /United Kingdom/ }).should("be.visible"); + + // Click again to close the dropdown + cy.findByRole("button", { name: "Country Dropdown" }).click(); + + // Assert that the default value is rendered again + cy.findByText(/\+1/).should("be.visible"); + }); + + cy.findByRole("button", { name: "Add" }).should("be.visible"); + }); + }); + + it("should support changing a display name", () => { + cy.get(".mx_SettingsTab.mx_GeneralUserSettingsTab .mx_ProfileSettings").within(() => { + // Change the diaplay name to USER_NAME_NEW + cy.findByRole("textbox", { name: "Display Name" }).type(`{selectAll}{del}${USER_NAME_NEW}{enter}`); + }); + + cy.closeDialog(); + + // Assert the avatar's initial characters are set + cy.get(".mx_UserMenu .mx_BaseAvatar_initial").findByText("A").should("exist"); // Alice + cy.get(".mx_RoomView_wrapper .mx_BaseAvatar_initial").findByText("A").should("exist"); // Alice + }); +}); diff --git a/src/components/views/settings/tabs/user/GeneralUserSettingsTab.tsx b/src/components/views/settings/tabs/user/GeneralUserSettingsTab.tsx index dc3bf9f408b9..233e999afb8d 100644 --- a/src/components/views/settings/tabs/user/GeneralUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/GeneralUserSettingsTab.tsx @@ -468,7 +468,7 @@ export default class GeneralUserSettingsTab extends React.Component +
{_t("Account management")} {_t("Deactivating your account is a permanent action — be careful!")} @@ -528,8 +528,10 @@ export default class GeneralUserSettingsTab extends React.Component -
{_t("General")}
+
+
+ {_t("General")} +
{this.renderProfileSection()} {this.renderAccountSection()} {this.renderLanguageSection()} From 0d9fa0515df077d5068cb3dc8d0a4cbbcc4a9191 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 20 Apr 2023 15:44:54 +0100 Subject: [PATCH 03/12] Fix typing tile duplicating users (#10678) --- src/components/views/rooms/WhoIsTypingTile.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/views/rooms/WhoIsTypingTile.tsx b/src/components/views/rooms/WhoIsTypingTile.tsx index 2ff8d1d9b46b..5552fc5c691c 100644 --- a/src/components/views/rooms/WhoIsTypingTile.tsx +++ b/src/components/views/rooms/WhoIsTypingTile.tsx @@ -200,7 +200,7 @@ export default class WhoIsTypingTile extends React.Component { } public render(): React.ReactNode { - const usersTyping = this.state.usersTyping; + const usersTyping = [...this.state.usersTyping]; // append the users that have been reported not typing anymore // but have a timeout timer running so they can disappear // when a message comes in From 2da52372d49cda1d1e932e584d39291d483c7d18 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 20 Apr 2023 15:56:21 +0100 Subject: [PATCH 04/12] Add arrow key controls to emoji and reaction pickers (#10637) * Add arrow key controls to emoji and reaction pickers * Iterate types * Switch to using aria-activedescendant * Add tests * Fix tests * Iterate * Update test * Tweak header keyboard navigation behaviour * Also handle scrolling on left/right arrow keys * Iterate --- cypress/e2e/threads/threads.spec.ts | 2 +- res/css/views/emojipicker/_EmojiPicker.pcss | 8 + src/accessibility/RovingTabIndex.tsx | 8 +- .../roving/RovingAccessibleButton.tsx | 13 +- src/components/structures/ContextMenu.tsx | 2 +- .../views/elements/LazyRenderList.tsx | 2 + src/components/views/emojipicker/Category.tsx | 22 ++- src/components/views/emojipicker/Emoji.tsx | 20 +- .../views/emojipicker/EmojiPicker.tsx | 177 ++++++++++++++---- src/components/views/emojipicker/Header.tsx | 10 +- .../views/emojipicker/QuickReactions.tsx | 8 +- .../views/emojipicker/ReactionPicker.tsx | 1 + src/components/views/emojipicker/Search.tsx | 14 +- src/components/views/rooms/EmojiButton.tsx | 15 +- .../views/emojipicker/EmojiPicker-test.tsx | 49 ++++- 15 files changed, 277 insertions(+), 74 deletions(-) diff --git a/cypress/e2e/threads/threads.spec.ts b/cypress/e2e/threads/threads.spec.ts index 1d36cbdd8501..f93d9990c842 100644 --- a/cypress/e2e/threads/threads.spec.ts +++ b/cypress/e2e/threads/threads.spec.ts @@ -174,7 +174,7 @@ describe("Threads", () => { .click({ force: true }); // Cypress has no ability to hover cy.get(".mx_EmojiPicker").within(() => { cy.get('input[type="text"]').type("wave"); - cy.contains('[role="menuitem"]', "👋").click(); + cy.contains('[role="gridcell"]', "👋").click(); }); cy.get(".mx_ThreadView").within(() => { diff --git a/res/css/views/emojipicker/_EmojiPicker.pcss b/res/css/views/emojipicker/_EmojiPicker.pcss index c9169dbe7d81..8e78061a11b8 100644 --- a/res/css/views/emojipicker/_EmojiPicker.pcss +++ b/res/css/views/emojipicker/_EmojiPicker.pcss @@ -179,6 +179,14 @@ limitations under the License. list-style: none; width: 38px; cursor: pointer; + + &:focus-within { + background-color: $focus-bg-color; + } +} + +.mx_EmojiPicker_body .mx_EmojiPicker_item_wrapper[tabindex="0"] .mx_EmojiPicker_item { + background-color: $focus-bg-color; } .mx_EmojiPicker_item { diff --git a/src/accessibility/RovingTabIndex.tsx b/src/accessibility/RovingTabIndex.tsx index b449b10710f3..7b8cb7ede58d 100644 --- a/src/accessibility/RovingTabIndex.tsx +++ b/src/accessibility/RovingTabIndex.tsx @@ -61,7 +61,7 @@ export interface IState { refs: Ref[]; } -interface IContext { +export interface IContext { state: IState; dispatch: Dispatch; } @@ -80,7 +80,7 @@ export enum Type { SetFocus = "SET_FOCUS", } -interface IAction { +export interface IAction { type: Type; payload: { ref: Ref; @@ -160,7 +160,7 @@ interface IProps { handleUpDown?: boolean; handleLeftRight?: boolean; children(renderProps: { onKeyDownHandler(ev: React.KeyboardEvent): void }): ReactNode; - onKeyDown?(ev: React.KeyboardEvent, state: IState): void; + onKeyDown?(ev: React.KeyboardEvent, state: IState, dispatch: Dispatch): void; } export const findSiblingElement = ( @@ -199,7 +199,7 @@ export const RovingTabIndexProvider: React.FC = ({ const onKeyDownHandler = useCallback( (ev: React.KeyboardEvent) => { if (onKeyDown) { - onKeyDown(ev, context.state); + onKeyDown(ev, context.state, context.dispatch); if (ev.defaultPrevented) { return; } diff --git a/src/accessibility/roving/RovingAccessibleButton.tsx b/src/accessibility/roving/RovingAccessibleButton.tsx index 71818c6cda15..28748de73fb4 100644 --- a/src/accessibility/roving/RovingAccessibleButton.tsx +++ b/src/accessibility/roving/RovingAccessibleButton.tsx @@ -22,10 +22,17 @@ import { Ref } from "./types"; interface IProps extends Omit, "inputRef" | "tabIndex"> { inputRef?: Ref; + focusOnMouseOver?: boolean; } // Wrapper to allow use of useRovingTabIndex for simple AccessibleButtons outside of React Functional Components. -export const RovingAccessibleButton: React.FC = ({ inputRef, onFocus, ...props }) => { +export const RovingAccessibleButton: React.FC = ({ + inputRef, + onFocus, + onMouseOver, + focusOnMouseOver, + ...props +}) => { const [onFocusInternal, isActive, ref] = useRovingTabIndex(inputRef); return ( = ({ inputRef, onFocus, .. onFocusInternal(); onFocus?.(event); }} + onMouseOver={(event: React.MouseEvent) => { + if (focusOnMouseOver) onFocusInternal(); + onMouseOver?.(event); + }} inputRef={ref} tabIndex={isActive ? 0 : -1} /> diff --git a/src/components/structures/ContextMenu.tsx b/src/components/structures/ContextMenu.tsx index 270a0b0a072e..8691c6c25d0e 100644 --- a/src/components/structures/ContextMenu.tsx +++ b/src/components/structures/ContextMenu.tsx @@ -148,7 +148,7 @@ export default class ContextMenu extends React.PureComponent('[role^="menuitem"]') || - element.querySelector("[tab-index]"); + element.querySelector("[tabindex]"); if (first) { first.focus(); diff --git a/src/components/views/elements/LazyRenderList.tsx b/src/components/views/elements/LazyRenderList.tsx index 0a041730339c..802e60ca1968 100644 --- a/src/components/views/elements/LazyRenderList.tsx +++ b/src/components/views/elements/LazyRenderList.tsx @@ -73,6 +73,7 @@ interface IProps { element?: string; className?: string; + role?: string; } interface IState { @@ -128,6 +129,7 @@ export default class LazyRenderList extends React.Component, const elementProps = { style: { paddingTop: `${paddingTop}px`, paddingBottom: `${paddingBottom}px` }, className: this.props.className, + role: this.props.role, }; return React.createElement(element, elementProps, renderedItems.map(renderItem)); } diff --git a/src/components/views/emojipicker/Category.tsx b/src/components/views/emojipicker/Category.tsx index f4ffce911b5e..cf662feea39b 100644 --- a/src/components/views/emojipicker/Category.tsx +++ b/src/components/views/emojipicker/Category.tsx @@ -21,6 +21,7 @@ import { CATEGORY_HEADER_HEIGHT, EMOJI_HEIGHT, EMOJIS_PER_ROW } from "./EmojiPic import LazyRenderList from "../elements/LazyRenderList"; import { DATA_BY_CATEGORY, IEmoji } from "../../../emoji"; import Emoji from "./Emoji"; +import { ButtonEvent } from "../elements/AccessibleButton"; const OVERFLOW_ROWS = 3; @@ -42,18 +43,31 @@ interface IProps { heightBefore: number; viewportHeight: number; scrollTop: number; - onClick(emoji: IEmoji): void; + onClick(ev: ButtonEvent, emoji: IEmoji): void; onMouseEnter(emoji: IEmoji): void; onMouseLeave(emoji: IEmoji): void; isEmojiDisabled?: (unicode: string) => boolean; } +function hexEncode(str: string): string { + let hex: string; + let i: number; + + let result = ""; + for (i = 0; i < str.length; i++) { + hex = str.charCodeAt(i).toString(16); + result += ("000" + hex).slice(-4); + } + + return result; +} + class Category extends React.PureComponent { private renderEmojiRow = (rowIndex: number): JSX.Element => { const { onClick, onMouseEnter, onMouseLeave, selectedEmojis, emojis } = this.props; const emojisForRow = emojis.slice(rowIndex * 8, (rowIndex + 1) * 8); return ( -
+
{emojisForRow.map((emoji) => ( { onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} disabled={this.props.isEmojiDisabled?.(emoji.unicode)} + id={`mx_EmojiPicker_item_${this.props.id}_${hexEncode(emoji.unicode)}`} + role="gridcell" /> ))}
@@ -101,7 +117,6 @@ class Category extends React.PureComponent { >

{name}

{ overflowItems={OVERFLOW_ROWS} overflowMargin={0} renderItem={this.renderEmojiRow} + role="grid" /> ); diff --git a/src/components/views/emojipicker/Emoji.tsx b/src/components/views/emojipicker/Emoji.tsx index 022c29a94a6e..627988730344 100644 --- a/src/components/views/emojipicker/Emoji.tsx +++ b/src/components/views/emojipicker/Emoji.tsx @@ -17,36 +17,40 @@ limitations under the License. import React from "react"; -import { MenuItem } from "../../structures/ContextMenu"; import { IEmoji } from "../../../emoji"; +import { ButtonEvent } from "../elements/AccessibleButton"; +import { RovingAccessibleButton } from "../../../accessibility/RovingTabIndex"; interface IProps { emoji: IEmoji; selectedEmojis?: Set; - onClick(emoji: IEmoji): void; + onClick(ev: ButtonEvent, emoji: IEmoji): void; onMouseEnter(emoji: IEmoji): void; onMouseLeave(emoji: IEmoji): void; disabled?: boolean; + id?: string; + role?: string; } class Emoji extends React.PureComponent { public render(): React.ReactNode { const { onClick, onMouseEnter, onMouseLeave, emoji, selectedEmojis } = this.props; - const isSelected = selectedEmojis && selectedEmojis.has(emoji.unicode); + const isSelected = selectedEmojis?.has(emoji.unicode); return ( - onClick(emoji)} + onClick(ev, emoji)} onMouseEnter={() => onMouseEnter(emoji)} onMouseLeave={() => onMouseLeave(emoji)} className="mx_EmojiPicker_item_wrapper" - label={emoji.unicode} disabled={this.props.disabled} + role={this.props.role} + focusOnMouseOver >
{emoji.unicode}
-
+ ); } } diff --git a/src/components/views/emojipicker/EmojiPicker.tsx b/src/components/views/emojipicker/EmojiPicker.tsx index b4a868f474d5..7a62c4dd079c 100644 --- a/src/components/views/emojipicker/EmojiPicker.tsx +++ b/src/components/views/emojipicker/EmojiPicker.tsx @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from "react"; +import React, { Dispatch } from "react"; import { _t } from "../../../languageHandler"; import * as recent from "../../../emojipicker/recent"; @@ -25,8 +25,18 @@ import Header from "./Header"; import Search from "./Search"; import Preview from "./Preview"; import QuickReactions from "./QuickReactions"; -import Category, { ICategory, CategoryKey } from "./Category"; +import Category, { CategoryKey, ICategory } from "./Category"; import { filterBoolean } from "../../../utils/arrays"; +import { + IAction as RovingAction, + IState as RovingState, + RovingTabIndexProvider, + Type, +} from "../../../accessibility/RovingTabIndex"; +import { Key } from "../../../Keyboard"; +import { clamp } from "../../../utils/numbers"; +import { ButtonEvent } from "../elements/AccessibleButton"; +import { Ref } from "../../../accessibility/roving/types"; export const CATEGORY_HEADER_HEIGHT = 20; export const EMOJI_HEIGHT = 35; @@ -37,6 +47,7 @@ const ZERO_WIDTH_JOINER = "\u200D"; interface IProps { selectedEmojis?: Set; onChoose(unicode: string): boolean; + onFinished(): void; isEmojiDisabled?: (unicode: string) => boolean; } @@ -150,6 +161,68 @@ class EmojiPicker extends React.Component { this.updateVisibility(); }; + private keyboardNavigation(ev: React.KeyboardEvent, state: RovingState, dispatch: Dispatch): void { + const node = state.activeRef.current; + const parent = node.parentElement; + if (!parent) return; + const rowIndex = Array.from(parent.children).indexOf(node); + const refIndex = state.refs.indexOf(state.activeRef); + + let focusRef: Ref | undefined; + let newParent: HTMLElement | undefined; + switch (ev.key) { + case Key.ARROW_LEFT: + focusRef = state.refs[refIndex - 1]; + newParent = focusRef?.current?.parentElement; + break; + + case Key.ARROW_RIGHT: + focusRef = state.refs[refIndex + 1]; + newParent = focusRef?.current?.parentElement; + break; + + case Key.ARROW_UP: + case Key.ARROW_DOWN: { + // For up/down we find the prev/next parent by inspecting the refs either side of our row + const ref = + ev.key === Key.ARROW_UP + ? state.refs[refIndex - rowIndex - 1] + : state.refs[refIndex - rowIndex + EMOJIS_PER_ROW]; + newParent = ref?.current?.parentElement; + const newTarget = newParent?.children[clamp(rowIndex, 0, newParent.children.length - 1)]; + focusRef = state.refs.find((r) => r.current === newTarget); + break; + } + } + + if (focusRef) { + dispatch({ + type: Type.SetFocus, + payload: { ref: focusRef }, + }); + + if (parent !== newParent) { + focusRef.current?.scrollIntoView({ + behavior: "auto", + block: "center", + inline: "center", + }); + } + } + + ev.preventDefault(); + ev.stopPropagation(); + } + + private onKeyDown = (ev: React.KeyboardEvent, state: RovingState, dispatch: Dispatch): void => { + if ( + state.activeRef?.current && + [Key.ARROW_DOWN, Key.ARROW_RIGHT, Key.ARROW_LEFT, Key.ARROW_UP].includes(ev.key) + ) { + this.keyboardNavigation(ev, state, dispatch); + } + }; + private updateVisibility = (): void => { const body = this.scrollRef.current?.containerRef.current; if (!body) return; @@ -239,11 +312,11 @@ class EmojiPicker extends React.Component { }; private onEnterFilter = (): void => { - const btn = - this.scrollRef.current?.containerRef.current?.querySelector(".mx_EmojiPicker_item"); - if (btn) { - btn.click(); - } + const btn = this.scrollRef.current?.containerRef.current?.querySelector( + '.mx_EmojiPicker_item_wrapper[tabindex="0"]', + ); + btn?.click(); + this.props.onFinished(); }; private onHoverEmoji = (emoji: IEmoji): void => { @@ -258,10 +331,13 @@ class EmojiPicker extends React.Component { }); }; - private onClickEmoji = (emoji: IEmoji): void => { + private onClickEmoji = (ev: ButtonEvent, emoji: IEmoji): void => { if (this.props.onChoose(emoji.unicode) !== false) { recent.add(emoji.unicode); } + if ((ev as React.KeyboardEvent).key === Key.ENTER) { + this.props.onFinished(); + } }; private static categoryHeightForEmojiCount(count: number): number { @@ -272,41 +348,60 @@ class EmojiPicker extends React.Component { } public render(): React.ReactNode { - let heightBefore = 0; return ( -
-
- - - {this.categories.map((category) => { - const emojis = this.memoizedDataByCategory[category.id]; - const categoryElement = ( - + {({ onKeyDownHandler }) => { + let heightBefore = 0; + return ( +
+
+ - ); - const height = EmojiPicker.categoryHeightForEmojiCount(emojis.length); - heightBefore += height; - return categoryElement; - })} - - {this.state.previewEmoji ? ( - - ) : ( - - )} -
+ + {this.categories.map((category) => { + const emojis = this.memoizedDataByCategory[category.id]; + const categoryElement = ( + + ); + const height = EmojiPicker.categoryHeightForEmojiCount(emojis.length); + heightBefore += height; + return categoryElement; + })} + + {this.state.previewEmoji ? ( + + ) : ( + + )} +
+ ); + }} + ); } } diff --git a/src/components/views/emojipicker/Header.tsx b/src/components/views/emojipicker/Header.tsx index 9a7005d63249..c3643f6e2a96 100644 --- a/src/components/views/emojipicker/Header.tsx +++ b/src/components/views/emojipicker/Header.tsx @@ -17,6 +17,7 @@ limitations under the License. import React from "react"; import classNames from "classnames"; +import { findLastIndex } from "lodash"; import { _t } from "../../../languageHandler"; import { CategoryKey, ICategory } from "./Category"; @@ -40,7 +41,14 @@ class Header extends React.PureComponent { } private changeCategoryRelative(delta: number): void { - const current = this.props.categories.findIndex((c) => c.visible); + let current: number; + // As multiple categories may be visible at once, we want to find the one closest to the relative direction + if (delta < 0) { + current = this.props.categories.findIndex((c) => c.visible); + } else { + // XXX: Switch to Array::findLastIndex once we enable ES2023 + current = findLastIndex(this.props.categories, (c) => c.visible); + } this.changeCategoryAbsolute(current + delta, delta); } diff --git a/src/components/views/emojipicker/QuickReactions.tsx b/src/components/views/emojipicker/QuickReactions.tsx index 6b149069481c..a58c6b875fd3 100644 --- a/src/components/views/emojipicker/QuickReactions.tsx +++ b/src/components/views/emojipicker/QuickReactions.tsx @@ -20,6 +20,8 @@ import React from "react"; import { _t } from "../../../languageHandler"; import { getEmojiFromUnicode, IEmoji } from "../../../emoji"; import Emoji from "./Emoji"; +import { ButtonEvent } from "../elements/AccessibleButton"; +import Toolbar from "../../../accessibility/Toolbar"; // We use the variation-selector Heart in Quick Reactions for some reason const QUICK_REACTIONS = ["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀"].map((emoji) => { @@ -32,7 +34,7 @@ const QUICK_REACTIONS = ["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀 interface IProps { selectedEmojis?: Set; - onClick(emoji: IEmoji): void; + onClick(ev: ButtonEvent, emoji: IEmoji): void; } interface IState { @@ -70,7 +72,7 @@ class QuickReactions extends React.Component { )} -
    + {QUICK_REACTIONS.map((emoji) => ( { selectedEmojis={this.props.selectedEmojis} /> ))} -
+ ); } diff --git a/src/components/views/emojipicker/ReactionPicker.tsx b/src/components/views/emojipicker/ReactionPicker.tsx index 6b13c7682315..97222740f888 100644 --- a/src/components/views/emojipicker/ReactionPicker.tsx +++ b/src/components/views/emojipicker/ReactionPicker.tsx @@ -135,6 +135,7 @@ class ReactionPicker extends React.Component { ); diff --git a/src/components/views/emojipicker/Search.tsx b/src/components/views/emojipicker/Search.tsx index edd6b2c4fca8..a34a14cbafd6 100644 --- a/src/components/views/emojipicker/Search.tsx +++ b/src/components/views/emojipicker/Search.tsx @@ -20,14 +20,19 @@ import React from "react"; import { _t } from "../../../languageHandler"; import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; import { getKeyBindingsManager } from "../../../KeyBindingsManager"; +import { RovingTabIndexContext } from "../../../accessibility/RovingTabIndex"; interface IProps { query: string; onChange(value: string): void; onEnter(): void; + onKeyDown(event: React.KeyboardEvent): void; } class Search extends React.PureComponent { + public static contextType = RovingTabIndexContext; + public context!: React.ContextType; + private inputRef = React.createRef(); public componentDidMount(): void { @@ -43,11 +48,14 @@ class Search extends React.PureComponent { ev.stopPropagation(); ev.preventDefault(); break; + + default: + this.props.onKeyDown(ev); } }; public render(): React.ReactNode { - let rightButton; + let rightButton: JSX.Element; if (this.props.query) { rightButton = (
diff --git a/src/components/views/rooms/EmojiButton.tsx b/src/components/views/rooms/EmojiButton.tsx index db7accb62c5d..b35aa2aef556 100644 --- a/src/components/views/rooms/EmojiButton.tsx +++ b/src/components/views/rooms/EmojiButton.tsx @@ -36,17 +36,14 @@ export function EmojiButton({ addEmoji, menuPosition, className }: IEmojiButtonP let contextMenu: React.ReactElement | null = null; if (menuDisplayed && button.current) { const position = menuPosition ?? aboveLeftOf(button.current.getBoundingClientRect()); + const onFinished = (): void => { + closeMenu(); + overflowMenuCloser?.(); + }; contextMenu = ( - { - closeMenu(); - overflowMenuCloser?.(); - }} - managed={false} - > - + + ); } diff --git a/test/components/views/emojipicker/EmojiPicker-test.tsx b/test/components/views/emojipicker/EmojiPicker-test.tsx index efd09825a9ce..4f8c091bb7ca 100644 --- a/test/components/views/emojipicker/EmojiPicker-test.tsx +++ b/test/components/views/emojipicker/EmojiPicker-test.tsx @@ -14,6 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ +import React from "react"; +import { render } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + import EmojiPicker from "../../../../src/components/views/emojipicker/EmojiPicker"; import { stubClient } from "../../../test-utils"; @@ -21,7 +25,7 @@ describe("EmojiPicker", function () { stubClient(); it("sort emojis by shortcode and size", function () { - const ep = new EmojiPicker({ onChoose: (str: String) => false }); + const ep = new EmojiPicker({ onChoose: (str: string) => false, onFinished: jest.fn() }); //@ts-ignore private access ep.onChangeFilter("heart"); @@ -31,4 +35,47 @@ describe("EmojiPicker", function () { //@ts-ignore private access expect(ep.memoizedDataByCategory["people"][1].shortcodes[0]).toEqual("heartbeat"); }); + + it("should allow keyboard navigation using arrow keys", async () => { + // mock offsetParent + Object.defineProperty(HTMLElement.prototype, "offsetParent", { + get() { + return this.parentNode; + }, + }); + + const onChoose = jest.fn(); + const onFinished = jest.fn(); + const { container } = render(); + + const input = container.querySelector("input")!; + expect(input).toHaveFocus(); + + function getEmoji(): string { + const activeDescendant = input.getAttribute("aria-activedescendant"); + return container.querySelector("#" + activeDescendant)!.textContent!; + } + + expect(getEmoji()).toEqual("😀"); + await userEvent.keyboard("[ArrowDown]"); + expect(getEmoji()).toEqual("🙂"); + await userEvent.keyboard("[ArrowUp]"); + expect(getEmoji()).toEqual("😀"); + await userEvent.keyboard("Flag"); + await userEvent.keyboard("[ArrowRight]"); + await userEvent.keyboard("[ArrowRight]"); + expect(getEmoji()).toEqual("📫️"); + await userEvent.keyboard("[ArrowDown]"); + expect(getEmoji()).toEqual("🇦🇨"); + await userEvent.keyboard("[ArrowLeft]"); + expect(getEmoji()).toEqual("📭️"); + await userEvent.keyboard("[ArrowUp]"); + expect(getEmoji()).toEqual("⛳️"); + await userEvent.keyboard("[ArrowRight]"); + expect(getEmoji()).toEqual("📫️"); + await userEvent.keyboard("[Enter]"); + + expect(onChoose).toHaveBeenCalledWith("📫️"); + expect(onFinished).toHaveBeenCalled(); + }); }); From 782060a26e381c8ca5138b140e51989ae9bf314c Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 20 Apr 2023 18:13:30 +0100 Subject: [PATCH 05/12] ARIA Accessibility improvements (#10674) * Add missing aria-expanded attributes * Improve autoComplete for phone numbers & email addresses * Fix room summary card heading order * Fix missing label on timeline search field * Use appropriate semantic elements for dropdown listbox * Use semantic list elements in keyboard settings tab * Use semantic list elements in spotlight * Fix types and i18n * Improve types * Update tests * Add snapshot test --- res/css/views/dialogs/_SpotlightDialog.pcss | 5 + res/css/views/right_panel/_BaseCard.pcss | 3 +- .../views/right_panel/_RoomSummaryCard.pcss | 5 +- .../tabs/user/_KeyboardUserSettingsTab.pcss | 5 + src/components/views/auth/CountryDropdown.tsx | 1 + .../views/dialogs/spotlight/Option.tsx | 14 +- src/components/views/elements/Dropdown.tsx | 20 +- src/components/views/right_panel/BaseCard.tsx | 2 +- .../views/right_panel/RoomSummaryCard.tsx | 2 +- src/components/views/rooms/SearchBar.tsx | 3 + .../views/settings/account/EmailAddresses.tsx | 2 +- .../views/settings/account/PhoneNumbers.tsx | 2 +- .../tabs/room/SecurityRoomSettingsTab.tsx | 1 + .../tabs/user/AppearanceUserSettingsTab.tsx | 6 +- .../tabs/user/KeyboardUserSettingsTab.tsx | 8 +- .../views/spaces/QuickSettingsButton.tsx | 1 + .../spaces/SpaceSettingsVisibilityTab.tsx | 1 + src/i18n/strings/en_EN.json | 2 + .../views/dialogs/SpotlightDialog-test.tsx | 14 +- .../FilterDropdown-test.tsx.snap | 12 +- .../RoomSummaryCard-test.tsx.snap | 12 +- .../user/AppearanceUserSettingsTab-test.tsx | 37 ++ .../AppearanceUserSettingsTab-test.tsx.snap | 386 ++++++++++++++++++ .../KeyboardUserSettingsTab-test.tsx.snap | 224 +++++----- 24 files changed, 611 insertions(+), 157 deletions(-) create mode 100644 test/components/views/settings/tabs/user/AppearanceUserSettingsTab-test.tsx create mode 100644 test/components/views/settings/tabs/user/__snapshots__/AppearanceUserSettingsTab-test.tsx.snap diff --git a/res/css/views/dialogs/_SpotlightDialog.pcss b/res/css/views/dialogs/_SpotlightDialog.pcss index b9fa63482547..c85d94bf4517 100644 --- a/res/css/views/dialogs/_SpotlightDialog.pcss +++ b/res/css/views/dialogs/_SpotlightDialog.pcss @@ -155,6 +155,11 @@ limitations under the License. overflow-y: auto; padding: $spacing-16; + ul { + padding: 0; + margin: 0; + } + .mx_SpotlightDialog_section { > h4, > .mx_SpotlightDialog_sectionHeader > h4 { diff --git a/res/css/views/right_panel/_BaseCard.pcss b/res/css/views/right_panel/_BaseCard.pcss index 22720a99e034..5f700dfbf388 100644 --- a/res/css/views/right_panel/_BaseCard.pcss +++ b/res/css/views/right_panel/_BaseCard.pcss @@ -151,10 +151,11 @@ limitations under the License. margin-right: $spacing-12; } - > h1 { + > h2 { color: $tertiary-content; font-size: $font-12px; font-weight: 500; + margin: $spacing-12; } .mx_BaseCard_Button { diff --git a/res/css/views/right_panel/_RoomSummaryCard.pcss b/res/css/views/right_panel/_RoomSummaryCard.pcss index f75743037b0d..a138e332ce14 100644 --- a/res/css/views/right_panel/_RoomSummaryCard.pcss +++ b/res/css/views/right_panel/_RoomSummaryCard.pcss @@ -19,8 +19,9 @@ limitations under the License. text-align: center; margin-top: $spacing-20; - h2 { + h1 { margin: $spacing-12 0 $spacing-4; + font-weight: $font-semi-bold; } .mx_RoomSummaryCard_alias { @@ -30,7 +31,7 @@ limitations under the License. text-overflow: ellipsis; } - h2, + h1, .mx_RoomSummaryCard_alias { display: -webkit-box; -webkit-line-clamp: 2; diff --git a/res/css/views/settings/tabs/user/_KeyboardUserSettingsTab.pcss b/res/css/views/settings/tabs/user/_KeyboardUserSettingsTab.pcss index aa65e6d49433..6f387380f24b 100644 --- a/res/css/views/settings/tabs/user/_KeyboardUserSettingsTab.pcss +++ b/res/css/views/settings/tabs/user/_KeyboardUserSettingsTab.pcss @@ -16,6 +16,11 @@ limitations under the License. */ .mx_KeyboardUserSettingsTab .mx_SettingsTab_section { + ul { + margin: 0; + padding: 0; + } + .mx_KeyboardShortcut_shortcutRow, .mx_KeyboardShortcut { display: flex; diff --git a/src/components/views/auth/CountryDropdown.tsx b/src/components/views/auth/CountryDropdown.tsx index 83f6eca71a4f..a9061f6e5af3 100644 --- a/src/components/views/auth/CountryDropdown.tsx +++ b/src/components/views/auth/CountryDropdown.tsx @@ -164,6 +164,7 @@ export default class CountryDropdown extends React.Component { searchEnabled={true} disabled={this.props.disabled} label={_t("Country Dropdown")} + autoComplete="tel-country-code" > {options} diff --git a/src/components/views/dialogs/spotlight/Option.tsx b/src/components/views/dialogs/spotlight/Option.tsx index 2bfd2e17a1c5..a1dc41a85247 100644 --- a/src/components/views/dialogs/spotlight/Option.tsx +++ b/src/components/views/dialogs/spotlight/Option.tsx @@ -15,18 +15,21 @@ limitations under the License. */ import classNames from "classnames"; -import React, { ComponentProps, ReactNode } from "react"; +import React, { ReactNode, RefObject } from "react"; -import { RovingAccessibleButton } from "../../../../accessibility/roving/RovingAccessibleButton"; import { useRovingTabIndex } from "../../../../accessibility/RovingTabIndex"; -import AccessibleButton from "../../elements/AccessibleButton"; +import AccessibleButton, { ButtonEvent } from "../../elements/AccessibleButton"; -interface OptionProps extends ComponentProps { +interface OptionProps { + inputRef?: RefObject; endAdornment?: ReactNode; + id?: string; + className?: string; + onClick: ((ev: ButtonEvent) => void) | null; } export const Option: React.FC = ({ inputRef, children, endAdornment, className, ...props }) => { - const [onFocus, isActive, ref] = useRovingTabIndex(inputRef); + const [onFocus, isActive, ref] = useRovingTabIndex(inputRef); return ( = ({ inputRef, children, endAdornment tabIndex={-1} aria-selected={isActive} role="option" + element="li" > {children}
diff --git a/src/components/views/elements/Dropdown.tsx b/src/components/views/elements/Dropdown.tsx index f603a4b95709..305cee51967f 100644 --- a/src/components/views/elements/Dropdown.tsx +++ b/src/components/views/elements/Dropdown.tsx @@ -30,7 +30,7 @@ interface IMenuOptionProps { highlighted?: boolean; dropdownKey: string; id?: string; - inputRef?: Ref; + inputRef?: Ref; onClick(dropdownKey: string): void; onMouseEnter(dropdownKey: string): void; } @@ -57,7 +57,7 @@ class MenuOption extends React.Component { }); return ( -
{ ref={this.props.inputRef} > {this.props.children} -
+ ); } } @@ -78,6 +78,7 @@ export interface DropdownProps { label: string; value?: string; className?: string; + autoComplete?: string; children: NonEmptyArray; // negative for consistency with HTML disabled?: boolean; @@ -318,21 +319,21 @@ export default class Dropdown extends React.Component { }); if (!options?.length) { return [ -
+
  • {_t("No results")} -
  • , + , ]; } return options; } public render(): React.ReactNode { - let currentValue; + let currentValue: JSX.Element | undefined; const menuStyle: CSSProperties = {}; if (this.props.menuWidth) menuStyle.width = this.props.menuWidth; - let menu; + let menu: JSX.Element | undefined; if (this.state.expanded) { if (this.props.searchEnabled) { currentValue = ( @@ -340,6 +341,7 @@ export default class Dropdown extends React.Component { id={`${this.props.id}_input`} type="text" autoFocus={true} + autoComplete={this.props.autoComplete} className="mx_Dropdown_option" onChange={this.onInputChange} value={this.state.searchQuery} @@ -355,9 +357,9 @@ export default class Dropdown extends React.Component { ); } menu = ( -
    +
      {this.getMenuOptions()} -
    + ); } diff --git a/src/components/views/right_panel/BaseCard.tsx b/src/components/views/right_panel/BaseCard.tsx index 615792057dd5..80e538c04879 100644 --- a/src/components/views/right_panel/BaseCard.tsx +++ b/src/components/views/right_panel/BaseCard.tsx @@ -47,7 +47,7 @@ interface IGroupProps { export const Group: React.FC = ({ className, title, children }) => { return (
    -

    {title}

    +

    {title}

    {children}
    ); diff --git a/src/components/views/right_panel/RoomSummaryCard.tsx b/src/components/views/right_panel/RoomSummaryCard.tsx index a32d8da047d3..860a58df024f 100644 --- a/src/components/views/right_panel/RoomSummaryCard.tsx +++ b/src/components/views/right_panel/RoomSummaryCard.tsx @@ -318,7 +318,7 @@ const RoomSummaryCard: React.FC = ({ room, permalinkCreator, onClose }) />
    - {(name) =>

    {name}

    }
    + {(name) =>

    {name}

    }
    {alias}
    diff --git a/src/components/views/rooms/SearchBar.tsx b/src/components/views/rooms/SearchBar.tsx index c75b340c29a0..a4dbfe60ce64 100644 --- a/src/components/views/rooms/SearchBar.tsx +++ b/src/components/views/rooms/SearchBar.tsx @@ -121,6 +121,9 @@ export default class SearchBar extends React.Component { type="text" autoFocus={true} placeholder={_t("Search…")} + aria-label={ + this.state.scope === SearchScope.Room ? _t("Search this room") : _t("Search all rooms") + } onKeyDown={this.onSearchChange} /> { { {this.state.showAdvancedSection ? _t("Hide advanced") : _t("Show advanced")} diff --git a/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx b/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx index 7a8157e38c75..f4b05b3631d5 100644 --- a/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx @@ -88,7 +88,11 @@ export default class AppearanceUserSettingsTab extends React.Component this.setState({ showAdvanced: !this.state.showAdvanced })}> + this.setState({ showAdvanced: !this.state.showAdvanced })} + aria-expanded={this.state.showAdvanced} + > {this.state.showAdvanced ? _t("Hide advanced") : _t("Show advanced")} ); diff --git a/src/components/views/settings/tabs/user/KeyboardUserSettingsTab.tsx b/src/components/views/settings/tabs/user/KeyboardUserSettingsTab.tsx index cf9a41a55409..ef79b98d4837 100644 --- a/src/components/views/settings/tabs/user/KeyboardUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/KeyboardUserSettingsTab.tsx @@ -41,10 +41,10 @@ const KeyboardShortcutRow: React.FC = ({ name }) => { if (!displayName || !value) return null; return ( -
    +
  • {displayName} -
  • + ); }; @@ -59,12 +59,12 @@ const KeyboardShortcutSection: React.FC = ({ cate return (
    {_t(category.categoryLabel)}
    -
    +
      {" "} {category.settingNames.map((shortcutName) => { return ; })}{" "} -
    +
    ); }; diff --git a/src/components/views/spaces/QuickSettingsButton.tsx b/src/components/views/spaces/QuickSettingsButton.tsx index 1adcd419600c..4f9529466b0a 100644 --- a/src/components/views/spaces/QuickSettingsButton.tsx +++ b/src/components/views/spaces/QuickSettingsButton.tsx @@ -134,6 +134,7 @@ const QuickSettingsButton: React.FC<{ title={_t("Quick settings")} inputRef={handle} forceHide={!isPanelCollapsed} + aria-expanded={!isPanelCollapsed} > {!isPanelCollapsed ? _t("Settings") : null} diff --git a/src/components/views/spaces/SpaceSettingsVisibilityTab.tsx b/src/components/views/spaces/SpaceSettingsVisibilityTab.tsx index 7993a2af642b..368d6c96fc05 100644 --- a/src/components/views/spaces/SpaceSettingsVisibilityTab.tsx +++ b/src/components/views/spaces/SpaceSettingsVisibilityTab.tsx @@ -97,6 +97,7 @@ const SpaceSettingsVisibilityTab: React.FC = ({ matrixClient: cli, space onClick={toggleAdvancedSection} kind="link" className="mx_SettingsTab_showAdvanced" + aria-expanded={showAdvancedSection} > {showAdvancedSection ? _t("Hide advanced") : _t("Show advanced")}
    diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index a391253494a0..dd9afaf5d758 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2141,6 +2141,8 @@ "This Room": "This Room", "All Rooms": "All Rooms", "Search…": "Search…", + "Search this room": "Search this room", + "Search all rooms": "Search all rooms", "Failed to connect to integration manager": "Failed to connect to integration manager", "You don't currently have any stickerpacks enabled": "You don't currently have any stickerpacks enabled", "Add some now": "Add some now", diff --git a/test/components/views/dialogs/SpotlightDialog-test.tsx b/test/components/views/dialogs/SpotlightDialog-test.tsx index e587ffbe5cd1..fb1fc1e65ffe 100644 --- a/test/components/views/dialogs/SpotlightDialog-test.tsx +++ b/test/components/views/dialogs/SpotlightDialog-test.tsx @@ -174,7 +174,7 @@ describe("Spotlight Dialog", () => { expect(filterChip.innerHTML).toContain("Public rooms"); const content = document.querySelector("#mx_SpotlightDialog_content")!; - const options = content.querySelectorAll("div.mx_SpotlightDialog_option"); + const options = content.querySelectorAll("li.mx_SpotlightDialog_option"); expect(options.length).toBe(1); expect(options[0].innerHTML).toContain(testPublicRoom.name); }); @@ -196,7 +196,7 @@ describe("Spotlight Dialog", () => { expect(filterChip.innerHTML).toContain("People"); const content = document.querySelector("#mx_SpotlightDialog_content")!; - const options = content.querySelectorAll("div.mx_SpotlightDialog_option"); + const options = content.querySelectorAll("li.mx_SpotlightDialog_option"); expect(options.length).toBeGreaterThanOrEqual(1); expect(options[0]!.innerHTML).toContain(testPerson.display_name); }); @@ -242,7 +242,7 @@ describe("Spotlight Dialog", () => { expect(filterChip.innerHTML).toContain("Public rooms"); const content = document.querySelector("#mx_SpotlightDialog_content")!; - const options = content.querySelectorAll("div.mx_SpotlightDialog_option"); + const options = content.querySelectorAll("li.mx_SpotlightDialog_option"); expect(options.length).toBe(1); expect(options[0]!.innerHTML).toContain(testPublicRoom.name); @@ -265,7 +265,7 @@ describe("Spotlight Dialog", () => { expect(filterChip.innerHTML).toContain("People"); const content = document.querySelector("#mx_SpotlightDialog_content")!; - const options = content.querySelectorAll("div.mx_SpotlightDialog_option"); + const options = content.querySelectorAll("li.mx_SpotlightDialog_option"); expect(options.length).toBeGreaterThanOrEqual(1); expect(options[0]!.innerHTML).toContain(testPerson.display_name); }); @@ -324,7 +324,7 @@ describe("Spotlight Dialog", () => { await flushPromisesWithFakeTimers(); const content = document.querySelector("#mx_SpotlightDialog_content")!; - options = content.querySelectorAll("div.mx_SpotlightDialog_option"); + options = content.querySelectorAll("li.mx_SpotlightDialog_option"); }); it("should find Rooms", () => { @@ -350,7 +350,7 @@ describe("Spotlight Dialog", () => { jest.advanceTimersByTime(200); await flushPromisesWithFakeTimers(); - const options = document.querySelectorAll("div.mx_SpotlightDialog_option"); + const options = document.querySelectorAll("li.mx_SpotlightDialog_option"); expect(options.length).toBeGreaterThanOrEqual(1); expect(options[0]!.innerHTML).toContain(testPerson.display_name); @@ -372,7 +372,7 @@ describe("Spotlight Dialog", () => { await flushPromisesWithFakeTimers(); const content = document.querySelector("#mx_SpotlightDialog_content")!; - const options = content.querySelectorAll("div.mx_SpotlightDialog_option"); + const options = content.querySelectorAll("li.mx_SpotlightDialog_option"); expect(options.length).toBe(1); expect(options[0].innerHTML).toContain(testPublicRoom.name); diff --git a/test/components/views/elements/__snapshots__/FilterDropdown-test.tsx.snap b/test/components/views/elements/__snapshots__/FilterDropdown-test.tsx.snap index d82ee64cefa4..1f1c5dff73c8 100644 --- a/test/components/views/elements/__snapshots__/FilterDropdown-test.tsx.snap +++ b/test/components/views/elements/__snapshots__/FilterDropdown-test.tsx.snap @@ -1,12 +1,12 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[` renders dropdown options in menu 1`] = ` -
    -
    renders dropdown options in menu 1`] = ` Option one
    -
    -
    +
  • renders dropdown options in menu 1`] = ` with description
  • -
    -
    + + `; exports[` renders selected option 1`] = ` diff --git a/test/components/views/right_panel/__snapshots__/RoomSummaryCard-test.tsx.snap b/test/components/views/right_panel/__snapshots__/RoomSummaryCard-test.tsx.snap index 23f6e600874b..ee66c73a6894 100644 --- a/test/components/views/right_panel/__snapshots__/RoomSummaryCard-test.tsx.snap +++ b/test/components/views/right_panel/__snapshots__/RoomSummaryCard-test.tsx.snap @@ -44,11 +44,11 @@ exports[` renders the room summary 1`] = ` tabindex="0" /> -

    !room:domain.org -

    +
    renders the room summary 1`] = `
    -

    +

    About -

    +
    renders the room summary 1`] = `
    -

    +

    Widgets -

    +
    Room
    -
    +
      -
      Search (must be enabled) @@ -445,8 +445,8 @@ exports[`KeyboardUserSettingsTab renders list of keyboard shortcuts 1`] = `
      -
    -
    +
  • Upload a file @@ -471,8 +471,8 @@ exports[`KeyboardUserSettingsTab renders list of keyboard shortcuts 1`] = `
  • -
    -
    +
  • Dismiss read marker and jump to bottom @@ -485,8 +485,8 @@ exports[`KeyboardUserSettingsTab renders list of keyboard shortcuts 1`] = `
  • -
    -
    +
  • Jump to oldest unread message @@ -505,8 +505,8 @@ exports[`KeyboardUserSettingsTab renders list of keyboard shortcuts 1`] = `
  • -
    -
    +
  • Scroll up in the timeline @@ -519,8 +519,8 @@ exports[`KeyboardUserSettingsTab renders list of keyboard shortcuts 1`] = `
  • -
    -
    +
  • Scroll down in the timeline @@ -533,8 +533,8 @@ exports[`KeyboardUserSettingsTab renders list of keyboard shortcuts 1`] = `
  • - -
    +
  • Jump to first message @@ -553,8 +553,8 @@ exports[`KeyboardUserSettingsTab renders list of keyboard shortcuts 1`] = `
  • - -
    +
  • Jump to last message @@ -573,9 +573,9 @@ exports[`KeyboardUserSettingsTab renders list of keyboard shortcuts 1`] = `
  • - + - +
    Room List
    -
    +
      -
      Select room from the room list @@ -600,8 +600,8 @@ exports[`KeyboardUserSettingsTab renders list of keyboard shortcuts 1`] = `
      -
    -
    +
  • Collapse room list section @@ -614,8 +614,8 @@ exports[`KeyboardUserSettingsTab renders list of keyboard shortcuts 1`] = `
  • - -
    +
  • Expand room list section @@ -628,8 +628,8 @@ exports[`KeyboardUserSettingsTab renders list of keyboard shortcuts 1`] = `
  • - -
    +
  • Navigate down in the room list @@ -642,8 +642,8 @@ exports[`KeyboardUserSettingsTab renders list of keyboard shortcuts 1`] = `
  • - -
    +
  • Navigate up in the room list @@ -656,9 +656,9 @@ exports[`KeyboardUserSettingsTab renders list of keyboard shortcuts 1`] = `
  • - + - +
    Accessibility
    -
    +
      -
      Close dialog or context menu @@ -683,8 +683,8 @@ exports[`KeyboardUserSettingsTab renders list of keyboard shortcuts 1`] = `
      -
    -
    +
  • Activate selected button @@ -697,9 +697,9 @@ exports[`KeyboardUserSettingsTab renders list of keyboard shortcuts 1`] = `
  • - + - +
    Navigation
    -
    +
      -
      Toggle the top left menu @@ -730,8 +730,8 @@ exports[`KeyboardUserSettingsTab renders list of keyboard shortcuts 1`] = `
      -
    -
    +
  • Toggle right panel @@ -750,8 +750,8 @@ exports[`KeyboardUserSettingsTab renders list of keyboard shortcuts 1`] = `
  • - -
    +
  • Toggle space panel @@ -776,8 +776,8 @@ exports[`KeyboardUserSettingsTab renders list of keyboard shortcuts 1`] = `
  • - -
    +
  • Open this settings tab @@ -796,8 +796,8 @@ exports[`KeyboardUserSettingsTab renders list of keyboard shortcuts 1`] = `
  • - -
    +
  • Go to Home View @@ -822,8 +822,8 @@ exports[`KeyboardUserSettingsTab renders list of keyboard shortcuts 1`] = `
  • - -
    +
  • Jump to room search @@ -842,8 +842,8 @@ exports[`KeyboardUserSettingsTab renders list of keyboard shortcuts 1`] = `
  • - -
    +
  • Next unread room or DM @@ -868,8 +868,8 @@ exports[`KeyboardUserSettingsTab renders list of keyboard shortcuts 1`] = `
  • - -
    +
  • Previous unread room or DM @@ -894,8 +894,8 @@ exports[`KeyboardUserSettingsTab renders list of keyboard shortcuts 1`] = `
  • - -
    +
  • Next room or DM @@ -914,8 +914,8 @@ exports[`KeyboardUserSettingsTab renders list of keyboard shortcuts 1`] = `
  • - -
    +
  • Previous room or DM @@ -934,9 +934,9 @@ exports[`KeyboardUserSettingsTab renders list of keyboard shortcuts 1`] = `
  • - + - +
    Autocomplete
    -
    +
      -
      Cancel autocomplete @@ -961,8 +961,8 @@ exports[`KeyboardUserSettingsTab renders list of keyboard shortcuts 1`] = `
      -
    -
    +
  • Next autocomplete suggestion @@ -975,8 +975,8 @@ exports[`KeyboardUserSettingsTab renders list of keyboard shortcuts 1`] = `
  • - -
    +
  • Previous autocomplete suggestion @@ -989,8 +989,8 @@ exports[`KeyboardUserSettingsTab renders list of keyboard shortcuts 1`] = `
  • - -
    +
  • Complete @@ -1003,8 +1003,8 @@ exports[`KeyboardUserSettingsTab renders list of keyboard shortcuts 1`] = `
  • - -
    +
  • Force complete @@ -1017,9 +1017,9 @@ exports[`KeyboardUserSettingsTab renders list of keyboard shortcuts 1`] = `
  • - + - + From 5445ee85cd0e5e81ed3cc48c08b6e4c0b2820c6e Mon Sep 17 00:00:00 2001 From: Suguru Hirahara Date: Thu, 20 Apr 2023 21:36:44 +0000 Subject: [PATCH 06/12] Update spaces.spec.ts - use Cypress Testing Library (#10620) Signed-off-by: Suguru Hirahara --- cypress/e2e/spaces/spaces.spec.ts | 146 ++++++++++++++++++------------ 1 file changed, 89 insertions(+), 57 deletions(-) diff --git a/cypress/e2e/spaces/spaces.spec.ts b/cypress/e2e/spaces/spaces.spec.ts index f89fa297d017..9b1fb241d0d6 100644 --- a/cypress/e2e/spaces/spaces.spec.ts +++ b/cypress/e2e/spaces/spaces.spec.ts @@ -24,7 +24,7 @@ import Chainable = Cypress.Chainable; import { UserCredentials } from "../../support/login"; function openSpaceCreateMenu(): Chainable { - cy.get(".mx_SpaceButton_new").click(); + cy.findByRole("button", { name: "Create a space" }).click(); return cy.get(".mx_SpaceCreateMenu_wrapper .mx_ContextualMenu"); } @@ -83,64 +83,72 @@ describe("Spaces", () => { openSpaceCreateMenu(); cy.get("#mx_ContextualMenu_Container").percySnapshotElement("Space create menu"); cy.get(".mx_SpaceCreateMenu_wrapper .mx_ContextualMenu").within(() => { - cy.get(".mx_SpaceCreateMenuType_public").click(); + // Regex pattern due to strings of "mx_SpaceCreateMenuType_public" + cy.findByRole("button", { name: /Public/ }).click(); + cy.get('.mx_SpaceBasicSettings_avatarContainer input[type="file"]').selectFile( "cypress/fixtures/riot.png", { force: true }, ); - cy.get('input[label="Name"]').type("Let's have a Riot"); - cy.get('input[label="Address"]').should("have.value", "lets-have-a-riot"); - cy.get('textarea[label="Description"]').type("This is a space to reminisce Riot.im!"); - cy.contains(".mx_AccessibleButton", "Create").click(); + cy.findByRole("textbox", { name: "Name" }).type("Let's have a Riot"); + cy.findByRole("textbox", { name: "Address" }).should("have.value", "lets-have-a-riot"); + cy.findByRole("textbox", { name: "Description" }).type("This is a space to reminisce Riot.im!"); + cy.findByRole("button", { name: "Create" }).click(); }); // Create the default General & Random rooms, as well as a custom "Jokes" room - cy.get('input[label="Room name"][value="General"]').should("exist"); - cy.get('input[label="Room name"][value="Random"]').should("exist"); - cy.get('input[placeholder="Support"]').type("Jokes"); - cy.contains(".mx_AccessibleButton", "Continue").click(); + cy.findByPlaceholderText("General").should("exist"); + cy.findByPlaceholderText("Random").should("exist"); + cy.findByPlaceholderText("Support").type("Jokes"); + cy.findByRole("button", { name: "Continue" }).click(); // Copy matrix.to link - cy.get(".mx_SpacePublicShare_shareButton").focus().realClick(); + // Regex pattern due to strings of "mx_SpacePublicShare_shareButton" + cy.findByRole("button", { name: /Share invite link/ }).realClick(); cy.getClipboardText().should("eq", "https://matrix.to/#/#lets-have-a-riot:localhost"); // Go to space home - cy.contains(".mx_AccessibleButton", "Go to my first room").click(); + cy.findByRole("button", { name: "Go to my first room" }).click(); // Assert rooms exist in the room list - cy.contains(".mx_RoomList .mx_RoomTile", "General").should("exist"); - cy.contains(".mx_RoomList .mx_RoomTile", "Random").should("exist"); - cy.contains(".mx_RoomList .mx_RoomTile", "Jokes").should("exist"); + cy.findByRole("treeitem", { name: "General" }).should("exist"); + cy.findByRole("treeitem", { name: "Random" }).should("exist"); + cy.findByRole("treeitem", { name: "Jokes" }).should("exist"); }); it("should allow user to create private space", () => { openSpaceCreateMenu().within(() => { - cy.get(".mx_SpaceCreateMenuType_private").click(); + // Regex pattern due to strings of "mx_SpaceCreateMenuType_private" + cy.findByRole("button", { name: /Private/ }).click(); + cy.get('.mx_SpaceBasicSettings_avatarContainer input[type="file"]').selectFile( "cypress/fixtures/riot.png", { force: true }, ); - cy.get('input[label="Name"]').type("This is not a Riot"); - cy.get('input[label="Address"]').should("not.exist"); - cy.get('textarea[label="Description"]').type("This is a private space of mourning Riot.im..."); - cy.contains(".mx_AccessibleButton", "Create").click(); + cy.findByRole("textbox", { name: "Name" }).type("This is not a Riot"); + cy.findByRole("textbox", { name: "Address" }).should("not.exist"); + cy.findByRole("textbox", { name: "Description" }).type("This is a private space of mourning Riot.im..."); + cy.findByRole("button", { name: "Create" }).click(); }); - cy.get(".mx_SpaceRoomView_privateScope_meAndMyTeammatesButton").click(); + // Regex pattern due to strings of "mx_SpaceRoomView_privateScope_meAndMyTeammatesButton" + cy.findByRole("button", { name: /Me and my teammates/ }).click(); // Create the default General & Random rooms, as well as a custom "Projects" room - cy.get('input[label="Room name"][value="General"]').should("exist"); - cy.get('input[label="Room name"][value="Random"]').should("exist"); - cy.get('input[placeholder="Support"]').type("Projects"); - cy.contains(".mx_AccessibleButton", "Continue").click(); - - cy.get(".mx_SpaceRoomView").should("contain", "Invite your teammates"); - cy.contains(".mx_AccessibleButton", "Skip for now").click(); + cy.findByPlaceholderText("General").should("exist"); + cy.findByPlaceholderText("Random").should("exist"); + cy.findByPlaceholderText("Support").type("Projects"); + cy.findByRole("button", { name: "Continue" }).click(); + + cy.get(".mx_SpaceRoomView").within(() => { + cy.get("h1").findByText("Invite your teammates"); + cy.findByRole("button", { name: "Skip for now" }).click(); + }); // Assert rooms exist in the room list - cy.contains(".mx_RoomList .mx_RoomTile", "General").should("exist"); - cy.contains(".mx_RoomList .mx_RoomTile", "Random").should("exist"); - cy.contains(".mx_RoomList .mx_RoomTile", "Projects").should("exist"); + cy.findByRole("treeitem", { name: "General" }).should("exist"); + cy.findByRole("treeitem", { name: "Random" }).should("exist"); + cy.findByRole("treeitem", { name: "Projects" }).should("exist"); // Assert rooms exist in the space explorer cy.contains(".mx_SpaceHierarchy_list .mx_SpaceHierarchy_roomTile", "General").should("exist"); @@ -154,23 +162,32 @@ describe("Spaces", () => { }); openSpaceCreateMenu().within(() => { - cy.get(".mx_SpaceCreateMenuType_private").click(); + // Regex pattern due to strings of "mx_SpaceCreateMenuType_private" + cy.findByRole("button", { name: /Private/ }).click(); + cy.get('.mx_SpaceBasicSettings_avatarContainer input[type="file"]').selectFile( "cypress/fixtures/riot.png", { force: true }, ); - cy.get('input[label="Address"]').should("not.exist"); - cy.get('textarea[label="Description"]').type("This is a personal space to mourn Riot.im..."); - cy.get('input[label="Name"]').type("This is my Riot{enter}"); + cy.findByRole("textbox", { name: "Address" }).should("not.exist"); + cy.findByRole("textbox", { name: "Description" }).type("This is a personal space to mourn Riot.im..."); + cy.findByRole("textbox", { name: "Name" }).type("This is my Riot{enter}"); }); - cy.get(".mx_SpaceRoomView_privateScope_justMeButton").click(); + // Regex pattern due to of strings of "mx_SpaceRoomView_privateScope_justMeButton" + cy.findByRole("button", { name: /Just me/ }).click(); + + cy.findByText("Sample Room").click({ force: true }); // force click as checkbox size is zero - cy.get(".mx_AddExistingToSpace_entry").click(); - cy.contains(".mx_AccessibleButton", "Add").click(); + // Temporal implementation as multiple elements with the role "button" and name "Add" are found + cy.get(".mx_AddExistingToSpace_footer").within(() => { + cy.findByRole("button", { name: "Add" }).click(); + }); - cy.contains(".mx_RoomList .mx_RoomTile", "Sample Room").should("exist"); - cy.contains(".mx_SpaceHierarchy_list .mx_SpaceHierarchy_roomTile", "Sample Room").should("exist"); + cy.get(".mx_SpaceHierarchy_list").within(() => { + // Regex pattern due to the strings of "mx_SpaceHierarchy_roomTile_joined" + cy.findByRole("treeitem", { name: /Sample Room/ }).should("exist"); + }); }); it("should allow user to invite another to a space", () => { @@ -185,20 +202,24 @@ describe("Spaces", () => { }).as("spaceId"); openSpaceContextMenu("#space:localhost").within(() => { - cy.get('.mx_SpacePanel_contextMenu_inviteButton[aria-label="Invite"]').click(); + cy.findByRole("menuitem", { name: "Invite" }).click(); }); cy.get(".mx_SpacePublicShare").within(() => { // Copy link first - cy.get(".mx_SpacePublicShare_shareButton").focus().realClick(); + // Regex pattern due to strings of "mx_SpacePublicShare_shareButton" + cy.findByRole("button", { name: /Share invite link/ }) + .focus() + .realClick(); cy.getClipboardText().should("eq", "https://matrix.to/#/#space:localhost"); // Start Matrix invite flow - cy.get(".mx_SpacePublicShare_inviteButton").click(); + // Regex pattern due to strings of "mx_SpacePublicShare_inviteButton" + cy.findByRole("button", { name: /Invite people/ }).click(); }); cy.get(".mx_InviteDialog_other").within(() => { - cy.get('input[type="text"]').type(bot.getUserId()); - cy.contains(".mx_AccessibleButton", "Invite").click(); + cy.findByRole("textbox").type(bot.getUserId()); + cy.findByRole("button", { name: "Invite" }).click(); }); cy.get(".mx_InviteDialog_other").should("not.exist"); @@ -219,7 +240,7 @@ describe("Spaces", () => { .should("exist") .parent() .next() - .find('.mx_SpaceButton[aria-label="My Space"]') + .findByRole("button", { name: "My Space" }) .should("exist"); }); @@ -243,8 +264,11 @@ describe("Spaces", () => { cy.viewSpaceHomeByName(spaceName); }); cy.get(".mx_SpaceRoomView .mx_SpaceHierarchy_list").within(() => { - cy.contains(".mx_SpaceHierarchy_roomTile", "Music").should("exist"); - cy.contains(".mx_SpaceHierarchy_roomTile", "Gaming").should("exist"); + // Regex pattern due to strings in "mx_SpaceHierarchy_roomTile_name" + cy.findByRole("treeitem", { name: /Music/ }).findByRole("button").should("exist"); + cy.findByRole("treeitem", { name: /Gaming/ }) + .findByRole("button") + .should("exist"); }); }); @@ -260,8 +284,12 @@ describe("Spaces", () => { initial_state: [spaceChildInitialState(spaceId)], }).as("spaceId"); }); - cy.get('.mx_SpacePanel .mx_SpaceButton[aria-label="Root Space"]').should("exist"); - cy.get('.mx_SpacePanel .mx_SpaceButton[aria-label="Child Space"]').should("not.exist"); + + // Find collapsed Space panel + cy.findByRole("tree", { name: "Spaces" }).within(() => { + cy.findByRole("button", { name: "Root Space" }).should("exist"); + cy.findByRole("button", { name: "Child Space" }).should("not.exist"); + }); const axeOptions = { rules: { @@ -274,8 +302,12 @@ describe("Spaces", () => { cy.checkA11y(undefined, axeOptions); cy.get(".mx_SpacePanel").percySnapshotElement("Space panel collapsed", { widths: [68] }); - cy.get(".mx_SpaceButton_toggleCollapse").click({ force: true }); - cy.get(".mx_SpacePanel:not(.collapsed)").should("exist"); + cy.findByRole("tree", { name: "Spaces" }).within(() => { + // This finds the expand button with the class name "mx_SpaceButton_toggleCollapse". Note there is another + // button with the same name with different class name "mx_SpacePanel_toggleCollapse". + cy.findByRole("button", { name: "Expand" }).realHover().click(); + }); + cy.get(".mx_SpacePanel:not(.collapsed)").should("exist"); // TODO: replace :not() selector cy.contains(".mx_SpaceItem", "Root Space") .should("exist") @@ -300,12 +332,12 @@ describe("Spaces", () => { cy.getSpacePanelButton("Test Space").should("exist"); cy.wait(500); // without this we can end up clicking too quickly and it ends up having no effect cy.viewSpaceByName("Test Space"); - cy.contains(".mx_AccessibleButton", "Accept").click(); + cy.findByRole("button", { name: "Accept" }).click(); - cy.contains(".mx_SpaceHierarchy_roomTile.mx_AccessibleButton", "Test Room").within(() => { - cy.contains("Join").should("exist").realHover().click(); - cy.contains("View", { timeout: 5000 }).should("exist").click(); - }); + // Regex pattern due to strings in "mx_SpaceHierarchy_roomTile_item" + cy.findByRole("button", { name: /Test Room/ }).realHover(); + cy.findByRole("button", { name: "Join" }).should("exist").realHover().click(); + cy.findByRole("button", { name: "View", timeout: 5000 }).should("exist").realHover().click(); // Assert we get shown the new room intro, and thus not the soft crash screen cy.get(".mx_NewRoomIntro").should("exist"); From 7a914e73f89d1afafe674896b94143f0148d31a2 Mon Sep 17 00:00:00 2001 From: Kerry Date: Fri, 21 Apr 2023 10:35:44 +1200 Subject: [PATCH 07/12] hack to fix console noise from unfaked timers and clearAllModals (#10660) --- test/test-utils/utilities.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/test/test-utils/utilities.ts b/test/test-utils/utilities.ts index 420e5d3bca26..6590ef52306d 100644 --- a/test/test-utils/utilities.ts +++ b/test/test-utils/utilities.ts @@ -211,6 +211,17 @@ export const clearAllModals = async (): Promise => { // of removing the same modal because the promises don't flush otherwise. // // XXX: Maybe in the future with Jest 29.5.0+, we could use `runAllTimersAsync` instead. - await flushPromisesWithFakeTimers(); + + // this is called in some places where timers are not faked + // which causes a lot of noise in the console + // to make a hack even hackier check if timers are faked using a weird trick from github + // then call the appropriate promise flusher + // https://github.com/facebook/jest/issues/10555#issuecomment-1136466942 + const jestTimersFaked = setTimeout.name === "setTimeout"; + if (jestTimersFaked) { + await flushPromisesWithFakeTimers(); + } else { + await flushPromises(); + } } }; From 05ef1d5560ccc5f8095564c7d9ada4641150a566 Mon Sep 17 00:00:00 2001 From: Suguru Hirahara Date: Fri, 21 Apr 2023 09:32:26 +0000 Subject: [PATCH 08/12] Update `sliding-sync.spec.ts` - use Cypress Testing Library (#10618) * Update sliding-sync.spec.ts - use Cypress Testing Library Signed-off-by: Suguru Hirahara * Use findByRole() - group, treeitem The elements with ARIA "treeitem" role resides in ones with ARIA "group" role such as Favourites, People, and Invites. The elements with the "treeitem" role correspond to rooms on the room list. Signed-off-by: Suguru Hirahara * Use the library more - 'should render the Rooms list in reverse chronological order by default and allowing sorting A-Z' Signed-off-by: Suguru Hirahara * Use cy.findAllByRole("treeitem") Note the Test room is excluded from being counted thanks to within(). Signed-off-by: Suguru Hirahara * Rename the rooms to avoid confusion Since it has been unclear "Join" etc. is a verb or a room name, the room names are changed as below: - Join -> Room to Join - Reject -> Room to Reject (the invite) - Rescind -> Room to Rescind (the invite) Signed-off-by: Suguru Hirahara * Specify ARIA label for the room sublist headers Have different ARIA labels specified for "mx_RoomSublist" and "mx_RoomSublist_headerContainer" to clarify the structure. Change the test to check the new ARIA label. Signed-off-by: Suguru Hirahara * lint Signed-off-by: Suguru Hirahara * Fix a race condition Signed-off-by: Suguru Hirahara * Revert "Specify ARIA label for the room sublist headers" This reverts commit 193a47de4c0fac4139d7c689fa020d6f0acc6819. Signed-off-by: Suguru Hirahara * Fix realHover() target Signed-off-by: Suguru Hirahara --------- Signed-off-by: Suguru Hirahara --- cypress/e2e/sliding-sync/sliding-sync.ts | 187 +++++++++++++++-------- 1 file changed, 122 insertions(+), 65 deletions(-) diff --git a/cypress/e2e/sliding-sync/sliding-sync.ts b/cypress/e2e/sliding-sync/sliding-sync.ts index 10bd24679709..6caa01a9903c 100644 --- a/cypress/e2e/sliding-sync/sliding-sync.ts +++ b/cypress/e2e/sliding-sync/sliding-sync.ts @@ -62,7 +62,7 @@ describe("Sliding Sync", () => { // assert order const checkOrder = (wantOrder: string[]) => { - cy.contains(".mx_RoomSublist", "Rooms") + cy.findByRole("group", { name: "Rooms" }) .find(".mx_RoomTile_title") .should((elements) => { expect( @@ -102,16 +102,31 @@ describe("Sliding Sync", () => { it("should render the Rooms list in reverse chronological order by default and allowing sorting A-Z", () => { // create rooms and check room names are correct - cy.createRoom({ name: "Apple" }).then(() => cy.contains(".mx_RoomSublist", "Apple")); - cy.createRoom({ name: "Pineapple" }).then(() => cy.contains(".mx_RoomSublist", "Pineapple")); - cy.createRoom({ name: "Orange" }).then(() => cy.contains(".mx_RoomSublist", "Orange")); - // check the rooms are in the right order - cy.get(".mx_RoomTile").should("have.length", 4); // due to the Test Room in beforeEach + cy.createRoom({ name: "Apple" }).then(() => cy.findByRole("treeitem", { name: "Apple" })); + cy.createRoom({ name: "Pineapple" }).then(() => cy.findByRole("treeitem", { name: "Pineapple" })); + cy.createRoom({ name: "Orange" }).then(() => cy.findByRole("treeitem", { name: "Orange" })); + + cy.get(".mx_RoomSublist_tiles").within(() => { + cy.findAllByRole("treeitem").should("have.length", 4); // due to the Test Room in beforeEach + }); + checkOrder(["Orange", "Pineapple", "Apple", "Test Room"]); - cy.contains(".mx_RoomSublist", "Rooms").find(".mx_RoomSublist_menuButton").click({ force: true }); - cy.contains("A-Z").click(); - cy.get(".mx_StyledRadioButton_checked").should("contain.text", "A-Z"); + cy.findByRole("group", { name: "Rooms" }).within(() => { + cy.get(".mx_RoomSublist_headerContainer") + .realHover() + .findByRole("button", { name: "List options" }) + .click(); + }); + + // force click as the radio button's size is zero + cy.findByRole("menuitemradio", { name: "A-Z" }).click({ force: true }); + + // Assert that the radio button is checked + cy.get(".mx_StyledRadioButton_checked").within(() => { + cy.findByText("A-Z").should("exist"); + }); + checkOrder(["Apple", "Orange", "Pineapple", "Test Room"]); }); @@ -119,16 +134,16 @@ describe("Sliding Sync", () => { // create rooms and check room names are correct cy.createRoom({ name: "Apple" }) .as("roomA") - .then(() => cy.contains(".mx_RoomSublist", "Apple")); + .then(() => cy.findByRole("treeitem", { name: "Apple" })); cy.createRoom({ name: "Pineapple" }) .as("roomP") - .then(() => cy.contains(".mx_RoomSublist", "Pineapple")); + .then(() => cy.findByRole("treeitem", { name: "Pineapple" })); cy.createRoom({ name: "Orange" }) .as("roomO") - .then(() => cy.contains(".mx_RoomSublist", "Orange")); + .then(() => cy.findByRole("treeitem", { name: "Orange" })); // Select the Test Room - cy.contains(".mx_RoomTile", "Test Room").click(); + cy.findByRole("treeitem", { name: "Test Room" }).click(); checkOrder(["Orange", "Pineapple", "Apple", "Test Room"]); bumpRoom("@roomA"); @@ -145,20 +160,20 @@ describe("Sliding Sync", () => { // create rooms and check room names are correct cy.createRoom({ name: "Apple" }) .as("roomA") - .then(() => cy.contains(".mx_RoomSublist", "Apple")); + .then(() => cy.findByRole("treeitem", { name: "Apple" })); cy.createRoom({ name: "Pineapple" }) .as("roomP") - .then(() => cy.contains(".mx_RoomSublist", "Pineapple")); + .then(() => cy.findByRole("treeitem", { name: "Pineapple" })); cy.createRoom({ name: "Orange" }) .as("roomO") - .then(() => cy.contains(".mx_RoomSublist", "Orange")); + .then(() => cy.findByRole("treeitem", { name: "Orange" })); // Given a list of Orange, Pineapple, Apple - if Pineapple is active and a message is sent in Apple, the list should // turn into Apple, Pineapple, Orange - the index position of Pineapple never changes even though the list should technically // be Apple, Orange Pineapple - only when you click on a different room do things reshuffle. // Select the Pineapple room - cy.contains(".mx_RoomTile", "Pineapple").click(); + cy.findByRole("treeitem", { name: "Pineapple" }).click(); checkOrder(["Orange", "Pineapple", "Apple", "Test Room"]); // Move Apple @@ -166,7 +181,7 @@ describe("Sliding Sync", () => { checkOrder(["Apple", "Pineapple", "Orange", "Test Room"]); // Select the Test Room - cy.contains(".mx_RoomTile", "Test Room").click(); + cy.findByRole("treeitem", { name: "Test Room" }).click(); // the rooms reshuffle to match reality checkOrder(["Apple", "Orange", "Pineapple", "Test Room"]); @@ -181,19 +196,22 @@ describe("Sliding Sync", () => { }); // check that there is an unread notification (grey) as 1 - cy.contains(".mx_RoomTile", "Test Room").contains(".mx_NotificationBadge_count", "1"); + cy.findByRole("treeitem", { name: "Test Room 1 unread message." }).contains(".mx_NotificationBadge_count", "1"); cy.get(".mx_NotificationBadge").should("not.have.class", "mx_NotificationBadge_highlighted"); // send an @mention: highlight count (red) should be 2. cy.all([cy.get("@roomId"), cy.get("@bob")]).then(([roomId, bob]) => { return bob.sendTextMessage(roomId, "Hello Sloth"); }); - cy.contains(".mx_RoomTile", "Test Room").contains(".mx_NotificationBadge_count", "2"); + cy.findByRole("treeitem", { name: "Test Room 2 unread messages including mentions." }).contains( + ".mx_NotificationBadge_count", + "2", + ); cy.get(".mx_NotificationBadge").should("have.class", "mx_NotificationBadge_highlighted"); // click on the room, the notif counts should disappear - cy.contains(".mx_RoomTile", "Test Room").click(); - cy.contains(".mx_RoomTile", "Test Room").should("not.have.class", "mx_NotificationBadge_count"); + cy.findByRole("treeitem", { name: "Test Room 2 unread messages including mentions." }).click(); + cy.findByRole("treeitem", { name: "Test Room" }).should("not.have.class", "mx_NotificationBadge_count"); }); it("should not show unread indicators", () => { @@ -201,8 +219,11 @@ describe("Sliding Sync", () => { createAndJoinBob(); // disable notifs in this room (TODO: CS API call?) - cy.contains(".mx_RoomTile", "Test Room").find(".mx_RoomTile_notificationsButton").click({ force: true }); - cy.contains("Mute room").click(); + cy.findByRole("treeitem", { name: "Test Room" }) + .realHover() + .findByRole("button", { name: "Notification options" }) + .click(); + cy.findByRole("menuitemradio", { name: "Mute room" }).click(); // create a new room so we know when the message has been received as it'll re-shuffle the room list cy.createRoom({ @@ -216,13 +237,13 @@ describe("Sliding Sync", () => { // wait for this message to arrive, tell by the room list resorting checkOrder(["Test Room", "Dummy"]); - cy.contains(".mx_RoomTile", "Test Room").get(".mx_NotificationBadge").should("not.exist"); + cy.findByRole("treeitem", { name: "Test Room" }).get(".mx_NotificationBadge").should("not.exist"); }); it("should update user settings promptly", () => { - cy.get(".mx_UserMenu_userAvatar").click(); - cy.contains("All settings").click(); - cy.contains("Preferences").click(); + cy.findByRole("button", { name: "User menu" }).click(); + cy.findByRole("menuitem", { name: "All settings" }).click(); + cy.findByRole("button", { name: "Preferences" }).click(); cy.contains(".mx_SettingsFlag", "Show timestamps in 12 hour format") .should("exist") .find(".mx_ToggleSwitch_on") @@ -257,9 +278,9 @@ describe("Sliding Sync", () => { .then((bob) => { bobClient = bob; return Promise.all([ - bob.createRoom({ name: "Join" }), - bob.createRoom({ name: "Reject" }), - bob.createRoom({ name: "Rescind" }), + bob.createRoom({ name: "Room to Join" }), + bob.createRoom({ name: "Room to Reject" }), + bob.createRoom({ name: "Room to Rescind" }), ]); }) .then(([join, reject, rescind]) => { @@ -273,23 +294,44 @@ describe("Sliding Sync", () => { ]); }); - // wait for them all to be on the UI - cy.get(".mx_RoomTile").should("have.length", 4); // due to the Test Room in beforeEach + cy.findByRole("group", { name: "Invites" }).within(() => { + // Exclude headerText + cy.get(".mx_RoomSublist_tiles").within(() => { + // Wait for them all to be on the UI + cy.findAllByRole("treeitem").should("have.length", 3); + }); + }); + + // Select the room to join + cy.findByRole("treeitem", { name: "Room to Join" }).click(); - cy.contains(".mx_RoomTile", "Join").click(); - cy.contains(".mx_AccessibleButton", "Accept").click(); + cy.get(".mx_RoomView").within(() => { + // Accept the invite + cy.findByRole("button", { name: "Accept" }).click(); + }); - checkOrder(["Join", "Test Room"]); + checkOrder(["Room to Join", "Test Room"]); - cy.contains(".mx_RoomTile", "Reject").click(); - cy.contains(".mx_RoomView .mx_AccessibleButton", "Reject").click(); + // Select the room to reject + cy.findByRole("treeitem", { name: "Room to Reject" }).click(); - // wait for the rejected room to disappear - cy.get(".mx_RoomTile").should("have.length", 3); + cy.get(".mx_RoomView").within(() => { + // Reject the invite + cy.findByRole("button", { name: "Reject" }).click(); + }); + + cy.findByRole("group", { name: "Invites" }).within(() => { + // Exclude headerText + cy.get(".mx_RoomSublist_tiles").within(() => { + // Wait for the rejected room to disappear + cy.findAllByRole("treeitem").should("have.length", 2); + }); + }); // check the lists are correct - checkOrder(["Join", "Test Room"]); - cy.contains(".mx_RoomSublist", "Invites") + checkOrder(["Room to Join", "Test Room"]); + + cy.findByRole("group", { name: "Invites" }) .find(".mx_RoomTile_title") .should((elements) => { expect( @@ -297,7 +339,7 @@ describe("Sliding Sync", () => { return e.textContent; }), "rooms are sorted", - ).to.deep.equal(["Rescind"]); + ).to.deep.equal(["Room to Rescind"]); }); // now rescind the invite @@ -305,9 +347,15 @@ describe("Sliding Sync", () => { return bob.kick(roomRescind, clientUserId); }); - // wait for the rescind to take effect and check the joined list once more - cy.get(".mx_RoomTile").should("have.length", 2); - checkOrder(["Join", "Test Room"]); + cy.findByRole("group", { name: "Rooms" }).within(() => { + // Exclude headerText + cy.get(".mx_RoomSublist_tiles").within(() => { + // Wait for the rescind to take effect and check the joined list once more + cy.findAllByRole("treeitem").should("have.length", 2); + }); + }); + + checkOrder(["Room to Join", "Test Room"]); }); it("should show a favourite DM only in the favourite sublist", () => { @@ -320,8 +368,8 @@ describe("Sliding Sync", () => { cy.getClient().then((cli) => cli.setRoomTag(roomId, "m.favourite", { order: 0.5 })); }); - cy.contains('.mx_RoomSublist[aria-label="Favourites"] .mx_RoomTile', "Favourite DM").should("exist"); - cy.contains('.mx_RoomSublist[aria-label="People"] .mx_RoomTile', "Favourite DM").should("not.exist"); + cy.findByRole("group", { name: "Favourites" }).findByText("Favourite DM").should("exist"); + cy.findByRole("group", { name: "People" }).findByText("Favourite DM").should("not.exist"); }); // Regression test for a bug in SS mode, but would be useful to have in non-SS mode too. @@ -329,7 +377,7 @@ describe("Sliding Sync", () => { it("should clear the reply to field when swapping rooms", () => { cy.createRoom({ name: "Other Room" }) .as("roomA") - .then(() => cy.contains(".mx_RoomSublist", "Other Room")); + .then(() => cy.findByRole("treeitem", { name: "Other Room" })); cy.get("@roomId").then((roomId) => { return cy.sendEvent(roomId, null, "m.room.message", { body: "Hello world", @@ -337,20 +385,24 @@ describe("Sliding Sync", () => { }); }); // select the room - cy.contains(".mx_RoomTile", "Test Room").click(); + cy.findByRole("treeitem", { name: "Test Room" }).click(); cy.get(".mx_ReplyPreview").should("not.exist"); // click reply-to on the Hello World message - cy.contains(".mx_EventTile", "Hello world") - .find('.mx_AccessibleButton[aria-label="Reply"]') - .click({ force: true }); + cy.get(".mx_EventTile_last") + .within(() => { + cy.findByText("Hello world", { timeout: 1000 }); + }) + .realHover() + .findByRole("button", { name: "Reply" }) + .click(); // check it's visible cy.get(".mx_ReplyPreview").should("exist"); // now click Other Room - cy.contains(".mx_RoomTile", "Other Room").click(); + cy.findByRole("treeitem", { name: "Other Room" }).click(); // ensure the reply-to disappears cy.get(".mx_ReplyPreview").should("not.exist"); // click back - cy.contains(".mx_RoomTile", "Test Room").click(); + cy.findByRole("treeitem", { name: "Test Room" }).click(); // ensure the reply-to reappears cy.get(".mx_ReplyPreview").should("exist"); }); @@ -378,12 +430,17 @@ describe("Sliding Sync", () => { }); }); // select the room - cy.contains(".mx_RoomTile", "Test Room").click(); + cy.findByRole("treeitem", { name: "Test Room" }).click(); cy.get(".mx_ReplyPreview").should("not.exist"); // click reply-to on the Reply to me message - cy.contains(".mx_EventTile", "Reply to me") - .find('.mx_AccessibleButton[aria-label="Reply"]') - .click({ force: true }); + cy.get(".mx_EventTile") + .last() + .within(() => { + cy.findByText("Reply to me"); + }) + .realHover() + .findByRole("button", { name: "Reply" }) + .click(); // check it's visible cy.get(".mx_ReplyPreview").should("exist"); // now click on the permalink for Permalink me @@ -401,15 +458,15 @@ describe("Sliding Sync", () => { cy.createRoom({ name: "Apple" }) .as("roomA") .then((roomId) => (roomAId = roomId)) - .then(() => cy.contains(".mx_RoomSublist", "Apple")); + .then(() => cy.findByRole("treeitem", { name: "Apple" })); cy.createRoom({ name: "Pineapple" }) .as("roomP") .then((roomId) => (roomPId = roomId)) - .then(() => cy.contains(".mx_RoomSublist", "Pineapple")); + .then(() => cy.findByRole("treeitem", { name: "Pineapple" })); cy.createRoom({ name: "Orange" }) .as("roomO") - .then(() => cy.contains(".mx_RoomSublist", "Orange")); + .then(() => cy.findByRole("treeitem", { name: "Orange" })); // Intercept all calls to /sync cy.intercept({ method: "POST", url: "**/sync*" }).as("syncRequest"); @@ -426,7 +483,7 @@ describe("Sliding Sync", () => { }; // Select the Test Room - cy.contains(".mx_RoomTile", "Apple").click(); + cy.findByRole("treeitem", { name: "Apple" }).click(); // and wait for cypress to get the result as alias cy.wait("@syncRequest").then((interception) => { @@ -435,11 +492,11 @@ describe("Sliding Sync", () => { }); // Switch to another room - cy.contains(".mx_RoomTile", "Pineapple").click(); + cy.findByRole("treeitem", { name: "Pineapple" }).click(); cy.wait("@syncRequest").then((interception) => assertUnsubExists(interception, roomPId, roomAId)); // And switch to even another room - cy.contains(".mx_RoomTile", "Apple").click(); + cy.findByRole("treeitem", { name: "Apple" }).click(); cy.wait("@syncRequest").then((interception) => assertUnsubExists(interception, roomPId, roomAId)); // TODO: Add tests for encrypted rooms From 259b5fe253b75e5dec754d27128a68ff5519539d Mon Sep 17 00:00:00 2001 From: Suguru Hirahara Date: Fri, 21 Apr 2023 09:45:10 +0000 Subject: [PATCH 09/12] Update `threads.spec.ts` - use Cypress Testing Library (#10680) * Set within() Signed-off-by: Suguru Hirahara * Use findByRole("textbox") Signed-off-by: Suguru Hirahara * Use findByText() Signed-off-by: Suguru Hirahara * Apply findByRole() to reaction and emoji picker Signed-off-by: Suguru Hirahara * Use findByText() and findByRole() for removing a message Signed-off-by: Suguru Hirahara * Apply findByRole() to the close button Signed-off-by: Suguru Hirahara * Replace other commands Signed-off-by: Suguru Hirahara --------- Signed-off-by: Suguru Hirahara --- cypress/e2e/threads/threads.spec.ts | 252 ++++++++++++++++------------ 1 file changed, 141 insertions(+), 111 deletions(-) diff --git a/cypress/e2e/threads/threads.spec.ts b/cypress/e2e/threads/threads.spec.ts index f93d9990c842..63546bd4174d 100644 --- a/cypress/e2e/threads/threads.spec.ts +++ b/cypress/e2e/threads/threads.spec.ts @@ -71,20 +71,22 @@ describe("Threads", () => { // Exclude timestamp and read marker from snapshots const percyCSS = ".mx_MessageTimestamp, .mx_RoomView_myReadMarker { visibility: hidden !important; }"; - // User sends message - cy.get(".mx_RoomView_body .mx_BasicMessageComposer_input").type("Hello Mr. Bot{enter}"); + cy.get(".mx_RoomView_body").within(() => { + // User sends message + cy.findByRole("textbox", { name: "Send a message…" }).type("Hello Mr. Bot{enter}"); - // Check the colour of timestamp on the main timeline - cy.get(".mx_RoomView_body .mx_EventTile_last .mx_EventTile_line .mx_MessageTimestamp").should( - "have.css", - "color", - MessageTimestampColor, - ); + // Check the colour of timestamp on the main timeline + cy.get(".mx_EventTile_last .mx_EventTile_line .mx_MessageTimestamp").should( + "have.css", + "color", + MessageTimestampColor, + ); - // Wait for message to send, get its ID and save as @threadId - cy.contains(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]", "Hello Mr. Bot") - .invoke("attr", "data-scroll-tokens") - .as("threadId"); + // Wait for message to send, get its ID and save as @threadId + cy.contains(".mx_EventTile[data-scroll-tokens]", "Hello Mr. Bot") + .invoke("attr", "data-scroll-tokens") + .as("threadId"); + }); // Bot starts thread cy.get("@threadId").then((threadId) => { @@ -96,15 +98,16 @@ describe("Threads", () => { }); // User asserts timeline thread summary visible & clicks it - cy.get(".mx_RoomView_body").within(() => { - cy.get(".mx_ThreadSummary .mx_ThreadSummary_sender").should("contain", "BotBob"); - cy.get(".mx_ThreadSummary .mx_ThreadSummary_content").should("contain", "Hello there"); - cy.get(".mx_ThreadSummary").click(); - }); + cy.get(".mx_RoomView_body .mx_ThreadSummary") + .within(() => { + cy.get(".mx_ThreadSummary_sender").findByText("BotBob").should("exist"); + cy.get(".mx_ThreadSummary_content").findByText(MessageLong).should("exist"); + }) + .click(); // Wait until the both messages are read cy.get(".mx_ThreadView .mx_EventTile_last[data-layout=group]").within(() => { - cy.get(".mx_EventTile_line .mx_MTextBody").should("have.text", MessageLong); + cy.get(".mx_EventTile_line .mx_MTextBody").findByText(MessageLong).should("exist"); cy.get(".mx_ReadReceiptGroup .mx_BaseAvatar_image").should("be.visible"); // Make sure the CSS style for spacing is applied to mx_EventTile_line on group/modern layout @@ -122,7 +125,7 @@ describe("Threads", () => { cy.get(".mx_ThreadView .mx_EventTile[data-layout='group'].mx_EventTile_last").within(() => { // Wait until the messages are rendered - cy.get(".mx_EventTile_line .mx_MTextBody").should("have.text", MessageLong); + cy.get(".mx_EventTile_line .mx_MTextBody").findByText(MessageLong).should("exist"); // Make sure the avatar inside ReadReceiptGroup is visible on the group layout cy.get(".mx_ReadReceiptGroup .mx_BaseAvatar_image").should("be.visible"); @@ -145,20 +148,22 @@ describe("Threads", () => { // Re-enable the group layout cy.setSettingValue("layout", null, SettingLevel.DEVICE, Layout.Group); - // User responds in thread - cy.get(".mx_ThreadView .mx_BasicMessageComposer_input").type("Test{enter}"); + cy.get(".mx_ThreadView").within(() => { + // User responds in thread + cy.findByRole("textbox", { name: "Send a message…" }).type("Test{enter}"); - // Check the colour of timestamp on EventTile in a thread (mx_ThreadView) - cy.get(".mx_ThreadView .mx_EventTile_last[data-layout='group'] .mx_EventTile_line .mx_MessageTimestamp").should( - "have.css", - "color", - MessageTimestampColor, - ); + // Check the colour of timestamp on EventTile in a thread (mx_ThreadView) + cy.get(".mx_EventTile_last[data-layout='group'] .mx_EventTile_line .mx_MessageTimestamp").should( + "have.css", + "color", + MessageTimestampColor, + ); + }); // User asserts summary was updated correctly cy.get(".mx_RoomView_body .mx_ThreadSummary").within(() => { - cy.get(".mx_ThreadSummary_sender").should("contain", "Tom"); - cy.get(".mx_ThreadSummary_content").should("contain", "Test"); + cy.get(".mx_ThreadSummary_sender").findByText("Tom").should("exist"); + cy.get(".mx_ThreadSummary_content").findByText("Test").should("exist"); }); //////////////////////////////////////////////////////////////////////////////////////////////////////////////// @@ -169,12 +174,17 @@ describe("Threads", () => { cy.setSettingValue("showHiddenEventsInTimeline", null, SettingLevel.DEVICE, true); // User reacts to message instead - cy.contains(".mx_ThreadView .mx_EventTile .mx_EventTile_line", "Hello there") - .find('[aria-label="React"]') - .click({ force: true }); // Cypress has no ability to hover + cy.get(".mx_ThreadView").within(() => { + cy.contains(".mx_EventTile .mx_EventTile_line", "Hello there") + .realHover() + .findByRole("toolbar", { name: "Message Actions" }) + .findByRole("button", { name: "React" }) + .click(); + }); + cy.get(".mx_EmojiPicker").within(() => { - cy.get('input[type="text"]').type("wave"); - cy.contains('[role="gridcell"]', "👋").click(); + cy.findByRole("textbox").type("wave"); + cy.findByRole("gridcell", { name: "👋" }).click(); }); cy.get(".mx_ThreadView").within(() => { @@ -231,17 +241,20 @@ describe("Threads", () => { // User redacts their prior response cy.contains(".mx_ThreadView .mx_EventTile .mx_EventTile_line", "Test") - .find('[aria-label="Options"]') - .click({ force: true }); // Cypress has no ability to hover + .realHover() + .findByRole("button", { name: "Options" }) + .click(); cy.get(".mx_IconizedContextMenu").within(() => { - cy.contains('[role="menuitem"]', "Remove").click(); + cy.findByRole("menuitem", { name: "Remove" }).click(); }); cy.get(".mx_TextInputDialog").within(() => { - cy.contains(".mx_Dialog_primary", "Remove").click(); + cy.findByRole("button", { name: "Remove" }).should("have.class", "mx_Dialog_primary").click(); }); - // Wait until the response is redacted - cy.get(".mx_ThreadView .mx_EventTile_last .mx_EventTile_receiptSent").should("be.visible"); + cy.get(".mx_ThreadView").within(() => { + // Wait until the response is redacted + cy.get(".mx_EventTile_last .mx_EventTile_receiptSent").should("be.visible"); + }); // Take Percy snapshots in group layout and bubble layout (IRC layout is not available on ThreadView) cy.get(".mx_ThreadView .mx_EventTile[data-layout='group']").should("be.visible"); @@ -258,12 +271,16 @@ describe("Threads", () => { cy.setSettingValue("layout", null, SettingLevel.DEVICE, Layout.Group); // User asserts summary was updated correctly - cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_sender").should("contain", "BotBob"); - cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_content").should("contain", "Hello there"); + cy.get(".mx_RoomView_body .mx_ThreadSummary").within(() => { + cy.get(".mx_ThreadSummary_sender").findByText("BotBob").should("exist"); + cy.get(".mx_ThreadSummary_content").findByText(MessageLong).should("exist"); + }); // User closes right panel after clicking back to thread list - cy.get(".mx_ThreadView .mx_BaseCard_back").click(); - cy.get(".mx_ThreadPanel .mx_BaseCard_close").click(); + cy.get(".mx_ThreadPanel").within(() => { + cy.findByRole("button", { name: "Threads" }).click(); + cy.findByRole("button", { name: "Close" }).click(); + }); // Bot responds to thread cy.get("@threadId").then((threadId) => { @@ -273,21 +290,22 @@ describe("Threads", () => { }); }); - cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_sender").should("contain", "BotBob"); - cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_content").should("contain", "How are things?"); - // User asserts thread list unread indicator - cy.get('.mx_HeaderButtons [aria-label="Threads"]').should("have.class", "mx_RightPanel_headerButton_unread"); + cy.get(".mx_RoomView_body .mx_ThreadSummary").within(() => { + cy.get(".mx_ThreadSummary_sender").findByText("BotBob").should("exist"); + cy.get(".mx_ThreadSummary_content").findByText("How are things?").should("exist"); + }); - // User opens thread list - cy.get('.mx_HeaderButtons [aria-label="Threads"]').click(); + cy.findByRole("tab", { name: "Threads" }) + .should("have.class", "mx_RightPanel_headerButton_unread") // User asserts thread list unread indicator + .click(); // User opens thread list // User asserts thread with correct root & latest events & unread dot cy.get(".mx_ThreadPanel .mx_EventTile_last").within(() => { - cy.get(".mx_EventTile_body").should("contain", "Hello Mr. Bot"); - cy.get(".mx_ThreadSummary_content").should("contain", "How are things?"); + cy.get(".mx_EventTile_body").findByText("Hello Mr. Bot").should("exist"); + cy.get(".mx_ThreadSummary_content").findByText("How are things?").should("exist"); // Check the number of the replies - cy.get(".mx_ThreadPanel_replies_amount").should("have.text", "2"); + cy.get(".mx_ThreadPanel_replies_amount").findByText("2").should("exist"); // Check the colour of timestamp on thread list cy.get(".mx_EventTile_details .mx_MessageTimestamp").should("have.css", "color", MessageTimestampColor); @@ -300,23 +318,29 @@ describe("Threads", () => { }); // User responds & asserts - cy.get(".mx_ThreadView .mx_BasicMessageComposer_input").type("Great!{enter}"); - cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_sender").should("contain", "Tom"); - cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_content").should("contain", "Great!"); + cy.get(".mx_ThreadView").within(() => { + cy.findByRole("textbox", { name: "Send a message…" }).type("Great!{enter}"); + }); + cy.get(".mx_RoomView_body .mx_ThreadSummary").within(() => { + cy.get(".mx_ThreadSummary_sender").findByText("Tom").should("exist"); + cy.get(".mx_ThreadSummary_content").findByText("Great!").should("exist"); + }); // User edits & asserts - cy.contains(".mx_ThreadView .mx_EventTile_last .mx_EventTile_line", "Great!").within(() => { - cy.get('[aria-label="Edit"]').click({ force: true }); // Cypress has no ability to hover - cy.get(".mx_BasicMessageComposer_input").type(" How about yourself?{enter}"); + cy.get(".mx_ThreadView .mx_EventTile_last").within(() => { + cy.findByText("Great!").should("exist"); + cy.get(".mx_EventTile_line").realHover().findByRole("button", { name: "Edit" }).click(); + cy.findByRole("textbox").type(" How about yourself?{enter}"); + }); + cy.get(".mx_RoomView_body .mx_ThreadSummary").within(() => { + cy.get(".mx_ThreadSummary_sender").findByText("Tom").should("exist"); + cy.get(".mx_ThreadSummary_content").findByText("Great! How about yourself?").should("exist"); }); - cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_sender").should("contain", "Tom"); - cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_content").should( - "contain", - "Great! How about yourself?", - ); // User closes right panel - cy.get(".mx_ThreadView .mx_BaseCard_close").click(); + cy.get(".mx_ThreadPanel").within(() => { + cy.findByRole("button", { name: "Close" }).click(); + }); // Bot responds to thread and saves the id of their message to @eventId cy.get("@threadId").then((threadId) => { @@ -331,11 +355,10 @@ describe("Threads", () => { }); // User asserts - cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_sender").should("contain", "BotBob"); - cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_content").should( - "contain", - "I'm very good thanks", - ); + cy.get(".mx_RoomView_body .mx_ThreadSummary").within(() => { + cy.get(".mx_ThreadSummary_sender").findByText("BotBob").should("exist"); + cy.get(".mx_ThreadSummary_content").findByText("I'm very good thanks").should("exist"); + }); // Bot edits their latest event cy.get("@eventId").then((eventId) => { @@ -354,11 +377,10 @@ describe("Threads", () => { }); // User asserts - cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_sender").should("contain", "BotBob"); - cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_content").should( - "contain", - "I'm very good thanks :)", - ); + cy.get(".mx_RoomView_body .mx_ThreadSummary").within(() => { + cy.get(".mx_ThreadSummary_sender").findByText("BotBob").should("exist"); + cy.get(".mx_ThreadSummary_content").findByText("I'm very good thanks :)").should("exist"); + }); }); it("can send voice messages", () => { @@ -375,18 +397,20 @@ describe("Threads", () => { }); // Send message - cy.get(".mx_RoomView_body .mx_BasicMessageComposer_input").type("Hello Mr. Bot{enter}"); + cy.get(".mx_RoomView_body").within(() => { + cy.findByRole("textbox", { name: "Send a message…" }).type("Hello Mr. Bot{enter}"); - // Create thread - cy.contains(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]", "Hello Mr. Bot") - .realHover() - .find(".mx_MessageActionBar_threadButton") - .click(); + // Create thread + cy.contains(".mx_EventTile[data-scroll-tokens]", "Hello Mr. Bot") + .realHover() + .findByRole("button", { name: "Reply in thread" }) + .click(); + }); cy.get(".mx_ThreadView_timelinePanelWrapper").should("have.length", 1); - cy.openMessageComposerOptions(true).find(`[aria-label="Voice Message"]`).click(); + cy.openMessageComposerOptions(true).findByRole("menuitem", { name: "Voice Message" }).click(); cy.wait(3000); - cy.getComposer(true).find(".mx_MessageComposer_sendMessage").click(); + cy.getComposer(true).findByRole("button", { name: "Send voice message" }).click(); cy.get(".mx_ThreadView .mx_MVoiceMessageBody").should("have.length", 1); }); @@ -394,10 +418,10 @@ describe("Threads", () => { it("should send location and reply to the location on ThreadView", () => { // See: location.spec.ts const selectLocationShareTypeOption = (shareType: string): Chainable => { - return cy.get(`[data-testid="share-location-option-${shareType}"]`); + return cy.findByTestId(`share-location-option-${shareType}`); }; const submitShareLocation = (): void => { - cy.get('[data-testid="location-picker-submit-button"]').click(); + cy.findByRole("button", { name: "Share location" }).click(); }; let bot: MatrixClient; @@ -423,13 +447,15 @@ describe("Threads", () => { const percyCSS = ".mx_MessageTimestamp, .mx_RoomView_myReadMarker, .mapboxgl-map { visibility: hidden !important; }"; - // User sends message - cy.get(".mx_RoomView_body .mx_BasicMessageComposer_input").type("Hello Mr. Bot{enter}"); + cy.get(".mx_RoomView_body").within(() => { + // User sends message + cy.findByRole("textbox", { name: "Send a message…" }).type("Hello Mr. Bot{enter}"); - // Wait for message to send, get its ID and save as @threadId - cy.contains(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]", "Hello Mr. Bot") - .invoke("attr", "data-scroll-tokens") - .as("threadId"); + // Wait for message to send, get its ID and save as @threadId + cy.contains(".mx_EventTile[data-scroll-tokens]", "Hello Mr. Bot") + .invoke("attr", "data-scroll-tokens") + .as("threadId"); + }); // Bot starts thread cy.get("@threadId").then((threadId) => { @@ -444,7 +470,7 @@ describe("Threads", () => { // User sends location on ThreadView cy.get(".mx_ThreadView").should("exist"); - cy.openMessageComposerOptions(true).find("[aria-label='Location']").click(); + cy.openMessageComposerOptions(true).findByRole("menuitem", { name: "Location" }).click(); selectLocationShareTypeOption("Pin").click(); cy.get("#mx_LocationPicker_map").click("center"); submitShareLocation(); @@ -452,13 +478,9 @@ describe("Threads", () => { // User replies to the location cy.get(".mx_ThreadView").within(() => { - cy.get(".mx_EventTile_last") - .realHover() - .within(() => { - cy.get("[aria-label='Reply']").click({ force: false }); - }); + cy.get(".mx_EventTile_last").realHover().findByRole("button", { name: "Reply" }).click(); - cy.get(".mx_BasicMessageComposer_input").type("Please come here.{enter}"); + cy.findByRole("textbox", { name: "Reply to thread…" }).type("Please come here.{enter}"); // Wait until the reply is sent cy.get(".mx_EventTile_last .mx_EventTile_receiptSent").should("be.visible"); @@ -475,30 +497,38 @@ describe("Threads", () => { roomId = _roomId; cy.visit("/#/room/" + roomId); }); + // Send message - cy.get(".mx_RoomView_body .mx_BasicMessageComposer_input").type("Hello Mr. Bot{enter}"); + cy.get(".mx_RoomView_body").within(() => { + cy.findByRole("textbox", { name: "Send a message…" }).type("Hello Mr. Bot{enter}"); - // Create thread - cy.contains(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]", "Hello Mr. Bot") - .realHover() - .find(".mx_MessageActionBar_threadButton") - .click(); + // Create thread + cy.contains(".mx_EventTile[data-scroll-tokens]", "Hello Mr. Bot") + .realHover() + .findByRole("button", { name: "Reply in thread" }) + .click(); + }); cy.get(".mx_ThreadView_timelinePanelWrapper").should("have.length", 1); // Send message to thread - cy.get(".mx_BaseCard .mx_BasicMessageComposer_input").type("Hello Mr. User{enter}"); - cy.get(".mx_BaseCard .mx_EventTile").should("contain", "Hello Mr. User"); + cy.get(".mx_ThreadPanel").within(() => { + cy.findByRole("textbox", { name: "Send a message…" }).type("Hello Mr. User{enter}"); + cy.get(".mx_EventTile_last").findByText("Hello Mr. User").should("exist"); - // Close thread - cy.get(".mx_BaseCard_close").click(); + // Close thread + cy.findByRole("button", { name: "Close" }).click(); + }); // Open existing thread cy.contains(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]", "Hello Mr. Bot") .realHover() - .find(".mx_MessageActionBar_threadButton") + .findByRole("button", { name: "Reply in thread" }) .click(); cy.get(".mx_ThreadView_timelinePanelWrapper").should("have.length", 1); - cy.get(".mx_BaseCard .mx_EventTile").should("contain", "Hello Mr. Bot"); - cy.get(".mx_BaseCard .mx_EventTile").should("contain", "Hello Mr. User"); + + cy.get(".mx_BaseCard").within(() => { + cy.get(".mx_EventTile").first().findByText("Hello Mr. Bot").should("exist"); + cy.get(".mx_EventTile").last().findByText("Hello Mr. User").should("exist"); + }); }); }); From 792a39a39b20a5ce2872cd020e1e7783120aff21 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 21 Apr 2023 10:48:48 +0100 Subject: [PATCH 10/12] ARIA Accessibility improvements (#10675) * Fix confusing tab indexes in EventTilePreview * Stop using headings inside buttons * Prefer labelledby and describedby over duplicated aria-labels * Improve semantics of tables used in settings * Fix types * Update tests * Fix timestamps --- res/css/structures/_SpaceRoomView.pcss | 13 ++--- .../views/settings/_CrossSigningPanel.pcss | 7 ++- .../views/settings/_CryptographyPanel.pcss | 30 ++++++++-- .../views/settings/_SecureBackupPanel.pcss | 7 ++- src/components/structures/SpaceRoomView.tsx | 4 +- .../views/elements/EventTilePreview.tsx | 4 +- .../views/elements/LabelledToggleSwitch.tsx | 19 +++--- .../views/elements/ToggleSwitch.tsx | 4 +- src/components/views/rooms/EventTile.tsx | 19 ++++-- .../views/settings/CrossSigningPanel.tsx | 58 +++++++++---------- .../views/settings/CryptographyPanel.tsx | 30 +++++----- .../views/settings/SecureBackupPanel.tsx | 58 +++++++++---------- .../views/spaces/SpaceCreateMenu.tsx | 4 +- .../views/spaces/SpacePublicShare.tsx | 6 +- .../structures/SpaceHierarchy-test.tsx | 8 +-- .../views/location/LocationShareMenu-test.tsx | 5 ++ .../LocationShareMenu-test.tsx.snap | 8 ++- .../views/settings/Notifications-test.tsx | 12 ++++ .../__snapshots__/Notifications-test.tsx.snap | 11 +++- .../SpaceSettingsVisibilityTab-test.tsx | 17 ++++-- .../SpaceSettingsVisibilityTab-test.tsx.snap | 10 +++- 21 files changed, 197 insertions(+), 137 deletions(-) diff --git a/res/css/structures/_SpaceRoomView.pcss b/res/css/structures/_SpaceRoomView.pcss index 433febae48de..6a71a75d9545 100644 --- a/res/css/structures/_SpaceRoomView.pcss +++ b/res/css/structures/_SpaceRoomView.pcss @@ -23,15 +23,14 @@ $SpaceRoomViewInnerWidth: 428px; box-sizing: border-box; border-radius: 8px; border: 1px solid $input-border-color; - font-size: $font-15px; + font-size: $font-17px; + font-weight: $font-semi-bold; margin: 20px 0; - > h3 { - font-weight: $font-semi-bold; - margin: 0 0 4px; - } - - > span { + > div { + margin-top: 4px; + font-weight: normal; + font-size: $font-15px; color: $secondary-content; } diff --git a/res/css/views/settings/_CrossSigningPanel.pcss b/res/css/views/settings/_CrossSigningPanel.pcss index 12a0e36835f7..1b5f7d1f74cc 100644 --- a/res/css/views/settings/_CrossSigningPanel.pcss +++ b/res/css/views/settings/_CrossSigningPanel.pcss @@ -17,7 +17,12 @@ limitations under the License. .mx_CrossSigningPanel_statusList { border-spacing: 0; - td { + th { + text-align: start; + } + + td, + th { padding: 0; &:first-of-type { diff --git a/res/css/views/settings/_CryptographyPanel.pcss b/res/css/views/settings/_CryptographyPanel.pcss index 98dab47c592b..855949d013d0 100644 --- a/res/css/views/settings/_CryptographyPanel.pcss +++ b/res/css/views/settings/_CryptographyPanel.pcss @@ -1,3 +1,19 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + .mx_CryptographyPanel_sessionInfo { padding: 0em; border-spacing: 0px; @@ -5,13 +21,15 @@ .mx_CryptographyPanel_sessionInfo > tr { vertical-align: baseline; padding: 0em; -} -.mx_CryptographyPanel_sessionInfo > tr > td { - padding-bottom: 0em; - padding-left: 0em; - padding-right: 1em; - padding-top: 0em; + th { + text-align: start; + } + + td, + th { + padding: 0 1em 0 0; + } } .mx_CryptographyPanel_importExportButtons .mx_AccessibleButton { diff --git a/res/css/views/settings/_SecureBackupPanel.pcss b/res/css/views/settings/_SecureBackupPanel.pcss index 86f7b2036d09..6dcc8321fd7e 100644 --- a/res/css/views/settings/_SecureBackupPanel.pcss +++ b/res/css/views/settings/_SecureBackupPanel.pcss @@ -50,7 +50,12 @@ limitations under the License. .mx_SecureBackupPanel_statusList { border-spacing: 0; - td { + th { + text-align: start; + } + + td, + th { padding: 0; &:first-of-type { diff --git a/src/components/structures/SpaceRoomView.tsx b/src/components/structures/SpaceRoomView.tsx index b8b020f039c0..85806913110f 100644 --- a/src/components/structures/SpaceRoomView.tsx +++ b/src/components/structures/SpaceRoomView.tsx @@ -476,7 +476,7 @@ const SpaceSetupPrivateScope: React.FC<{ onFinished(false); }} > -

    {_t("Just me")}

    + {_t("Just me")}
    {_t("A private space to organise your rooms")}
    -

    {_t("Me and my teammates")}

    + {_t("Me and my teammates")}
    {_t("A private space for you and your teammates")}
    diff --git a/src/components/views/elements/EventTilePreview.tsx b/src/components/views/elements/EventTilePreview.tsx index eaa41903f700..2648aac6a706 100644 --- a/src/components/views/elements/EventTilePreview.tsx +++ b/src/components/views/elements/EventTilePreview.tsx @@ -128,8 +128,8 @@ export default class EventTilePreview extends React.Component { const event = this.fakeEvent(this.state); return ( -
    - +
    +
    ); } diff --git a/src/components/views/elements/LabelledToggleSwitch.tsx b/src/components/views/elements/LabelledToggleSwitch.tsx index 4455b16a9f38..2a1540920b2b 100644 --- a/src/components/views/elements/LabelledToggleSwitch.tsx +++ b/src/components/views/elements/LabelledToggleSwitch.tsx @@ -16,6 +16,7 @@ limitations under the License. import React from "react"; import classNames from "classnames"; +import { randomString } from "matrix-js-sdk/src/randomstring"; import ToggleSwitch from "./ToggleSwitch"; import { Caption } from "../typography/Caption"; @@ -43,18 +44,15 @@ interface IProps { } export default class LabelledToggleSwitch extends React.PureComponent { + private readonly id = `mx_LabelledToggleSwitch_${randomString(12)}`; + public render(): React.ReactNode { // This is a minimal version of a SettingsFlag const { label, caption } = this.props; let firstPart = ( - {label} - {caption && ( - <> -
    - {caption} - - )} +
    {label}
    + {caption && {caption}}
    ); let secondPart = ( @@ -62,15 +60,14 @@ export default class LabelledToggleSwitch extends React.PureComponent { checked={this.props.value} disabled={this.props.disabled} onChange={this.props.onChange} - title={this.props.label} tooltip={this.props.tooltip} + aria-labelledby={this.id} + aria-describedby={caption ? `${this.id}_caption` : undefined} /> ); if (this.props.toggleInFront) { - const temp = firstPart; - firstPart = secondPart; - secondPart = temp; + [firstPart, secondPart] = [secondPart, firstPart]; } const classes = classNames("mx_SettingsFlag", this.props.className, { diff --git a/src/components/views/elements/ToggleSwitch.tsx b/src/components/views/elements/ToggleSwitch.tsx index f29405ba8d5f..588374d17b67 100644 --- a/src/components/views/elements/ToggleSwitch.tsx +++ b/src/components/views/elements/ToggleSwitch.tsx @@ -41,7 +41,7 @@ interface IProps { } // Controlled Toggle Switch element, written with Accessibility in mind -export default ({ checked, disabled = false, title, tooltip, onChange, ...props }: IProps): JSX.Element => { +export default ({ checked, disabled = false, onChange, ...props }: IProps): JSX.Element => { const _onClick = (): void => { if (disabled) return; onChange(!checked); @@ -61,8 +61,6 @@ export default ({ checked, disabled = false, title, tooltip, onChange, ...props role="switch" aria-checked={checked} aria-disabled={disabled} - title={title} - tooltip={tooltip} >
    diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index ab3a4493dafd..fb44993553a5 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -218,6 +218,10 @@ export interface EventTileProps { // displayed to the current user either because they're // the author or they are a moderator isSeeingThroughMessageHiddenForModeration?: boolean; + + // The following properties are used by EventTilePreview to disable tab indexes within the event tile + hideTimestamp?: boolean; + inhibitInteraction?: boolean; } interface IState { @@ -1006,7 +1010,7 @@ export class UnwrappedEventTile extends React.Component } if (this.props.mxEvent.sender && avatarSize) { - let member; + let member: RoomMember | null = null; // set member to receiver (target) if it is a 3PID invite // so that the correct avatar is shown as the text is // `$target accepted the invitation for $email` @@ -1016,9 +1020,11 @@ export class UnwrappedEventTile extends React.Component member = this.props.mxEvent.sender; } // In the ThreadsList view we use the entire EventTile as a click target to open the thread instead - const viewUserOnClick = ![TimelineRenderingType.ThreadsList, TimelineRenderingType.Notification].includes( - this.context.timelineRenderingType, - ); + const viewUserOnClick = + !this.props.inhibitInteraction && + ![TimelineRenderingType.ThreadsList, TimelineRenderingType.Notification].includes( + this.context.timelineRenderingType, + ); avatar = (
    const showTimestamp = this.props.mxEvent.getTs() && + !this.props.hideTimestamp && (this.props.alwaysShowTimestamps || this.props.last || this.state.hover || @@ -1101,7 +1108,7 @@ export class UnwrappedEventTile extends React.Component ); } - const linkedTimestamp = ( + const linkedTimestamp = !this.props.hideTimestamp ? ( > {timestamp} - ); + ) : null; const useIRCLayout = this.props.layout === Layout.IRC; const groupTimestamp = !useIRCLayout ? linkedTimestamp : null; diff --git a/src/components/views/settings/CrossSigningPanel.tsx b/src/components/views/settings/CrossSigningPanel.tsx index e3f62d4ba263..d3926d954fa9 100644 --- a/src/components/views/settings/CrossSigningPanel.tsx +++ b/src/components/views/settings/CrossSigningPanel.tsx @@ -243,36 +243,34 @@ export default class CrossSigningPanel extends React.PureComponent<{}, IState> {
    {_t("Advanced")} - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + +
    {_t("Cross-signing public keys:")}{crossSigningPublicKeysOnDevice ? _t("in memory") : _t("not found")}
    {_t("Cross-signing private keys:")} - {crossSigningPrivateKeysInStorage - ? _t("in secret storage") - : _t("not found in storage")} -
    {_t("Master private key:")}{masterPrivateKeyCached ? _t("cached locally") : _t("not found locally")}
    {_t("Self signing private key:")}{selfSigningPrivateKeyCached ? _t("cached locally") : _t("not found locally")}
    {_t("User signing private key:")}{userSigningPrivateKeyCached ? _t("cached locally") : _t("not found locally")}
    {_t("Homeserver feature support:")}{homeserverSupportsCrossSigning ? _t("exists") : _t("not found")}
    {_t("Cross-signing public keys:")}{crossSigningPublicKeysOnDevice ? _t("in memory") : _t("not found")}
    {_t("Cross-signing private keys:")} + {crossSigningPrivateKeysInStorage + ? _t("in secret storage") + : _t("not found in storage")} +
    {_t("Master private key:")}{masterPrivateKeyCached ? _t("cached locally") : _t("not found locally")}
    {_t("Self signing private key:")}{selfSigningPrivateKeyCached ? _t("cached locally") : _t("not found locally")}
    {_t("User signing private key:")}{userSigningPrivateKeyCached ? _t("cached locally") : _t("not found locally")}
    {_t("Homeserver feature support:")}{homeserverSupportsCrossSigning ? _t("exists") : _t("not found")}
    {errorSection} diff --git a/src/components/views/settings/CryptographyPanel.tsx b/src/components/views/settings/CryptographyPanel.tsx index 34b52e405ee9..79ddad2544ee 100644 --- a/src/components/views/settings/CryptographyPanel.tsx +++ b/src/components/views/settings/CryptographyPanel.tsx @@ -75,22 +75,20 @@ export default class CryptographyPanel extends React.Component {
    {_t("Cryptography")} - - - - - - - - - - + + + + + + + +
    {_t("Session ID:")} - {deviceId} -
    {_t("Session key:")} - - {identityKey} - -
    {_t("Session ID:")} + {deviceId} +
    {_t("Session key:")} + + {identityKey} + +
    {importExportButtons} {noSendUnverifiedSetting} diff --git a/src/components/views/settings/SecureBackupPanel.tsx b/src/components/views/settings/SecureBackupPanel.tsx index 747378684c08..2b19a8af583e 100644 --- a/src/components/views/settings/SecureBackupPanel.tsx +++ b/src/components/views/settings/SecureBackupPanel.tsx @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from "react"; +import React, { ReactNode } from "react"; import { IKeyBackupInfo } from "matrix-js-sdk/src/crypto/keybackup"; import { TrustInfo } from "matrix-js-sdk/src/crypto/backup"; import { CryptoEvent } from "matrix-js-sdk/src/crypto"; @@ -231,9 +231,9 @@ export default class SecureBackupPanel extends React.PureComponent<{}, IState> { sessionsRemaining, } = this.state; - let statusDescription; - let extraDetailsTableRows; - let extraDetails; + let statusDescription: JSX.Element; + let extraDetailsTableRows: JSX.Element | undefined; + let extraDetails: JSX.Element | undefined; const actions: JSX.Element[] = []; if (error) { statusDescription =
    {_t("Unable to load key backup status")}
    ; @@ -267,7 +267,7 @@ export default class SecureBackupPanel extends React.PureComponent<{}, IState> { restoreButtonCaption = _t("Connect this session to Key Backup"); } - let uploadStatus; + let uploadStatus: ReactNode; if (!MatrixClientPeg.get().getKeyBackupEnabled()) { // No upload status to show when backup disabled. uploadStatus = ""; @@ -391,11 +391,11 @@ export default class SecureBackupPanel extends React.PureComponent<{}, IState> { extraDetailsTableRows = ( <> - {_t("Backup version:")} + {_t("Backup version:")} {backupInfo.version} - {_t("Algorithm:")} + {_t("Algorithm:")} {backupInfo.algorithm} @@ -460,7 +460,7 @@ export default class SecureBackupPanel extends React.PureComponent<{}, IState> { } } - let actionRow; + let actionRow: JSX.Element | undefined; if (actions.length) { actionRow =
    {actions}
    ; } @@ -478,28 +478,26 @@ export default class SecureBackupPanel extends React.PureComponent<{}, IState> {
    {_t("Advanced")} - - - - - - - - - - - - - - - - - - {extraDetailsTableRows} - + + + + + + + + + + + + + + + + + {extraDetailsTableRows}
    {_t("Backup key stored:")}{backupKeyStored === true ? _t("in secret storage") : _t("not stored")}
    {_t("Backup key cached:")} - {backupKeyCached ? _t("cached locally") : _t("not found locally")} - {backupKeyWellFormedText} -
    {_t("Secret storage public key:")}{secretStorageKeyInAccount ? _t("in account data") : _t("not found")}
    {_t("Secret storage:")}{secretStorageReady ? _t("ready") : _t("not ready")}
    {_t("Backup key stored:")}{backupKeyStored === true ? _t("in secret storage") : _t("not stored")}
    {_t("Backup key cached:")} + {backupKeyCached ? _t("cached locally") : _t("not found locally")} + {backupKeyWellFormedText} +
    {_t("Secret storage public key:")}{secretStorageKeyInAccount ? _t("in account data") : _t("not found")}
    {_t("Secret storage:")}{secretStorageReady ? _t("ready") : _t("not ready")}
    {extraDetails}
    diff --git a/src/components/views/spaces/SpaceCreateMenu.tsx b/src/components/views/spaces/SpaceCreateMenu.tsx index 64fc408b7745..ded069778d5b 100644 --- a/src/components/views/spaces/SpaceCreateMenu.tsx +++ b/src/components/views/spaces/SpaceCreateMenu.tsx @@ -89,8 +89,8 @@ const SpaceCreateMenuType: React.FC<{ }> = ({ title, description, className, onClick }) => { return ( -

    {title}

    - {description} + {title} +
    {description}
    ); }; diff --git a/src/components/views/spaces/SpacePublicShare.tsx b/src/components/views/spaces/SpacePublicShare.tsx index 85446ab25175..68bf940831a8 100644 --- a/src/components/views/spaces/SpacePublicShare.tsx +++ b/src/components/views/spaces/SpacePublicShare.tsx @@ -52,7 +52,7 @@ const SpacePublicShare: React.FC = ({ space, onFinished }) => { } }} > -

    {_t("Share invite link")}

    + {_t("Share invite link")} {copiedText} {space.canInvite(MatrixClientPeg.get()?.getUserId()) && shouldShowComponent(UIComponent.InviteUsers) ? ( @@ -63,8 +63,8 @@ const SpacePublicShare: React.FC = ({ space, onFinished }) => { showRoomInviteDialog(space.roomId); }} > -

    {_t("Invite people")}

    - {_t("Invite with email or username")} + {_t("Invite people")} +
    {_t("Invite with email or username")}
    ) : null}
    diff --git a/test/components/structures/SpaceHierarchy-test.tsx b/test/components/structures/SpaceHierarchy-test.tsx index 5f248140c89a..b81a84facaa5 100644 --- a/test/components/structures/SpaceHierarchy-test.tsx +++ b/test/components/structures/SpaceHierarchy-test.tsx @@ -32,11 +32,9 @@ import DMRoomMap from "../../../src/utils/DMRoomMap"; import SettingsStore from "../../../src/settings/SettingsStore"; // Fake random strings to give a predictable snapshot for checkbox IDs -jest.mock("matrix-js-sdk/src/randomstring", () => { - return { - randomString: () => "abdefghi", - }; -}); +jest.mock("matrix-js-sdk/src/randomstring", () => ({ + randomString: () => "abdefghi", +})); describe("SpaceHierarchy", () => { describe("showRoom", () => { diff --git a/test/components/views/location/LocationShareMenu-test.tsx b/test/components/views/location/LocationShareMenu-test.tsx index 9ee667f319e9..8ab7b46cbd0c 100644 --- a/test/components/views/location/LocationShareMenu-test.tsx +++ b/test/components/views/location/LocationShareMenu-test.tsx @@ -72,6 +72,11 @@ jest.mock("../../../../src/Modal", () => ({ ModalManagerEvent: { Opened: "opened" }, })); +// Fake random strings to give a predictable snapshot for IDs +jest.mock("matrix-js-sdk/src/randomstring", () => ({ + randomString: () => "abdefghi", +})); + describe("", () => { const userId = "@ernie:server.org"; const mockClient = getMockClientWithEventEmitter({ diff --git a/test/components/views/location/__snapshots__/LocationShareMenu-test.tsx.snap b/test/components/views/location/__snapshots__/LocationShareMenu-test.tsx.snap index cd492db7a2ee..e83d959d5cdb 100644 --- a/test/components/views/location/__snapshots__/LocationShareMenu-test.tsx.snap +++ b/test/components/views/location/__snapshots__/LocationShareMenu-test.tsx.snap @@ -25,12 +25,16 @@ exports[` with live location disabled goes to labs flag scr - Enable live location sharing +
    + Enable live location sharing +
    ({ + randomString: jest.fn(), +})); + const masterRule: IPushRule = { actions: [PushRuleActionName.DontNotify], conditions: [], @@ -271,6 +278,11 @@ describe("", () => { mockClient.getPushRules.mockResolvedValue(pushRules); beforeEach(() => { + let i = 0; + mocked(randomString).mockImplementation(() => { + return "testid_" + i++; + }); + mockClient.getPushRules.mockClear().mockResolvedValue(pushRules); mockClient.getPushers.mockClear().mockResolvedValue({ pushers: [] }); mockClient.getThreePids.mockClear().mockResolvedValue({ threepids: [] }); diff --git a/test/components/views/settings/__snapshots__/Notifications-test.tsx.snap b/test/components/views/settings/__snapshots__/Notifications-test.tsx.snap index 23fec442b250..6d9c127b5f8e 100644 --- a/test/components/views/settings/__snapshots__/Notifications-test.tsx.snap +++ b/test/components/views/settings/__snapshots__/Notifications-test.tsx.snap @@ -12,18 +12,23 @@ exports[` main notification switches renders only enable notifi - Enable notifications for this account -
    +
    + Enable notifications for this account +
    Turn off to disable notifications on all your devices and sessions
    ({ + randomString: jest.fn(), +})); + jest.useFakeTimers(); describe("", () => { @@ -89,13 +95,16 @@ describe("", () => { const toggleButton = getByTestId("toggle-guest-access-btn")!; fireEvent.click(toggleButton); }; - const getGuestAccessToggle = ({ container }: RenderResult) => - container.querySelector('[aria-label="Enable guest access"]'); - const getHistoryVisibilityToggle = ({ container }: RenderResult) => - container.querySelector('[aria-label="Preview Space"]'); + const getGuestAccessToggle = ({ getByLabelText }: RenderResult) => getByLabelText("Enable guest access"); + const getHistoryVisibilityToggle = ({ getByLabelText }: RenderResult) => getByLabelText("Preview Space"); const getErrorMessage = ({ getByTestId }: RenderResult) => getByTestId("space-settings-error")?.textContent; beforeEach(() => { + let i = 0; + mocked(randomString).mockImplementation(() => { + return "testid_" + i++; + }); + (mockMatrixClient.sendStateEvent as jest.Mock).mockClear().mockResolvedValue({}); MatrixClientPeg.get = jest.fn().mockReturnValue(mockMatrixClient); }); diff --git a/test/components/views/spaces/__snapshots__/SpaceSettingsVisibilityTab-test.tsx.snap b/test/components/views/spaces/__snapshots__/SpaceSettingsVisibilityTab-test.tsx.snap index 8de0ae2c153e..a93fda9d6a56 100644 --- a/test/components/views/spaces/__snapshots__/SpaceSettingsVisibilityTab-test.tsx.snap +++ b/test/components/views/spaces/__snapshots__/SpaceSettingsVisibilityTab-test.tsx.snap @@ -4,7 +4,7 @@ exports[` for a public space Access renders guest
    renders container 1`] = ` - Preview Space +
    + Preview Space +
    Date: Fri, 21 Apr 2023 11:50:42 +0100 Subject: [PATCH 11/12] Conform more of the codebase to strictNullChecks (#10672) * Conform more of the codebase to `strictNullChecks` * Iterate * Iterate * Iterate * Iterate * Conform more of the codebase to `strictNullChecks` * Iterate * Update record key --- src/Avatar.ts | 2 +- src/Searching.ts | 23 +++++++++----- src/actions/RoomListActions.ts | 11 ++----- src/components/structures/MessagePanel.tsx | 3 +- src/components/structures/RoomView.tsx | 11 ++++--- src/components/structures/ScrollPanel.tsx | 8 ++--- src/components/structures/UserView.tsx | 3 +- .../views/context_menus/RoomContextMenu.tsx | 6 ++-- .../context_menus/RoomGeneralContextMenu.tsx | 2 +- .../views/dialogs/ReportEventDialog.tsx | 6 ++-- .../security/AccessSecretStorageDialog.tsx | 2 +- .../security/RestoreKeyBackupDialog.tsx | 5 ++- .../views/rooms/MessageComposerButtons.tsx | 21 +++++-------- src/components/views/rooms/RoomPreviewBar.tsx | 10 +++--- .../views/rooms/SearchResultTile.tsx | 2 +- .../views/rooms/SendMessageComposer.tsx | 12 ++++--- .../tabs/room/AdvancedRoomSettingsTab.tsx | 2 +- .../tabs/user/GeneralUserSettingsTab.tsx | 31 ++++++++++++------- src/components/views/voip/AudioFeed.tsx | 4 +-- src/components/views/voip/LegacyCallView.tsx | 20 ++++++------ src/components/views/voip/VideoFeed.tsx | 2 +- src/indexing/EventIndex.ts | 15 +++++---- src/models/Call.ts | 4 +-- src/resizer/sizer.ts | 4 +-- src/stores/BreadcrumbsStore.ts | 2 +- src/stores/OwnBeaconStore.ts | 9 +++--- src/stores/local-echo/RoomEchoChamber.ts | 7 +++-- src/stores/spaces/SpaceStore.ts | 4 +-- src/stores/widgets/StopGapWidgetDriver.ts | 9 ++++-- src/stores/widgets/WidgetLayoutStore.ts | 2 +- src/stores/widgets/WidgetPermissionStore.ts | 2 +- src/utils/SortMembers.ts | 10 +++--- src/utils/notifications.ts | 2 +- .../dialogs/MessageEditHistoryDialog-test.tsx | 2 +- 34 files changed, 143 insertions(+), 115 deletions(-) diff --git a/src/Avatar.ts b/src/Avatar.ts index a023ba0ee754..79254ef1b59f 100644 --- a/src/Avatar.ts +++ b/src/Avatar.ts @@ -26,7 +26,7 @@ import { isLocalRoom } from "./utils/localRoom/isLocalRoom"; // Not to be used for BaseAvatar urls as that has similar default avatar fallback already export function avatarUrlForMember( - member: RoomMember, + member: RoomMember | undefined, width: number, height: number, resizeMethod: ResizeMethod, diff --git a/src/Searching.ts b/src/Searching.ts index 85efeea8c809..25800c8e06e7 100644 --- a/src/Searching.ts +++ b/src/Searching.ts @@ -176,7 +176,10 @@ async function localSearch( searchArgs.room_id = roomId; } - const localResult = await eventIndex.search(searchArgs); + const localResult = await eventIndex!.search(searchArgs); + if (!localResult) { + throw new Error("Local search failed"); + } searchArgs.next_batch = localResult.next_batch; @@ -225,7 +228,11 @@ async function localPagination(searchResult: ISeshatSearchResults): Promise[2] | null = null; @@ -63,12 +62,8 @@ export default class RoomListActions { newList.sort((a, b) => a.tags[newTag].order - b.tags[newTag].order); - // If the room was moved "down" (increasing index) in the same list we - // need to use the orders of the tiles with indices shifted by +1 - const offset = newTag === oldTag && oldIndex < newIndex ? 1 : 0; - - const indexBefore = offset + newIndex - 1; - const indexAfter = offset + newIndex; + const indexBefore = newIndex - 1; + const indexAfter = newIndex; const prevOrder = indexBefore <= 0 ? 0 : newList[indexBefore].tags[newTag].order; const nextOrder = indexAfter >= newList.length ? 1 : newList[indexAfter].tags[newTag].order; diff --git a/src/components/structures/MessagePanel.tsx b/src/components/structures/MessagePanel.tsx index ed6f778bd591..3d48c925fe27 100644 --- a/src/components/structures/MessagePanel.tsx +++ b/src/components/structures/MessagePanel.tsx @@ -883,6 +883,7 @@ export default class MessagePanel extends React.Component { const existingReceipts = receiptsByEvent.get(lastShownEventId) || []; const newReceipts = this.getReadReceiptsForEvent(event); + if (!newReceipts) continue; receiptsByEvent.set(lastShownEventId, existingReceipts.concat(newReceipts)); // Record these receipts along with their last shown event ID for @@ -1218,7 +1219,7 @@ class CreationGrouper extends BaseGrouper { key="roomcreationsummary" events={this.events} onToggle={panel.onHeightChanged} // Update scroll state - summaryMembers={[ev.sender]} + summaryMembers={ev.sender ? [ev.sender] : undefined} summaryText={summaryText} layout={this.panel.props.layout} > diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 4e14733d04ba..d1d2a5807ba9 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -627,7 +627,7 @@ export class RoomView extends React.Component { mainSplitContentType: room ? this.getMainSplitContentType(room) : undefined, initialEventId: undefined, // default to clearing this, will get set later in the method if needed showRightPanel: this.context.rightPanelStore.isOpenForRoom(roomId), - activeCall: CallStore.instance.getActiveCall(roomId), + activeCall: roomId ? CallStore.instance.getActiveCall(roomId) : null, }; if ( @@ -1071,6 +1071,7 @@ export class RoomView extends React.Component { }; private onAction = async (payload: ActionPayload): Promise => { + if (!this.context.client) return; switch (payload.action) { case "message_sent": this.checkDesktopNotifications(); @@ -1228,7 +1229,7 @@ export class RoomView extends React.Component { this.handleEffects(ev); } - if (ev.getSender() !== this.context.client.getSafeUserId()) { + if (this.context.client && ev.getSender() !== this.context.client.getSafeUserId()) { // update unread count when scrolled up if (!this.state.search && this.state.atEndOfLiveTimeline) { // no change @@ -1469,7 +1470,7 @@ export class RoomView extends React.Component { }; private updatePermissions(room: Room): void { - if (room) { + if (room && this.context.client) { const me = this.context.client.getSafeUserId(); const canReact = room.getMyMembership() === "join" && room.currentState.maySendEvent(EventType.Reaction, me); @@ -1956,6 +1957,8 @@ export class RoomView extends React.Component { } public render(): React.ReactNode { + if (!this.context.client) return null; + if (this.state.room instanceof LocalRoom) { if (this.state.room.state === LocalRoomState.CREATING) { return this.renderLocalRoomCreateLoader(this.state.room); @@ -2064,7 +2067,7 @@ export class RoomView extends React.Component { const inviteEvent = myMember ? myMember.events.member : null; let inviterName = _t("Unknown"); if (inviteEvent) { - inviterName = inviteEvent.sender?.name ?? inviteEvent.getSender(); + inviterName = inviteEvent.sender?.name ?? inviteEvent.getSender()!; } // We deliberately don't try to peek into invites, even if we have permission to peek diff --git a/src/components/structures/ScrollPanel.tsx b/src/components/structures/ScrollPanel.tsx index 3b380c1d193b..5c2edef0ea5f 100644 --- a/src/components/structures/ScrollPanel.tsx +++ b/src/components/structures/ScrollPanel.tsx @@ -786,7 +786,7 @@ export default class ScrollPanel extends React.Component { const scrollState = this.scrollState; const trackedNode = scrollState.trackedNode; - if (!trackedNode?.parentElement) { + if (!trackedNode?.parentElement && this.itemlist.current) { let node: HTMLElement | undefined = undefined; const messages = this.itemlist.current.children; const scrollToken = scrollState.trackedScrollToken; @@ -890,7 +890,7 @@ export default class ScrollPanel extends React.Component { public clearPreventShrinking = (): void => { const messageList = this.itemlist.current; const balanceElement = messageList && messageList.parentElement; - if (balanceElement) balanceElement.style.paddingBottom = null; + if (balanceElement) balanceElement.style.removeProperty("paddingBottom"); this.preventShrinkingState = null; debuglog("prevent shrinking cleared"); }; @@ -904,7 +904,7 @@ export default class ScrollPanel extends React.Component { what it was when marking. */ public updatePreventShrinking = (): void => { - if (this.preventShrinkingState) { + if (this.preventShrinkingState && this.itemlist.current) { const sn = this.getScrollNode(); const scrollState = this.scrollState; const messageList = this.itemlist.current; @@ -922,7 +922,7 @@ export default class ScrollPanel extends React.Component { if (!shouldClear) { const currentOffset = messageList.clientHeight - (offsetNode.offsetTop + offsetNode.clientHeight); const offsetDiff = offsetFromBottom - currentOffset; - if (offsetDiff > 0) { + if (offsetDiff > 0 && balanceElement) { balanceElement.style.paddingBottom = `${offsetDiff}px`; debuglog("update prevent shrinking ", offsetDiff, "px from bottom"); } else if (offsetDiff < 0) { diff --git a/src/components/structures/UserView.tsx b/src/components/structures/UserView.tsx index a5cdb0b584c8..4cff508dfba7 100644 --- a/src/components/structures/UserView.tsx +++ b/src/components/structures/UserView.tsx @@ -79,7 +79,8 @@ export default class UserView extends React.Component { return; } const fakeEvent = new MatrixEvent({ type: "m.room.member", content: profileInfo }); - const member = new RoomMember(null, this.props.userId); + // We pass an empty string room ID here, this is slight abuse of the class to simplify code + const member = new RoomMember("", this.props.userId); member.setMembershipEvent(fakeEvent); this.setState({ member, loading: false }); } diff --git a/src/components/views/context_menus/RoomContextMenu.tsx b/src/components/views/context_menus/RoomContextMenu.tsx index 4be907c161d0..4a01503496f9 100644 --- a/src/components/views/context_menus/RoomContextMenu.tsx +++ b/src/components/views/context_menus/RoomContextMenu.tsx @@ -169,8 +169,8 @@ const RoomContextMenu: React.FC = ({ room, onFinished, ...props }) => { ); const echoChamber = EchoChamber.forRoom(room); - let notificationLabel: string; - let iconClassName: string; + let notificationLabel: string | undefined; + let iconClassName: string | undefined; switch (echoChamber.notificationVolume) { case RoomNotifState.AllMessages: notificationLabel = _t("Default"); @@ -337,7 +337,7 @@ const RoomContextMenu: React.FC = ({ room, onFinished, ...props }) => { const isApplied = RoomListStore.instance.getTagsForRoom(room).includes(tagId); const removeTag = isApplied ? tagId : inverseTag; const addTag = isApplied ? null : tagId; - dis.dispatch(RoomListActions.tagRoom(cli, room, removeTag, addTag, undefined, 0)); + dis.dispatch(RoomListActions.tagRoom(cli, room, removeTag, addTag, 0)); } else { logger.warn(`Unexpected tag ${tagId} applied to ${room.roomId}`); } diff --git a/src/components/views/context_menus/RoomGeneralContextMenu.tsx b/src/components/views/context_menus/RoomGeneralContextMenu.tsx index 901ed519b69b..0401b20b51c2 100644 --- a/src/components/views/context_menus/RoomGeneralContextMenu.tsx +++ b/src/components/views/context_menus/RoomGeneralContextMenu.tsx @@ -100,7 +100,7 @@ export const RoomGeneralContextMenu: React.FC = ({ const isApplied = RoomListStore.instance.getTagsForRoom(room).includes(tagId); const removeTag = isApplied ? tagId : inverseTag; const addTag = isApplied ? null : tagId; - dis.dispatch(RoomListActions.tagRoom(cli, room, removeTag, addTag, undefined, 0)); + dis.dispatch(RoomListActions.tagRoom(cli, room, removeTag, addTag, 0)); } else { logger.warn(`Unexpected tag ${tagId} applied to ${room.roomId}`); } diff --git a/src/components/views/dialogs/ReportEventDialog.tsx b/src/components/views/dialogs/ReportEventDialog.tsx index 8eaa64bc34bd..8ce08208c0d6 100644 --- a/src/components/views/dialogs/ReportEventDialog.tsx +++ b/src/components/views/dialogs/ReportEventDialog.tsx @@ -260,7 +260,7 @@ export default class ReportEventDialog extends React.Component { // if the user should also be ignored, do that if (this.state.ignoreUserToo) { - await client.setIgnoredUsers([...client.getIgnoredUsers(), ev.getSender()]); + await client.setIgnoredUsers([...client.getIgnoredUsers(), ev.getSender()!]); } this.props.onFinished(true); @@ -309,8 +309,8 @@ export default class ReportEventDialog extends React.Component { // Display report-to-moderator dialog. // We let the user pick a nature. const client = MatrixClientPeg.get(); - const homeServerName = SdkConfig.get("validated_server_config").hsName; - let subtitle; + const homeServerName = SdkConfig.get("validated_server_config")!.hsName; + let subtitle: string; switch (this.state.nature) { case Nature.Disagreement: subtitle = _t( diff --git a/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx b/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx index a261e4410418..a4ab59956313 100644 --- a/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx +++ b/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx @@ -130,7 +130,7 @@ export default class AccessSecretStorageDialog extends React.PureComponent(null const MessageComposerButtons: React.FC = (props: IProps) => { const matrixClient = useContext(MatrixClientContext); - const { room, roomId, narrow } = useContext(RoomContext); + const { room, narrow } = useContext(RoomContext); const isWysiwygLabEnabled = useSettingValue("feature_wysiwyg_composer"); - if (props.haveRecording) { + if (!matrixClient || !room || props.haveRecording) { return null; } @@ -93,7 +93,7 @@ const MessageComposerButtons: React.FC = (props: IProps) => { voiceRecordingButton(props, narrow), startVoiceBroadcastButton(props), props.showPollsButton ? pollButton(room, props.relation) : null, - showLocationButton(props, room, roomId, matrixClient), + showLocationButton(props, room, matrixClient), ]; } else { mainButtons = [ @@ -113,7 +113,7 @@ const MessageComposerButtons: React.FC = (props: IProps) => { voiceRecordingButton(props, narrow), startVoiceBroadcastButton(props), props.showPollsButton ? pollButton(room, props.relation) : null, - showLocationButton(props, room, roomId, matrixClient), + showLocationButton(props, room, matrixClient), ]; } @@ -127,7 +127,7 @@ const MessageComposerButtons: React.FC = (props: IProps) => { }); return ( - + {mainButtons} {moreButtons.length > 0 && ( { } } -function showLocationButton( - props: IProps, - room: Room, - roomId: string, - matrixClient: MatrixClient, -): ReactElement | null { - const sender = room.getMember(matrixClient.getUserId()!); +function showLocationButton(props: IProps, room: Room, matrixClient: MatrixClient): ReactElement | null { + const sender = room.getMember(matrixClient.getSafeUserId()); return props.showLocationButton && sender ? ( { const result = await MatrixClientPeg.get().lookupThreePid( "email", this.props.invitedEmail, - identityAccessToken, + identityAccessToken!, ); this.setState({ invitedEmailMxid: result.mxid }); } catch (err) { @@ -243,8 +243,8 @@ export default class RoomPreviewBar extends React.Component { if (!inviteEvent) { return null; } - const inviterUserId = inviteEvent.events.member.getSender(); - return room.currentState.getMember(inviterUserId); + const inviterUserId = inviteEvent.events.member?.getSender(); + return inviterUserId ? room.currentState.getMember(inviterUserId) : null; } private isDMInvite(): boolean { @@ -252,8 +252,8 @@ export default class RoomPreviewBar extends React.Component { if (!myMember) { return false; } - const memberContent = myMember.events.member.getContent(); - return memberContent.membership === "invite" && memberContent.is_direct; + const memberContent = myMember.events.member?.getContent(); + return memberContent?.membership === "invite" && memberContent.is_direct; } private makeScreenAfterLogin(): { screen: string; params: Record } { diff --git a/src/components/views/rooms/SearchResultTile.tsx b/src/components/views/rooms/SearchResultTile.tsx index be15ea969481..437b13b899c0 100644 --- a/src/components/views/rooms/SearchResultTile.tsx +++ b/src/components/views/rooms/SearchResultTile.tsx @@ -64,7 +64,7 @@ export default class SearchResultTile extends React.Component { const eventId = resultEvent.getId(); const ts1 = resultEvent.getTs(); - const ret = []; + const ret = []; const layout = SettingsStore.getValue("layout"); const isTwelveHour = SettingsStore.getValue("showTwelveHourTimestamps"); const alwaysShowTimestamps = SettingsStore.getValue("alwaysShowTimestamps"); diff --git a/src/components/views/rooms/SendMessageComposer.tsx b/src/components/views/rooms/SendMessageComposer.tsx index 24fbf5ccad12..700776d54cda 100644 --- a/src/components/views/rooms/SendMessageComposer.tsx +++ b/src/components/views/rooms/SendMessageComposer.tsx @@ -372,7 +372,10 @@ export class SendMessageComposer extends React.Component= 0; i--) { @@ -443,8 +447,8 @@ export class SendMessageComposer extends React.Component(posthogEvent); @@ -480,7 +484,7 @@ export class SendMessageComposer extends React.Component { const room = MatrixClientPeg.get().getRoom(this.props.roomId); - Modal.createDialog(RoomUpgradeDialog, { room }); + if (room) Modal.createDialog(RoomUpgradeDialog, { room }); }; private onOldRoomClicked = (e: ButtonEvent): void => { diff --git a/src/components/views/settings/tabs/user/GeneralUserSettingsTab.tsx b/src/components/views/settings/tabs/user/GeneralUserSettingsTab.tsx index 233e999afb8d..0827065fac33 100644 --- a/src/components/views/settings/tabs/user/GeneralUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/GeneralUserSettingsTab.tsx @@ -66,13 +66,20 @@ interface IState { haveIdServer: boolean; serverSupportsSeparateAddAndBind?: boolean; idServerHasUnsignedTerms: boolean; - requiredPolicyInfo: { - // This object is passed along to a component for handling - hasTerms: boolean; - policiesAndServices: ServicePolicyPair[] | null; // From the startTermsFlow callback - agreedUrls: string[] | null; // From the startTermsFlow callback - resolve: ((values: string[]) => void) | null; // Promise resolve function for startTermsFlow callback - }; + requiredPolicyInfo: + | { + // This object is passed along to a component for handling + hasTerms: false; + policiesAndServices: null; // From the startTermsFlow callback + agreedUrls: null; // From the startTermsFlow callback + resolve: null; // Promise resolve function for startTermsFlow callback + } + | { + hasTerms: boolean; + policiesAndServices: ServicePolicyPair[]; + agreedUrls: string[]; + resolve: (values: string[]) => void; + }; emails: IThreepid[]; msisdns: IThreepid[]; loading3pids: boolean; // whether or not the emails and msisdns have been loaded @@ -191,19 +198,19 @@ export default class GeneralUserSettingsTab extends React.Component { - if (!this.state.haveIdServer) { + // By starting the terms flow we get the logic for checking which terms the user has signed + // for free. So we might as well use that for our own purposes. + const idServerUrl = MatrixClientPeg.get().getIdentityServerUrl(); + if (!this.state.haveIdServer || !idServerUrl) { this.setState({ idServerHasUnsignedTerms: false }); return; } - // By starting the terms flow we get the logic for checking which terms the user has signed - // for free. So we might as well use that for our own purposes. - const idServerUrl = MatrixClientPeg.get().getIdentityServerUrl(); const authClient = new IdentityAuthClient(); try { const idAccessToken = await authClient.getAccessToken({ check: false }); await startTermsFlow( - [new Service(SERVICE_TYPES.IS, idServerUrl, idAccessToken)], + [new Service(SERVICE_TYPES.IS, idServerUrl, idAccessToken!)], (policiesAndServices, agreedUrls, extraClassNames) => { return new Promise((resolve, reject) => { this.setState({ diff --git a/src/components/views/voip/AudioFeed.tsx b/src/components/views/voip/AudioFeed.tsx index d9a36af6ccfe..e079360b4735 100644 --- a/src/components/views/voip/AudioFeed.tsx +++ b/src/components/views/voip/AudioFeed.tsx @@ -62,7 +62,7 @@ export default class AudioFeed extends React.Component { // it fails. // It seems reliable if you set the sink ID after setting the srcObject and then set the sink ID // back to the default after the call is over - Dave - element.setSinkId(audioOutput); + element!.setSinkId(audioOutput); } catch (e) { logger.error("Couldn't set requested audio output device: using default", e); logger.warn("Couldn't set requested audio output device: using default", e); @@ -103,7 +103,7 @@ export default class AudioFeed extends React.Component { if (!element) return; element.pause(); - element.src = null; + element.removeAttribute("src"); // As per comment in componentDidMount, setting the sink ID back to the // default once the call is over makes setSinkId work reliably. - Dave diff --git a/src/components/views/voip/LegacyCallView.tsx b/src/components/views/voip/LegacyCallView.tsx index 86be87608d59..5978acb316fd 100644 --- a/src/components/views/voip/LegacyCallView.tsx +++ b/src/components/views/voip/LegacyCallView.tsx @@ -339,16 +339,17 @@ export default class LegacyCallView extends React.Component { private onCallResumeClick = (): void => { const userFacingRoomId = LegacyCallHandler.instance.roomIdForCall(this.props.call); - LegacyCallHandler.instance.setActiveCallRoomId(userFacingRoomId); + if (userFacingRoomId) LegacyCallHandler.instance.setActiveCallRoomId(userFacingRoomId); }; private onTransferClick = (): void => { const transfereeCall = LegacyCallHandler.instance.getTransfereeForCallId(this.props.call.callId); - this.props.call.transferToCall(transfereeCall); + if (transfereeCall) this.props.call.transferToCall(transfereeCall); }; private onHangupClick = (): void => { - LegacyCallHandler.instance.hangupOrReject(LegacyCallHandler.instance.roomIdForCall(this.props.call)); + const roomId = LegacyCallHandler.instance.roomIdForCall(this.props.call); + if (roomId) LegacyCallHandler.instance.hangupOrReject(roomId); }; private onToggleSidebar = (): void => { @@ -451,13 +452,12 @@ export default class LegacyCallView extends React.Component { let holdTransferContent: React.ReactNode; if (transfereeCall) { - const transferTargetRoom = MatrixClientPeg.get().getRoom( - LegacyCallHandler.instance.roomIdForCall(call), - ); + const cli = MatrixClientPeg.get(); + const callRoomId = LegacyCallHandler.instance.roomIdForCall(call); + const transferTargetRoom = callRoomId ? cli.getRoom(callRoomId) : null; const transferTargetName = transferTargetRoom ? transferTargetRoom.name : _t("unknown person"); - const transfereeRoom = MatrixClientPeg.get().getRoom( - LegacyCallHandler.instance.roomIdForCall(transfereeCall), - ); + const transfereeCallRoomId = LegacyCallHandler.instance.roomIdForCall(transfereeCall); + const transfereeRoom = transfereeCallRoomId ? cli.getRoom(transfereeCallRoomId) : null; const transfereeName = transfereeRoom ? transfereeRoom.name : _t("unknown person"); holdTransferContent = ( @@ -579,6 +579,8 @@ export default class LegacyCallView extends React.Component { const callRoomId = LegacyCallHandler.instance.roomIdForCall(call); const secondaryCallRoomId = LegacyCallHandler.instance.roomIdForCall(secondaryCall); const callRoom = callRoomId ? client.getRoom(callRoomId) : null; + if (!callRoom) return null; + const secCallRoom = secondaryCallRoomId ? client.getRoom(secondaryCallRoomId) : null; const callViewClasses = classNames({ diff --git a/src/components/views/voip/VideoFeed.tsx b/src/components/views/voip/VideoFeed.tsx index 503c53ec66e9..c02154936f45 100644 --- a/src/components/views/voip/VideoFeed.tsx +++ b/src/components/views/voip/VideoFeed.tsx @@ -150,7 +150,7 @@ export default class VideoFeed extends React.PureComponent { if (!element) return; element.pause(); - element.src = null; + element.removeAttribute("src"); // As per comment in componentDidMount, setting the sink ID back to the // default once the call is over makes setSinkId work reliably. - Dave diff --git a/src/indexing/EventIndex.ts b/src/indexing/EventIndex.ts index bca7547b6ced..84c0a3ec2042 100644 --- a/src/indexing/EventIndex.ts +++ b/src/indexing/EventIndex.ts @@ -235,8 +235,11 @@ export default class EventIndex extends EventEmitter { const indexManager = PlatformPeg.get()?.getEventIndexingManager(); if (!indexManager) return; + const associatedId = ev.getAssociatedId(); + if (!associatedId) return; + try { - await indexManager.deleteEvent(ev.getAssociatedId()); + await indexManager.deleteEvent(associatedId); } catch (e) { logger.log("EventIndex: Error deleting event from index", e); } @@ -519,10 +522,10 @@ export default class EventIndex extends EventEmitter { const profiles: Record = {}; stateEvents.forEach((ev) => { - if (ev.event.content && ev.event.content.membership === "join") { - profiles[ev.event.sender] = { - displayname: ev.event.content.displayname, - avatar_url: ev.event.content.avatar_url, + if (ev.getContent().membership === "join") { + profiles[ev.getSender()!] = { + displayname: ev.getContent().displayname, + avatar_url: ev.getContent().avatar_url, }; } }); @@ -733,7 +736,7 @@ export default class EventIndex extends EventEmitter { const matrixEvents = events.map((e) => { const matrixEvent = eventMapper(e.event); - const member = new RoomMember(room.roomId, matrixEvent.getSender()); + const member = new RoomMember(room.roomId, matrixEvent.getSender()!); // We can't really reconstruct the whole room state from our // EventIndex to calculate the correct display name. Use the diff --git a/src/models/Call.ts b/src/models/Call.ts index 6f96e9d887cf..d3b99db28437 100644 --- a/src/models/Call.ts +++ b/src/models/Call.ts @@ -213,7 +213,7 @@ export abstract class Call extends TypedEventEmitter { await this.updateState({ enabled: SettingsStore.getValue("breadcrumbs", null) }); } } else if (payload.action === Action.ViewRoom) { - if (payload.auto_join && !this.matrixClient.getRoom(payload.room_id)) { + if (payload.auto_join && payload.room_id && !this.matrixClient.getRoom(payload.room_id)) { // Queue the room instead of pushing it immediately. We're probably just // waiting for a room join to complete. this.waitingRooms.push({ roomId: payload.room_id, addedTs: Date.now() }); diff --git a/src/stores/OwnBeaconStore.ts b/src/stores/OwnBeaconStore.ts index db9e57f46c21..2509dc92a320 100644 --- a/src/stores/OwnBeaconStore.ts +++ b/src/stores/OwnBeaconStore.ts @@ -426,6 +426,7 @@ export class OwnBeaconStore extends AsyncStoreWithClient { roomId: Room["roomId"], beaconInfoContent: MBeaconInfoEventContent, ): Promise => { + if (!this.matrixClient) return; // explicitly stop any live beacons this user has // to ensure they remain stopped // if the new replacing beacon is redacted @@ -435,7 +436,7 @@ export class OwnBeaconStore extends AsyncStoreWithClient { // eslint-disable-next-line camelcase const { event_id } = await doMaybeLocalRoomAction( roomId, - (actualRoomId: string) => this.matrixClient.unstable_createLiveBeacon(actualRoomId, beaconInfoContent), + (actualRoomId: string) => this.matrixClient!.unstable_createLiveBeacon(actualRoomId, beaconInfoContent), this.matrixClient, ); @@ -552,7 +553,7 @@ export class OwnBeaconStore extends AsyncStoreWithClient { const updateContent = makeBeaconInfoContent(timeout, live, description, assetType, timestamp); try { - await this.matrixClient.unstable_setLiveBeacon(beacon.roomId, updateContent); + await this.matrixClient!.unstable_setLiveBeacon(beacon.roomId, updateContent); // cleanup any errors const hadError = this.beaconUpdateErrors.has(beacon.identifier); if (hadError) { @@ -576,7 +577,7 @@ export class OwnBeaconStore extends AsyncStoreWithClient { this.lastPublishedPositionTimestamp = Date.now(); await Promise.all( this.healthyLiveBeaconIds.map((beaconId) => - this.sendLocationToBeacon(this.beacons.get(beaconId), position), + this.beacons.has(beaconId) ? this.sendLocationToBeacon(this.beacons.get(beaconId)!, position) : null, ), ); }; @@ -589,7 +590,7 @@ export class OwnBeaconStore extends AsyncStoreWithClient { private sendLocationToBeacon = async (beacon: Beacon, { geoUri, timestamp }: TimedGeoUri): Promise => { const content = makeBeaconContent(geoUri, timestamp, beacon.beaconInfoId); try { - await this.matrixClient.sendEvent(beacon.roomId, M_BEACON.name, content); + await this.matrixClient!.sendEvent(beacon.roomId, M_BEACON.name, content); this.incrementBeaconLocationPublishErrorCount(beacon.identifier, false); } catch (error) { logger.error(error); diff --git a/src/stores/local-echo/RoomEchoChamber.ts b/src/stores/local-echo/RoomEchoChamber.ts index 15a3affdda42..2eac117451b3 100644 --- a/src/stores/local-echo/RoomEchoChamber.ts +++ b/src/stores/local-echo/RoomEchoChamber.ts @@ -27,7 +27,7 @@ export enum CachedRoomKey { NotificationVolume, } -export class RoomEchoChamber extends GenericEchoChamber { +export class RoomEchoChamber extends GenericEchoChamber { private properties = new Map(); public constructor(context: RoomEchoContext) { @@ -67,11 +67,12 @@ export class RoomEchoChamber extends GenericEchoChamber { public fetchSuggestedRooms = async (space: Room, limit = MAX_SUGGESTED_ROOMS): Promise => { try { - const { rooms } = await this.matrixClient.getRoomHierarchy(space.roomId, limit, 1, true); + const { rooms } = await this.matrixClient!.getRoomHierarchy(space.roomId, limit, 1, true); const viaMap = new EnhancedMap>(); rooms.forEach((room) => { @@ -979,7 +979,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { private onRoomState = (ev: MatrixEvent): void => { const room = this.matrixClient?.getRoom(ev.getRoomId()); - if (!room) return; + if (!this.matrixClient || !room) return; switch (ev.getType()) { case EventType.SpaceChild: { diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts index 4c37ffd84c5c..9e24126b15dc 100644 --- a/src/stores/widgets/StopGapWidgetDriver.ts +++ b/src/stores/widgets/StopGapWidgetDriver.ts @@ -274,19 +274,22 @@ export class StopGapWidgetDriver extends WidgetDriver { await Promise.all( Object.entries(contentMap).flatMap(([userId, userContentMap]) => Object.entries(userContentMap).map(async ([deviceId, content]): Promise => { + const devices = deviceInfoMap.get(userId); + if (!devices) return; + if (deviceId === "*") { // Send the message to all devices we have keys for await client.encryptAndSendToDevices( - Array.from(deviceInfoMap.get(userId).values()).map((deviceInfo) => ({ + Array.from(devices.values()).map((deviceInfo) => ({ userId, deviceInfo, })), content, ); - } else { + } else if (devices.has(deviceId)) { // Send the message to a specific device await client.encryptAndSendToDevices( - [{ userId, deviceInfo: deviceInfoMap.get(userId).get(deviceId) }], + [{ userId, deviceInfo: devices.get(deviceId)! }], content, ); } diff --git a/src/stores/widgets/WidgetLayoutStore.ts b/src/stores/widgets/WidgetLayoutStore.ts index f260895c30a2..2bfd555ea30f 100644 --- a/src/stores/widgets/WidgetLayoutStore.ts +++ b/src/stores/widgets/WidgetLayoutStore.ts @@ -363,7 +363,7 @@ export class WidgetLayoutStore extends ReadyWatchingStore { } public getContainerWidgets(room: Optional, container: Container): IApp[] { - return this.byRoom.get(room?.roomId)?.get(container)?.ordered || []; + return (room && this.byRoom.get(room.roomId)?.get(container)?.ordered) || []; } public isInContainer(room: Room, widget: IApp, container: Container): boolean { diff --git a/src/stores/widgets/WidgetPermissionStore.ts b/src/stores/widgets/WidgetPermissionStore.ts index 244a95f06cba..b6ea52c162ce 100644 --- a/src/stores/widgets/WidgetPermissionStore.ts +++ b/src/stores/widgets/WidgetPermissionStore.ts @@ -58,7 +58,7 @@ export class WidgetPermissionStore { return OIDCState.Unknown; } - public setOIDCState(widget: Widget, kind: WidgetKind, roomId: string, newState: OIDCState): void { + public setOIDCState(widget: Widget, kind: WidgetKind, roomId: string | undefined, newState: OIDCState): void { const settingsKey = this.packSettingKey(widget, kind, roomId); let currentValues = SettingsStore.getValue<{ diff --git a/src/utils/SortMembers.ts b/src/utils/SortMembers.ts index d19b461b1e96..8be4e8a9399d 100644 --- a/src/utils/SortMembers.ts +++ b/src/utils/SortMembers.ts @@ -67,7 +67,7 @@ interface IActivityScore { // We do this by checking every room to see who has sent a message in the last few hours, and giving them // a score which correlates to the freshness of their message. In theory, this results in suggestions // which are closer to "continue this conversation" rather than "this person exists". -export function buildActivityScores(cli: MatrixClient): { [key: string]: IActivityScore | undefined } { +export function buildActivityScores(cli: MatrixClient): { [userId: string]: IActivityScore } { const now = new Date().getTime(); const earliestAgeConsidered = now - 60 * 60 * 1000; // 1 hour ago const maxMessagesConsidered = 50; // so we don't iterate over a huge amount of traffic @@ -75,6 +75,7 @@ export function buildActivityScores(cli: MatrixClient): { [key: string]: IActivi .flatMap((room) => takeRight(room.getLiveTimeline().getEvents(), maxMessagesConsidered)) .filter((ev) => ev.getTs() > earliestAgeConsidered); const senderEvents = groupBy(events, (ev) => ev.getSender()); + // If the iteratee in mapValues returns undefined that key will be removed from the resultant object return mapValues(senderEvents, (events) => { if (!events.length) return; const lastEvent = maxBy(events, (ev) => ev.getTs())!; @@ -87,7 +88,7 @@ export function buildActivityScores(cli: MatrixClient): { [key: string]: IActivi // an approximate maximum for being selected. score: Math.max(1, inverseTime / (15 * 60 * 1000)), // 15min segments to keep scores sane }; - }); + }) as { [key: string]: IActivityScore }; } interface IMemberScore { @@ -96,13 +97,14 @@ interface IMemberScore { numRooms: number; } -export function buildMemberScores(cli: MatrixClient): { [key: string]: IMemberScore | undefined } { +export function buildMemberScores(cli: MatrixClient): { [userId: string]: IMemberScore } { const maxConsideredMembers = 200; const consideredRooms = joinedRooms(cli).filter((room) => room.getJoinedMemberCount() < maxConsideredMembers); const memberPeerEntries = consideredRooms.flatMap((room) => room.getJoinedMembers().map((member) => ({ member, roomSize: room.getJoinedMemberCount() })), ); const userMeta = groupBy(memberPeerEntries, ({ member }) => member.userId); + // If the iteratee in mapValues returns undefined that key will be removed from the resultant object return mapValues(userMeta, (roomMemberships) => { if (!roomMemberships.length) return; const maximumPeers = maxConsideredMembers * roomMemberships.length; @@ -112,5 +114,5 @@ export function buildMemberScores(cli: MatrixClient): { [key: string]: IMemberSc numRooms: roomMemberships.length, score: Math.max(0, Math.pow(1 - totalPeers / maximumPeers, 5)), }; - }); + }) as { [userId: string]: IMemberScore }; } diff --git a/src/utils/notifications.ts b/src/utils/notifications.ts index eda75bb17a44..45ae7566048f 100644 --- a/src/utils/notifications.ts +++ b/src/utils/notifications.ts @@ -28,7 +28,7 @@ export const deviceNotificationSettingsKeys = [ "audioNotificationsEnabled", ]; -export function getLocalNotificationAccountDataEventType(deviceId: string): string { +export function getLocalNotificationAccountDataEventType(deviceId: string | null): string { return `${LOCAL_NOTIFICATION_SETTINGS_PREFIX.name}.${deviceId}`; } diff --git a/test/components/views/dialogs/MessageEditHistoryDialog-test.tsx b/test/components/views/dialogs/MessageEditHistoryDialog-test.tsx index 28f1aa47658f..c220e5a0f4a4 100644 --- a/test/components/views/dialogs/MessageEditHistoryDialog-test.tsx +++ b/test/components/views/dialogs/MessageEditHistoryDialog-test.tsx @@ -43,7 +43,7 @@ describe("", () => { return result; } - function mockEdits(...edits: { msg: string; ts: number | undefined }[]) { + function mockEdits(...edits: { msg: string; ts?: number }[]) { client.relations.mockImplementation(() => Promise.resolve({ events: edits.map( From fdfe800b2c1a098c8264a1a7a9f2bced4d6e28bd Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 21 Apr 2023 10:49:05 +0100 Subject: [PATCH 12/12] Fix lack of screen reader indication when triggering auto complete (#10664) --- src/components/views/rooms/BasicMessageComposer.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index d7d25356101d..fa45e56d1996 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -798,7 +798,7 @@ export default class BasicMessageEditor extends React.Component }; const { completionIndex } = this.state; - const hasAutocomplete = Boolean(this.state.autoComplete); + const hasAutocomplete = !!this.state.autoComplete; let activeDescendant: string | undefined; if (hasAutocomplete && completionIndex! >= 0) { activeDescendant = generateCompletionDomId(completionIndex!); @@ -828,7 +828,7 @@ export default class BasicMessageEditor extends React.Component aria-multiline="true" aria-autocomplete="list" aria-haspopup="listbox" - aria-expanded={hasAutocomplete ? true : undefined} + aria-expanded={hasAutocomplete ? !this.autocompleteRef.current?.state.hide : undefined} aria-owns={hasAutocomplete ? "mx_Autocomplete" : undefined} aria-activedescendant={activeDescendant} dir="auto"