diff --git a/cypress/e2e/integration-manager/send_event.spec.ts b/cypress/e2e/integration-manager/send_event.spec.ts index ac412e2468a..a62188b95e4 100644 --- a/cypress/e2e/integration-manager/send_event.spec.ts +++ b/cypress/e2e/integration-manager/send_event.spec.ts @@ -67,9 +67,9 @@ const INTEGRATION_MANAGER_HTML = ` `; function openIntegrationManager() { - cy.get(".mx_RightPanel_roomSummaryButton").click(); + cy.findByRole("tab", { name: "Room info" }).click(); cy.get(".mx_RoomSummaryCard_appsGroup").within(() => { - cy.contains("Add widgets, bridges & bots").click(); + cy.findByRole("button", { name: "Add widgets, bridges & bots" }).click(); }); } diff --git a/cypress/e2e/lazy-loading/lazy-loading.spec.ts b/cypress/e2e/lazy-loading/lazy-loading.spec.ts index e174364aeba..1efc69e0323 100644 --- a/cypress/e2e/lazy-loading/lazy-loading.spec.ts +++ b/cypress/e2e/lazy-loading/lazy-loading.spec.ts @@ -116,9 +116,12 @@ describe("Lazy Loading", () => { } function openMemberlist(): void { - cy.get('.mx_HeaderButtons [aria-label="Room info"]').click(); + cy.get(".mx_HeaderButtons").within(() => { + cy.findByRole("tab", { name: "Room info" }).click(); + }); + cy.get(".mx_RoomSummaryCard").within(() => { - cy.get(".mx_RoomSummaryCard_icon_people").click(); + cy.findByRole("button", { name: /People \d/ }).click(); // \d represents the number of the room members }); } diff --git a/cypress/e2e/location/location.spec.ts b/cypress/e2e/location/location.spec.ts index b716fe543b4..6588cde1a49 100644 --- a/cypress/e2e/location/location.spec.ts +++ b/cypress/e2e/location/location.spec.ts @@ -23,11 +23,11 @@ describe("Location sharing", () => { let homeserver: HomeserverInstance; const selectLocationShareTypeOption = (shareType: string): Chainable => { - return cy.get(`[data-test-id="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(); }; beforeEach(() => { @@ -53,7 +53,7 @@ describe("Location sharing", () => { }); cy.openMessageComposerOptions().within(() => { - cy.get('[aria-label="Location"]').click(); + cy.findByRole("menuitem", { name: "Location" }).click(); }); selectLocationShareTypeOption("Pin").click(); @@ -67,7 +67,7 @@ describe("Location sharing", () => { // clicking location tile opens maximised map cy.get(".mx_LocationViewDialog_wrapper").should("exist"); - cy.get('[aria-label="Close dialog"]').click(); + cy.closeDialog(); cy.get(".mx_Marker").should("exist"); }); diff --git a/cypress/e2e/polls/pollHistory.spec.ts b/cypress/e2e/polls/pollHistory.spec.ts index 2491f9f1590..93eefc49d21 100644 --- a/cypress/e2e/polls/pollHistory.spec.ts +++ b/cypress/e2e/polls/pollHistory.spec.ts @@ -75,9 +75,9 @@ describe("Poll history", () => { }; function openPollHistory(): void { - cy.get('.mx_HeaderButtons [aria-label="Room info"]').click(); + cy.findByRole("tab", { name: "Room info" }).click(); cy.get(".mx_RoomSummaryCard").within(() => { - cy.contains("Poll history").click(); + cy.findByRole("button", { name: "Poll history" }).click(); }); } @@ -124,7 +124,7 @@ describe("Poll history", () => { cy.inviteUser(roomId, bot.getUserId()); cy.visit("/#/room/" + roomId); // wait until Bob joined - cy.contains(".mx_TextualEvent", "BotBob joined the room").should("exist"); + cy.findByText("BotBob joined the room").should("exist"); }); // active poll @@ -153,19 +153,23 @@ describe("Poll history", () => { cy.get(".mx_Dialog").within(() => { // active poll is in active polls list // open poll detail - cy.contains(pollParams1.title).click(); + cy.findByText(pollParams1.title).click(); // vote in the poll - cy.contains("Yes").click(); - cy.get('[data-testid="totalVotes"]').should("have.text", "Based on 2 votes"); + cy.findByText("Yes").click(); + cy.findByTestId("totalVotes").within(() => { + cy.findByText("Based on 2 votes"); + }); // navigate back to list - cy.contains("Active polls").click(); + cy.get(".mx_PollHistory_header").within(() => { + cy.findByRole("button", { name: "Active polls" }).click(); + }); // go to past polls list - cy.contains("Past polls").click(); + cy.findByText("Past polls").click(); - cy.contains(pollParams2.title).should("exist"); + cy.findByText(pollParams2.title).should("exist"); }); // end poll1 while dialog is open @@ -175,13 +179,13 @@ describe("Poll history", () => { cy.get(".mx_Dialog").within(() => { // both ended polls are in past polls list - cy.contains(pollParams2.title).should("exist"); - cy.contains(pollParams1.title).should("exist"); + cy.findByText(pollParams2.title).should("exist"); + cy.findByText(pollParams1.title).should("exist"); - cy.contains("Active polls").click(); + cy.findByText("Active polls").click(); // no more active polls - cy.contains("There are no active polls in this room").should("exist"); + cy.findByText("There are no active polls in this room").should("exist"); }); }); }); diff --git a/cypress/e2e/polls/polls.spec.ts b/cypress/e2e/polls/polls.spec.ts index fbaf12fa2c5..a3850dcdbc1 100644 --- a/cypress/e2e/polls/polls.spec.ts +++ b/cypress/e2e/polls/polls.spec.ts @@ -36,19 +36,21 @@ describe("Polls", () => { throw new Error("Poll must have at least two options"); } cy.get(".mx_PollCreateDialog").within((pollCreateDialog) => { - cy.get("#poll-topic-input").type(title); + cy.findByRole("textbox", { name: "Question or topic" }).type(title); options.forEach((option, index) => { const optionId = `#pollcreate_option_${index}`; // click 'add option' button if needed if (pollCreateDialog.find(optionId).length === 0) { - cy.get(".mx_PollCreateDialog_addOption").scrollIntoView().click(); + cy.findByRole("button", { name: "Add option" }).scrollIntoView().click(); } cy.get(optionId).scrollIntoView().type(option); }); }); - cy.get('.mx_Dialog button[type="submit"]').click(); + cy.get(".mx_Dialog").within(() => { + cy.findByRole("button", { name: "Create Poll" }).click(); + }); }; const getPollTile = (pollId: string): Chainable => { @@ -67,7 +69,7 @@ describe("Polls", () => { const botVoteForOption = (bot: MatrixClient, roomId: string, pollId: string, optionText: string): void => { getPollOption(pollId, optionText).within((ref) => { - cy.get('input[type="radio"]') + cy.findByRole("radio") .invoke("attr", "value") .then((optionId) => { // We can't use the js-sdk types for this stuff directly, so manually construct the event. @@ -111,11 +113,11 @@ describe("Polls", () => { cy.inviteUser(roomId, bot.getUserId()); cy.visit("/#/room/" + roomId); // wait until Bob joined - cy.contains(".mx_TextualEvent", "BotBob joined the room").should("exist"); + cy.findByText("BotBob joined the room").should("exist"); }); cy.openMessageComposerOptions().within(() => { - cy.get('[aria-label="Poll"]').click(); + cy.findByRole("menuitem", { name: "Poll" }).click(); }); // Disabled because flaky - see https://github.com/vector-im/element-web/issues/24688 @@ -142,7 +144,9 @@ describe("Polls", () => { botVoteForOption(bot, roomId, pollId, pollParams.options[2]); // no votes shown until I vote, check bots vote has arrived - cy.get(".mx_MPollBody_totalVotes").should("contain", "1 vote cast"); + cy.get(".mx_MPollBody_totalVotes").within(() => { + cy.findByText("1 vote cast. Vote to see the results"); + }); // vote 'Maybe' getPollOption(pollId, pollParams.options[2]).click("topLeft"); @@ -183,7 +187,7 @@ describe("Polls", () => { }); cy.openMessageComposerOptions().within(() => { - cy.get('[aria-label="Poll"]').click(); + cy.findByRole("menuitem", { name: "Poll" }).click(); }); const pollParams = { @@ -203,9 +207,7 @@ describe("Polls", () => { getPollTile(pollId).rightclick(); // Select edit item - cy.get(".mx_ContextualMenu").within(() => { - cy.get('[aria-label="Edit"]').click(); - }); + cy.findByRole("menuitem", { name: "Edit" }).click(); // Expect poll editing dialog cy.get(".mx_PollCreateDialog"); @@ -226,7 +228,7 @@ describe("Polls", () => { }); cy.openMessageComposerOptions().within(() => { - cy.get('[aria-label="Poll"]').click(); + cy.findByRole("menuitem", { name: "Poll" }).click(); }); const pollParams = { @@ -252,9 +254,7 @@ describe("Polls", () => { getPollTile(pollId).rightclick(); // Select edit item - cy.get(".mx_ContextualMenu").within(() => { - cy.get('[aria-label="Edit"]').click(); - }); + cy.findByRole("menuitem", { name: "Edit" }).click(); // Expect error dialog cy.get(".mx_ErrorDialog"); @@ -278,11 +278,11 @@ describe("Polls", () => { cy.inviteUser(roomId, botCharlie.getUserId()); cy.visit("/#/room/" + roomId); // wait until the bots joined - cy.contains(".mx_TextualEvent", "and one other were invited and joined").should("exist"); + cy.findByText("BotBob and one other were invited and joined", { timeout: 10000 }).should("exist"); }); cy.openMessageComposerOptions().within(() => { - cy.get('[aria-label="Poll"]').click(); + cy.findByRole("menuitem", { name: "Poll" }).click(); }); const pollParams = { @@ -304,7 +304,7 @@ describe("Polls", () => { }); // open the thread summary - cy.get(".mx_RoomView_body .mx_ThreadSummary").click(); + cy.findByRole("button", { name: "Open thread" }).click(); // Bob votes 'Maybe' in the poll botVoteForOption(botBob, roomId, pollId, pollParams.options[2]); @@ -312,9 +312,13 @@ describe("Polls", () => { botVoteForOption(botCharlie, roomId, pollId, pollParams.options[1]); // no votes shown until I vote, check votes have arrived in main tl - cy.get(".mx_RoomView_body .mx_MPollBody_totalVotes").should("contain", "2 votes cast"); + cy.get(".mx_RoomView_body .mx_MPollBody_totalVotes").within(() => { + cy.findByText("2 votes cast. Vote to see the results").should("exist"); + }); // and thread view - cy.get(".mx_ThreadView .mx_MPollBody_totalVotes").should("contain", "2 votes cast"); + cy.get(".mx_ThreadView .mx_MPollBody_totalVotes").within(() => { + cy.findByText("2 votes cast. Vote to see the results").should("exist"); + }); // Take snapshots of poll on ThreadView cy.setSettingValue("layout", null, SettingLevel.DEVICE, Layout.Bubble); diff --git a/cypress/e2e/regression-tests/pills-click-in-app.spec.ts b/cypress/e2e/regression-tests/pills-click-in-app.spec.ts index 8540736f3ee..f8e607a4f21 100644 --- a/cypress/e2e/regression-tests/pills-click-in-app.spec.ts +++ b/cypress/e2e/regression-tests/pills-click-in-app.spec.ts @@ -50,11 +50,11 @@ describe("Pills", () => { cy.url().should("contain", `/#/room/${messageRoomId}`); // send a message using the built-in room mention functionality (autocomplete) - cy.get(".mx_SendMessageComposer .mx_BasicMessageComposer_input").type( + cy.findByRole("textbox", { name: "Send a message…" }).type( `Hello world! Join here: #${targetLocalpart.substring(0, 3)}`, ); cy.get(".mx_Autocomplete_Completion_title").click(); - cy.get(".mx_MessageComposer_sendMessage").click(); + cy.findByRole("button", { name: "Send message" }).click(); // find the pill in the timeline and click it cy.get(".mx_EventTile_body .mx_Pill").click(); diff --git a/cypress/e2e/right-panel/file-panel.spec.ts b/cypress/e2e/right-panel/file-panel.spec.ts index 5af62815856..318167bb1ee 100644 --- a/cypress/e2e/right-panel/file-panel.spec.ts +++ b/cypress/e2e/right-panel/file-panel.spec.ts @@ -24,7 +24,7 @@ const NAME = "Alice"; const viewRoomSummaryByName = (name: string): Chainable> => { cy.viewRoomByName(name); - cy.get(".mx_RightPanel_roomSummaryButton").click(); + cy.findByRole("tab", { name: "Room info" }).click(); return checkRoomSummaryCard(name); }; @@ -38,8 +38,7 @@ const uploadFile = (file: string) => { cy.get(".mx_MessageComposer_actions input[type='file']").selectFile(file, { force: true }); cy.get(".mx_Dialog").within(() => { - // Click "Upload" button - cy.get("[data-testid='dialog-primary-button']").should("have.text", "Upload").click(); + cy.findByRole("button", { name: "Upload" }).click(); }); // Wait until the file is sent @@ -106,8 +105,7 @@ describe("FilePanel", () => { cy.get(".mx_MFileBody_download").should("have.length", 3); // Assert that the sender of the files is rendered on all of the tiles - cy.get(".mx_EventTile_senderDetails .mx_DisambiguatedProfile_displayName").should("have.length", 3); - cy.contains(".mx_EventTile_senderDetails .mx_DisambiguatedProfile_displayName", NAME); + cy.findAllByText(NAME).should("have.length", 3); // Detect the image file cy.get(".mx_EventTile_mediaLine.mx_EventTile_image").within(() => { @@ -123,16 +121,17 @@ describe("FilePanel", () => { // Assert that the audio player is rendered cy.get(".mx_AudioPlayer_container").within(() => { // Assert that the play button is rendered - cy.get("[data-testid='play-pause-button']").should("exist"); + cy.findByRole("button", { name: "Play" }).should("exist"); }); }); // Detect the JSON file // Assert that the tile is rendered as a button cy.get(".mx_EventTile_mediaLine .mx_MFileBody .mx_MFileBody_info[role='button']").within(() => { - // Assert that the file name is rendered inside the button - // File name: matrix-org-client-versions.json - cy.contains(".mx_MFileBody_info_filename", "matrix-org"); + // Assert that the file name is rendered inside the button with ellipsis + cy.get(".mx_MFileBody_info_filename").within(() => { + cy.findByText(/matrix.*?\.json/); + }); }); }); }); @@ -186,7 +185,9 @@ describe("FilePanel", () => { cy.get(".mx_AudioPlayer_container").within(() => { // Assert that the audio file information is rendered cy.get(".mx_AudioPlayer_mediaInfo").within(() => { - cy.get(".mx_AudioPlayer_mediaName").should("have.text", "1sec.ogg"); + cy.get(".mx_AudioPlayer_mediaName").within(() => { + cy.findByText("1sec.ogg"); + }); cy.contains(".mx_AudioPlayer_byline", "00:01").should("exist"); cy.contains(".mx_AudioPlayer_byline", "(3.56 KB)").should("exist"); // actual size }); @@ -195,16 +196,16 @@ describe("FilePanel", () => { cy.contains(".mx_AudioPlayer_seek [role='timer']", "00:00").should("exist"); // Click the play button - cy.get("[data-testid='play-pause-button'][aria-label='Play']").click(); + cy.findByRole("button", { name: "Play" }).click(); // Assert that the pause button is rendered - cy.get("[data-testid='play-pause-button'][aria-label='Pause']").should("exist"); + cy.findByRole("button", { name: "Pause" }).should("exist"); // Assert that the timer is reset when the audio file finished playing cy.contains(".mx_AudioPlayer_seek [role='timer']", "00:00").should("exist"); // Assert that the play button is rendered - cy.get("[data-testid='play-pause-button'][aria-label='Play']").should("exist"); + cy.findByRole("button", { name: "Play" }).should("exist"); }); }); }); diff --git a/cypress/e2e/right-panel/right-panel.spec.ts b/cypress/e2e/right-panel/right-panel.spec.ts index 6ada7f41d46..ded88e231a4 100644 --- a/cypress/e2e/right-panel/right-panel.spec.ts +++ b/cypress/e2e/right-panel/right-panel.spec.ts @@ -27,10 +27,6 @@ const getMemberTileByName = (name: string): Chainable> => { return cy.get(`.mx_EntityTile, [title="${name}"]`); }; -const goBack = (): Chainable> => { - return cy.get(".mx_BaseCard_back").click(); -}; - const viewRoomSummaryByName = (name: string): Chainable> => { cy.viewRoomByName(name); cy.get(".mx_RightPanel_roomSummaryButton").click(); @@ -65,57 +61,62 @@ describe("RightPanel", () => { it("should handle clicking add widgets", () => { viewRoomSummaryByName(ROOM_NAME); - cy.get(".mx_RoomSummaryCard_appsGroup .mx_AccessibleButton").click(); + cy.findByRole("button", { name: "Add widgets, bridges & bots" }).click(); cy.get(".mx_IntegrationManager").should("have.length", 1); }); it("should handle viewing export chat", () => { viewRoomSummaryByName(ROOM_NAME); - cy.get(".mx_RoomSummaryCard_icon_export").click(); + cy.findByRole("button", { name: "Export chat" }).click(); cy.get(".mx_ExportDialog").should("have.length", 1); }); it("should handle viewing share room", () => { viewRoomSummaryByName(ROOM_NAME); - cy.get(".mx_RoomSummaryCard_icon_share").click(); + cy.findByRole("button", { name: "Share room" }).click(); cy.get(".mx_ShareDialog").should("have.length", 1); }); it("should handle viewing room settings", () => { viewRoomSummaryByName(ROOM_NAME); - cy.get(".mx_RoomSummaryCard_icon_settings").click(); + cy.findByRole("button", { name: "Room settings" }).click(); cy.get(".mx_RoomSettingsDialog").should("have.length", 1); - cy.get(".mx_Dialog_title").should("contain", ROOM_NAME); + cy.get(".mx_Dialog_title").within(() => { + cy.findByText("Room Settings - " + ROOM_NAME).should("exist"); + }); }); it("should handle viewing files", () => { viewRoomSummaryByName(ROOM_NAME); - cy.get(".mx_RoomSummaryCard_icon_files").click(); + cy.findByRole("button", { name: "Files" }).click(); cy.get(".mx_FilePanel").should("have.length", 1); cy.get(".mx_FilePanel_empty").should("have.length", 1); - goBack(); + cy.findByRole("button", { name: "Room information" }).click(); checkRoomSummaryCard(ROOM_NAME); }); it("should handle viewing room member", () => { viewRoomSummaryByName(ROOM_NAME); - cy.get(".mx_RoomSummaryCard_icon_people").click(); + // \d represents the number of the room members inside mx_BaseCard_Button_sublabel + cy.findByRole("button", { name: /People \d/ }).click(); cy.get(".mx_MemberList").should("have.length", 1); getMemberTileByName(NAME).click(); cy.get(".mx_UserInfo").should("have.length", 1); - cy.get(".mx_UserInfo_profile").should("contain", NAME); + cy.get(".mx_UserInfo_profile").within(() => { + cy.findByText(NAME); + }); - goBack(); + cy.findByRole("button", { name: "Room members" }).click(); cy.get(".mx_MemberList").should("have.length", 1); - goBack(); + cy.findByRole("button", { name: "Room information" }).click(); checkRoomSummaryCard(ROOM_NAME); }); }); @@ -123,16 +124,26 @@ describe("RightPanel", () => { describe("in spaces", () => { it("should handle viewing space member", () => { cy.viewSpaceHomeByName(SPACE_NAME); - cy.get(".mx_RoomInfoLine_members").click(); + + cy.get(".mx_RoomInfoLine_private").within(() => { + // \d represents the number of the space members + cy.findByRole("button", { name: /\d member/ }).click(); + }); cy.get(".mx_MemberList").should("have.length", 1); - cy.get(".mx_RightPanel_scopeHeader").should("contain", SPACE_NAME); + cy.get(".mx_RightPanel_scopeHeader").within(() => { + cy.findByText(SPACE_NAME); + }); getMemberTileByName(NAME).click(); cy.get(".mx_UserInfo").should("have.length", 1); - cy.get(".mx_UserInfo_profile").should("contain", NAME); - cy.get(".mx_RightPanel_scopeHeader").should("contain", SPACE_NAME); - - goBack(); + cy.get(".mx_UserInfo_profile").within(() => { + cy.findByText(NAME); + }); + cy.get(".mx_RightPanel_scopeHeader").within(() => { + cy.findByText(SPACE_NAME); + }); + + cy.findByRole("button", { name: "Back" }).click(); cy.get(".mx_MemberList").should("have.length", 1); }); }); diff --git a/cypress/e2e/room-directory/room-directory.spec.ts b/cypress/e2e/room-directory/room-directory.spec.ts index 8a70c50fdbe..a7fcfaf61f1 100644 --- a/cypress/e2e/room-directory/room-directory.spec.ts +++ b/cypress/e2e/room-directory/room-directory.spec.ts @@ -48,15 +48,15 @@ describe("Room Directory", () => { // First add a local address `gaming` cy.contains(".mx_SettingsFieldset", "Local Addresses").within(() => { - cy.get(".mx_Field input").type("gaming"); - cy.contains(".mx_AccessibleButton", "Add").click(); - cy.get(".mx_EditableItem_item").should("contain", "#gaming:localhost"); + cy.findByRole("textbox").type("gaming"); + cy.findByRole("button", { name: "Add" }).click(); + cy.findByText("#gaming:localhost").should("have.class", "mx_EditableItem_item").should("exist"); }); // Publish into the public rooms directory cy.contains(".mx_SettingsFieldset", "Published Addresses").within(() => { - cy.get("#canonicalAlias").find(":selected").should("contain", "#gaming:localhost"); - cy.get(`[aria-label="Publish this room to the public in localhost's room directory?"]`) + cy.get("#canonicalAlias").find(":selected").findByText("#gaming:localhost"); + cy.findByLabelText("Publish this room to the public in localhost's room directory?") .click() .should("have.attr", "aria-checked", "true"); }); @@ -81,20 +81,25 @@ describe("Room Directory", () => { }); }); - cy.get('[role="button"][aria-label="Explore rooms"]').click(); + cy.findByRole("button", { name: "Explore rooms" }).click(); - cy.get('.mx_SpotlightDialog [aria-label="Search"]').type("Unknown Room"); - cy.get(".mx_SpotlightDialog .mx_SpotlightDialog_otherSearches_messageSearchText").should( - "contain", - "can't find the room you're looking for", - ); + cy.get(".mx_SpotlightDialog").within(() => { + cy.findByRole("textbox", { name: "Search" }).type("Unknown Room"); + cy.findByText("If you can't find the room you're looking for, ask for an invite or create a new room.") + .should("have.class", "mx_SpotlightDialog_otherSearches_messageSearchText") + .should("exist"); + }); cy.get(".mx_SpotlightDialog_wrapper").percySnapshotElement("Room Directory - filtered no results"); - cy.get('.mx_SpotlightDialog [aria-label="Search"]').type("{selectAll}{backspace}test1234"); - cy.contains(".mx_SpotlightDialog .mx_SpotlightDialog_result_publicRoomName", name).should("exist"); + cy.get(".mx_SpotlightDialog").within(() => { + cy.findByRole("textbox", { name: "Search" }).type("{selectAll}{backspace}test1234"); + cy.findByText(name).should("have.class", "mx_SpotlightDialog_result_publicRoomName").should("exist"); + }); + // Disabled because flaky - see https://github.com/vector-im/element-web/issues/24881 //cy.get(".mx_SpotlightDialog_wrapper").percySnapshotElement("Room Directory - filtered one result"); - cy.get(".mx_SpotlightDialog .mx_SpotlightDialog_option").find(".mx_AccessibleButton").contains("Join").click(); + + cy.get(".mx_SpotlightDialog .mx_SpotlightDialog_option").findByRole("button", { name: "Join" }).click(); cy.url().should("contain", `/#/room/#test1234:localhost`); }); diff --git a/cypress/e2e/room/room.spec.ts b/cypress/e2e/room/room.spec.ts index a8a3a9a7e65..843258a7780 100644 --- a/cypress/e2e/room/room.spec.ts +++ b/cypress/e2e/room/room.spec.ts @@ -71,11 +71,11 @@ describe("Room Directory", () => { // we want to make sure it is never displayed when switching these rooms cy.get(".mx_RoomPreviewBar_spinnerTitle", { timeout: 1 }).should("not.exist"); // confirm the room was loaded - cy.contains("Bob joined the room").should("exist"); + cy.findByText("Bob joined the room").should("exist"); cy.viewRoomByName("Charlie"); cy.get(".mx_RoomPreviewBar_spinnerTitle", { timeout: 1 }).should("not.exist"); // confirm the room was loaded - cy.contains("Charlie joined the room").should("exist"); + cy.findByText("Charlie joined the room").should("exist"); }); }); diff --git a/cypress/e2e/spotlight/spotlight.spec.ts b/cypress/e2e/spotlight/spotlight.spec.ts index d8453b9d993..0d4c33926bf 100644 --- a/cypress/e2e/spotlight/spotlight.spec.ts +++ b/cypress/e2e/spotlight/spotlight.spec.ts @@ -100,7 +100,7 @@ Cypress.Commands.add( Cypress.Commands.add( "spotlightSearch", (options?: Partial): Chainable> => { - return cy.get(".mx_SpotlightDialog_searchBox input", options); + return cy.get(".mx_SpotlightDialog_searchBox", options).findByRole("textbox", { name: "Search" }); }, ); @@ -129,10 +129,10 @@ Cypress.Commands.add("startDM", (name: string) => { cy.spotlightResults().eq(0).click(); }); // send first message to start DM - cy.get(".mx_BasicMessageComposer_input").should("have.focus").type("Hey!{enter}"); + cy.findByRole("textbox", { name: "Send a message…" }).should("have.focus").type("Hey!{enter}"); // The DM room is created at this point, this can take a little bit of time - cy.contains(".mx_EventTile_body", "Hey!", { timeout: 30000 }); - cy.contains(".mx_RoomSublist[aria-label=People]", name); + cy.get(".mx_EventTile_body", { timeout: 30000 }).findByText("Hey!"); + cy.findByRole("group", { name: "People" }).findByText(name); }); describe("Spotlight", () => { @@ -290,7 +290,7 @@ describe("Spotlight", () => { cy.url().should("contain", room3Id); }) .then(() => { - cy.get(".mx_RoomPreviewBar_actions .mx_AccessibleButton").click(); + cy.findByRole("button", { name: "Join the discussion" }).click(); cy.roomHeaderName().should("contain", room3Name); }); }); @@ -365,11 +365,11 @@ describe("Spotlight", () => { // Send first message to actually start DM cy.roomHeaderName().should("contain", bot2Name); - cy.get(".mx_BasicMessageComposer_input").click().should("have.focus").type("Hey!{enter}"); + cy.findByRole("textbox", { name: "Send a message…" }).type("Hey!{enter}"); // Assert DM exists by checking for the first message and the room being in the room list cy.contains(".mx_EventTile_body", "Hey!", { timeout: 30000 }); - cy.get(".mx_RoomSublist[aria-label=People]").should("contain", bot2Name); + cy.findByRole("group", { name: "People" }).should("contain", bot2Name); // Invite BotBob into existing DM with ByteBot cy.getDmRooms(bot2.getUserId()) @@ -378,7 +378,7 @@ describe("Spotlight", () => { .then((groupDm) => { cy.inviteUser(groupDm.roomId, bot1.getUserId()); cy.roomHeaderName().should(($element) => expect($element.get(0).innerText).contains(groupDm.name)); - cy.get(".mx_RoomSublist[aria-label=People]").should(($element) => + cy.findByRole("group", { name: "People" }).should(($element) => expect($element.get(0).innerText).contains(groupDm.name), ); @@ -440,7 +440,7 @@ describe("Spotlight", () => { cy.get(".mx_SpotlightDialog_startGroupChat").click(); }) .then(() => { - cy.get("[role=dialog]").should("contain", "Direct Messages"); + cy.findByRole("dialog").should("contain", "Direct Messages"); }); }); diff --git a/cypress/e2e/threads/threads.spec.ts b/cypress/e2e/threads/threads.spec.ts index 75585c888b3..ee1fd78d082 100644 --- a/cypress/e2e/threads/threads.spec.ts +++ b/cypress/e2e/threads/threads.spec.ts @@ -392,7 +392,7 @@ 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-test-id="share-location-option-${shareType}"]`); + return cy.get(`[data-testid="share-location-option-${shareType}"]`); }; const submitShareLocation = (): void => { cy.get('[data-testid="location-picker-submit-button"]').click(); diff --git a/cypress/e2e/widgets/stickers.spec.ts b/cypress/e2e/widgets/stickers.spec.ts index 44c5b250da0..1a172055f97 100644 --- a/cypress/e2e/widgets/stickers.spec.ts +++ b/cypress/e2e/widgets/stickers.spec.ts @@ -67,8 +67,7 @@ const WIDGET_HTML = ` `; function openStickerPicker() { - cy.get(".mx_MessageComposer_buttonMenu").click(); - cy.get("#stickersButton").click(); + cy.openMessageComposerOptions().findByRole("menuitem", { name: "Sticker" }).click(); } function sendStickerFromPicker() { diff --git a/cypress/support/composer.ts b/cypress/support/composer.ts index 347c581a477..ab094d6280a 100644 --- a/cypress/support/composer.ts +++ b/cypress/support/composer.ts @@ -39,7 +39,7 @@ Cypress.Commands.add("getComposer", (isRightPanel?: boolean): Chainable Cypress.Commands.add("openMessageComposerOptions", (isRightPanel?: boolean): Chainable => { cy.getComposer(isRightPanel).within(() => { - cy.get('[aria-label="More options"]').click(); + cy.findByRole("button", { name: "More options" }).click(); }); return cy.get(".mx_MessageComposer_Menu"); }); diff --git a/cypress/support/settings.ts b/cypress/support/settings.ts index 78c3f68878c..d94811af6ec 100644 --- a/cypress/support/settings.ts +++ b/cypress/support/settings.ts @@ -125,13 +125,13 @@ Cypress.Commands.add( ); Cypress.Commands.add("openUserMenu", (): Chainable> => { - cy.get('[aria-label="User menu"]').click(); + cy.findByRole("button", { name: "User menu" }).click(); return cy.get(".mx_ContextualMenu"); }); Cypress.Commands.add("openUserSettings", (tab?: string): Chainable> => { cy.openUserMenu().within(() => { - cy.get('[aria-label="All settings"]').click(); + cy.findByRole("menuitem", { name: "All settings" }).click(); }); return cy.get(".mx_UserSettingsDialog").within(() => { if (tab) { @@ -141,9 +141,9 @@ Cypress.Commands.add("openUserSettings", (tab?: string): Chainable> => { - cy.get(".mx_RoomHeader_name").click(); + cy.findByRole("button", { name: "Room options" }).click(); cy.get(".mx_RoomTile_contextMenu").within(() => { - cy.get('[aria-label="Settings"]').click(); + cy.findByRole("menuitem", { name: "Settings" }).click(); }); return cy.get(".mx_RoomSettingsDialog").within(() => { if (tab) { @@ -159,7 +159,7 @@ Cypress.Commands.add("switchTab", (tab: string): Chainable> }); Cypress.Commands.add("closeDialog", (): Chainable> => { - return cy.get('[aria-label="Close dialog"]').click(); + return cy.findByRole("button", { name: "Close dialog" }).click(); }); Cypress.Commands.add("joinBeta", (name: string): Chainable> => { @@ -167,7 +167,7 @@ Cypress.Commands.add("joinBeta", (name: string): Chainable> .contains(".mx_BetaCard_title", name) .closest(".mx_BetaCard") .within(() => { - return cy.get(".mx_BetaCard_buttons").contains("Join the beta").click(); + return cy.get(".mx_BetaCard_buttons").findByRole("button", { name: "Join the beta" }).click(); }); }); @@ -176,7 +176,7 @@ Cypress.Commands.add("leaveBeta", (name: string): Chainable> .contains(".mx_BetaCard_title", name) .closest(".mx_BetaCard") .within(() => { - return cy.get(".mx_BetaCard_buttons").contains("Leave the beta").click(); + return cy.get(".mx_BetaCard_buttons").findByRole("button", { name: "Leave the beta" }).click(); }); }); diff --git a/cypress/support/views.ts b/cypress/support/views.ts index 45337cd558a..2100e894599 100644 --- a/cypress/support/views.ts +++ b/cypress/support/views.ts @@ -54,11 +54,11 @@ declare global { } Cypress.Commands.add("viewRoomByName", (name: string): Chainable> => { - return cy.get(`.mx_RoomTile[aria-label="${name}"]`).click(); + return cy.findByRole("treeitem", { name: name }).should("have.class", "mx_RoomTile").click(); }); Cypress.Commands.add("getSpacePanelButton", (name: string): Chainable> => { - return cy.get(`.mx_SpaceButton[aria-label="${name}"]`); + return cy.findByRole("button", { name: name }).should("have.class", "mx_SpaceButton"); }); Cypress.Commands.add("viewSpaceByName", (name: string): Chainable> => { diff --git a/res/css/components/views/settings/devices/_SelectableDeviceTile.pcss b/res/css/components/views/settings/devices/_SelectableDeviceTile.pcss index aa0cf91a9cb..ec6a13b26c5 100644 --- a/res/css/components/views/settings/devices/_SelectableDeviceTile.pcss +++ b/res/css/components/views/settings/devices/_SelectableDeviceTile.pcss @@ -22,6 +22,11 @@ limitations under the License. } .mx_SelectableDeviceTile_checkbox { - flex: 0 0; - margin-right: $spacing-16; + flex: 1 0; + + .mx_Checkbox_background + div { + flex: 1 0; + /* override more specific selector */ + margin-left: $spacing-16 !important; + } } diff --git a/src/Markdown.ts b/src/Markdown.ts index 89cfcf65fec..05efdcfd56d 100644 --- a/src/Markdown.ts +++ b/src/Markdown.ts @@ -28,7 +28,11 @@ const ALLOWED_HTML_TAGS = ["sub", "sup", "del", "u"]; const TEXT_NODES = ["text", "softbreak", "linebreak", "paragraph", "document"]; function isAllowedHtmlTag(node: commonmark.Node): boolean { - if (node.literal != null && node.literal.match('^<((div|span) data-mx-maths="[^"]*"|/(div|span))>$') != null) { + if (!node.literal) { + return false; + } + + if (node.literal.match('^<((div|span) data-mx-maths="[^"]*"|/(div|span))>$') != null) { return true; } @@ -57,9 +61,9 @@ function isMultiLine(node: commonmark.Node): boolean { } function getTextUntilEndOrLinebreak(node: commonmark.Node): string { - let currentNode = node; + let currentNode: commonmark.Node | null = node; let text = ""; - while (currentNode !== null && currentNode.type !== "softbreak" && currentNode.type !== "linebreak") { + while (currentNode && currentNode.type !== "softbreak" && currentNode.type !== "linebreak") { const { literal, type } = currentNode; if (type === "text" && literal) { let n = 0; @@ -95,7 +99,7 @@ const innerNodeLiteral = (node: commonmark.Node): string => { let literal = ""; const walker = node.walker(); - let step: commonmark.NodeWalkingStep; + let step: commonmark.NodeWalkingStep | null; while ((step = walker.next())) { const currentNode = step.node; @@ -166,7 +170,7 @@ export default class Markdown { } // Break up text nodes on spaces, so that we don't shoot past them without resetting - if (node.type === "text") { + if (node.type === "text" && node.literal) { const [thisPart, ...nextParts] = node.literal.split(/( )/); node.literal = thisPart; text += thisPart; @@ -184,11 +188,11 @@ export default class Markdown { } // We should not do this if previous node was not a textnode, as we can't combine it then. - if ((node.type === "emph" || node.type === "strong") && previousNode.type === "text") { + if ((node.type === "emph" || node.type === "strong") && previousNode?.type === "text") { if (event.entering) { const foundLinks = linkify.find(text); for (const { value } of foundLinks) { - if (node.firstChild.literal) { + if (node?.firstChild?.literal) { /** * NOTE: This technically should unlink the emph node and create LINK nodes instead, adding all the next elements as siblings * but this solution seems to work well and is hopefully slightly easier to understand too @@ -205,10 +209,12 @@ export default class Markdown { previousNode.insertAfter(emphasisTextNode); node.firstChild.literal = ""; event = node.walker().next(); - // Remove `em` opening and closing nodes - node.unlink(); - previousNode.insertAfter(event.node); - shouldUnlinkFormattingNode = true; + if (event) { + // Remove `em` opening and closing nodes + node.unlink(); + previousNode.insertAfter(event.node); + shouldUnlinkFormattingNode = true; + } } else { logger.error( "Markdown links escaping found too many links for following text: ", @@ -237,7 +243,7 @@ export default class Markdown { public isPlainText(): boolean { const walker = this.parsed.walker(); - let ev: commonmark.NodeWalkingStep; + let ev: commonmark.NodeWalkingStep | null; while ((ev = walker.next())) { const node = ev.node; if (TEXT_NODES.indexOf(node.type) > -1) { @@ -294,7 +300,7 @@ export default class Markdown { renderer.link = function (node, entering) { const attrs = this.attrs(node); - if (entering) { + if (entering && node.destination) { attrs.push(["href", this.esc(node.destination)]); if (node.title) { attrs.push(["title", this.esc(node.title)]); @@ -312,10 +318,12 @@ export default class Markdown { }; renderer.html_inline = function (node: commonmark.Node) { - if (isAllowedHtmlTag(node)) { - this.lit(node.literal); - } else { - this.lit(escape(node.literal)); + if (node.literal) { + if (isAllowedHtmlTag(node)) { + this.lit(node.literal); + } else { + this.lit(escape(node.literal)); + } } }; @@ -358,7 +366,7 @@ export default class Markdown { }; renderer.html_block = function (node: commonmark.Node) { - this.lit(node.literal); + if (node.literal) this.lit(node.literal); if (isMultiLine(node) && node.next) this.lit("\n\n"); }; diff --git a/src/Notifier.ts b/src/Notifier.ts index 52983f6fc3e..e947f35a7fd 100644 --- a/src/Notifier.ts +++ b/src/Notifier.ts @@ -172,14 +172,22 @@ class NotifierClass { } } - public getSoundForRoom(roomId: string): { + /* + * We do no caching here because the SDK caches setting + * and the browser will cache the sound. + * + * @returns {object} {url: string, name: string, type: string, size: string} or null + */ + public getNotificationSound(roomId?: string): { url: string; name: string; type: string; size: string; } | null { - // We do no caching here because the SDK caches setting - // and the browser will cache the sound. + if (!roomId) { + return null; + } + const content = SettingsStore.getValue("notificationSound", roomId); if (!content) { return null; @@ -212,14 +220,13 @@ class NotifierClass { return; } - const sound = this.getSoundForRoom(room.roomId); + const sound = this.getNotificationSound(room.roomId); logger.log(`Got sound ${(sound && sound.name) || "default"} for ${room.roomId}`); try { - const selector = document.querySelector( + let audioElement = document.querySelector( sound ? `audio[src='${sound.url}']` : "#messageAudio", ); - let audioElement = selector; if (!audioElement) { if (!sound) { logger.error("No audio element or sound to play for notification"); @@ -330,12 +337,11 @@ class NotifierClass { return this.isPossible() && SettingsStore.getValue("notificationsEnabled"); } + // returns true if notifications possible, but not necessarily enabled public isPossible(): boolean { - const plaf = PlatformPeg.get(); - if (!plaf?.supportsNotifications()) return false; - if (!plaf.maySendNotifications()) return false; - - return true; // possible, but not necessarily enabled + const platform = PlatformPeg.get(); + if (!platform?.supportsNotifications()) return false; + return platform.maySendNotifications(); } public isBodyEnabled(): boolean { @@ -454,10 +460,10 @@ class NotifierClass { }; // XXX: exported for tests - public evaluateEvent(ev: MatrixEvent): void { + public evaluateEvent(event: MatrixEvent): void { // Mute notifications for broadcast info events - if (ev.getType() === VoiceBroadcastInfoEventType) return; - let roomId = ev.getRoomId()!; + if (event.getType() === VoiceBroadcastInfoEventType) return; + let roomId = event.getRoomId()!; if (LegacyCallHandler.instance.getSupportsVirtualRooms()) { // Attempt to translate a virtual room to a native one const nativeRoomId = VoipUserMapper.sharedInstance().nativeRoomForVirtualRoom(roomId); @@ -472,29 +478,28 @@ class NotifierClass { return; } - const actions = MatrixClientPeg.get().getPushActionsForEvent(ev); + const actions = MatrixClientPeg.get().getPushActionsForEvent(event); if (actions?.notify) { - this.performCustomEventHandling(ev); + this.performCustomEventHandling(event); const store = SdkContextClass.instance.roomViewStore; const isViewingRoom = store.getRoomId() === room.roomId; - const threadId: string | undefined = ev.getId() !== ev.threadRootId ? ev.threadRootId : undefined; + const threadId: string | undefined = event.getId() !== event.threadRootId ? event.threadRootId : undefined; const isViewingThread = store.getThreadId() === threadId; - const isViewingEventTimeline = isViewingRoom && (!threadId || isViewingThread); + // if user is in the room, and was recently active: don't notify them if (isViewingEventTimeline && UserActivity.sharedInstance().userActiveRecently() && !Modal.hasDialogs()) { - // don't bother notifying as user was recently active in this room return; } if (this.isEnabled()) { - this.displayPopupNotification(ev, room); + this.displayPopupNotification(event, room); } if (actions.tweaks.sound && this.isAudioEnabled()) { - PlatformPeg.get()?.loudNotification(ev, room); - this.playAudioNotification(ev, room); + PlatformPeg.get()?.loudNotification(event, room); + this.playAudioNotification(event, room); } } } diff --git a/src/SlashCommands.tsx b/src/SlashCommands.tsx index ae9a618d601..d06e6858bc1 100644 --- a/src/SlashCommands.tsx +++ b/src/SlashCommands.tsx @@ -1427,9 +1427,8 @@ Commands.forEach((cmd) => { }); export function parseCommandString(input: string): { cmd?: string; args?: string } { - // trim any trailing whitespace, as it can confuse the parser for - // IRC-style commands - input = input.replace(/\s+$/, ""); + // trim any trailing whitespace, as it can confuse the parser for IRC-style commands + input = input.trimEnd(); if (input[0] !== "/") return {}; // not a command const bits = input.match(/^(\S+?)(?:[ \n]+((.|\n)*))?$/); diff --git a/src/autocomplete/Autocompleter.ts b/src/autocomplete/Autocompleter.ts index 73933a23a9b..51c160320b4 100644 --- a/src/autocomplete/Autocompleter.ts +++ b/src/autocomplete/Autocompleter.ts @@ -39,7 +39,7 @@ export interface ICompletion { type?: "at-room" | "command" | "community" | "room" | "user"; completion: string; completionId?: string; - component?: ReactElement; + component: ReactElement; range: ISelectionRange; command?: string; suffix?: string; diff --git a/src/components/structures/FilePanel.tsx b/src/components/structures/FilePanel.tsx index 9d92892d3bf..a6cce06c354 100644 --- a/src/components/structures/FilePanel.tsx +++ b/src/components/structures/FilePanel.tsx @@ -154,11 +154,8 @@ class FilePanel extends React.Component { }, }); - const filterId = await client.getOrCreateFilter("FILTER_FILES_" + client.credentials.userId, filter); - filter.filterId = filterId; - const timelineSet = room.getOrCreateFilteredTimelineSet(filter); - - return timelineSet; + filter.filterId = await client.getOrCreateFilter("FILTER_FILES_" + client.credentials.userId, filter); + return room.getOrCreateFilteredTimelineSet(filter); } private onPaginationRequest = ( diff --git a/src/components/structures/LegacyCallEventGrouper.ts b/src/components/structures/LegacyCallEventGrouper.ts index ab15ce5b642..2d51b4ee187 100644 --- a/src/components/structures/LegacyCallEventGrouper.ts +++ b/src/components/structures/LegacyCallEventGrouper.ts @@ -103,7 +103,7 @@ export default class LegacyCallEventGrouper extends EventEmitter { public get isVoice(): boolean | undefined { const invite = this.invite; - if (!invite) return; + if (!invite) return undefined; // FIXME: Find a better way to determine this from the event? if (invite.getContent()?.offer?.sdp?.indexOf("m=video") !== -1) return false; diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index c14e081a80f..9c72b269f1c 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -231,7 +231,7 @@ class LoggedInView extends React.Component { }; private createResizer(): Resizer { - let panelSize: number; + let panelSize: number | null; let panelCollapsed: boolean; const collapseConfig: ICollapseConfig = { // TODO decrease this once Spaces launches as it'll no longer need to include the 56px Community Panel diff --git a/src/components/structures/MessagePanel.tsx b/src/components/structures/MessagePanel.tsx index dd2ce65efb5..ab01fad9397 100644 --- a/src/components/structures/MessagePanel.tsx +++ b/src/components/structures/MessagePanel.tsx @@ -327,7 +327,11 @@ export default class MessagePanel extends React.Component { } private shouldHideSender(): boolean { - return this.props.room?.getInvitedAndJoinedMemberCount() <= 2 && this.props.layout === Layout.Bubble; + return ( + !!this.props.room && + this.props.room.getInvitedAndJoinedMemberCount() <= 2 && + this.props.layout === Layout.Bubble + ); } private calculateRoomMembersCount = (): void => { @@ -465,7 +469,7 @@ export default class MessagePanel extends React.Component { } } - if (MatrixClientPeg.get().isUserIgnored(mxEv.getSender())) { + if (MatrixClientPeg.get().isUserIgnored(mxEv.getSender()!)) { return false; // ignored = no show (only happens if the ignore happens after an event was received) } @@ -647,7 +651,7 @@ export default class MessagePanel extends React.Component { for (let i = 0; i < events.length; i++) { const eventAndShouldShow = events[i]; const { event, shouldShow } = eventAndShouldShow; - const eventId = event.getId(); + const eventId = event.getId()!; const last = event === lastShownEvent; const { nextEventAndShouldShow, nextTile } = this.getNextEventInfo(events, i); @@ -745,7 +749,7 @@ export default class MessagePanel extends React.Component { !wantsDateSeparator && shouldFormContinuation(prevEvent, mxEv, this.showHiddenEvents, this.context.timelineRenderingType); - const eventId = mxEv.getId(); + const eventId = mxEv.getId()!; const highlight = eventId === this.props.highlightedEventId; const readReceipts = this.readReceiptsByEvent.get(eventId); @@ -1075,7 +1079,7 @@ abstract class BaseGrouper { public readonly nextEventTile?: MatrixEvent | null, ) { this.readMarker = panel.readMarkerForEvent( - firstEventAndShouldShow.event.getId(), + firstEventAndShouldShow.event.getId()!, firstEventAndShouldShow.event === lastShownEvent, ); } @@ -1143,7 +1147,7 @@ class CreationGrouper extends BaseGrouper { public add({ event: ev, shouldShow }: EventAndShouldShow): void { const panel = this.panel; - this.readMarker = this.readMarker || panel.readMarkerForEvent(ev.getId(), ev === this.lastShownEvent); + this.readMarker = this.readMarker || panel.readMarkerForEvent(ev.getId()!, ev === this.lastShownEvent); if (!shouldShow) { return; } @@ -1295,7 +1299,7 @@ class MainGrouper extends BaseGrouper { // We can ignore any events that don't actually have a message to display if (!hasText(ev, this.panel.showHiddenEvents)) return; } - this.readMarker = this.readMarker || this.panel.readMarkerForEvent(ev.getId(), ev === this.lastShownEvent); + this.readMarker = this.readMarker || this.panel.readMarkerForEvent(ev.getId()!, ev === this.lastShownEvent); if (!this.panel.showHiddenEvents && !shouldShow) { // absorb hidden events to not split the summary return; @@ -1331,7 +1335,10 @@ class MainGrouper extends BaseGrouper { // This will prevent it from being re-created unnecessarily, and instead will allow new props to be provided. // In turn, the shouldComponentUpdate method on ELS can be used to prevent unnecessary renderings. const keyEvent = this.events.find((e) => this.panel.grouperKeyMap.get(e)); - const key = keyEvent ? this.panel.grouperKeyMap.get(keyEvent) : this.generateKey(); + const key = + keyEvent && this.panel.grouperKeyMap.has(keyEvent) + ? this.panel.grouperKeyMap.get(keyEvent)! + : this.generateKey(); if (!keyEvent) { // Populate the weak map with the key. // Note that we only set the key on the specific event it refers to, since this group might get diff --git a/src/components/structures/RoomStatusBar.tsx b/src/components/structures/RoomStatusBar.tsx index 2a92a49a5f8..24530d4cc51 100644 --- a/src/components/structures/RoomStatusBar.tsx +++ b/src/components/structures/RoomStatusBar.tsx @@ -213,7 +213,7 @@ export default class RoomStatusBar extends React.PureComponent { {}, { consentLink: (sub) => ( - + {sub} ), diff --git a/src/components/structures/ViewSource.tsx b/src/components/structures/ViewSource.tsx index bf1115c00f1..a0a500810f6 100644 --- a/src/components/structures/ViewSource.tsx +++ b/src/components/structures/ViewSource.tsx @@ -31,6 +31,7 @@ import CopyableText from "../views/elements/CopyableText"; interface IProps { mxEvent: MatrixEvent; // the MatrixEvent associated with the context menu + ignoreEdits?: boolean; onFinished(): void; } @@ -58,7 +59,11 @@ export default class ViewSource extends React.Component { // returns the dialog body for viewing the event source private viewSourceContent(): JSX.Element { - const mxEvent = this.props.mxEvent.replacingEvent() || this.props.mxEvent; // show the replacing event, not the original, if it is an edit + let mxEvent = this.props.mxEvent.replacingEvent() || this.props.mxEvent; // show the replacing event, not the original, if it is an edit + if (this.props.ignoreEdits) { + mxEvent = this.props.mxEvent; + } + const isEncrypted = mxEvent.isEncrypted(); // @ts-ignore const decryptedEventSource = mxEvent.clearEvent; // FIXME: clearEvent is private diff --git a/src/components/views/avatars/RoomAvatar.tsx b/src/components/views/avatars/RoomAvatar.tsx index 69ee06a2cc2..ab434e1a69b 100644 --- a/src/components/views/avatars/RoomAvatar.tsx +++ b/src/components/views/avatars/RoomAvatar.tsx @@ -110,6 +110,7 @@ export default class RoomAvatar extends React.Component { private onRoomAvatarClick = (): void => { const avatarUrl = Avatar.avatarUrlForRoom(this.props.room ?? null, undefined, undefined, undefined); + if (!avatarUrl) return; const params = { src: avatarUrl, name: this.props.room?.name, diff --git a/src/components/views/beacon/LiveTimeRemaining.tsx b/src/components/views/beacon/LiveTimeRemaining.tsx index b6682d710be..b1fc767f6f9 100644 --- a/src/components/views/beacon/LiveTimeRemaining.tsx +++ b/src/components/views/beacon/LiveTimeRemaining.tsx @@ -69,7 +69,7 @@ const LiveTimeRemaining: React.FC<{ beacon: Beacon }> = ({ beacon }) => { const liveTimeRemaining = _t(`%(timeRemaining)s left`, { timeRemaining }); return ( - + {liveTimeRemaining} ); diff --git a/src/components/views/beacon/OwnBeaconStatus.tsx b/src/components/views/beacon/OwnBeaconStatus.tsx index 92afa950da2..344a2fff30c 100644 --- a/src/components/views/beacon/OwnBeaconStatus.tsx +++ b/src/components/views/beacon/OwnBeaconStatus.tsx @@ -57,7 +57,7 @@ const OwnBeaconStatus: React.FC> = ({ beacon, > {ownDisplayStatus === BeaconDisplayStatus.Active && ( > = ({ beacon, )} {hasLocationPublishError && ( > = ({ beacon, )} {hasStopSharingError && ( = ({ space, onAddExistingSpaceClick const [busy, setBusy] = useState(false); const [name, setName] = useState(""); - const spaceNameField = useRef() as RefObject; + const spaceNameField = useRef(null); const [alias, setAlias] = useState(""); - const spaceAliasField = useRef() as RefObject; + const spaceAliasField = useRef(null); const [avatar, setAvatar] = useState(); const [topic, setTopic] = useState(""); diff --git a/src/components/views/dialogs/ExportDialog.tsx b/src/components/views/dialogs/ExportDialog.tsx index 7402c3413cc..ce5c4a9156c 100644 --- a/src/components/views/dialogs/ExportDialog.tsx +++ b/src/components/views/dialogs/ExportDialog.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { useRef, useState, Dispatch, SetStateAction, RefObject } from "react"; +import React, { useRef, useState, Dispatch, SetStateAction } from "react"; import { Room } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; @@ -104,8 +104,8 @@ const ExportDialog: React.FC = ({ room, onFinished }) => { } = useExportFormState(); const [isExporting, setExporting] = useState(false); - const sizeLimitRef = useRef() as RefObject; - const messageCountRef = useRef() as RefObject; + const sizeLimitRef = useRef(null); + const messageCountRef = useRef(null); const [exportProgressText, setExportProgressText] = useState(_t("Processing…")); const [displayCancel, setCancelWarning] = useState(false); const [exportCancelled, setExportCancelled] = useState(false); @@ -182,7 +182,7 @@ const ExportDialog: React.FC = ({ room, onFinished }) => { { key: "number", test: ({ value }) => { - const parsedSize = parseInt(value, 10); + const parsedSize = parseInt(value!, 10); return validateNumberInRange(1, 2000)(parsedSize); }, invalid: () => { @@ -218,7 +218,7 @@ const ExportDialog: React.FC = ({ room, onFinished }) => { { key: "number", test: ({ value }) => { - const parsedSize = parseInt(value, 10); + const parsedSize = parseInt(value!, 10); return validateNumberInRange(1, 10 ** 8)(parsedSize); }, invalid: () => { @@ -398,7 +398,7 @@ const ExportDialog: React.FC = ({ room, onFinished }) => { )} {isExporting ? ( -
+

{exportProgressText}

= (props: IProps) => { - const feedbackRef = useRef(); + const feedbackRef = useRef(null); const [comment, setComment] = useState(""); const [canContact, toggleCanContact] = useStateToggle(false); diff --git a/src/components/views/dialogs/InviteDialog.tsx b/src/components/views/dialogs/InviteDialog.tsx index e637e3081eb..03d26459969 100644 --- a/src/components/views/dialogs/InviteDialog.tsx +++ b/src/components/views/dialogs/InviteDialog.tsx @@ -1228,6 +1228,8 @@ export default class InviteDialog extends React.PureComponent = ({ onFinished }) => { const [email, setEmail] = useState(""); - const fieldRef = useRef() as RefObject; + const fieldRef = useRef(null); const onSubmit = async (e: SyntheticEvent): Promise => { e.preventDefault(); diff --git a/src/components/views/dialogs/ReportEventDialog.tsx b/src/components/views/dialogs/ReportEventDialog.tsx index 02ec9d4a356..8eaa64bc34b 100644 --- a/src/components/views/dialogs/ReportEventDialog.tsx +++ b/src/components/views/dialogs/ReportEventDialog.tsx @@ -255,7 +255,7 @@ export default class ReportEventDialog extends React.Component { }); } else { // Report to homeserver admin through the dedicated Matrix API. - await client.reportEvent(ev.getRoomId(), ev.getId(), -100, this.state.reason.trim()); + await client.reportEvent(ev.getRoomId()!, ev.getId()!, -100, this.state.reason.trim()); } // if the user should also be ignored, do that @@ -340,7 +340,7 @@ export default class ReportEventDialog extends React.Component { ); break; case NonStandardValue.Admin: - if (client.isRoomEncrypted(this.props.mxEvent.getRoomId())) { + if (client.isRoomEncrypted(this.props.mxEvent.getRoomId()!)) { subtitle = _t( "This room is dedicated to illegal or toxic content " + "or the moderators fail to moderate illegal or toxic content.\n" + diff --git a/src/components/views/dialogs/spotlight/SpotlightDialog.tsx b/src/components/views/dialogs/spotlight/SpotlightDialog.tsx index b45b0582b86..05ab8c1749a 100644 --- a/src/components/views/dialogs/spotlight/SpotlightDialog.tsx +++ b/src/components/views/dialogs/spotlight/SpotlightDialog.tsx @@ -288,8 +288,8 @@ interface IDirectoryOpts { } const SpotlightDialog: React.FC = ({ initialText = "", initialFilter = null, onFinished }) => { - const inputRef = useRef() as RefObject; - const scrollContainerRef = useRef() as RefObject; + const inputRef = useRef(null); + const scrollContainerRef = useRef(null); const cli = MatrixClientPeg.get(); const rovingContext = useContext(RovingTabIndexContext); const [query, _setQuery] = useState(initialText); diff --git a/src/components/views/elements/EffectsOverlay.tsx b/src/components/views/elements/EffectsOverlay.tsx index 11e11211b25..d372d9ed710 100644 --- a/src/components/views/elements/EffectsOverlay.tsx +++ b/src/components/views/elements/EffectsOverlay.tsx @@ -90,6 +90,7 @@ const EffectsOverlay: FunctionComponent = ({ roomWidth }) => { top: 0, right: 0, }} + aria-hidden={true} /> ); }; diff --git a/src/components/views/elements/EventTilePreview.tsx b/src/components/views/elements/EventTilePreview.tsx index 10d7b458ccd..eaa41903f70 100644 --- a/src/components/views/elements/EventTilePreview.tsx +++ b/src/components/views/elements/EventTilePreview.tsx @@ -18,6 +18,7 @@ import React from "react"; import classnames from "classnames"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { RoomMember } from "matrix-js-sdk/src/models/room-member"; +import { MsgType } from "matrix-js-sdk/src/@types/event"; import * as Avatar from "../../../Avatar"; import EventTile from "../rooms/EventTile"; @@ -78,12 +79,12 @@ export default class EventTilePreview extends React.Component { sender: this.props.userId, content: { "m.new_content": { - msgtype: "m.text", + msgtype: MsgType.Text, body: message, displayname: this.props.displayName, avatar_url: this.props.avatarUrl, }, - "msgtype": "m.text", + "msgtype": MsgType.Text, "body": message, "displayname": this.props.displayName, "avatar_url": this.props.avatarUrl, diff --git a/src/components/views/elements/IRCTimelineProfileResizer.tsx b/src/components/views/elements/IRCTimelineProfileResizer.tsx index 48bf1a188dc..bae76d3f879 100644 --- a/src/components/views/elements/IRCTimelineProfileResizer.tsx +++ b/src/components/views/elements/IRCTimelineProfileResizer.tsx @@ -22,7 +22,7 @@ import { SettingLevel } from "../../../settings/SettingLevel"; interface IProps { // Current room - roomId: string; + roomId: string | null; minWidth: number; maxWidth: number; } diff --git a/src/components/views/elements/ImageView.tsx b/src/components/views/elements/ImageView.tsx index aa8f82bb031..cd339eadee8 100644 --- a/src/components/views/elements/ImageView.tsx +++ b/src/components/views/elements/ImageView.tsx @@ -540,8 +540,9 @@ export default class ImageView extends React.Component { = ({ setShow(false); }, 13000); // hide after being shown for 10 seconds - const uploadRef = useRef() as RefObject; + const uploadRef = useRef(null); const label = hasAvatar || busy ? hasAvatarLabel : noAvatarLabel; diff --git a/src/components/views/elements/RoomTopic.tsx b/src/components/views/elements/RoomTopic.tsx index ad59012c78d..eb9ae028a48 100644 --- a/src/components/views/elements/RoomTopic.tsx +++ b/src/components/views/elements/RoomTopic.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { RefObject, useCallback, useContext, useRef } from "react"; +import React, { useCallback, useContext, useRef } from "react"; import { Room } from "matrix-js-sdk/src/models/room"; import classNames from "classnames"; import { EventType } from "matrix-js-sdk/src/@types/event"; @@ -38,7 +38,7 @@ interface IProps extends React.HTMLProps { export default function RoomTopic({ room, ...props }: IProps): JSX.Element { const client = useContext(MatrixClientContext); - const ref = useRef() as RefObject; + const ref = useRef(null); const topic = useTopic(room); const body = topicToHtml(topic?.text, topic?.html, ref); diff --git a/src/components/views/elements/SettingsFlag.tsx b/src/components/views/elements/SettingsFlag.tsx index 2bb1d4ddb77..70e9fda1b59 100644 --- a/src/components/views/elements/SettingsFlag.tsx +++ b/src/components/views/elements/SettingsFlag.tsx @@ -62,7 +62,7 @@ export default class SettingsFlag extends React.Component { } private getSettingValue(): boolean { - return SettingsStore.getValueAt( + return !!SettingsStore.getValueAt( this.props.level, this.props.name, this.props.roomId ?? null, diff --git a/src/components/views/elements/Slider.tsx b/src/components/views/elements/Slider.tsx index a6f11aa0751..44947aa6518 100644 --- a/src/components/views/elements/Slider.tsx +++ b/src/components/views/elements/Slider.tsx @@ -35,6 +35,8 @@ interface IProps { // Whether the slider is disabled disabled: boolean; + + label: string; } const THUMB_SIZE = 2.4; // em @@ -77,6 +79,7 @@ export default class Slider extends React.Component { disabled={this.props.disabled} step={this.props.step} autoComplete="off" + aria-label={this.props.label} /> {selection}
diff --git a/src/components/views/location/LiveDurationDropdown.tsx b/src/components/views/location/LiveDurationDropdown.tsx index fbb88ca6bc3..4deeae023bf 100644 --- a/src/components/views/location/LiveDurationDropdown.tsx +++ b/src/components/views/location/LiveDurationDropdown.tsx @@ -71,7 +71,7 @@ const LiveDurationDropdown: React.FC = ({ timeout, onChange }) => { > { options.map(({ key, label }) => ( -
+
{label}
)) as NonEmptyArray diff --git a/src/components/views/location/LocationPicker.tsx b/src/components/views/location/LocationPicker.tsx index c012a1ab786..9660457099a 100644 --- a/src/components/views/location/LocationPicker.tsx +++ b/src/components/views/location/LocationPicker.tsx @@ -119,10 +119,13 @@ class LocationPicker extends React.Component { } } catch (e) { logger.error("Failed to render map", e); - const errorType = - (e as Error)?.message === LocationShareError.MapStyleUrlNotConfigured - ? LocationShareError.MapStyleUrlNotConfigured - : LocationShareError.Default; + const errorMessage = (e as Error)?.message; + let errorType; + if (errorMessage === LocationShareError.MapStyleUrlNotConfigured) + errorType = LocationShareError.MapStyleUrlNotConfigured; + else if (errorMessage.includes("Failed to initialize WebGL")) + errorType = LocationShareError.WebGLNotEnabled; + else errorType = LocationShareError.Default; this.setState({ error: errorType }); } } diff --git a/src/components/views/location/Map.tsx b/src/components/views/location/Map.tsx index 2fc9f972801..f74bbd77209 100644 --- a/src/components/views/location/Map.tsx +++ b/src/components/views/location/Map.tsx @@ -158,7 +158,7 @@ interface MapProps { children?: (renderProps: { map: maplibregl.Map }) => ReactNode; } -const Map: React.FC = ({ +const MapComponent: React.FC = ({ bounds, centerGeoUri, children, @@ -188,4 +188,4 @@ const Map: React.FC = ({ ); }; -export default Map; +export default MapComponent; diff --git a/src/components/views/location/ShareType.tsx b/src/components/views/location/ShareType.tsx index 81d232e6f50..235a385dc17 100644 --- a/src/components/views/location/ShareType.tsx +++ b/src/components/views/location/ShareType.tsx @@ -91,7 +91,7 @@ const ShareType: React.FC = ({ setShareType, enabledShareTypes }) => { onClick={() => setShareType(type)} label={labels[type]} shareType={type} - data-test-id={`share-location-option-${type}`} + data-testid={`share-location-option-${type}`} /> ))}
diff --git a/src/components/views/messages/DateSeparator.tsx b/src/components/views/messages/DateSeparator.tsx index 7f2b5afc880..4eed475d5cc 100644 --- a/src/components/views/messages/DateSeparator.tsx +++ b/src/components/views/messages/DateSeparator.tsx @@ -322,7 +322,7 @@ export default class DateSeparator extends React.Component { public render(): React.ReactNode { const label = this.getLabel(); - let dateHeaderContent; + let dateHeaderContent: JSX.Element; if (this.state.jumpToDateEnabled) { dateHeaderContent = this.renderJumpToDateMenu(); } else { @@ -336,9 +336,8 @@ export default class DateSeparator extends React.Component { } // ARIA treats
s as separators, here we abuse them slightly so manually treat this entire thing as one - // tab-index=-1 to allow it to be focusable but do not add tab stop for it, primarily for screen readers return ( -
+

{dateHeaderContent}
diff --git a/src/components/views/messages/EditHistoryMessage.tsx b/src/components/views/messages/EditHistoryMessage.tsx index 421673d7711..54f24973c51 100644 --- a/src/components/views/messages/EditHistoryMessage.tsx +++ b/src/components/views/messages/EditHistoryMessage.tsx @@ -17,6 +17,7 @@ limitations under the License. import React, { createRef } from "react"; import { EventStatus, IContent, MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/models/event"; import classNames from "classnames"; +import { MsgType } from "matrix-js-sdk/src/@types/event"; import * as HtmlUtils from "../../../HtmlUtils"; import { editBodyDiffToHtml } from "../../../utils/MessageDiffUtils"; @@ -91,6 +92,7 @@ export default class EditHistoryMessage extends React.PureComponent diff --git a/src/components/views/right_panel/EncryptionInfo.tsx b/src/components/views/right_panel/EncryptionInfo.tsx index d9952ef05b4..fb5503c788f 100644 --- a/src/components/views/right_panel/EncryptionInfo.tsx +++ b/src/components/views/right_panel/EncryptionInfo.tsx @@ -108,7 +108,7 @@ const EncryptionInfo: React.FC = ({ return ( -
+

{_t("Encryption")}

{description}
diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx index c0fb6dd443f..270e837a703 100644 --- a/src/components/views/right_panel/UserInfo.tsx +++ b/src/components/views/right_panel/UserInfo.tsx @@ -1504,9 +1504,10 @@ export const UserInfoHeader: React.FC<{ const avatarUrl = (member as RoomMember).getMxcAvatarUrl ? (member as RoomMember).getMxcAvatarUrl() : (member as User).avatarUrl; - if (!avatarUrl) return; const httpUrl = mediaFromMxc(avatarUrl).srcHttp; + if (!httpUrl) return; + const params = { src: httpUrl, name: (member as RoomMember).name || (member as User).displayName, diff --git a/src/components/views/rooms/AuxPanel.tsx b/src/components/views/rooms/AuxPanel.tsx index 66ce83d6e86..dfcc6f27a16 100644 --- a/src/components/views/rooms/AuxPanel.tsx +++ b/src/components/views/rooms/AuxPanel.tsx @@ -100,14 +100,14 @@ export default class AuxPanel extends React.Component { if (this.props.room && SettingsStore.getValue("feature_state_counters")) { const stateEvs = this.props.room.currentState.getStateEvents("re.jki.counter"); - stateEvs.sort((a, b) => lexicographicCompare(a.getStateKey(), b.getStateKey())); + stateEvs.sort((a, b) => lexicographicCompare(a.getStateKey()!, b.getStateKey()!)); for (const ev of stateEvs) { const title = ev.getContent().title; const value = ev.getContent().value; const link = ev.getContent().link; const severity = ev.getContent().severity || "normal"; - const stateKey = ev.getStateKey(); + const stateKey = ev.getStateKey()!; // We want a non-empty title but can accept falsy values (e.g. // zero) diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index 2eb19e9e3c6..d7d25356101 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -765,7 +765,7 @@ export default class BasicMessageEditor extends React.Component public render(): React.ReactNode { let autoComplete: JSX.Element | undefined; - if (this.state.autoComplete) { + if (this.state.autoComplete && this.state.query) { const query = this.state.query; const queryLen = query.length; autoComplete = ( @@ -800,8 +800,8 @@ export default class BasicMessageEditor extends React.Component const { completionIndex } = this.state; const hasAutocomplete = Boolean(this.state.autoComplete); let activeDescendant: string | undefined; - if (hasAutocomplete && completionIndex >= 0) { - activeDescendant = generateCompletionDomId(completionIndex); + if (hasAutocomplete && completionIndex! >= 0) { + activeDescendant = generateCompletionDomId(completionIndex!); } return ( diff --git a/src/components/views/rooms/EditMessageComposer.tsx b/src/components/views/rooms/EditMessageComposer.tsx index bed42fd0a89..96c66dddfb6 100644 --- a/src/components/views/rooms/EditMessageComposer.tsx +++ b/src/components/views/rooms/EditMessageComposer.tsx @@ -229,11 +229,11 @@ class EditMessageComposer extends React.Component { const item = SendHistoryManager.createItem(this.model); this.clearPreviousEdit(); - localStorage.setItem(this.editorRoomKey, this.props.editState.getEvent().getId()); + localStorage.setItem(this.editorRoomKey, this.props.editState.getEvent().getId()!); localStorage.setItem(this.editorStateKey, JSON.stringify(item)); }; @@ -329,7 +329,7 @@ class EditMessageComposer extends React.Component { if (ev.button != 0 || ev.metaKey) return; ev.preventDefault(); - let src = p["og:image"]; + let src: string | null | undefined = p["og:image"]; if (src?.startsWith("mxc://")) { src = mediaFromMxc(src).srcHttp; } + if (!src) return; + const params: Omit, "onFinished"> = { src: src, width: p["og:image:width"], diff --git a/src/components/views/rooms/MessageComposerButtons.tsx b/src/components/views/rooms/MessageComposerButtons.tsx index 9572a118eb5..17990d3aa66 100644 --- a/src/components/views/rooms/MessageComposerButtons.tsx +++ b/src/components/views/rooms/MessageComposerButtons.tsx @@ -17,7 +17,7 @@ limitations under the License. import classNames from "classnames"; import { IEventRelation } from "matrix-js-sdk/src/models/event"; import { M_POLL_START } from "matrix-js-sdk/src/@types/polls"; -import React, { createContext, MouseEventHandler, ReactElement, ReactNode, RefObject, useContext, useRef } from "react"; +import React, { createContext, MouseEventHandler, ReactElement, ReactNode, useContext, useRef } from "react"; import { Room } from "matrix-js-sdk/src/models/room"; import { MatrixClient } from "matrix-js-sdk/src/client"; import { THREAD_RELATION_TYPE } from "matrix-js-sdk/src/models/thread"; @@ -180,7 +180,7 @@ interface IUploadButtonProps { const UploadButtonContextProvider: React.FC = ({ roomId, relation, children }) => { const cli = useContext(MatrixClientContext); const roomContext = useContext(RoomContext); - const uploadInput = useRef() as RefObject; + const uploadInput = useRef(null); const onUploadClick = (): void => { if (cli?.isGuest()) { diff --git a/src/components/views/rooms/ReadReceiptGroup.tsx b/src/components/views/rooms/ReadReceiptGroup.tsx index 3472ee8db31..7eb625de478 100644 --- a/src/components/views/rooms/ReadReceiptGroup.tsx +++ b/src/components/views/rooms/ReadReceiptGroup.tsx @@ -293,7 +293,7 @@ interface ISectionHeaderProps { } function SectionHeader({ className, children }: PropsWithChildren): JSX.Element { - const ref = useRef(); + const ref = useRef(null); const [onFocus] = useRovingTabIndex(ref); return ( diff --git a/src/components/views/rooms/ReadReceiptMarker.tsx b/src/components/views/rooms/ReadReceiptMarker.tsx index e5e2fafdd75..6d453085d54 100644 --- a/src/components/views/rooms/ReadReceiptMarker.tsx +++ b/src/components/views/rooms/ReadReceiptMarker.tsx @@ -124,7 +124,7 @@ export default class ReadReceiptMarker extends React.PureComponent { - const tooltipRef = useRef() as RefObject; + const tooltipRef = useRef(null); const crumbs = useEventEmitterState(BreadcrumbsStore.instance, UPDATE_EVENT, () => BreadcrumbsStore.instance.rooms); const content = ( diff --git a/src/components/views/rooms/ReplyTile.tsx b/src/components/views/rooms/ReplyTile.tsx index b9a5766dd9a..f3fbf73db81 100644 --- a/src/components/views/rooms/ReplyTile.tsx +++ b/src/components/views/rooms/ReplyTile.tsx @@ -134,7 +134,7 @@ export default class ReplyTile extends React.PureComponent { let permalink = "#"; if (this.props.permalinkCreator) { - permalink = this.props.permalinkCreator.forEvent(mxEvent.getId()); + permalink = this.props.permalinkCreator.forEvent(mxEvent.getId()!); } let sender; diff --git a/src/components/views/rooms/SendMessageComposer.tsx b/src/components/views/rooms/SendMessageComposer.tsx index 745b2c3ab66..24fbf5ccad1 100644 --- a/src/components/views/rooms/SendMessageComposer.tsx +++ b/src/components/views/rooms/SendMessageComposer.tsx @@ -18,7 +18,7 @@ import React, { ClipboardEvent, createRef, KeyboardEvent } from "react"; import EMOJI_REGEX from "emojibase-regex"; import { IContent, MatrixEvent, IEventRelation, IMentions } from "matrix-js-sdk/src/models/event"; import { DebouncedFunc, throttle } from "lodash"; -import { EventType, RelationType } from "matrix-js-sdk/src/@types/event"; +import { EventType, MsgType, RelationType } from "matrix-js-sdk/src/@types/event"; import { logger } from "matrix-js-sdk/src/logger"; import { Room } from "matrix-js-sdk/src/models/room"; import { Composer as ComposerEvent } from "@matrix-org/analytics-events/types/typescript/Composer"; @@ -187,7 +187,7 @@ export function createMessageContent( const body = textSerialize(model); const content: IContent = { - msgtype: isEmote ? "m.emote" : "m.text", + msgtype: isEmote ? MsgType.Emote : MsgType.Text, body: body, }; const formattedBody = htmlSerializeIfNeeded(model, { diff --git a/src/components/views/settings/BridgeTile.tsx b/src/components/views/settings/BridgeTile.tsx index de40898f0d7..673337f39ba 100644 --- a/src/components/views/settings/BridgeTile.tsx +++ b/src/components/views/settings/BridgeTile.tsx @@ -97,7 +97,7 @@ export default class BridgeTile extends React.PureComponent { ), diff --git a/src/components/views/settings/ChangePassword.tsx b/src/components/views/settings/ChangePassword.tsx index 9978985ad87..8ea83263250 100644 --- a/src/components/views/settings/ChangePassword.tsx +++ b/src/components/views/settings/ChangePassword.tsx @@ -213,7 +213,7 @@ export default class ChangePassword extends React.Component { const modal = Modal.createDialog(SetEmailDialog, { title: _t("Do you want to set an email address?"), }); - return modal.finished.then(([confirmed]) => confirmed); + return modal.finished.then(([confirmed]) => !!confirmed); } private onExportE2eKeysClicked = (): void => { diff --git a/src/components/views/settings/CrossSigningPanel.tsx b/src/components/views/settings/CrossSigningPanel.tsx index 0ba8f580f58..e3f62d4ba26 100644 --- a/src/components/views/settings/CrossSigningPanel.tsx +++ b/src/components/views/settings/CrossSigningPanel.tsx @@ -94,9 +94,9 @@ export default class CrossSigningPanel extends React.PureComponent<{}, IState> { const secretStorage = cli.crypto!.secretStorage; const crossSigningPublicKeysOnDevice = Boolean(crossSigning.getId()); const crossSigningPrivateKeysInStorage = Boolean(await crossSigning.isStoredInSecretStorage(secretStorage)); - const masterPrivateKeyCached = !!(pkCache && (await pkCache.getCrossSigningKeyCache("master"))); - const selfSigningPrivateKeyCached = !!(pkCache && (await pkCache.getCrossSigningKeyCache("self_signing"))); - const userSigningPrivateKeyCached = !!(pkCache && (await pkCache.getCrossSigningKeyCache("user_signing"))); + const masterPrivateKeyCached = !!(await pkCache?.getCrossSigningKeyCache?.("master")); + const selfSigningPrivateKeyCached = !!(await pkCache?.getCrossSigningKeyCache?.("self_signing")); + const userSigningPrivateKeyCached = !!(await pkCache?.getCrossSigningKeyCache?.("user_signing")); const homeserverSupportsCrossSigning = await cli.doesServerSupportUnstableFeature( "org.matrix.e2e_cross_signing", ); diff --git a/src/components/views/settings/DevicesPanelEntry.tsx b/src/components/views/settings/DevicesPanelEntry.tsx index 57f0bc24b29..95bcdb9e949 100644 --- a/src/components/views/settings/DevicesPanelEntry.tsx +++ b/src/components/views/settings/DevicesPanelEntry.tsx @@ -51,7 +51,7 @@ export default class DevicesPanelEntry extends React.Component { super(props); this.state = { renaming: false, - displayName: props.device.display_name, + displayName: props.device.display_name ?? "", }; } @@ -103,11 +103,11 @@ export default class DevicesPanelEntry extends React.Component { }); } else { const cli = MatrixClientPeg.get(); - const userId = cli.getUserId()!; + const userId = cli.getSafeUserId(); const verificationRequestPromise = cli.requestVerification(userId, [this.props.device.device_id]); Modal.createDialog(VerificationRequestDialog, { verificationRequestPromise, - member: cli.getUser(userId), + member: cli.getUser(userId) ?? undefined, onFinished: async (): Promise => { const request = await verificationRequestPromise; request.cancel(); diff --git a/src/components/views/settings/EventIndexPanel.tsx b/src/components/views/settings/EventIndexPanel.tsx index 8a1d93c98f5..ea4c2b8c5c4 100644 --- a/src/components/views/settings/EventIndexPanel.tsx +++ b/src/components/views/settings/EventIndexPanel.tsx @@ -26,7 +26,6 @@ import EventIndexPeg from "../../../indexing/EventIndexPeg"; import { SettingLevel } from "../../../settings/SettingLevel"; import SeshatResetDialog from "../dialogs/SeshatResetDialog"; import InlineSpinner from "../elements/InlineSpinner"; -import { IIndexStats } from "../../../indexing/BaseEventIndexManager"; interface IState { enabling: boolean; @@ -49,15 +48,9 @@ export default class EventIndexPanel extends React.Component<{}, IState> { public updateCurrentRoom = async (): Promise => { const eventIndex = EventIndexPeg.get(); - let stats: IIndexStats | undefined; - - try { - stats = await eventIndex?.getStats(); - } catch { - // This call may fail if sporadically, not a huge issue as we will - // try later again and probably succeed. - return; - } + const stats = await eventIndex?.getStats().catch(() => {}); + // This call may fail if sporadically, not a huge issue as we will try later again and probably succeed. + if (!stats) return; this.setState({ eventIndexSize: stats.size, @@ -88,14 +81,13 @@ export default class EventIndexPanel extends React.Component<{}, IState> { if (eventIndex !== null) { eventIndex.on("changedCheckpoint", this.updateCurrentRoom); - try { - const stats = await eventIndex.getStats(); + const stats = await eventIndex.getStats().catch(() => {}); + // This call may fail if sporadically, not a huge issue as we + // will try later again in the updateCurrentRoom call and + // probably succeed. + if (stats) { eventIndexSize = stats.size; roomCount = stats.roomCount; - } catch { - // This call may fail if sporadically, not a huge issue as we - // will try later again in the updateCurrentRoom call and - // probably succeed. } } diff --git a/src/components/views/settings/FontScalingPanel.tsx b/src/components/views/settings/FontScalingPanel.tsx index 49fe5576718..89cb785f195 100644 --- a/src/components/views/settings/FontScalingPanel.tsx +++ b/src/components/views/settings/FontScalingPanel.tsx @@ -83,7 +83,7 @@ export default class FontScalingPanel extends React.Component { }; private onValidateFontSize = async ({ value }: Pick): Promise => { - const parsedSize = parseFloat(value); + const parsedSize = parseFloat(value!); const min = FontWatcher.MIN_SIZE + FontWatcher.SIZE_DIFF; const max = FontWatcher.MAX_SIZE + FontWatcher.SIZE_DIFF; @@ -98,7 +98,7 @@ export default class FontScalingPanel extends React.Component { }; } - SettingsStore.setValue("baseFontSize", null, SettingLevel.DEVICE, parseInt(value, 10) - FontWatcher.SIZE_DIFF); + SettingsStore.setValue("baseFontSize", null, SettingLevel.DEVICE, parseInt(value!, 10) - FontWatcher.SIZE_DIFF); return { valid: true, feedback: _t("Use between %(min)s pt and %(max)s pt", { min, max }) }; }; @@ -128,6 +128,7 @@ export default class FontScalingPanel extends React.Component { onChange={this.onFontSizeChanged} displayFunc={(_) => ""} disabled={this.state.useCustomFontSize} + label={_t("Font size")} />
Aa
diff --git a/src/components/views/settings/JoinRuleSettings.tsx b/src/components/views/settings/JoinRuleSettings.tsx index 5b1c5e0bbf0..5706aa4dfaa 100644 --- a/src/components/views/settings/JoinRuleSettings.tsx +++ b/src/components/views/settings/JoinRuleSettings.tsx @@ -61,7 +61,7 @@ const JoinRuleSettings: React.FC = ({ const disabled = !room.currentState.mayClientSendStateEvent(EventType.RoomJoinRules, cli); - const [content, setContent] = useLocalEcho( + const [content, setContent] = useLocalEcho( () => room.currentState.getStateEvents(EventType.RoomJoinRules, "")?.getContent(), (content) => cli.sendStateEvent(room.roomId, EventType.RoomJoinRules, content, ""), onError, @@ -70,10 +70,10 @@ const JoinRuleSettings: React.FC = ({ const { join_rule: joinRule = JoinRule.Invite } = content || {}; const restrictedAllowRoomIds = joinRule === JoinRule.Restricted - ? content.allow?.filter((o) => o.type === RestrictedAllowType.RoomMembership).map((o) => o.room_id) + ? content?.allow?.filter((o) => o.type === RestrictedAllowType.RoomMembership).map((o) => o.room_id) : undefined; - const editRestrictedRoomIds = async (): Promise => { + const editRestrictedRoomIds = async (): Promise => { let selected = restrictedAllowRoomIds; if (!selected?.length && SpaceStore.instance.activeSpaceRoom) { selected = [SpaceStore.instance.activeSpaceRoom.roomId]; @@ -207,7 +207,7 @@ const JoinRuleSettings: React.FC = ({ "Anyone in can find and join. You can select other spaces too.", {}, { - spaceName: () => {SpaceStore.instance.activeSpaceRoom.name}, + spaceName: () => {SpaceStore.instance.activeSpaceRoom!.name}, }, ); } else { @@ -229,7 +229,7 @@ const JoinRuleSettings: React.FC = ({ } const onChange = async (joinRule: JoinRule): Promise => { - const beforeJoinRule = content.join_rule; + const beforeJoinRule = content?.join_rule; let restrictedAllowRoomIds: string[] | undefined; if (joinRule === JoinRule.Restricted) { diff --git a/src/components/views/settings/NotificationSound.tsx b/src/components/views/settings/NotificationSound.tsx new file mode 100644 index 00000000000..a5e402d9d3c --- /dev/null +++ b/src/components/views/settings/NotificationSound.tsx @@ -0,0 +1,221 @@ +/* +Copyright 2019 - 2021 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. +*/ + +import React, {createRef} from "react"; + +import {_t} from "../../../languageHandler"; +import AccessibleButton, {ButtonEvent} from "../elements/AccessibleButton"; +import {chromeFileInputFix} from "../../../utils/BrowserWorkarounds"; +import {SettingLevel} from "../../../settings/SettingLevel"; +import {logger} from "../../../../../matrix-js-sdk/src/logger"; +import {MatrixClientPeg} from "../../../MatrixClientPeg"; +import SettingsStore from "../../../settings/SettingsStore"; +import {Notifier} from "../../../Notifier"; + +interface IProps { + roomId?: string | null, + currentSound: string, + level: SettingLevel, +} + +interface IState { + uploadedFile: File | null, + currentSound: string, +} + +class NotificationSound extends React.Component { + private soundUpload: React.RefObject = createRef(); + + private constructor(props: IProps) { + super(props); + + let currentSound = "default"; + const soundData: { url: string; name: string; type: string; size: string } = + Notifier.getNotificationSound(this.props.roomId); // we should set roomId to account when notificationSettingLevel is account + if (soundData) { + currentSound = soundData.name || soundData.url; + } + + this.state = { + uploadedFile: null, + currentSound: currentSound, + }; + } + + /* + * Save the sound to the server + * @param {SettingLevel} level - The SettingLevel to save the sound at. Only ROOM_ACCOUNT and ACCOUNT are valid. + * @returns {Promise} resolves when the sound is saved + */ + private async saveSound(level: SettingLevel): Promise { + // if no file, or SettingLevel is not ROOM_ACCOUNT or ACCOUNT, return + if (!this.state.uploadedFile || + (level !== SettingLevel.ROOM_ACCOUNT && level !== SettingLevel.ACCOUNT)) { + return; + } + + let type = this.state.uploadedFile.type; + if (type === "video/ogg") { + // XXX: I've observed browsers allowing users to pick audio/ogg files, + // and then calling it a video/ogg. This is a lame hack, but man browsers + // suck at detecting mimetypes. + type = "audio/ogg"; + } + + const { content_uri: url } = await MatrixClientPeg.get().uploadContent(this.state.uploadedFile, { + type, + }); + + await SettingsStore.setValue("notificationSound", this.props.roomId, level, { + name: this.state.uploadedFile.name, + type: type, + size: this.state.uploadedFile.size, + url, + }); + + this.setState({ + uploadedFile: null, + currentSound: this.state.uploadedFile.name, + }); + } + + private onClickSaveSound = async (e: React.MouseEvent): Promise => { + e.stopPropagation(); + e.preventDefault(); + + try { + await this.saveSound(this.props.level); + } catch (ex) { + if (this.props.roomId) { + logger.error(`Unable to save notification sound for ${this.props.roomId}`); + logger.error(ex); + } else { + logger.error("Unable to save notification sound for account"); + logger.error(ex); + } + } + }; + + private onSoundUploadChanged = (e: React.ChangeEvent): void => { + // if no file, return + if (!e.target.files || !e.target.files.length) { + this.setState({ + uploadedFile: null, + }); + return; + } + + // set uploadedFile to the first file in the list + const file = e.target.files[0]; + this.setState({ + uploadedFile: file, + }); + }; + + private triggerUploader = async (e: React.MouseEvent): Promise => { + e.stopPropagation(); + e.preventDefault(); + + this.soundUpload.current?.click(); + }; + + private clearSound = (e: ButtonEvent, level: SettingLevel): void => { + // if SettingLevel is not ROOM_ACCOUNT or ACCOUNT, return + if (level !== SettingLevel.ROOM_ACCOUNT && level !== SettingLevel.ACCOUNT) return; + + e.stopPropagation(); + e.preventDefault(); + SettingsStore.setValue("notificationSound", this.props.roomId, level, null); + + this.setState({ + currentSound: "default", + }); + }; + + public render(): JSX.Element { + let currentUploadedFile: JSX.Element | undefined; + if (this.state.uploadedFile) { + currentUploadedFile = ( +
+ {/* TODO I want to change this text to something clearer. This text should only pop up when + the sound is sent to the server. this would change the use of this variable though. + bc there's already a visual indication of success when you upload to + the app, no need to duplicate it. + i like "Set sound to: " I'll do it when I figure out how translation strings work */} + {_t("Uploaded sound")}: {this.state.uploadedFile.name} + +
+ ); + } + + return
+ {_t("Sounds")} +
+
+ + {_t("Notification sound")}: {this.state.currentSound} + +
+ this.clearSound(e, this.props.level)} + kind="primary" + > + {_t("Reset")} + +
+
+

{_t("Set a new custom sound")}

+
+
+ +
+ + {currentUploadedFile} +
+ + + {_t("Browse")} + + + + {_t("Save")} + +
+
+
; + } +} + +export default NotificationSound; diff --git a/src/components/views/settings/Notifications.tsx b/src/components/views/settings/Notifications.tsx index 3e833b315fe..af6a98a2596 100644 --- a/src/components/views/settings/Notifications.tsx +++ b/src/components/views/settings/Notifications.tsx @@ -48,6 +48,8 @@ import { updatePushRuleActions, } from "../../../utils/pushRules/updatePushRuleActions"; import { Caption } from "../typography/Caption"; +import NotificationSound from "./NotificationSound"; +import {Notifier} from "../../../Notifier"; // TODO: this "view" component still has far too much application logic in it, // which should be factored out to other files. @@ -56,7 +58,7 @@ enum Phase { Loading = "loading", Ready = "ready", Persisting = "persisting", // technically a meta-state for Ready, but whatever - // unrecoverable error - eg can't load push rules + // unrecoverable error - e.g. can't load push rules Error = "error", // error saving individual rule SavingError = "savingError", @@ -68,6 +70,7 @@ enum RuleClass { // The vector sections map approximately to UI sections VectorGlobal = "vector_global", VectorMentions = "vector_mentions", + // VectorSound = "vector_sound", VectorOther = "vector_other", Other = "other", // unknown rules, essentially } @@ -108,6 +111,10 @@ interface IVectorPushRule { interface IProps {} interface IState { + notificationSettingLevel: SettingLevel; + currentSound: string; + uploadedFile: File | null; + phase: Phase; // Optional stuff is required when `phase === Ready` @@ -148,8 +155,8 @@ const findInDefaultRules = ( const OrderedVectorStates = [VectorState.Off, VectorState.On, VectorState.Loud]; /** - * Find the 'loudest' vector state assigned to a rule - * and it's synced rules + * Find the 'loudest' vector state assigned to + * a rule and its synced rules * If rules have fallen out of sync, * the loudest rule can determine the display value * @param defaultRules @@ -176,7 +183,7 @@ const maximumVectorState = ( if (syncedRule) { const syncedRuleVectorState = definition.ruleToVectorState(syncedRule); // if syncedRule is 'louder' than current maximum - // set maximum to louder vectorState + // set to louder vectorState if (OrderedVectorStates.indexOf(syncedRuleVectorState) > OrderedVectorStates.indexOf(maxVectorState)) { return syncedRuleVectorState; } @@ -193,14 +200,23 @@ export default class Notifications extends React.PureComponent { public constructor(props: IProps) { super(props); + let currentSound = "default"; + const soundData = Notifier.getNotificationSound(); + if (soundData) { + currentSound = soundData.name || soundData.url; + } + this.state = { + notificationSettingLevel: SettingLevel.ACCOUNT, + currentSound: currentSound, + uploadedFile: null, phase: Phase.Loading, deviceNotificationsEnabled: SettingsStore.getValue("deviceNotificationsEnabled") ?? true, desktopNotifications: SettingsStore.getValue("notificationsEnabled"), desktopShowBody: SettingsStore.getValue("notificationBodyEnabled"), audioNotifications: SettingsStore.getValue("audioNotificationsEnabled"), clearingNotifications: false, - ruleIdsWithError: {}, + ruleIdsWithError: {} }; this.settingWatchers = [ @@ -339,7 +355,11 @@ export default class Notifications extends React.PureComponent { // Prepare rendering for all of our known rules preparedNewState.vectorPushRules = {}; - const vectorCategories = [RuleClass.VectorGlobal, RuleClass.VectorMentions, RuleClass.VectorOther]; + const vectorCategories = [RuleClass.VectorGlobal, + RuleClass.VectorMentions, + // RuleClass.VectorSound, + RuleClass.VectorOther]; + for (const category of vectorCategories) { preparedNewState.vectorPushRules[category] = []; for (const rule of defaultRules[category]) { @@ -709,11 +729,20 @@ export default class Notifications extends React.PureComponent { ); } + /* + render section for a given category + + returns null if the section should be hidden + @param {string} category - the category to render + @returns {ReactNode} the rendered section, or null if the section should be hidden + */ private renderCategory(category: RuleClass): ReactNode { if (category !== RuleClass.VectorOther && this.isInhibited) { return null; // nothing to show for the section } + // if we're showing the 'Other' section, and there are + // unread notifications, show a button to clear them let clearNotifsButton: JSX.Element | undefined; if ( category === RuleClass.VectorOther && @@ -832,6 +861,11 @@ export default class Notifications extends React.PureComponent { ); } + /* + render section for notification targets + + @returns {ReactNode} the rendered section, or null if the section should be hidden + */ private renderTargets(): ReactNode { if (this.isInhibited) return null; // no targets if there's no notifications @@ -868,6 +902,7 @@ export default class Notifications extends React.PureComponent { {this.renderCategory(RuleClass.VectorGlobal)} {this.renderCategory(RuleClass.VectorMentions)} {this.renderCategory(RuleClass.VectorOther)} + {this.renderTargets()}
); diff --git a/src/components/views/settings/ProfileSettings.tsx b/src/components/views/settings/ProfileSettings.tsx index 4a36b44973f..d7fdd9c143f 100644 --- a/src/components/views/settings/ProfileSettings.tsx +++ b/src/components/views/settings/ProfileSettings.tsx @@ -31,7 +31,6 @@ import { chromeFileInputFix } from "../../../utils/BrowserWorkarounds"; import PosthogTrackers from "../../../PosthogTrackers"; interface IState { - userId?: string; originalDisplayName: string; displayName: string; originalAvatarUrl: string | null; @@ -41,16 +40,16 @@ interface IState { } export default class ProfileSettings extends React.Component<{}, IState> { + private readonly userId: string; private avatarUpload: React.RefObject = createRef(); public constructor(props: {}) { super(props); - const client = MatrixClientPeg.get(); + this.userId = MatrixClientPeg.get().getSafeUserId(); let avatarUrl = OwnProfileStore.instance.avatarMxc; if (avatarUrl) avatarUrl = mediaFromMxc(avatarUrl).getSquareThumbnailHttp(96); this.state = { - userId: client.getUserId()!, originalDisplayName: OwnProfileStore.instance.displayName ?? "", displayName: OwnProfileStore.instance.displayName ?? "", originalAvatarUrl: avatarUrl, @@ -150,7 +149,7 @@ export default class ProfileSettings extends React.Component<{}, IState> { const reader = new FileReader(); reader.onload = (ev) => { this.setState({ - avatarUrl: ev.target?.result, + avatarUrl: ev.target?.result ?? undefined, avatarFile: file, enableProfileSave: true, }); @@ -159,7 +158,7 @@ export default class ProfileSettings extends React.Component<{}, IState> { }; public render(): React.ReactNode { - const userIdentifier = UserIdentifierCustomisations.getDisplayUserIdentifier(this.state.userId, { + const userIdentifier = UserIdentifierCustomisations.getDisplayUserIdentifier(this.userId, { withDisplayName: true, }); @@ -198,7 +197,7 @@ export default class ProfileSettings extends React.Component<{}, IState> {
{ return
{sigStatus}
; }); - if (backupSigStatus.sigs.length === 0) { + if (!backupSigStatus?.sigs?.length) { backupSigStatuses = _t("Backup is not signed by any of your sessions"); } - let trustedLocally; - if (backupSigStatus.trusted_locally) { + let trustedLocally: string | undefined; + if (backupSigStatus?.trusted_locally) { trustedLocally = _t("This backup is trusted because it has been restored on this session"); } diff --git a/src/components/views/settings/devices/DeviceDetails.tsx b/src/components/views/settings/devices/DeviceDetails.tsx index 56df38c6cfe..fea916eb24b 100644 --- a/src/components/views/settings/devices/DeviceDetails.tsx +++ b/src/components/views/settings/devices/DeviceDetails.tsx @@ -105,13 +105,13 @@ const DeviceDetails: React.FC = ({ const showPushNotificationSection = !!pusher || !!localNotificationSettings; - function isPushNotificationsEnabled(pusher: IPusher, notificationSettings: LocalNotificationSettings): boolean { - if (pusher) return pusher[PUSHER_ENABLED.name]; + function isPushNotificationsEnabled(pusher?: IPusher, notificationSettings?: LocalNotificationSettings): boolean { + if (pusher) return !!pusher[PUSHER_ENABLED.name]; if (localNotificationSettings) return !localNotificationSettings.is_silenced; return true; } - function isCheckboxDisabled(pusher: IPusher, notificationSettings: LocalNotificationSettings): boolean { + function isCheckboxDisabled(pusher?: IPusher, notificationSettings?: LocalNotificationSettings): boolean { if (localNotificationSettings) return false; if (pusher && !supportsMSC3881) return true; return false; diff --git a/src/components/views/settings/devices/DeviceTypeIcon.tsx b/src/components/views/settings/devices/DeviceTypeIcon.tsx index 06360c9a2f0..c2fc0f5f2ef 100644 --- a/src/components/views/settings/devices/DeviceTypeIcon.tsx +++ b/src/components/views/settings/devices/DeviceTypeIcon.tsx @@ -47,8 +47,8 @@ const deviceTypeLabel: Record = { }; export const DeviceTypeIcon: React.FC = ({ isVerified, isSelected, deviceType }) => { - const Icon = deviceTypeIcon[deviceType] || deviceTypeIcon[DeviceType.Unknown]; - const label = deviceTypeLabel[deviceType] || deviceTypeLabel[DeviceType.Unknown]; + const Icon = deviceTypeIcon[deviceType!] || deviceTypeIcon[DeviceType.Unknown]; + const label = deviceTypeLabel[deviceType!] || deviceTypeLabel[DeviceType.Unknown]; return (
= ({ children, device, isSelected, o className="mx_SelectableDeviceTile_checkbox" id={`device-tile-checkbox-${device.device_id}`} data-testid={`device-tile-checkbox-${device.device_id}`} - /> - - {children} - + > + + {children} + +
); }; diff --git a/src/components/views/settings/tabs/room/NotificationSettingsTab.tsx b/src/components/views/settings/tabs/room/NotificationSettingsTab.tsx index b9c481125a9..78f737461a6 100644 --- a/src/components/views/settings/tabs/room/NotificationSettingsTab.tsx +++ b/src/components/views/settings/tabs/room/NotificationSettingsTab.tsx @@ -14,24 +14,21 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { createRef } from "react"; -import { logger } from "matrix-js-sdk/src/logger"; +import React from "react"; -import { _t } from "../../../../../languageHandler"; -import { MatrixClientPeg } from "../../../../../MatrixClientPeg"; -import AccessibleButton, { ButtonEvent } from "../../../elements/AccessibleButton"; +import {_t} from "../../../../../languageHandler"; +import AccessibleButton, {ButtonEvent} from "../../../elements/AccessibleButton"; import Notifier from "../../../../../Notifier"; -import SettingsStore from "../../../../../settings/SettingsStore"; -import { SettingLevel } from "../../../../../settings/SettingLevel"; -import { RoomEchoChamber } from "../../../../../stores/local-echo/RoomEchoChamber"; -import { EchoChamber } from "../../../../../stores/local-echo/EchoChamber"; +import {RoomEchoChamber} from "../../../../../stores/local-echo/RoomEchoChamber"; +import {EchoChamber} from "../../../../../stores/local-echo/EchoChamber"; import MatrixClientContext from "../../../../../contexts/MatrixClientContext"; import StyledRadioGroup from "../../../elements/StyledRadioGroup"; -import { RoomNotifState } from "../../../../../RoomNotifs"; +import {RoomNotifState} from "../../../../../RoomNotifs"; import defaultDispatcher from "../../../../../dispatcher/dispatcher"; -import { Action } from "../../../../../dispatcher/actions"; -import { UserTab } from "../../../dialogs/UserTab"; -import { chromeFileInputFix } from "../../../../../utils/BrowserWorkarounds"; +import {Action} from "../../../../../dispatcher/actions"; +import {UserTab} from "../../../dialogs/UserTab"; +import NotificationSound from "../../NotificationSound"; +import {SettingLevel} from "../../../../../settings/SettingLevel"; interface IProps { roomId: string; @@ -39,13 +36,13 @@ interface IProps { } interface IState { + notificationSettingLevel: SettingLevel; currentSound: string; uploadedFile: File | null; } export default class NotificationsSettingsTab extends React.Component { private readonly roomProps: RoomEchoChamber; - private soundUpload = createRef(); public static contextType = MatrixClientContext; public context!: React.ContextType; @@ -56,90 +53,18 @@ export default class NotificationsSettingsTab extends React.Component => { - e.stopPropagation(); - e.preventDefault(); - - this.soundUpload.current?.click(); - }; - - private onSoundUploadChanged = (e: React.ChangeEvent): void => { - if (!e.target.files || !e.target.files.length) { - this.setState({ - uploadedFile: null, - }); - return; - } - - const file = e.target.files[0]; - this.setState({ - uploadedFile: file, - }); - }; - - private onClickSaveSound = async (e: React.MouseEvent): Promise => { - e.stopPropagation(); - e.preventDefault(); - - try { - await this.saveSound(); - } catch (ex) { - logger.error(`Unable to save notification sound for ${this.props.roomId}`); - logger.error(ex); - } - }; - - private async saveSound(): Promise { - if (!this.state.uploadedFile) { - return; - } - - let type = this.state.uploadedFile.type; - if (type === "video/ogg") { - // XXX: I've observed browsers allowing users to pick a audio/ogg files, - // and then calling it a video/ogg. This is a lame hack, but man browsers - // suck at detecting mimetypes. - type = "audio/ogg"; - } - - const { content_uri: url } = await MatrixClientPeg.get().uploadContent(this.state.uploadedFile, { - type, - }); - - await SettingsStore.setValue("notificationSound", this.props.roomId, SettingLevel.ROOM_ACCOUNT, { - name: this.state.uploadedFile.name, - type: type, - size: this.state.uploadedFile.size, - url, - }); - - this.setState({ - uploadedFile: null, - currentSound: this.state.uploadedFile.name, - }); - } - - private clearSound = (e: React.MouseEvent): void => { - e.stopPropagation(); - e.preventDefault(); - SettingsStore.setValue("notificationSound", this.props.roomId, SettingLevel.ROOM_ACCOUNT, null); - - this.setState({ - currentSound: "default", - }); - }; - private onRoomNotificationChange = (value: RoomNotifState): void => { this.roomProps.notificationVolume = value; this.forceUpdate(); @@ -156,17 +81,6 @@ export default class NotificationsSettingsTab extends React.Component - - {_t("Uploaded sound")}: {this.state.uploadedFile.name} - -
- ); - } - return (
{_t("Notifications")}
@@ -221,7 +135,7 @@ export default class NotificationsSettingsTab extends React.Component {_t( "Get notified only with mentions and keywords " + - "as set up in your settings", + "as set up in your settings", {}, { a: (sub) => ( @@ -256,59 +170,12 @@ export default class NotificationsSettingsTab extends React.Component
-
- {_t("Sounds")} -
-
- - {_t("Notification sound")}: {this.state.currentSound} - -
- - {_t("Reset")} - -
-
-

{_t("Set a new custom sound")}

-
-
- -
- - {currentUploadedFile} -
- - - {_t("Browse")} - - - - {_t("Save")} - -
-
-
+ ); } diff --git a/src/components/views/spaces/SpaceBasicSettings.tsx b/src/components/views/spaces/SpaceBasicSettings.tsx index 5ce844cc863..30c05c8ddd5 100644 --- a/src/components/views/spaces/SpaceBasicSettings.tsx +++ b/src/components/views/spaces/SpaceBasicSettings.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { ChangeEvent, RefObject, useRef, useState } from "react"; +import React, { ChangeEvent, useRef, useState } from "react"; import { _t } from "../../../languageHandler"; import AccessibleButton from "../elements/AccessibleButton"; @@ -38,7 +38,7 @@ export const SpaceAvatar: React.FC { - const avatarUploadRef = useRef() as RefObject; + const avatarUploadRef = useRef(null); const [avatar, setAvatarDataUrl] = useState(avatarUrl); // avatar data url cache let avatarSection; diff --git a/src/components/views/spaces/SpaceCreateMenu.tsx b/src/components/views/spaces/SpaceCreateMenu.tsx index 344067203db..64fc408b774 100644 --- a/src/components/views/spaces/SpaceCreateMenu.tsx +++ b/src/components/views/spaces/SpaceCreateMenu.tsx @@ -212,9 +212,9 @@ const SpaceCreateMenu: React.FC<{ const [busy, setBusy] = useState(false); const [name, setName] = useState(""); - const spaceNameField = useRef(); + const spaceNameField = useRef(null); const [alias, setAlias] = useState(""); - const spaceAliasField = useRef(); + const spaceAliasField = useRef(null); const [avatar, setAvatar] = useState(undefined); const [topic, setTopic] = useState(""); diff --git a/src/components/views/spaces/SpacePanel.tsx b/src/components/views/spaces/SpacePanel.tsx index a138b909e0e..8e95f29667e 100644 --- a/src/components/views/spaces/SpacePanel.tsx +++ b/src/components/views/spaces/SpacePanel.tsx @@ -331,9 +331,9 @@ const InnerSpacePanel = React.memo( const SpacePanel: React.FC = () => { const [isPanelCollapsed, setPanelCollapsed] = useState(true); - const ref = useRef(); + const ref = useRef(null); useLayoutEffect(() => { - UIStore.instance.trackElementDimensions("SpacePanel", ref.current); + if (ref.current) UIStore.instance.trackElementDimensions("SpacePanel", ref.current); return () => UIStore.instance.stopTrackingElementDimensions("SpacePanel"); }, []); diff --git a/src/customisations/Media.ts b/src/customisations/Media.ts index 9c07288b182..e9cade175fb 100644 --- a/src/customisations/Media.ts +++ b/src/customisations/Media.ts @@ -77,7 +77,7 @@ export class Media { */ public get srcHttp(): string | null { // eslint-disable-next-line no-restricted-properties - return this.client.mxcUrlToHttp(this.srcMxc); + return this.client.mxcUrlToHttp(this.srcMxc) || null; } /** diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 5f6ced12911..c64a49abd14 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -786,6 +786,7 @@ "No media permissions": "No media permissions", "You may need to manually permit %(brand)s to access your microphone/webcam": "You may need to manually permit %(brand)s to access your microphone/webcam", "This homeserver is not configured to display maps.": "This homeserver is not configured to display maps.", + "WebGL is required to display maps, please enable it in your browser settings.": "WebGL is required to display maps, please enable it in your browser settings.", "This homeserver is not configured correctly to display maps, or the configured map server may be unreachable.": "This homeserver is not configured correctly to display maps, or the configured map server may be unreachable.", "Toggle attribution": "Toggle attribution", "Map feedback": "Map feedback", @@ -1700,6 +1701,7 @@ "Sounds": "Sounds", "Notification sound": "Notification sound", "Set a new custom sound": "Set a new custom sound", + "Upload custom sound": "Upload custom sound", "Browse": "Browse", "Failed to unban": "Failed to unban", "Unban": "Unban", @@ -2599,6 +2601,7 @@ "%(oneUser)ssent %(count)s hidden messages|one": "%(oneUser)ssent a hidden message", "collapse": "collapse", "expand": "expand", + "Image view": "Image view", "Rotate Left": "Rotate Left", "Rotate Right": "Rotate Right", "Information": "Information", diff --git a/src/resizer/resizer.ts b/src/resizer/resizer.ts index d45898b525d..07e74337a77 100644 --- a/src/resizer/resizer.ts +++ b/src/resizer/resizer.ts @@ -44,7 +44,7 @@ export default class Resizer { // TODO move vertical/horizontal to config option/container class // as it doesn't make sense to mix them within one container/Resizer public constructor( - public container: HTMLElement, + public container: HTMLElement | null, private readonly distributorCtor: { new (item: ResizeItem): FixedDistributor; createItem( @@ -53,7 +53,7 @@ export default class Resizer { sizer: Sizer, container?: HTMLElement, ): ResizeItem; - createSizer(containerElement: HTMLElement, vertical: boolean, reverse: boolean): Sizer; + createSizer(containerElement: HTMLElement | null, vertical: boolean, reverse: boolean): Sizer; }, public readonly config?: C, ) { @@ -71,13 +71,13 @@ export default class Resizer { public attach(): void { const attachment = this?.config?.handler?.parentElement ?? this.container; - attachment.addEventListener("mousedown", this.onMouseDown, false); + attachment?.addEventListener("mousedown", this.onMouseDown, false); window.addEventListener("resize", this.onResize); } public detach(): void { const attachment = this?.config?.handler?.parentElement ?? this.container; - attachment.removeEventListener("mousedown", this.onMouseDown, false); + attachment?.removeEventListener("mousedown", this.onMouseDown, false); window.removeEventListener("resize", this.onResize); } @@ -194,7 +194,7 @@ export default class Resizer { const Distributor = this.distributorCtor; const useItemContainer = this.config?.handler ? this.container : undefined; const sizer = Distributor.createSizer(this.container, vertical, reverse); - const item = Distributor.createItem(resizeHandle, this, sizer, useItemContainer); + const item = Distributor.createItem(resizeHandle, this, sizer, useItemContainer ?? undefined); const distributor = new Distributor(item); return { sizer, distributor }; } diff --git a/src/stores/spaces/SpaceStore.ts b/src/stores/spaces/SpaceStore.ts index 06dfa66971f..5d94f1e3b07 100644 --- a/src/stores/spaces/SpaceStore.ts +++ b/src/stores/spaces/SpaceStore.ts @@ -553,6 +553,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { }; private rebuildSpaceHierarchy = (): void => { + if (!this.matrixClient) return; const visibleSpaces = this.matrixClient .getVisibleRooms(this._msc3946ProcessDynamicPredecessor) .filter((r) => r.isSpaceRoom()); @@ -589,6 +590,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { }; private rebuildParentMap = (): void => { + if (!this.matrixClient) return; const joinedSpaces = this.matrixClient.getVisibleRooms(this._msc3946ProcessDynamicPredecessor).filter((r) => { return r.isSpaceRoom() && r.getMyMembership() === "join"; }); @@ -624,6 +626,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { }; private rebuildMetaSpaces = (): void => { + if (!this.matrixClient) return; const enabledMetaSpaces = new Set(this.enabledMetaSpaces); const visibleRooms = this.matrixClient.getVisibleRooms(this._msc3946ProcessDynamicPredecessor); @@ -658,6 +661,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { }; private updateNotificationStates = (spaces?: SpaceKey[]): void => { + if (!this.matrixClient) return; const enabledMetaSpaces = new Set(this.enabledMetaSpaces); const visibleRooms = this.matrixClient.getVisibleRooms(this._msc3946ProcessDynamicPredecessor); @@ -745,6 +749,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { }; private onRoomsUpdate = (): void => { + if (!this.matrixClient) return; const visibleRooms = this.matrixClient.getVisibleRooms(this._msc3946ProcessDynamicPredecessor); const prevRoomsBySpace = this.roomIdsBySpace; diff --git a/src/utils/exportUtils/HtmlExport.tsx b/src/utils/exportUtils/HtmlExport.tsx index 3ec04a460b5..d18a3806252 100644 --- a/src/utils/exportUtils/HtmlExport.tsx +++ b/src/utils/exportUtils/HtmlExport.tsx @@ -344,7 +344,7 @@ export default class HTMLExporter extends Exporter { protected createModifiedEvent(text: string, mxEv: MatrixEvent, italic = true): MatrixEvent { const modifiedContent = { - msgtype: "m.text", + msgtype: MsgType.Text, body: `${text}`, format: "org.matrix.custom.html", formatted_body: `${text}`, diff --git a/src/utils/location/LocationShareErrors.ts b/src/utils/location/LocationShareErrors.ts index a7f34b42217..a59c9295924 100644 --- a/src/utils/location/LocationShareErrors.ts +++ b/src/utils/location/LocationShareErrors.ts @@ -19,6 +19,7 @@ import { _t } from "../../languageHandler"; export enum LocationShareError { MapStyleUrlNotConfigured = "MapStyleUrlNotConfigured", MapStyleUrlNotReachable = "MapStyleUrlNotReachable", + WebGLNotEnabled = "WebGLNotEnabled", Default = "Default", } @@ -26,6 +27,8 @@ export const getLocationShareErrorMessage = (errorType?: LocationShareError): st switch (errorType) { case LocationShareError.MapStyleUrlNotConfigured: return _t("This homeserver is not configured to display maps."); + case LocationShareError.WebGLNotEnabled: + return _t("WebGL is required to display maps, please enable it in your browser settings."); case LocationShareError.MapStyleUrlNotReachable: default: return _t( diff --git a/src/utils/location/map.ts b/src/utils/location/map.ts index 8c8271f9c42..061f5068c0d 100644 --- a/src/utils/location/map.ts +++ b/src/utils/location/map.ts @@ -57,6 +57,8 @@ export const createMap = (interactive: boolean, bodyId: string, onError?: (error return map; } catch (e) { logger.error("Failed to render map", e); + const errorMessage = (e as Error)?.message; + if (errorMessage.includes("Failed to initialize WebGL")) throw new Error(LocationShareError.WebGLNotEnabled); throw e; } }; diff --git a/test/Notifier-test.ts b/test/Notifier-test.ts index 033360d04cc..3675661b1a3 100644 --- a/test/Notifier-test.ts +++ b/test/Notifier-test.ts @@ -309,12 +309,12 @@ describe("Notifier", () => { }); }); - describe("getSoundForRoom", () => { + describe("getNotificationSound", () => { it("should not explode if given invalid url", () => { - jest.spyOn(SettingsStore, "getValue").mockImplementation((name: string) => { + jest.spyOn(SettingsStore, "getValue").mockImplementation(() => { return { url: { content_uri: "foobar" } }; }); - expect(Notifier.getSoundForRoom("!roomId:server")).toBeNull(); + expect(Notifier.getNotificationSound("!roomId:server")).toBeNull(); }); }); @@ -327,11 +327,11 @@ describe("Notifier", () => { it.each(testCases)("does not dispatch when notifications are silenced", ({ event, count }) => { // It's not ideal to only look at whether this function has been called // but avoids starting to look into DOM stuff - Notifier.getSoundForRoom = jest.fn(); + Notifier.getNotificationSound = jest.fn(); mockClient.setAccountData(accountDataEventKey, event!); Notifier.playAudioNotification(testEvent, testRoom); - expect(Notifier.getSoundForRoom).toHaveBeenCalledTimes(count); + expect(Notifier.getNotificationSound).toHaveBeenCalledTimes(count); }); }); diff --git a/test/components/structures/AutocompleteInput-test.tsx b/test/components/structures/AutocompleteInput-test.tsx index 9827ba2ddae..76a007a25e7 100644 --- a/test/components/structures/AutocompleteInput-test.tsx +++ b/test/components/structures/AutocompleteInput-test.tsx @@ -24,8 +24,20 @@ import { AutocompleteInput } from "../../../src/components/structures/Autocomple describe("AutocompleteInput", () => { const mockCompletion: ICompletion[] = [ - { type: "user", completion: "user_1", completionId: "@user_1:host.local", range: { start: 1, end: 1 } }, - { type: "user", completion: "user_2", completionId: "@user_2:host.local", range: { start: 1, end: 1 } }, + { + type: "user", + completion: "user_1", + completionId: "@user_1:host.local", + range: { start: 1, end: 1 }, + component:
, + }, + { + type: "user", + completion: "user_2", + completionId: "@user_2:host.local", + range: { start: 1, end: 1 }, + component:
, + }, ]; const constructMockProvider = (data: ICompletion[]) => diff --git a/test/components/structures/ThreadPanel-test.tsx b/test/components/structures/ThreadPanel-test.tsx index a851d0cf9b7..b6cf8431479 100644 --- a/test/components/structures/ThreadPanel-test.tsx +++ b/test/components/structures/ThreadPanel-test.tsx @@ -133,7 +133,7 @@ describe("ThreadPanel", () => { jest.spyOn(mockClient, "getRoom").mockReturnValue(room); await room.createThreadsTimelineSets(); const [allThreads, myThreads] = room.threadsTimelineSets; - jest.spyOn(room, "createThreadsTimelineSets").mockReturnValue(Promise.resolve([allThreads, myThreads])); + jest.spyOn(room, "createThreadsTimelineSets").mockReturnValue(Promise.resolve([allThreads!, myThreads!])); }); function toggleThreadFilter(container: HTMLElement, newFilter: ThreadFilterType) { @@ -195,11 +195,11 @@ describe("ThreadPanel", () => { return event ? Promise.resolve(event) : Promise.reject(); }); const [allThreads, myThreads] = room.threadsTimelineSets; - allThreads.addLiveEvent(otherThread.rootEvent); - allThreads.addLiveEvent(mixedThread.rootEvent); - allThreads.addLiveEvent(ownThread.rootEvent); - myThreads.addLiveEvent(mixedThread.rootEvent); - myThreads.addLiveEvent(ownThread.rootEvent); + allThreads!.addLiveEvent(otherThread.rootEvent); + allThreads!.addLiveEvent(mixedThread.rootEvent); + allThreads!.addLiveEvent(ownThread.rootEvent); + myThreads!.addLiveEvent(mixedThread.rootEvent); + myThreads!.addLiveEvent(ownThread.rootEvent); let events: EventData[] = []; const renderResult = render(); @@ -245,7 +245,7 @@ describe("ThreadPanel", () => { return event ? Promise.resolve(event) : Promise.reject(); }); const [allThreads] = room.threadsTimelineSets; - allThreads.addLiveEvent(otherThread.rootEvent); + allThreads!.addLiveEvent(otherThread.rootEvent); let events: EventData[] = []; const renderResult = render(); diff --git a/test/components/structures/__snapshots__/MessagePanel-test.tsx.snap b/test/components/structures/__snapshots__/MessagePanel-test.tsx.snap index 5d139543026..008246e855c 100644 --- a/test/components/structures/__snapshots__/MessagePanel-test.tsx.snap +++ b/test/components/structures/__snapshots__/MessagePanel-test.tsx.snap @@ -42,7 +42,6 @@ exports[`MessagePanel should handle lots of membership events quickly 1`] = ` aria-label="Thu, Jan 1 1970" class="mx_DateSeparator" role="separator" - tabindex="-1" >
renders own beacon status when user is live sharin 1h left