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/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 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"); diff --git a/cypress/e2e/threads/threads.spec.ts b/cypress/e2e/threads/threads.spec.ts index 1d36cbdd8501..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="menuitem"]', "👋").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"); + }); }); }); 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/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/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/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/_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/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/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; } @@ -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/actions/RoomListActions.ts b/src/actions/RoomListActions.ts index 945c98394971..6f8f944ab94c 100644 --- a/src/actions/RoomListActions.ts +++ b/src/actions/RoomListActions.ts @@ -51,8 +51,7 @@ export default class RoomListActions { room: Room, oldTag: TagID | null, newTag: TagID | null, - oldIndex?: number, - newIndex?: number, + newIndex: number, ): AsyncActionPayload { let metaData: Parameters[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/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/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/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/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/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/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 { +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/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/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/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/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/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/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" 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/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index 30f262cf5916..a6393b7c8255 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 { @@ -1016,7 +1020,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` @@ -1026,9 +1030,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 || @@ -1111,7 +1118,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/rooms/MessageComposerButtons.tsx b/src/components/views/rooms/MessageComposerButtons.tsx index 17990d3aa66e..81491d35c260 100644 --- a/src/components/views/rooms/MessageComposerButtons.tsx +++ b/src/components/views/rooms/MessageComposerButtons.tsx @@ -65,11 +65,11 @@ export const OverflowMenuContext = createContext(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/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} /> { 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 { } 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 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/settings/account/EmailAddresses.tsx b/src/components/views/settings/account/EmailAddresses.tsx index 3d3cb8fb1870..c90e1c2768cf 100644 --- a/src/components/views/settings/account/EmailAddresses.tsx +++ b/src/components/views/settings/account/EmailAddresses.tsx @@ -280,7 +280,7 @@ export default class EmailAddresses 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/room/SecurityRoomSettingsTab.tsx b/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx index 78bb1fe782a5..4f5124d7dc08 100644 --- a/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx +++ b/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx @@ -443,6 +443,7 @@ export default class SecurityRoomSettingsTab extends React.Component {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/GeneralUserSettingsTab.tsx b/src/components/views/settings/tabs/user/GeneralUserSettingsTab.tsx index dc3bf9f408b9..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({ @@ -468,7 +475,7 @@ export default class GeneralUserSettingsTab extends React.Component +
    {_t("Account management")} {_t("Deactivating your account is a permanent action — be careful!")} @@ -528,8 +535,10 @@ export default class GeneralUserSettingsTab extends React.Component -
    {_t("General")}
    +
    +
    + {_t("General")} +
    {this.renderProfileSection()} {this.renderAccountSection()} {this.renderLanguageSection()} 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/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/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/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/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/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index bbd71d70f33c..caca153edb0d 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", @@ -2138,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/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/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/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( 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/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(); + }); }); 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 +
    renders the room summary 1`] = ` tabindex="0" />
    -

    !room:domain.org -

    +
    renders the room summary 1`] = `
    -

    +

    About -

    +
    renders the room summary 1`] = `
    -

    +

    Widgets -

    + -
    +
  • 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`] = `
  • - + - + diff --git a/test/components/views/spaces/SpaceSettingsVisibilityTab-test.tsx b/test/components/views/spaces/SpaceSettingsVisibilityTab-test.tsx index 1d2ee066f982..a7d507e01146 100644 --- a/test/components/views/spaces/SpaceSettingsVisibilityTab-test.tsx +++ b/test/components/views/spaces/SpaceSettingsVisibilityTab-test.tsx @@ -16,6 +16,7 @@ limitations under the License. import React from "react"; import { mocked } from "jest-mock"; +import { randomString } from "matrix-js-sdk/src/randomstring"; import { act, fireEvent, render, RenderResult } from "@testing-library/react"; import { EventType, MatrixClient, Room } from "matrix-js-sdk/src/matrix"; import { GuestAccess, HistoryVisibility, JoinRule } from "matrix-js-sdk/src/@types/partials"; @@ -27,6 +28,11 @@ import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; const SpaceSettingsVisibilityTab = wrapInMatrixClientContext(_SpaceSettingsVisibilityTab); +// Fake random strings to give a predictable snapshot for IDs +jest.mock("matrix-js-sdk/src/randomstring", () => ({ + 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 +
    => { // 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(); + } } };