From c97f619cac8845fe6f2a62a7f6ddc879db9976b1 Mon Sep 17 00:00:00 2001 From: kenwu Date: Fri, 31 Mar 2023 12:01:32 -0500 Subject: [PATCH 01/30] Add custom notification setting - extract NotificationSound.tsx component - rename getSoundForRoom, generalize function usage - rename unnecessarily short variable name --- src/Notifier.ts | 51 ++-- .../views/settings/NotificationSound.tsx | 221 ++++++++++++++++++ .../views/settings/Notifications.tsx | 47 +++- .../tabs/room/NotificationSettingsTab.tsx | 173 ++------------ test/Notifier-test.ts | 10 +- 5 files changed, 315 insertions(+), 187 deletions(-) create mode 100644 src/components/views/settings/NotificationSound.tsx diff --git a/src/Notifier.ts b/src/Notifier.ts index 52983f6fc3e..2565f2723b1 100644 --- a/src/Notifier.ts +++ b/src/Notifier.ts @@ -172,14 +172,24 @@ class NotifierClass { } } - public getSoundForRoom(roomId: string): { + /* + * returns account's default sound if no valid roomId + * + * 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 +222,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 +339,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 +462,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 +480,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/components/views/settings/NotificationSound.tsx b/src/components/views/settings/NotificationSound.tsx new file mode 100644 index 00000000000..5ca5818c53b --- /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); + 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 => { // TODO add ", level: SettingLevel" to the function parameters + e.stopPropagation(); + e.preventDefault(); + + try { + await this.saveSound(SettingLevel.ACCOUNT); // TODO this should be a variable + } 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 6d517fb6357..848618159f6 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]) { @@ -707,11 +727,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 && @@ -830,6 +859,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 @@ -866,6 +900,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/tabs/room/NotificationSettingsTab.tsx b/src/components/views/settings/tabs/room/NotificationSettingsTab.tsx index b9c481125a9..249f491d795 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,10 @@ 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/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); }); }); From b2032f147f1449ec75f7b32902edf28aaf9b38c1 Mon Sep 17 00:00:00 2001 From: Ken Wu Date: Sat, 15 Apr 2023 16:51:36 -0700 Subject: [PATCH 02/30] Call NotificationSound with roomId --- src/components/views/settings/NotificationSound.tsx | 7 +++---- src/components/views/settings/Notifications.tsx | 2 +- .../views/settings/tabs/room/NotificationSettingsTab.tsx | 6 ++++-- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/components/views/settings/NotificationSound.tsx b/src/components/views/settings/NotificationSound.tsx index 5ca5818c53b..24fefc62c26 100644 --- a/src/components/views/settings/NotificationSound.tsx +++ b/src/components/views/settings/NotificationSound.tsx @@ -44,7 +44,7 @@ class NotificationSound extends React.Component { let currentSound = "default"; const soundData: { url: string; name: string; type: string; size: string } = - Notifier.getNotificationSound(this.props.roomId); + Notifier.getNotificationSound(this.props.roomId); // we should set roomId to account when notificationSettingLevel is account if (soundData) { currentSound = soundData.name || soundData.url; } @@ -62,7 +62,6 @@ class NotificationSound extends React.Component { */ 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; @@ -93,12 +92,12 @@ class NotificationSound extends React.Component { }); } - private onClickSaveSound = async (e: React.MouseEvent): Promise => { // TODO add ", level: SettingLevel" to the function parameters + private onClickSaveSound = async (e: React.MouseEvent): Promise => { e.stopPropagation(); e.preventDefault(); try { - await this.saveSound(SettingLevel.ACCOUNT); // TODO this should be a variable + await this.saveSound(this.props.level); } catch (ex) { if (this.props.roomId) { logger.error(`Unable to save notification sound for ${this.props.roomId}`); diff --git a/src/components/views/settings/Notifications.tsx b/src/components/views/settings/Notifications.tsx index 848618159f6..c12bd9987aa 100644 --- a/src/components/views/settings/Notifications.tsx +++ b/src/components/views/settings/Notifications.tsx @@ -900,7 +900,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/tabs/room/NotificationSettingsTab.tsx b/src/components/views/settings/tabs/room/NotificationSettingsTab.tsx index 249f491d795..78f737461a6 100644 --- a/src/components/views/settings/tabs/room/NotificationSettingsTab.tsx +++ b/src/components/views/settings/tabs/room/NotificationSettingsTab.tsx @@ -170,8 +170,10 @@ export default class NotificationsSettingsTab extends React.Component - From c6c5112cb1911bb6418b4b3646249d529e9d328b Mon Sep 17 00:00:00 2001 From: Ken Wu Date: Sat, 15 Apr 2023 17:12:10 -0700 Subject: [PATCH 03/30] remove comment --- src/Notifier.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Notifier.ts b/src/Notifier.ts index 2565f2723b1..e947f35a7fd 100644 --- a/src/Notifier.ts +++ b/src/Notifier.ts @@ -173,8 +173,6 @@ class NotifierClass { } /* - * returns account's default sound if no valid roomId - * * We do no caching here because the SDK caches setting * and the browser will cache the sound. * From 41c0b0881a50f31664bcc5b677ff70f6f299b19f Mon Sep 17 00:00:00 2001 From: Suguru Hirahara Date: Sun, 16 Apr 2023 22:50:57 +0000 Subject: [PATCH 04/30] Update pills-click-in-app.spec.ts - use Cypress Testing Library (#10582) Signed-off-by: Suguru Hirahara --- cypress/e2e/regression-tests/pills-click-in-app.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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(); From 7751f9c622e2fd3a19240d96be2bb74fc8068876 Mon Sep 17 00:00:00 2001 From: Rashmit Pankhania Date: Mon, 17 Apr 2023 09:55:04 +0530 Subject: [PATCH 05/30] #21451 Fix WebGL disabled error message (#10589) * #21451 Fix WebGl disabled error message * #21451 Fix WebGl disabled error message Signed-off-by: Rashmit Pankhania Signed-off-by: Rashmit Pankhania * Fix message Signed-off-by: Rashmit Pankhania * Fix ordering of cases in LocationShareErrors.ts Signed-off-by: Rashmit Pankhania * Fix linting LocationPicker.tsx Signed-off-by: Rashmit Pankhania * Fix eslint Signed-off-by: Rashmit Pankhania * Fix file encoding for i18n CI issue Signed-off-by: Rashmit Pankhania * Fix ts strict CI issue Signed-off-by: Rashmit Pankhania --------- Signed-off-by: Rashmit Pankhania Signed-off-by: Rashmit Pankhania Co-authored-by: Rashmit Pankhania Co-authored-by: Kerry --- src/components/views/location/LocationPicker.tsx | 11 +++++++---- src/i18n/strings/en_EN.json | 1 + src/utils/location/LocationShareErrors.ts | 3 +++ src/utils/location/map.ts | 2 ++ .../views/location/LocationPicker-test.tsx | 14 ++++++++++++++ 5 files changed, 27 insertions(+), 4 deletions(-) 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/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 5f6ced12911..a057beb7429 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", 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/components/views/location/LocationPicker-test.tsx b/test/components/views/location/LocationPicker-test.tsx index 50b5af248f7..ed6dba95f06 100644 --- a/test/components/views/location/LocationPicker-test.tsx +++ b/test/components/views/location/LocationPicker-test.tsx @@ -118,6 +118,20 @@ describe("LocationPicker", () => { expect(getByText("This homeserver is not configured to display maps.")).toBeInTheDocument(); }); + it("displays error when WebGl is not enabled", () => { + // suppress expected error log + jest.spyOn(logger, "error").mockImplementation(() => {}); + mocked(findMapStyleUrl).mockImplementation(() => { + throw new Error("Failed to initialize WebGL"); + }); + + const { getByText } = getComponent(); + + expect( + getByText("WebGL is required to display maps, please enable it in your browser settings."), + ).toBeInTheDocument(); + }); + it("displays error when map setup throws", () => { // suppress expected error log jest.spyOn(logger, "error").mockImplementation(() => {}); From 93858813a31400383d152ec361be5228a11911ed Mon Sep 17 00:00:00 2001 From: Suguru Hirahara Date: Mon, 17 Apr 2023 05:56:37 +0000 Subject: [PATCH 06/30] Update stickers.spec.ts - use Cypress Testing Library (#10622) Signed-off-by: Suguru Hirahara --- cypress/e2e/widgets/stickers.spec.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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() { From daad630827d077b4722d9f32e954aa6c6d2c6b6f Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 17 Apr 2023 08:31:58 +0100 Subject: [PATCH 07/30] Conform more of the codebase to `strictNullChecks` (#10602) * Conform more of the codebase to `strictNullChecks` * Conform more of the codebase to `strictNullChecks` * Fix types --- src/autocomplete/Autocompleter.ts | 2 +- src/components/structures/LoggedInView.tsx | 2 +- src/components/views/avatars/RoomAvatar.tsx | 1 + .../views/dialogs/CreateSubspaceDialog.tsx | 6 +++--- src/components/views/dialogs/ExportDialog.tsx | 6 +++--- .../dialogs/RegistrationEmailPromptDialog.tsx | 4 ++-- .../views/dialogs/ReportEventDialog.tsx | 4 ++-- .../dialogs/spotlight/SpotlightDialog.tsx | 4 ++-- .../views/elements/MiniAvatarUploader.tsx | 4 ++-- src/components/views/elements/RoomTopic.tsx | 4 ++-- src/components/views/right_panel/UserInfo.tsx | 3 ++- src/components/views/rooms/AuxPanel.tsx | 4 ++-- .../views/rooms/BasicMessageComposer.tsx | 6 +++--- .../views/rooms/EditMessageComposer.tsx | 8 ++++---- .../views/rooms/LinkPreviewWidget.tsx | 4 +++- .../views/rooms/MessageComposerButtons.tsx | 4 ++-- .../views/rooms/ReadReceiptGroup.tsx | 2 +- .../views/rooms/ReadReceiptMarker.tsx | 2 +- .../views/rooms/RecentlyViewedButton.tsx | 4 ++-- src/components/views/rooms/ReplyTile.tsx | 2 +- .../views/spaces/SpaceBasicSettings.tsx | 4 ++-- src/customisations/Media.ts | 2 +- src/resizer/resizer.ts | 10 +++++----- .../structures/AutocompleteInput-test.tsx | 16 ++++++++++++++-- .../utils/autocomplete-test.ts | 2 ++ .../views/settings/AddPrivilegedUsers-test.tsx | 18 +++++++++++++++--- 26 files changed, 79 insertions(+), 49 deletions(-) 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/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/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/dialogs/CreateSubspaceDialog.tsx b/src/components/views/dialogs/CreateSubspaceDialog.tsx index 447702bdae0..d994b9fc642 100644 --- a/src/components/views/dialogs/CreateSubspaceDialog.tsx +++ b/src/components/views/dialogs/CreateSubspaceDialog.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { RefObject, useRef, useState } from "react"; +import React, { useRef, useState } from "react"; import { Room } from "matrix-js-sdk/src/models/room"; import { JoinRule } from "matrix-js-sdk/src/@types/partials"; import { logger } from "matrix-js-sdk/src/logger"; @@ -41,9 +41,9 @@ const CreateSubspaceDialog: React.FC = ({ 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..7e01def9e18 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); diff --git a/src/components/views/dialogs/RegistrationEmailPromptDialog.tsx b/src/components/views/dialogs/RegistrationEmailPromptDialog.tsx index 426b1770381..df204093e9f 100644 --- a/src/components/views/dialogs/RegistrationEmailPromptDialog.tsx +++ b/src/components/views/dialogs/RegistrationEmailPromptDialog.tsx @@ -15,7 +15,7 @@ limitations under the License. */ import * as React from "react"; -import { RefObject, SyntheticEvent, useRef, useState } from "react"; +import { SyntheticEvent, useRef, useState } from "react"; import { _t, _td } from "../../../languageHandler"; import Field from "../elements/Field"; @@ -30,7 +30,7 @@ interface IProps { const RegistrationEmailPromptDialog: React.FC = ({ 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/MiniAvatarUploader.tsx b/src/components/views/elements/MiniAvatarUploader.tsx index 464c89ba237..663c8fd3d08 100644 --- a/src/components/views/elements/MiniAvatarUploader.tsx +++ b/src/components/views/elements/MiniAvatarUploader.tsx @@ -16,7 +16,7 @@ limitations under the License. import classNames from "classnames"; import { EventType } from "matrix-js-sdk/src/@types/event"; -import React, { useContext, useRef, useState, MouseEvent, ReactNode, RefObject } from "react"; +import React, { useContext, useRef, useState, MouseEvent, ReactNode } from "react"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import RoomContext from "../../../contexts/RoomContext"; @@ -59,7 +59,7 @@ const MiniAvatarUploader: React.FC = ({ 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/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/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/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/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/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/views/rooms/wysiwyg_composer/utils/autocomplete-test.ts b/test/components/views/rooms/wysiwyg_composer/utils/autocomplete-test.ts index 366375380c3..0553f61f14a 100644 --- a/test/components/views/rooms/wysiwyg_composer/utils/autocomplete-test.ts +++ b/test/components/views/rooms/wysiwyg_composer/utils/autocomplete-test.ts @@ -15,6 +15,7 @@ limitations under the License. */ import { mocked } from "jest-mock"; +import React from "react"; import { ICompletion } from "../../../../../../src/autocomplete/Autocompleter"; import { @@ -34,6 +35,7 @@ const createMockCompletion = (props: Partial): ICompletion => { return { completion: "mock", range: { beginning: true, start: 0, end: 0 }, + component: React.createElement("div"), ...props, }; }; diff --git a/test/components/views/settings/AddPrivilegedUsers-test.tsx b/test/components/views/settings/AddPrivilegedUsers-test.tsx index 68acb7395db..118529349ce 100644 --- a/test/components/views/settings/AddPrivilegedUsers-test.tsx +++ b/test/components/views/settings/AddPrivilegedUsers-test.tsx @@ -32,9 +32,21 @@ import { ICompletion } from "../../../../src/autocomplete/Autocompleter"; jest.mock("../../../../src/autocomplete/UserProvider"); const completions: 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_without_completion_id", range: { start: 1, end: 1 } }, + { + component:
, + 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:
, type: "user", completion: "user_without_completion_id", range: { start: 1, end: 1 } }, ]; describe("", () => { From 8a4a584ba0b749b0e01b66d9ee85b2dbe67e72bd Mon Sep 17 00:00:00 2001 From: Suguru Hirahara Date: Mon, 17 Apr 2023 06:10:11 +0000 Subject: [PATCH 08/30] Make test ID compatible with findByTestId() of Cypress Testing Library (#10617) 'data-test-id' is not discoverable with findByTestId() of Cypress Testing Library. Signed-off-by: Suguru Hirahara --- cypress/e2e/location/location.spec.ts | 2 +- cypress/e2e/threads/threads.spec.ts | 2 +- src/components/views/beacon/LiveTimeRemaining.tsx | 2 +- src/components/views/beacon/OwnBeaconStatus.tsx | 6 +++--- src/components/views/dialogs/ExportDialog.tsx | 2 +- src/components/views/location/LiveDurationDropdown.tsx | 2 +- src/components/views/location/ShareType.tsx | 2 +- src/components/views/right_panel/EncryptionInfo.tsx | 2 +- .../beacon/__snapshots__/BeaconViewDialog-test.tsx.snap | 4 ++-- .../beacon/__snapshots__/RoomLiveShareWarning-test.tsx.snap | 4 ++-- .../location/__snapshots__/LocationPicker-test.tsx.snap | 2 +- 11 files changed, 15 insertions(+), 15 deletions(-) diff --git a/cypress/e2e/location/location.spec.ts b/cypress/e2e/location/location.spec.ts index b716fe543b4..65ddd767ba1 100644 --- a/cypress/e2e/location/location.spec.ts +++ b/cypress/e2e/location/location.spec.ts @@ -23,7 +23,7 @@ describe("Location sharing", () => { let homeserver: HomeserverInstance; 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 => { 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/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 && ( = ({ room, onFinished }) => { )}
{isExporting ? ( -
+

{exportProgressText}

= ({ timeout, onChange }) => { > { options.map(({ key, label }) => ( -
+
{label}
)) as NonEmptyArray 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/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/test/components/views/beacon/__snapshots__/BeaconViewDialog-test.tsx.snap b/test/components/views/beacon/__snapshots__/BeaconViewDialog-test.tsx.snap index e2f61aaca5e..f369aed66a8 100644 --- a/test/components/views/beacon/__snapshots__/BeaconViewDialog-test.tsx.snap +++ b/test/components/views/beacon/__snapshots__/BeaconViewDialog-test.tsx.snap @@ -65,14 +65,14 @@ exports[` renders own beacon status when user is live sharin 1h left
{ 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 (
(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/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/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(); From 4c0efc5e68398b34d273f1582be37057a267d72a Mon Sep 17 00:00:00 2001 From: Suguru Hirahara Date: Mon, 17 Apr 2023 07:37:49 +0000 Subject: [PATCH 11/30] Update Cypress test files under `support/` directory - use Cypress Testing Library (#10619) * Update support files - use Cypress Testing Library Signed-off-by: Suguru Hirahara * Fix openMessageComposerOptions() Signed-off-by: Suguru Hirahara --------- Signed-off-by: Suguru Hirahara --- cypress/support/composer.ts | 2 +- cypress/support/settings.ts | 14 +++++++------- cypress/support/views.ts | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) 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> => { From 1d9df7ec5159deb25cb29323f78efbc003f432b5 Mon Sep 17 00:00:00 2001 From: Timothy Amello Date: Mon, 17 Apr 2023 03:41:51 -0400 Subject: [PATCH 12/30] Apply strictNullChecks to Markdown.ts (#10623) Co-authored-by: Kerry --- src/Markdown.ts | 44 ++++++++++++++++++++++++++------------------ 1 file changed, 26 insertions(+), 18 deletions(-) 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"); }; From 812564940d39c6fa89a246633aa787b8cdf5b06f Mon Sep 17 00:00:00 2001 From: Suguru Hirahara Date: Mon, 17 Apr 2023 08:24:09 +0000 Subject: [PATCH 13/30] Update `location.spec.ts` - use Cypress Testing Library (#10612) * Update location.spec.ts - use Cypress Testing Library Signed-off-by: Suguru Hirahara * Make the test id of location share option discoverable with findByTestId() findByTestId seeks for data-testid, instead of data-test-id. Signed-off-by: Suguru Hirahara --------- Signed-off-by: Suguru Hirahara --- cypress/e2e/location/location.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cypress/e2e/location/location.spec.ts b/cypress/e2e/location/location.spec.ts index 65ddd767ba1..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-testid="share-location-option-${shareType}"]`); + return cy.findByTestId(`share-location-option-${shareType}`); }; const submitShareLocation = (): void => { - cy.get('[data-testid="location-picker-submit-button"]').click(); + cy.findByRole("button", { name: "Share location" }).click(); }; 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"); }); From 816a0786517450580fc61e7e3567cf8176a6353e Mon Sep 17 00:00:00 2001 From: Suguru Hirahara Date: Mon, 17 Apr 2023 08:24:10 +0000 Subject: [PATCH 14/30] Update pollHistory.spec.ts - use Cypress Testing Library (#10611) Signed-off-by: Suguru Hirahara --- cypress/e2e/polls/pollHistory.spec.ts | 30 +++++++++++++++------------ 1 file changed, 17 insertions(+), 13 deletions(-) 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"); }); }); }); From 8471e45622db80a804d021161e5d5195711acf9b Mon Sep 17 00:00:00 2001 From: Suguru Hirahara Date: Mon, 17 Apr 2023 08:32:32 +0000 Subject: [PATCH 15/30] Update send_event.spec.ts - use Cypress Testing Library (#10613) Signed-off-by: Suguru Hirahara --- cypress/e2e/integration-manager/send_event.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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(); }); } From 4d859a34e72a4a30a6487090c5abd4445d8b4522 Mon Sep 17 00:00:00 2001 From: Suguru Hirahara Date: Mon, 17 Apr 2023 09:19:40 +0000 Subject: [PATCH 16/30] Update right-panel.spec.ts - use Cypress Testing Library (#10539) Signed-off-by: Suguru Hirahara --- cypress/e2e/right-panel/right-panel.spec.ts | 53 +++++++++++++-------- 1 file changed, 32 insertions(+), 21 deletions(-) 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); }); }); From dc4bb237d40be4b5c83eea883031b0d6504e2a91 Mon Sep 17 00:00:00 2001 From: kenwuuu Date: Mon, 17 Apr 2023 02:37:29 -0700 Subject: [PATCH 17/30] Replace hardcoded strings with MsgType constants (#10604) * replace hardcoded strings with MsgType constants * fix import and revert comments Signed-off-by: Ken Wu kenqiwu@gmail.com * fix import Signed-off-by: Ken Wu kenqiwu@gmail.com --------- Signed-off-by: Ken Wu kenqiwu@gmail.com --- src/components/structures/FilePanel.tsx | 7 ++----- src/components/views/elements/EventTilePreview.tsx | 5 +++-- src/components/views/messages/EditHistoryMessage.tsx | 3 ++- src/components/views/rooms/SendMessageComposer.tsx | 4 ++-- src/utils/exportUtils/HtmlExport.tsx | 2 +- 5 files changed, 10 insertions(+), 11 deletions(-) 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/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/messages/EditHistoryMessage.tsx b/src/components/views/messages/EditHistoryMessage.tsx index 421673d7711..930c6d7b9d6 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"; @@ -166,7 +167,7 @@ export default class EditHistoryMessage extends React.PureComponent 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/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}`, From 013b7204d3bee6aec0f78964cc4e66d128f2d87e Mon Sep 17 00:00:00 2001 From: Suguru Hirahara Date: Mon, 17 Apr 2023 09:54:35 +0000 Subject: [PATCH 18/30] Update room-directory.spec.ts - use Cypress Testing Library (#10596) Signed-off-by: Suguru Hirahara --- .../e2e/room-directory/room-directory.spec.ts | 33 +++++++++++-------- 1 file changed, 19 insertions(+), 14 deletions(-) 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`); }); From 9c277d6b0249e9fbe1dde98762380045e10a84a5 Mon Sep 17 00:00:00 2001 From: Suguru Hirahara Date: Mon, 17 Apr 2023 11:07:27 +0000 Subject: [PATCH 19/30] Update file-panel.spec.ts - use Cypress Testing Library (#10574) Signed-off-by: Suguru Hirahara --- cypress/e2e/right-panel/file-panel.spec.ts | 27 +++++++++++----------- 1 file changed, 14 insertions(+), 13 deletions(-) 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"); }); }); }); From 568ec77208b1ae3cf186cc20d7f1c32653ed56fd Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 17 Apr 2023 13:57:19 +0100 Subject: [PATCH 20/30] Make SonarCloud happier about our code quality (#10630) --- src/SlashCommands.tsx | 5 ++--- src/components/structures/LegacyCallEventGrouper.ts | 2 +- src/components/views/dialogs/InviteDialog.tsx | 2 ++ src/components/views/location/Map.tsx | 4 ++-- 4 files changed, 7 insertions(+), 6 deletions(-) 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/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/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 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; From 7114f82e6f69568c5c85aeb82561544967a10aaf Mon Sep 17 00:00:00 2001 From: Suguru Hirahara Date: Mon, 17 Apr 2023 14:06:20 +0000 Subject: [PATCH 21/30] Update spotlight.spec.ts - use Cypress Testing Library (#10621) Signed-off-by: Suguru Hirahara --- cypress/e2e/spotlight/spotlight.spec.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) 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"); }); }); From a092a91cd91b07f01e1d773f1614cafec218f84e Mon Sep 17 00:00:00 2001 From: Suguru Hirahara Date: Mon, 17 Apr 2023 16:01:09 +0000 Subject: [PATCH 22/30] Update lazy-loading.spec.ts - use Cypress Testing Library (#10591) Signed-off-by: Suguru Hirahara --- cypress/e2e/lazy-loading/lazy-loading.spec.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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 }); } From 270a26d89a867eb728183fa8ddf76ed3c1ded32f Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 17 Apr 2023 17:09:17 +0100 Subject: [PATCH 23/30] Fix view source from edit history dialog always showing latest event (#10626) --- src/components/structures/ViewSource.tsx | 7 ++++++- src/components/views/messages/EditHistoryMessage.tsx | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) 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/messages/EditHistoryMessage.tsx b/src/components/views/messages/EditHistoryMessage.tsx index 930c6d7b9d6..54f24973c51 100644 --- a/src/components/views/messages/EditHistoryMessage.tsx +++ b/src/components/views/messages/EditHistoryMessage.tsx @@ -92,6 +92,7 @@ export default class EditHistoryMessage extends React.PureComponent Date: Mon, 17 Apr 2023 17:09:45 +0100 Subject: [PATCH 24/30] Fix multiple accessibility defects identified by AXE (#10606) * Mark effects overlay canvas as aria hidden * Ensure date separators aren't seen as focusable aria separators * Fix * Fix font slider not having aria label * Add missing aria labels * Fix settings flags setting aria-checked={null} * Update snapshots --- .../devices/_SelectableDeviceTile.pcss | 9 +- .../views/elements/EffectsOverlay.tsx | 1 + src/components/views/elements/ImageView.tsx | 5 +- .../views/elements/SettingsFlag.tsx | 2 +- src/components/views/elements/Slider.tsx | 3 + .../views/messages/DateSeparator.tsx | 5 +- .../views/settings/FontScalingPanel.tsx | 1 + .../settings/devices/SelectableDeviceTile.tsx | 9 +- .../tabs/room/NotificationSettingsTab.tsx | 1 + src/i18n/strings/en_EN.json | 2 + .../__snapshots__/MessagePanel-test.tsx.snap | 1 - .../MessageEditHistoryDialog-test.tsx.snap | 2 - .../__snapshots__/DateSeparator-test.tsx.snap | 2 - .../__snapshots__/DevicesPanel-test.tsx.snap | 232 +++++++++--------- .../FontScalingPanel-test.tsx.snap | 1 + .../SelectableDeviceTile-test.tsx.snap | 120 ++++----- .../__snapshots__/HTMLExport-test.ts.snap | 2 +- 17 files changed, 207 insertions(+), 191 deletions(-) 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/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/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 { { } 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/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/settings/FontScalingPanel.tsx b/src/components/views/settings/FontScalingPanel.tsx index 03a8b963b8a..89cb785f195 100644 --- a/src/components/views/settings/FontScalingPanel.tsx +++ b/src/components/views/settings/FontScalingPanel.tsx @@ -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/devices/SelectableDeviceTile.tsx b/src/components/views/settings/devices/SelectableDeviceTile.tsx index 71e625c36c3..b42c4d34216 100644 --- a/src/components/views/settings/devices/SelectableDeviceTile.tsx +++ b/src/components/views/settings/devices/SelectableDeviceTile.tsx @@ -35,10 +35,11 @@ const SelectableDeviceTile: React.FC = ({ 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..d5ab1075406 100644 --- a/src/components/views/settings/tabs/room/NotificationSettingsTab.tsx +++ b/src/components/views/settings/tabs/room/NotificationSettingsTab.tsx @@ -284,6 +284,7 @@ export default class NotificationsSettingsTab extends React.Component diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index a057beb7429..c64a49abd14 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1701,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", @@ -2600,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/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" >
should match the snapshot 1`] = ` aria-label="Thu, Jan 1 1970" class="mx_DateSeparator" role="separator" - tabindex="-1" >
should support events with 1`] = ` aria-label=", NaN NaN" class="mx_DateSeparator" role="separator" - tabindex="-1" >


renders device panel with devices 1`] = ` class="mx_Checkbox_checkmark" />
- - -
-
-
+
- -
-

- device_2 -

- -
-
- Rename -
-
-
+ +
renders device panel with devices 1`] = ` class="mx_Checkbox_checkmark" />
- - -
-
-
+
- -
-

- device_3 -

- -
-
- Rename -
-
-
+ +
renders unselected device tile with checkbox 1 class="mx_Checkbox_checkmark" />
- - -
-
-
+
- -
-

- My Device -

- -
-
- test -
-
-
+ +
`; diff --git a/test/utils/exportUtils/__snapshots__/HTMLExport-test.ts.snap b/test/utils/exportUtils/__snapshots__/HTMLExport-test.ts.snap index 86f0f717efa..6ebf8957b08 100644 --- a/test/utils/exportUtils/__snapshots__/HTMLExport-test.ts.snap +++ b/test/utils/exportUtils/__snapshots__/HTMLExport-test.ts.snap @@ -66,7 +66,7 @@ exports[`HTMLExport should export 1`] = `

-
  • @user49:example.com
    Message #49
  • @user48:example.com
    Message #48
  • @user47:example.com
    Message #47
  • @user46:example.com
    Message #46
  • @user45:example.com
    Message #45
  • @user44:example.com
    Message #44
  • @user43:example.com
    Message #43
  • @user42:example.com
    Message #42
  • @user41:example.com
    Message #41
  • @user40:example.com
    Message #40
  • @user39:example.com
    Message #39
  • @user38:example.com
    Message #38
  • @user37:example.com
    Message #37
  • @user36:example.com
    Message #36
  • @user35:example.com
    Message #35
  • @user34:example.com
    Message #34
  • @user33:example.com
    Message #33
  • @user32:example.com
    Message #32
  • @user31:example.com
    Message #31
  • @user30:example.com
    Message #30
  • @user29:example.com
    Message #29
  • @user28:example.com
    Message #28
  • @user27:example.com
    Message #27
  • @user26:example.com
    Message #26
  • @user25:example.com
    Message #25
  • @user24:example.com
    Message #24
  • @user23:example.com
    Message #23
  • @user22:example.com
    Message #22
  • @user21:example.com
    Message #21
  • @user20:example.com
    Message #20
  • @user19:example.com
    Message #19
  • @user18:example.com
    Message #18
  • @user17:example.com
    Message #17
  • @user16:example.com
    Message #16
  • @user15:example.com
    Message #15
  • @user14:example.com
    Message #14
  • @user13:example.com
    Message #13
  • @user12:example.com
    Message #12
  • @user11:example.com
    Message #11
  • @user10:example.com
    Message #10
  • @user9:example.com
    Message #9
  • @user8:example.com
    Message #8
  • @user7:example.com
    Message #7
  • @user6:example.com
    Message #6
  • @user5:example.com
    Message #5
  • @user4:example.com
    Message #4
  • @user3:example.com
    Message #3
  • @user2:example.com
    Message #2
  • @user1:example.com
    Message #1
  • @user0:example.com
    Message #0
  • +
  • @user49:example.com
    Message #49
  • @user48:example.com
    Message #48
  • @user47:example.com
    Message #47
  • @user46:example.com
    Message #46
  • @user45:example.com
    Message #45
  • @user44:example.com
    Message #44
  • @user43:example.com
    Message #43
  • @user42:example.com
    Message #42
  • @user41:example.com
    Message #41
  • @user40:example.com
    Message #40
  • @user39:example.com
    Message #39
  • @user38:example.com
    Message #38
  • @user37:example.com
    Message #37
  • @user36:example.com
    Message #36
  • @user35:example.com
    Message #35
  • @user34:example.com
    Message #34
  • @user33:example.com
    Message #33
  • @user32:example.com
    Message #32
  • @user31:example.com
    Message #31
  • @user30:example.com
    Message #30
  • @user29:example.com
    Message #29
  • @user28:example.com
    Message #28
  • @user27:example.com
    Message #27
  • @user26:example.com
    Message #26
  • @user25:example.com
    Message #25
  • @user24:example.com
    Message #24
  • @user23:example.com
    Message #23
  • @user22:example.com
    Message #22
  • @user21:example.com
    Message #21
  • @user20:example.com
    Message #20
  • @user19:example.com
    Message #19
  • @user18:example.com
    Message #18
  • @user17:example.com
    Message #17
  • @user16:example.com
    Message #16
  • @user15:example.com
    Message #15
  • @user14:example.com
    Message #14
  • @user13:example.com
    Message #13
  • @user12:example.com
    Message #12
  • @user11:example.com
    Message #11
  • @user10:example.com
    Message #10
  • @user9:example.com
    Message #9
  • @user8:example.com
    Message #8
  • @user7:example.com
    Message #7
  • @user6:example.com
    Message #6
  • @user5:example.com
    Message #5
  • @user4:example.com
    Message #4
  • @user3:example.com
    Message #3
  • @user2:example.com
    Message #2
  • @user1:example.com
    Message #1
  • @user0:example.com
    Message #0
  • From d1461d3d7d0103b9d54d95ccb917c63c446f2bad Mon Sep 17 00:00:00 2001 From: Suguru Hirahara Date: Mon, 17 Apr 2023 18:19:08 +0000 Subject: [PATCH 25/30] Update polls.spec.ts - use Cypress Testing Library (#10609) Signed-off-by: Suguru Hirahara --- cypress/e2e/polls/polls.spec.ts | 44 ++++++++++++++++++--------------- 1 file changed, 24 insertions(+), 20 deletions(-) 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); From 6bf18156085ac22cff57117032a9722c146df8fe Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 17 Apr 2023 19:43:25 +0100 Subject: [PATCH 26/30] Add missing rel noreferrer noopener attributes (#10629) --- src/components/structures/RoomStatusBar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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} ), From 2cf678201cfc32fd026e2f0a5a3da52789fe2147 Mon Sep 17 00:00:00 2001 From: kenwu Date: Fri, 31 Mar 2023 12:01:32 -0500 Subject: [PATCH 27/30] Add custom notification setting - extract NotificationSound.tsx component - rename getSoundForRoom, generalize function usage - rename unnecessarily short variable name --- src/Notifier.ts | 51 ++-- .../views/settings/NotificationSound.tsx | 221 ++++++++++++++++++ .../views/settings/Notifications.tsx | 47 +++- .../tabs/room/NotificationSettingsTab.tsx | 174 ++------------ test/Notifier-test.ts | 10 +- 5 files changed, 315 insertions(+), 188 deletions(-) create mode 100644 src/components/views/settings/NotificationSound.tsx diff --git a/src/Notifier.ts b/src/Notifier.ts index 52983f6fc3e..2565f2723b1 100644 --- a/src/Notifier.ts +++ b/src/Notifier.ts @@ -172,14 +172,24 @@ class NotifierClass { } } - public getSoundForRoom(roomId: string): { + /* + * returns account's default sound if no valid roomId + * + * 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 +222,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 +339,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 +462,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 +480,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/components/views/settings/NotificationSound.tsx b/src/components/views/settings/NotificationSound.tsx new file mode 100644 index 00000000000..5ca5818c53b --- /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); + 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 => { // TODO add ", level: SettingLevel" to the function parameters + e.stopPropagation(); + e.preventDefault(); + + try { + await this.saveSound(SettingLevel.ACCOUNT); // TODO this should be a variable + } 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..aef42f613aa 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/tabs/room/NotificationSettingsTab.tsx b/src/components/views/settings/tabs/room/NotificationSettingsTab.tsx index d5ab1075406..249f491d795 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,60 +170,10 @@ 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/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); }); }); From aa90786c081b55de95719f391c62b68c914d149d Mon Sep 17 00:00:00 2001 From: Ken Wu Date: Sat, 15 Apr 2023 16:51:36 -0700 Subject: [PATCH 28/30] Call NotificationSound with roomId --- src/components/views/settings/NotificationSound.tsx | 7 +++---- src/components/views/settings/Notifications.tsx | 2 +- .../views/settings/tabs/room/NotificationSettingsTab.tsx | 6 ++++-- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/components/views/settings/NotificationSound.tsx b/src/components/views/settings/NotificationSound.tsx index 5ca5818c53b..24fefc62c26 100644 --- a/src/components/views/settings/NotificationSound.tsx +++ b/src/components/views/settings/NotificationSound.tsx @@ -44,7 +44,7 @@ class NotificationSound extends React.Component { let currentSound = "default"; const soundData: { url: string; name: string; type: string; size: string } = - Notifier.getNotificationSound(this.props.roomId); + Notifier.getNotificationSound(this.props.roomId); // we should set roomId to account when notificationSettingLevel is account if (soundData) { currentSound = soundData.name || soundData.url; } @@ -62,7 +62,6 @@ class NotificationSound extends React.Component { */ 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; @@ -93,12 +92,12 @@ class NotificationSound extends React.Component { }); } - private onClickSaveSound = async (e: React.MouseEvent): Promise => { // TODO add ", level: SettingLevel" to the function parameters + private onClickSaveSound = async (e: React.MouseEvent): Promise => { e.stopPropagation(); e.preventDefault(); try { - await this.saveSound(SettingLevel.ACCOUNT); // TODO this should be a variable + await this.saveSound(this.props.level); } catch (ex) { if (this.props.roomId) { logger.error(`Unable to save notification sound for ${this.props.roomId}`); diff --git a/src/components/views/settings/Notifications.tsx b/src/components/views/settings/Notifications.tsx index aef42f613aa..af6a98a2596 100644 --- a/src/components/views/settings/Notifications.tsx +++ b/src/components/views/settings/Notifications.tsx @@ -902,7 +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/tabs/room/NotificationSettingsTab.tsx b/src/components/views/settings/tabs/room/NotificationSettingsTab.tsx index 249f491d795..78f737461a6 100644 --- a/src/components/views/settings/tabs/room/NotificationSettingsTab.tsx +++ b/src/components/views/settings/tabs/room/NotificationSettingsTab.tsx @@ -170,8 +170,10 @@ export default class NotificationsSettingsTab extends React.Component
    -
    From 037be66c8201e2c2aff9217b8c019b81a23c459b Mon Sep 17 00:00:00 2001 From: Ken Wu Date: Sat, 15 Apr 2023 17:12:10 -0700 Subject: [PATCH 29/30] remove comment --- src/Notifier.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Notifier.ts b/src/Notifier.ts index 2565f2723b1..e947f35a7fd 100644 --- a/src/Notifier.ts +++ b/src/Notifier.ts @@ -173,8 +173,6 @@ class NotifierClass { } /* - * returns account's default sound if no valid roomId - * * We do no caching here because the SDK caches setting * and the browser will cache the sound. * From 1c6488802df25cf40564deb0a145a611e1702ec8 Mon Sep 17 00:00:00 2001 From: Ken Wu Date: Mon, 17 Apr 2023 15:33:26 -0700 Subject: [PATCH 30/30] add aria-label --- src/components/views/settings/NotificationSound.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/views/settings/NotificationSound.tsx b/src/components/views/settings/NotificationSound.tsx index 24fefc62c26..a5e402d9d3c 100644 --- a/src/components/views/settings/NotificationSound.tsx +++ b/src/components/views/settings/NotificationSound.tsx @@ -189,6 +189,7 @@ class NotificationSound extends React.Component { onClick={chromeFileInputFix} onChange={this.onSoundUploadChanged} accept="audio/*" + aria-label={_t("Upload custom sound")} />