diff --git a/res/css/_components.pcss b/res/css/_components.pcss index 2851313c54e..0fd24fbaf2a 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -321,6 +321,8 @@ @import "./views/settings/_JoinRuleSettings.pcss"; @import "./views/settings/_KeyboardShortcut.pcss"; @import "./views/settings/_LayoutSwitcher.pcss"; +@import "./views/settings/_NotificationPusherSettings.pcss"; +@import "./views/settings/_NotificationSettings2.pcss"; @import "./views/settings/_Notifications.pcss"; @import "./views/settings/_PhoneNumbers.pcss"; @import "./views/settings/_ProfileSettings.pcss"; @@ -331,6 +333,8 @@ @import "./views/settings/_SpellCheckLanguages.pcss"; @import "./views/settings/_ThemeChoicePanel.pcss"; @import "./views/settings/_UpdateCheckButton.pcss"; +@import "./views/settings/tabs/_SettingsBanner.pcss"; +@import "./views/settings/tabs/_SettingsIndent.pcss"; @import "./views/settings/tabs/_SettingsSection.pcss"; @import "./views/settings/tabs/_SettingsTab.pcss"; @import "./views/settings/tabs/room/_NotificationSettingsTab.pcss"; diff --git a/res/css/views/settings/_NotificationPusherSettings.pcss b/res/css/views/settings/_NotificationPusherSettings.pcss new file mode 100644 index 00000000000..db573bb4eed --- /dev/null +++ b/res/css/views/settings/_NotificationPusherSettings.pcss @@ -0,0 +1,26 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_NotificationPusherSettings { + .mx_NotificationPusherSettings_description { + color: $primary-content; + } + + .mx_NotificationPusherSettings_detail { + margin-top: -4px; + margin-bottom: 12px; + } +} diff --git a/res/css/views/settings/_NotificationSettings2.pcss b/res/css/views/settings/_NotificationSettings2.pcss new file mode 100644 index 00000000000..0bad2e992c6 --- /dev/null +++ b/res/css/views/settings/_NotificationSettings2.pcss @@ -0,0 +1,85 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_NotificationSettings2 { + .mx_SettingsSection_subSections { + color: $primary-content; + gap: 32px; + display: flex; + flex-direction: column; + } + + .mx_SettingsSubsection_description { + margin-bottom: 20px; + + .mx_SettingsSubsection_text { + font-size: 1.2rem; + + .mx_NotificationBadge { + vertical-align: baseline; + display: inline-flex; + margin: 0 2px; + } + } + } + + .mx_SettingsSubsection_content { + margin-top: 12px; + grid-gap: 12px; + justify-items: stretch; + justify-content: stretch; + } + + .mx_SettingsBanner { + margin-bottom: 32px; + } + + .mx_NotificationSettings2_flags { + grid-gap: 4px; + } + + .mx_StyledRadioButton_content { + margin-left: 10px; + margin-right: 10px; + } + + .mx_TagComposer { + margin-top: 16px; + + &.mx_TagComposer_disabled { + opacity: 0.7; + } + + .mx_TagComposer_tags { + margin-top: 16px; + gap: 8px; + + .mx_Tag { + border-radius: 18px; + line-height: 2.4rem; + padding: 6px 12px; + background: $panel-actions; + margin: 0; + + .mx_Tag_delete { + background: $tertiary-content; + color: #fff; + align-self: initial; + } + } + } + } +} diff --git a/res/css/views/settings/tabs/_SettingsBanner.pcss b/res/css/views/settings/tabs/_SettingsBanner.pcss new file mode 100644 index 00000000000..f0b9e7106bb --- /dev/null +++ b/res/css/views/settings/tabs/_SettingsBanner.pcss @@ -0,0 +1,35 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_SettingsBanner { + background: $system; + line-height: 2.25rem; + border-radius: 8px; + padding: 12px 16px; + gap: 12px; + display: flex; + flex-direction: row; + align-items: center; + + .mx_SettingsBanner_content { + margin: 0; + } + + .mx_AccessibleButton { + align-self: initial; + white-space: nowrap; + } +} diff --git a/res/css/views/settings/tabs/_SettingsIndent.pcss b/res/css/views/settings/tabs/_SettingsIndent.pcss new file mode 100644 index 00000000000..fe328d43643 --- /dev/null +++ b/res/css/views/settings/tabs/_SettingsIndent.pcss @@ -0,0 +1,22 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_SettingsIndent { + padding-left: 16px; + display: flex; + flex-direction: column; + gap: 12px; +} diff --git a/res/img/element-icons/new-and-improved.svg b/res/img/element-icons/new-and-improved.svg new file mode 100644 index 00000000000..113dfe8d6bd --- /dev/null +++ b/res/img/element-icons/new-and-improved.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/components/views/elements/Tag.tsx b/src/components/views/elements/Tag.tsx index d7d46fe7e11..6ce2ba69fcb 100644 --- a/src/components/views/elements/Tag.tsx +++ b/src/components/views/elements/Tag.tsx @@ -1,5 +1,5 @@ /* -Copyright 2022 The Matrix.org Foundation C.I.C. +Copyright 2022-2023 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,21 +14,21 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from "react"; +import React, { DetailedHTMLProps, HTMLAttributes } from "react"; import AccessibleButton from "./AccessibleButton"; import { Icon as CancelRounded } from "../../../../res/img/element-icons/cancel-rounded.svg"; -interface IProps { +interface IProps extends DetailedHTMLProps, HTMLDivElement> { icon?: () => JSX.Element; label: string; onDeleteClick?: () => void; disabled?: boolean; } -export const Tag: React.FC = ({ icon, label, onDeleteClick, disabled = false }) => { +export const Tag: React.FC = ({ icon, label, onDeleteClick, disabled = false, ...other }) => { return ( -
+
{icon?.()} {label} {onDeleteClick && ( diff --git a/src/components/views/elements/TagComposer.tsx b/src/components/views/elements/TagComposer.tsx index 0fdd5e98cdb..4e7ed302f08 100644 --- a/src/components/views/elements/TagComposer.tsx +++ b/src/components/views/elements/TagComposer.tsx @@ -1,5 +1,5 @@ /* -Copyright 2021 The Matrix.org Foundation C.I.C. +Copyright 2021-2023 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,6 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ +import classNames from "classnames"; import React, { ChangeEvent, FormEvent } from "react"; import Field from "./Field"; @@ -22,6 +23,7 @@ import AccessibleButton from "./AccessibleButton"; import { Tag } from "./Tag"; interface IProps { + id?: string; tags: string[]; onAdd: (tag: string) => void; onRemove: (tag: string) => void; @@ -67,9 +69,14 @@ export default class TagComposer extends React.PureComponent { public render(): React.ReactNode { return ( -
+
{ {_t("Add")} -
+
{this.props.tags.map((t, i) => ( ))}
diff --git a/src/components/views/settings/notifications/NotificationPusherSettings.tsx b/src/components/views/settings/notifications/NotificationPusherSettings.tsx new file mode 100644 index 00000000000..5035e585509 --- /dev/null +++ b/src/components/views/settings/notifications/NotificationPusherSettings.tsx @@ -0,0 +1,134 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { ThreepidMedium } from "matrix-js-sdk/src/@types/threepids"; +import { IPusher } from "matrix-js-sdk/src/matrix"; +import React, { useCallback, useMemo } from "react"; + +import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext"; +import { Action } from "../../../../dispatcher/actions"; +import dispatcher from "../../../../dispatcher/dispatcher"; +import { usePushers } from "../../../../hooks/usePushers"; +import { useThreepids } from "../../../../hooks/useThreepids"; +import { _t } from "../../../../languageHandler"; +import SdkConfig from "../../../../SdkConfig"; +import { UserTab } from "../../dialogs/UserTab"; +import AccessibleButton from "../../elements/AccessibleButton"; +import LabelledCheckbox from "../../elements/LabelledCheckbox"; +import { SettingsIndent } from "../shared/SettingsIndent"; +import SettingsSubsection, { SettingsSubsectionText } from "../shared/SettingsSubsection"; + +function generalTabButton(content: string): JSX.Element { + return ( + { + dispatcher.dispatch({ + action: Action.ViewUserSettings, + initialTabId: UserTab.General, + }); + }} + > + {content} + + ); +} + +export function NotificationPusherSettings(): JSX.Element { + const EmailPusherTemplate: Omit = useMemo( + () => ({ + kind: "email", + app_id: "m.email", + app_display_name: _t("Email Notifications"), + lang: navigator.language, + data: { + brand: SdkConfig.get().brand, + }, + }), + [], + ); + + const cli = useMatrixClientContext(); + const [pushers, refreshPushers] = usePushers(cli); + const [threepids, refreshThreepids] = useThreepids(cli); + + const setEmailEnabled = useCallback( + (email: string, enabled: boolean) => { + if (enabled) { + cli.setPusher({ + ...EmailPusherTemplate, + pushkey: email, + device_display_name: email, + // We always append for email pushers since we don't want to stop other + // accounts notifying to the same email address + append: true, + }).catch((err) => console.error(err)); + } else { + const pusher = pushers.find((p) => p.kind === "email" && p.pushkey === email); + if (pusher) { + cli.removePusher(pusher.pushkey, pusher.app_id).catch((err) => console.error(err)); + } + } + refreshThreepids(); + refreshPushers(); + }, + [EmailPusherTemplate, cli, pushers, refreshPushers, refreshThreepids], + ); + + const notificationTargets = pushers.filter((it) => it.kind !== "email"); + + return ( + <> + + + {_t("Receive an email summary of missed notifications")} + +
+ + {_t( + "Select which emails you want to send summaries to. Manage your emails in .", + {}, + { button: generalTabButton }, + )} + +
+ + {threepids + .filter((t) => t.medium === ThreepidMedium.Email) + .map((email) => ( + it.pushkey === email.address) !== undefined} + onChange={(value) => setEmailEnabled(email.address, value)} + /> + ))} + +
+ {notificationTargets.length > 0 && ( + +
    + {pushers + .filter((it) => it.kind !== "email") + .map((pusher) => ( +
  • {pusher.device_display_name || pusher.app_display_name}
  • + ))} +
+
+ )} + + ); +} diff --git a/src/components/views/settings/notifications/NotificationSettings2.tsx b/src/components/views/settings/notifications/NotificationSettings2.tsx new file mode 100644 index 00000000000..c3ba045602b --- /dev/null +++ b/src/components/views/settings/notifications/NotificationSettings2.tsx @@ -0,0 +1,370 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { useState } from "react"; + +import NewAndImprovedIcon from "../../../../../res/img/element-icons/new-and-improved.svg"; +import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext"; +import { useNotificationSettings } from "../../../../hooks/useNotificationSettings"; +import { useSettingValue } from "../../../../hooks/useSettings"; +import { _t } from "../../../../languageHandler"; +import { + DefaultNotificationSettings, + NotificationSettings, +} from "../../../../models/notificationsettings/NotificationSettings"; +import { RoomNotifState } from "../../../../RoomNotifs"; +import { SettingLevel } from "../../../../settings/SettingLevel"; +import SettingsStore from "../../../../settings/SettingsStore"; +import { NotificationColor } from "../../../../stores/notifications/NotificationColor"; +import { clearAllNotifications } from "../../../../utils/notifications"; +import AccessibleButton from "../../elements/AccessibleButton"; +import LabelledCheckbox from "../../elements/LabelledCheckbox"; +import LabelledToggleSwitch from "../../elements/LabelledToggleSwitch"; +import StyledRadioGroup from "../../elements/StyledRadioGroup"; +import TagComposer from "../../elements/TagComposer"; +import { StatelessNotificationBadge } from "../../rooms/NotificationBadge/StatelessNotificationBadge"; +import { SettingsBanner } from "../shared/SettingsBanner"; +import { SettingsSection } from "../shared/SettingsSection"; +import SettingsSubsection from "../shared/SettingsSubsection"; +import { NotificationPusherSettings } from "./NotificationPusherSettings"; + +enum NotificationDefaultLevels { + AllMessages = "all_messages", + PeopleMentionsKeywords = "people_mentions_keywords", + MentionsKeywords = "mentions_keywords", +} + +function toDefaultLevels(levels: NotificationSettings["defaultLevels"]): NotificationDefaultLevels { + if (levels.room === RoomNotifState.AllMessages) { + return NotificationDefaultLevels.AllMessages; + } else if (levels.dm === RoomNotifState.AllMessages) { + return NotificationDefaultLevels.PeopleMentionsKeywords; + } else { + return NotificationDefaultLevels.MentionsKeywords; + } +} + +const NotificationOptions = [ + { + value: NotificationDefaultLevels.AllMessages, + label: _t("All messages"), + }, + { + value: NotificationDefaultLevels.PeopleMentionsKeywords, + label: _t("People, Mentions and Keywords"), + }, + { + value: NotificationDefaultLevels.MentionsKeywords, + label: _t("Mentions and Keywords only"), + }, +]; + +function boldText(text: string): JSX.Element { + return {text}; +} + +function useHasUnreadNotifications(): boolean { + const cli = useMatrixClientContext(); + return cli.getRooms().some((room) => room.getUnreadNotificationCount() > 0); +} + +export default function NotificationSettings2(): JSX.Element { + const cli = useMatrixClientContext(); + + const desktopNotifications = useSettingValue("notificationsEnabled"); + const desktopShowBody = useSettingValue("notificationBodyEnabled"); + const audioNotifications = useSettingValue("audioNotificationsEnabled"); + + const { model, hasPendingChanges, reconcile } = useNotificationSettings(cli); + + const disabled = model === null || hasPendingChanges; + const settings = model ?? DefaultNotificationSettings; + + const [updatingUnread, setUpdatingUnread] = useState(false); + const hasUnreadNotifications = useHasUnreadNotifications(); + + return ( +
+ {hasPendingChanges && model !== null && ( + } + action={_t("Switch now")} + onAction={() => reconcile(model!)} + > + {_t( + "Update: We have updated our notification settings. This won’t affect your previously selected settings.", + {}, + { strong: boldText }, + )} + + )} + +
+ { + reconcile({ + ...model!, + globalMute: !value, + }); + }} + /> + + SettingsStore.setValue("notificationsEnabled", null, SettingLevel.DEVICE, value) + } + /> + + SettingsStore.setValue("notificationBodyEnabled", null, SettingLevel.DEVICE, value) + } + /> + + SettingsStore.setValue("audioNotificationsEnabled", null, SettingLevel.DEVICE, value) + } + /> +
+ + { + reconcile({ + ...model!, + defaultLevels: { + ...model!.defaultLevels, + dm: + value !== NotificationDefaultLevels.MentionsKeywords + ? RoomNotifState.AllMessages + : RoomNotifState.MentionsOnly, + room: + value === NotificationDefaultLevels.AllMessages + ? RoomNotifState.AllMessages + : RoomNotifState.MentionsOnly, + }, + }); + }} + /> + + + { + reconcile({ + ...model!, + sound: { + ...model!.sound, + people: value ? "default" : undefined, + }, + }); + }} + /> + { + reconcile({ + ...model!, + sound: { + ...model!.sound, + mentions: value ? "default" : undefined, + }, + }); + }} + /> + { + reconcile({ + ...model!, + sound: { + ...model!.sound, + calls: value ? "ring" : undefined, + }, + }); + }} + /> + + + { + reconcile({ + ...model!, + activity: { + ...model!.activity, + invite: value, + }, + }); + }} + /> + { + reconcile({ + ...model!, + activity: { + ...model!.activity, + status_event: value, + }, + }); + }} + /> + { + reconcile({ + ...model!, + activity: { + ...model!.activity, + bot_notices: value, + }, + }); + }} + /> + + when keywords are used in a room.", + {}, + { + badge: , + }, + )} + > + { + reconcile({ + ...model!, + mentions: { + ...model!.mentions, + room: value, + }, + }); + }} + /> + { + reconcile({ + ...model!, + mentions: { + ...model!.mentions, + user: value, + }, + }); + }} + /> + { + reconcile({ + ...model!, + mentions: { + ...model!.mentions, + keywords: value, + }, + }); + }} + /> + { + reconcile({ + ...model!, + keywords: [keyword, ...model!.keywords], + }); + }} + onRemove={(keyword) => { + reconcile({ + ...model!, + keywords: model!.keywords.filter((it) => it !== keyword), + }); + }} + label={_t("Keyword")} + placeholder={_t("New keyword")} + /> + + + + {hasUnreadNotifications && ( + { + setUpdatingUnread(true); + await clearAllNotifications(cli); + setUpdatingUnread(false); + }} + > + {_t("Mark all messages as read")} + + )} + { + reconcile(DefaultNotificationSettings); + }} + > + {_t("Reset to default settings")} + + +
+
+ ); +} diff --git a/src/components/views/settings/shared/SettingsBanner.tsx b/src/components/views/settings/shared/SettingsBanner.tsx new file mode 100644 index 00000000000..85dfb1d89d8 --- /dev/null +++ b/src/components/views/settings/shared/SettingsBanner.tsx @@ -0,0 +1,39 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { PropsWithChildren, ReactNode } from "react"; + +import AccessibleButton from "../../elements/AccessibleButton"; + +interface Props { + icon?: ReactNode; + action?: ReactNode; + onAction?: () => void; +} + +export function SettingsBanner({ children, icon, action, onAction }: PropsWithChildren): JSX.Element { + return ( +
+ {icon} +
{children}
+ {action && ( + + {action} + + )} +
+ ); +} diff --git a/src/components/views/settings/shared/SettingsIndent.tsx b/src/components/views/settings/shared/SettingsIndent.tsx new file mode 100644 index 00000000000..48ddf2fb70e --- /dev/null +++ b/src/components/views/settings/shared/SettingsIndent.tsx @@ -0,0 +1,27 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { HTMLAttributes } from "react"; + +export interface SettingsIndentProps extends HTMLAttributes { + children?: React.ReactNode; +} + +export const SettingsIndent: React.FC = ({ children, ...rest }) => ( +
+ {children} +
+); diff --git a/src/components/views/settings/shared/SettingsSection.tsx b/src/components/views/settings/shared/SettingsSection.tsx index 243346c0473..926ef5e2323 100644 --- a/src/components/views/settings/shared/SettingsSection.tsx +++ b/src/components/views/settings/shared/SettingsSection.tsx @@ -1,5 +1,5 @@ /* -Copyright 2022 The Matrix.org Foundation C.I.C. +Copyright 2022-2023 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,6 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ +import classnames from "classnames"; import React, { HTMLAttributes } from "react"; import Heading from "../../typography/Heading"; @@ -40,8 +41,8 @@ export interface SettingsSectionProps extends HTMLAttributes { * * ``` */ -export const SettingsSection: React.FC = ({ heading, children, ...rest }) => ( -
+export const SettingsSection: React.FC = ({ className, heading, children, ...rest }) => ( +
{typeof heading === "string" ? {heading} : <>{heading}}
{children}
diff --git a/src/components/views/settings/tabs/user/NotificationUserSettingsTab.tsx b/src/components/views/settings/tabs/user/NotificationUserSettingsTab.tsx index 4e95220df1f..50afdf91c93 100644 --- a/src/components/views/settings/tabs/user/NotificationUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/NotificationUserSettingsTab.tsx @@ -1,5 +1,5 @@ /* -Copyright 2019-2021 The Matrix.org Foundation C.I.C. +Copyright 2019-2023 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -17,17 +17,26 @@ limitations under the License. import React from "react"; import { _t } from "../../../../../languageHandler"; +import { Features } from "../../../../../settings/Settings"; +import SettingsStore from "../../../../../settings/SettingsStore"; import Notifications from "../../Notifications"; +import NotificationSettings2 from "../../notifications/NotificationSettings2"; import { SettingsSection } from "../../shared/SettingsSection"; import SettingsTab from "../SettingsTab"; export default class NotificationUserSettingsTab extends React.Component { public render(): React.ReactNode { + const newNotificationSettingsEnabled = SettingsStore.getValue(Features.NotificationSettings2); + return ( - - - + {newNotificationSettingsEnabled ? ( + + ) : ( + + + + )} ); } diff --git a/src/hooks/useNotificationSettings.tsx b/src/hooks/useNotificationSettings.tsx index b4174b4924e..0e5f26b88f0 100644 --- a/src/hooks/useNotificationSettings.tsx +++ b/src/hooks/useNotificationSettings.tsx @@ -44,6 +44,7 @@ type UseNotificationSettings = { }; export function useNotificationSettings(cli: MatrixClient): UseNotificationSettings { + const run = useLinearisedPromise(); const supportsIntentionalMentions = useMemo(() => cli.supportsIntentionalMentions(), [cli]); const pushRules = useRef(null); @@ -61,21 +62,41 @@ export function useNotificationSettings(cli: MatrixClient): UseNotificationSetti }, [cli, supportsIntentionalMentions]); useEffect(() => { - updatePushRules().catch((err) => console.error(err)); - }, [cli, updatePushRules]); + run(updatePushRules).catch((err) => console.error(err)); + }, [cli, run, updatePushRules]); const reconcile = useCallback( (model: NotificationSettings) => { - if (pushRules.current !== null) { - setModel(model); - const changes = reconcileNotificationSettings(pushRules.current, model, supportsIntentionalMentions); - applyChanges(cli, changes) - .then(updatePushRules) - .catch((err) => console.error(err)); - } + setModel(model); + run(async () => { + if (pushRules.current !== null) { + const changes = reconcileNotificationSettings( + pushRules.current, + model, + supportsIntentionalMentions, + ); + await applyChanges(cli, changes); + await updatePushRules(); + } + }).catch((err) => console.error(err)); }, - [cli, updatePushRules, supportsIntentionalMentions], + [run, supportsIntentionalMentions, cli, updatePushRules], ); return { model, hasPendingChanges, reconcile }; } + +function useLinearisedPromise(): (fun: () => Promise) => Promise { + const lastPromise = useRef | null>(null); + + return useCallback((fun: () => Promise): Promise => { + let next: Promise; + if (lastPromise.current === null) { + next = fun(); + } else { + next = lastPromise.current.then(fun); + } + lastPromise.current = next; + return next; + }, []); +} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index d9f74d40a56..dff93e43b99 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -953,6 +953,9 @@ "Can I use text chat alongside the video call?": "Can I use text chat alongside the video call?", "Yes, the chat timeline is displayed alongside the video.": "Yes, the chat timeline is displayed alongside the video.", "Thank you for trying the beta, please go into as much detail as you can so we can improve it.": "Thank you for trying the beta, please go into as much detail as you can so we can improve it.", + "New Notification Settings": "New Notification Settings", + "Notification Settings": "Notification Settings", + "Introducing a simpler way to change your notification settings. Customize your %(brand)s, just the way you like.": "Introducing a simpler way to change your notification settings. Customize your %(brand)s, just the way you like.", "Explore public spaces in the new search dialog": "Explore public spaces in the new search dialog", "Requires your server to support the stable version of MSC3827": "Requires your server to support the stable version of MSC3827", "Let moderators hide messages pending moderation.": "Let moderators hide messages pending moderation.", @@ -1767,6 +1770,33 @@ "%(brand)s is end-to-end encrypted, but is currently limited to smaller numbers of users.": "%(brand)s is end-to-end encrypted, but is currently limited to smaller numbers of users.", "You do not have sufficient permissions to change this.": "You do not have sufficient permissions to change this.", "Call type": "Call type", + "Email Notifications": "Email Notifications", + "Email summary": "Email summary", + "Receive an email summary of missed notifications": "Receive an email summary of missed notifications", + "Select which emails you want to send summaries to. Manage your emails in .": "Select which emails you want to send summaries to. Manage your emails in .", + "People, Mentions and Keywords": "People, Mentions and Keywords", + "Mentions and Keywords only": "Mentions and Keywords only", + "Switch now": "Switch now", + "Update: We have updated our notification settings. This won’t affect your previously selected settings.": "Update: We have updated our notification settings. This won’t affect your previously selected settings.", + "Show message preview in desktop notification": "Show message preview in desktop notification", + "I want to be notified for (Default Setting)": "I want to be notified for (Default Setting)", + "This setting will be applied by default to all your rooms.": "This setting will be applied by default to all your rooms.", + "Play a sound for": "Play a sound for", + "Applied by default to all rooms on all devices.": "Applied by default to all rooms on all devices.", + "Mentions and Keywords": "Mentions and Keywords", + "Audio and Video calls": "Audio and Video calls", + "Other things we think you might be interested in:": "Other things we think you might be interested in:", + "Invited to a room": "Invited to a room", + "New room activity, upgrades and status messages occur": "New room activity, upgrades and status messages occur", + "Messages sent by bots": "Messages sent by bots", + "Show a badge when keywords are used in a room.": "Show a badge when keywords are used in a room.", + "Notify when someone mentions using @room": "Notify when someone mentions using @room", + "Notify when someone mentions using @displayname or %(mxid)s": "Notify when someone mentions using @displayname or %(mxid)s", + "Notify when someone uses a keyword": "Notify when someone uses a keyword", + "Enter keywords here, or use for spelling variations or nicknames": "Enter keywords here, or use for spelling variations or nicknames", + "Quick Actions": "Quick Actions", + "Mark all messages as read": "Mark all messages as read", + "Reset to default settings": "Reset to default settings", "Unable to revoke sharing for email address": "Unable to revoke sharing for email address", "Unable to share email address": "Unable to share email address", "Your email address hasn't been verified yet": "Your email address hasn't been verified yet", diff --git a/src/models/notificationsettings/reconcileNotificationSettings.ts b/src/models/notificationsettings/reconcileNotificationSettings.ts index 7fdd366e633..510e7ca3af1 100644 --- a/src/models/notificationsettings/reconcileNotificationSettings.ts +++ b/src/models/notificationsettings/reconcileNotificationSettings.ts @@ -196,6 +196,11 @@ export function reconcileNotificationSettings( } } + const mentionActions = NotificationUtils.encodeActions({ + notify: true, + sound: model.sound.mentions, + highlight: true, + }); const contentRules = pushRules.global.content?.filter((rule) => !rule.rule_id.startsWith(".")) ?? []; const newKeywords = new Set(model.keywords); for (const rule of contentRules) { @@ -204,12 +209,27 @@ export function reconcileNotificationSettings( rule_id: rule.rule_id, kind: PushRuleKind.ContentSpecific, }); - } else if (rule.enabled !== model.mentions.keywords) { - changes.updated.push({ - rule_id: rule.rule_id, - kind: PushRuleKind.ContentSpecific, - enabled: model.mentions.keywords, - }); + } else { + let changed = false; + if (rule.enabled !== model.mentions.keywords) { + changed = true; + } else if (rule.actions !== undefined) { + const originalActions = NotificationUtils.decodeActions(rule.actions); + const actions = NotificationUtils.decodeActions(mentionActions); + if (originalActions === null || actions === null) { + changed = true; + } else if (!deepCompare(actions, originalActions)) { + changed = true; + } + } + if (changed) { + changes.updated.push({ + rule_id: rule.rule_id, + kind: PushRuleKind.ContentSpecific, + enabled: model.mentions.keywords, + actions: mentionActions, + }); + } } newKeywords.delete(rule.pattern!); } @@ -220,7 +240,7 @@ export function reconcileNotificationSettings( default: false, enabled: model.mentions.keywords, pattern: keyword, - actions: StandardActions.ACTION_NOTIFY, + actions: mentionActions, }); } diff --git a/src/models/notificationsettings/toNotificationSettings.ts b/src/models/notificationsettings/toNotificationSettings.ts index cfb28718c48..9a3f15453d2 100644 --- a/src/models/notificationsettings/toNotificationSettings.ts +++ b/src/models/notificationsettings/toNotificationSettings.ts @@ -37,6 +37,22 @@ function shouldNotify(rules: (IPushRule | null | undefined | false)[]): boolean return false; } +function isMuted(rules: (IPushRule | null | undefined | false)[]): boolean { + if (rules.length === 0) { + return false; + } + for (const rule of rules) { + if (rule === null || rule === undefined || rule === false || !rule.enabled) { + continue; + } + const actions = NotificationUtils.decodeActions(rule.actions); + if (actions !== null && !actions.notify && actions.highlight !== true && actions.sound === undefined) { + return true; + } + } + return false; +} + function determineSound(rules: (IPushRule | null | undefined | false)[]): string | undefined { for (const rule of rules) { if (rule === null || rule === undefined || rule === false || !rule.enabled) { @@ -74,7 +90,7 @@ export function toNotificationSettings( people: determineSound(dmRules), }, activity: { - bot_notices: shouldNotify([standardRules.get(RuleId.SuppressNotices)]), + bot_notices: !isMuted([standardRules.get(RuleId.SuppressNotices)]), invite: shouldNotify([standardRules.get(RuleId.InviteToSelf)]), status_event: shouldNotify([standardRules.get(RuleId.MemberEvent), standardRules.get(RuleId.Tombstone)]), }, diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 7b932eb3b99..5f1cbe8165c 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -94,6 +94,7 @@ export enum LabGroup { export enum Features { VoiceBroadcast = "feature_voice_broadcast", VoiceBroadcastForceSmallChunks = "feature_voice_broadcast_force_small_chunks", + NotificationSettings2 = "feature_notification_settings2", OidcNativeFlow = "feature_oidc_native_flow", } @@ -229,6 +230,28 @@ export const SETTINGS: { [setting: string]: ISetting } = { requiresRefresh: true, }, }, + [Features.NotificationSettings2]: { + isFeature: true, + labsGroup: LabGroup.Experimental, + supportedLevels: LEVELS_FEATURE, + displayName: _td("New Notification Settings"), + default: false, + betaInfo: { + title: _td("Notification Settings"), + caption: () => ( + <> +

+ {_t( + "Introducing a simpler way to change your notification settings. Customize your %(brand)s, just the way you like.", + { + brand: SdkConfig.get().brand, + }, + )} +

+ + ), + }, + }, "feature_exploring_public_spaces": { isFeature: true, labsGroup: LabGroup.Spaces, diff --git a/test/components/views/settings/notifications/Notifications2-test.tsx b/test/components/views/settings/notifications/Notifications2-test.tsx new file mode 100644 index 00000000000..11030abc411 --- /dev/null +++ b/test/components/views/settings/notifications/Notifications2-test.tsx @@ -0,0 +1,762 @@ +/* +Copyright 2022-2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { act, findByRole, getByRole, queryByRole, render, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { ThreepidMedium } from "matrix-js-sdk/src/@types/threepids"; +import { IPushRules, MatrixClient, NotificationCountType, PushRuleKind, Room, RuleId } from "matrix-js-sdk/src/matrix"; +import React from "react"; + +import NotificationSettings2 from "../../../../../src/components/views/settings/notifications/NotificationSettings2"; +import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; +import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; +import { StandardActions } from "../../../../../src/notifications/StandardActions"; +import { PredictableRandom } from "../../../../predictableRandom"; +import { mkMessage, stubClient } from "../../../../test-utils"; +import Mock = jest.Mock; + +const mockRandom = new PredictableRandom(); + +// Fake random strings to give a predictable snapshot for IDs +jest.mock("matrix-js-sdk/src/randomstring", () => ({ + randomString: jest.fn((len): string => { + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + let ret = ""; + + for (let i = 0; i < len; ++i) { + ret += chars.charAt(Math.floor(mockRandom.get() * chars.length)); + } + + return ret; + }), +})); + +const waitForUpdate = (): Promise => new Promise((resolve) => setTimeout(resolve)); + +const labelGlobalMute = "Enable notifications for this account"; +const labelLevelAllMessage = "All messages"; +const labelLevelMentionsOnly = "Mentions and Keywords only"; +const labelSoundPeople = "People"; +const labelSoundMentions = "Mentions and Keywords"; +const labelSoundCalls = "Audio and Video calls"; +const labelActivityInvites = "Invited to a room"; +const labelActivityStatus = "New room activity, upgrades and status messages occur"; +const labelActivityBots = "Messages sent by bots"; +const labelMentionUser = "Notify when someone mentions using @displayname or @mxid"; +const labelMentionRoom = "Notify when someone mentions using @room"; +const labelMentionKeyword = + "Notify when someone uses a keyword" + "Enter keywords here, or use for spelling variations or nicknames"; +const labelResetDefault = "Reset to default settings"; + +const keywords = ["justjann3", "justj4nn3", "justj4nne", "Janne", "J4nne", "Jann3", "jann3", "j4nne", "janne"]; + +describe("", () => { + let cli: MatrixClient; + let pushRules: IPushRules; + + beforeAll(async () => { + pushRules = (await import("../../../../models/notificationsettings/pushrules_sample.json")) as IPushRules; + }); + + beforeEach(() => { + stubClient(); + cli = MatrixClientPeg.safeGet(); + cli.getPushRules = jest.fn(cli.getPushRules).mockResolvedValue(pushRules); + cli.supportsIntentionalMentions = jest.fn(cli.supportsIntentionalMentions).mockReturnValue(false); + cli.setPushRuleEnabled = jest.fn(cli.setPushRuleEnabled); + cli.setPushRuleActions = jest.fn(cli.setPushRuleActions); + cli.addPushRule = jest.fn(cli.addPushRule).mockResolvedValue({}); + cli.deletePushRule = jest.fn(cli.deletePushRule).mockResolvedValue({}); + cli.removePusher = jest.fn(cli.removePusher).mockResolvedValue({}); + cli.setPusher = jest.fn(cli.setPusher).mockResolvedValue({}); + + mockRandom.reset(); + }); + + it("matches the snapshot", async () => { + cli.getPushers = jest.fn(cli.getPushers).mockResolvedValue({ + pushers: [ + { + app_display_name: "Element", + app_id: "im.vector.app", + data: {}, + device_display_name: "My EyeFon", + kind: "http", + lang: "en", + pushkey: "", + enabled: true, + }, + ], + }); + cli.getThreePids = jest.fn(cli.getThreePids).mockResolvedValue({ + threepids: [ + { + medium: ThreepidMedium.Email, + address: "test@example.tld", + validated_at: 1656633600, + added_at: 1656633600, + }, + ], + }); + + const screen = render( + + + , + ); + await act(waitForUpdate); + expect(screen.container).toMatchSnapshot(); + }); + + it("correctly handles the loading/disabled state", async () => { + (cli.getPushRules as Mock).mockReturnValue(new Promise(() => {})); + + const user = userEvent.setup(); + const screen = render( + + + , + ); + await act(async () => { + await waitForUpdate(); + expect(screen.container).toMatchSnapshot(); + + const globalMute = screen.getByLabelText(labelGlobalMute); + expect(globalMute).toHaveAttribute("aria-disabled", "true"); + + const levelAllMessages = screen.getByLabelText(labelLevelAllMessage); + expect(levelAllMessages).toBeDisabled(); + + const soundPeople = screen.getByLabelText(labelSoundPeople); + expect(soundPeople).toBeDisabled(); + const soundMentions = screen.getByLabelText(labelSoundMentions); + expect(soundMentions).toBeDisabled(); + const soundCalls = screen.getByLabelText(labelSoundCalls); + expect(soundCalls).toBeDisabled(); + + const activityInvites = screen.getByLabelText(labelActivityInvites); + expect(activityInvites).toBeDisabled(); + const activityStatus = screen.getByLabelText(labelActivityStatus); + expect(activityStatus).toBeDisabled(); + const activityBots = screen.getByLabelText(labelActivityBots); + expect(activityBots).toBeDisabled(); + + const mentionUser = screen.getByLabelText(labelMentionUser.replace("@mxid", cli.getUserId()!)); + expect(mentionUser).toBeDisabled(); + const mentionRoom = screen.getByLabelText(labelMentionRoom); + expect(mentionRoom).toBeDisabled(); + const mentionKeyword = screen.getByLabelText(labelMentionKeyword); + expect(mentionKeyword).toBeDisabled(); + await Promise.all([ + user.click(globalMute), + user.click(levelAllMessages), + user.click(soundPeople), + user.click(soundMentions), + user.click(soundCalls), + user.click(activityInvites), + user.click(activityStatus), + user.click(activityBots), + user.click(mentionUser), + user.click(mentionRoom), + user.click(mentionKeyword), + ]); + }); + + expect(cli.setPushRuleActions).not.toHaveBeenCalled(); + expect(cli.setPushRuleEnabled).not.toHaveBeenCalled(); + expect(cli.addPushRule).not.toHaveBeenCalled(); + expect(cli.deletePushRule).not.toHaveBeenCalled(); + }); + + describe("form elements actually toggle the model value", () => { + it("global mute", async () => { + const label = labelGlobalMute; + + const user = userEvent.setup(); + const screen = render( + + + , + ); + await act(waitForUpdate); + expect(screen.getByLabelText(label)).not.toBeDisabled(); + await act(async () => { + await user.click(screen.getByLabelText(label)); + await waitForUpdate(); + }); + expect(cli.setPushRuleEnabled).toHaveBeenCalledWith("global", PushRuleKind.Override, RuleId.Master, true); + }); + + it("notification level", async () => { + const user = userEvent.setup(); + const screen = render( + + + , + ); + await act(waitForUpdate); + expect(screen.getByLabelText(labelLevelAllMessage)).not.toBeDisabled(); + await act(async () => { + await user.click(screen.getByLabelText(labelLevelAllMessage)); + await waitForUpdate(); + }); + expect(cli.setPushRuleEnabled).toHaveBeenCalledWith( + "global", + PushRuleKind.Underride, + RuleId.EncryptedMessage, + true, + ); + expect(cli.setPushRuleEnabled).toHaveBeenCalledWith("global", PushRuleKind.Underride, RuleId.Message, true); + (cli.setPushRuleEnabled as Mock).mockClear(); + expect(screen.getByLabelText(labelLevelMentionsOnly)).not.toBeDisabled(); + await act(async () => { + await user.click(screen.getByLabelText(labelLevelMentionsOnly)); + await waitForUpdate(); + }); + expect(cli.setPushRuleEnabled).toHaveBeenCalledWith( + "global", + PushRuleKind.Underride, + RuleId.EncryptedDM, + true, + ); + expect(cli.setPushRuleEnabled).toHaveBeenCalledWith("global", PushRuleKind.Underride, RuleId.DM, true); + }); + + describe("play a sound for", () => { + it("people", async () => { + const label = labelSoundPeople; + + const user = userEvent.setup(); + const screen = render( + + + , + ); + await act(waitForUpdate); + expect(screen.getByLabelText(label)).not.toBeDisabled(); + await act(async () => { + await user.click(screen.getByLabelText(label)); + await waitForUpdate(); + }); + expect(cli.setPushRuleActions).toHaveBeenCalledWith( + "global", + PushRuleKind.Underride, + RuleId.EncryptedDM, + StandardActions.ACTION_NOTIFY_DEFAULT_SOUND, + ); + expect(cli.setPushRuleActions).toHaveBeenCalledWith( + "global", + PushRuleKind.Underride, + RuleId.DM, + StandardActions.ACTION_NOTIFY_DEFAULT_SOUND, + ); + expect(cli.setPushRuleActions).toHaveBeenCalledWith( + "global", + PushRuleKind.Override, + RuleId.InviteToSelf, + StandardActions.ACTION_NOTIFY_DEFAULT_SOUND, + ); + }); + + it("mentions", async () => { + const label = labelSoundMentions; + + const user = userEvent.setup(); + const screen = render( + + + , + ); + await act(waitForUpdate); + expect(screen.getByLabelText(label)).not.toBeDisabled(); + await act(async () => { + await user.click(screen.getByLabelText(label)); + await waitForUpdate(); + }); + expect(cli.setPushRuleActions).toHaveBeenCalledWith( + "global", + PushRuleKind.Override, + RuleId.ContainsDisplayName, + StandardActions.ACTION_HIGHLIGHT, + ); + expect(cli.setPushRuleActions).toHaveBeenCalledWith( + "global", + PushRuleKind.ContentSpecific, + RuleId.ContainsUserName, + StandardActions.ACTION_HIGHLIGHT, + ); + }); + + it("calls", async () => { + const label = labelSoundCalls; + + const user = userEvent.setup(); + const screen = render( + + + , + ); + await act(waitForUpdate); + expect(screen.getByLabelText(label)).not.toBeDisabled(); + await act(async () => { + await user.click(screen.getByLabelText(label)); + await waitForUpdate(); + }); + expect(cli.setPushRuleActions).toHaveBeenCalledWith( + "global", + PushRuleKind.Underride, + RuleId.IncomingCall, + StandardActions.ACTION_NOTIFY, + ); + }); + }); + + describe("activity", () => { + it("invite", async () => { + const label = labelActivityInvites; + + const user = userEvent.setup(); + const screen = render( + + + , + ); + await act(waitForUpdate); + expect(screen.getByLabelText(label)).not.toBeDisabled(); + await act(async () => { + await user.click(screen.getByLabelText(label)); + await waitForUpdate(); + }); + expect(cli.setPushRuleActions).toHaveBeenCalledWith( + "global", + PushRuleKind.Override, + RuleId.InviteToSelf, + StandardActions.ACTION_NOTIFY, + ); + }); + it("status messages", async () => { + const label = labelActivityStatus; + + const user = userEvent.setup(); + const screen = render( + + + , + ); + await act(waitForUpdate); + expect(screen.getByLabelText(label)).not.toBeDisabled(); + await act(async () => { + await user.click(screen.getByLabelText(label)); + await waitForUpdate(); + }); + expect(cli.setPushRuleActions).toHaveBeenCalledWith( + "global", + PushRuleKind.Override, + RuleId.MemberEvent, + StandardActions.ACTION_NOTIFY, + ); + expect(cli.setPushRuleActions).toHaveBeenCalledWith( + "global", + PushRuleKind.Override, + RuleId.Tombstone, + StandardActions.ACTION_HIGHLIGHT, + ); + }); + it("notices", async () => { + const label = labelActivityBots; + + const user = userEvent.setup(); + const screen = render( + + + , + ); + await act(waitForUpdate); + expect(screen.getByLabelText(label)).not.toBeDisabled(); + await act(async () => { + await user.click(screen.getByLabelText(label)); + await waitForUpdate(); + }); + expect(cli.setPushRuleActions).toHaveBeenCalledWith( + "global", + PushRuleKind.Override, + RuleId.SuppressNotices, + StandardActions.ACTION_DONT_NOTIFY, + ); + }); + }); + describe("mentions", () => { + it("room mentions", async () => { + const label = labelMentionRoom; + + const user = userEvent.setup(); + const screen = render( + + + , + ); + await act(waitForUpdate); + expect(screen.getByLabelText(label)).not.toBeDisabled(); + await act(async () => { + await user.click(screen.getByLabelText(label)); + await waitForUpdate(); + }); + expect(cli.setPushRuleActions).toHaveBeenCalledWith( + "global", + PushRuleKind.Override, + RuleId.AtRoomNotification, + StandardActions.ACTION_DONT_NOTIFY, + ); + }); + it("user mentions", async () => { + const label = labelMentionUser.replace("@mxid", cli.getUserId()!); + + const user = userEvent.setup(); + const screen = render( + + + , + ); + await act(waitForUpdate); + expect(screen.getByLabelText(label)).not.toBeDisabled(); + await act(async () => { + await user.click(screen.getByLabelText(label)); + await waitForUpdate(); + }); + expect(cli.setPushRuleActions).toHaveBeenCalledWith( + "global", + PushRuleKind.Override, + RuleId.ContainsDisplayName, + StandardActions.ACTION_DONT_NOTIFY, + ); + expect(cli.setPushRuleActions).toHaveBeenCalledWith( + "global", + PushRuleKind.ContentSpecific, + RuleId.ContainsUserName, + StandardActions.ACTION_DONT_NOTIFY, + ); + }); + it("keywords", async () => { + const label = labelMentionKeyword; + + const user = userEvent.setup(); + const screen = render( + + + , + ); + await act(waitForUpdate); + expect(screen.getByLabelText(label)).not.toBeDisabled(); + await act(async () => { + await user.click(screen.getByLabelText(label)); + await waitForUpdate(); + }); + for (const pattern of keywords) { + expect(cli.setPushRuleEnabled).toHaveBeenCalledWith( + "global", + PushRuleKind.ContentSpecific, + pattern, + false, + ); + } + }); + }); + describe("keywords", () => { + it("allows adding keywords", async () => { + const user = userEvent.setup(); + const screen = render( + + + , + ); + await act(waitForUpdate); + const inputField = screen.getByRole("textbox", { name: "Keyword" }); + const addButton = screen.getByRole("button", { name: "Add" }); + expect(inputField).not.toBeDisabled(); + expect(addButton).not.toBeDisabled(); + await act(async () => { + await user.type(inputField, "testkeyword"); + await user.click(addButton); + await waitForUpdate(); + }); + expect(cli.addPushRule).toHaveBeenCalledWith("global", PushRuleKind.ContentSpecific, "testkeyword", { + kind: PushRuleKind.ContentSpecific, + rule_id: "testkeyword", + enabled: true, + default: false, + actions: StandardActions.ACTION_HIGHLIGHT_DEFAULT_SOUND, + pattern: "testkeyword", + }); + }); + + it("allows deleting keywords", async () => { + const user = userEvent.setup(); + const screen = render( + + + , + ); + await act(waitForUpdate); + const tag = screen.getByText("justj4nn3"); + const deleteButton = getByRole(tag, "button", { name: "Remove" }); + expect(deleteButton).not.toBeDisabled(); + await act(async () => { + await user.click(deleteButton); + await waitForUpdate(); + }); + expect(cli.deletePushRule).toHaveBeenCalledWith("global", PushRuleKind.ContentSpecific, "justj4nn3"); + }); + }); + + it("resets the model correctly", async () => { + const user = userEvent.setup(); + const screen = render( + + + , + ); + await act(waitForUpdate); + const button = screen.getByText(labelResetDefault); + expect(button).not.toBeDisabled(); + await act(async () => { + await user.click(button); + await waitForUpdate(); + }); + expect(cli.setPushRuleEnabled).toHaveBeenCalledWith( + "global", + PushRuleKind.Underride, + RuleId.EncryptedMessage, + true, + ); + expect(cli.setPushRuleEnabled).toHaveBeenCalledWith("global", PushRuleKind.Underride, RuleId.Message, true); + expect(cli.setPushRuleEnabled).toHaveBeenCalledWith( + "global", + PushRuleKind.Underride, + RuleId.EncryptedDM, + true, + ); + expect(cli.setPushRuleEnabled).toHaveBeenCalledWith("global", PushRuleKind.Underride, RuleId.DM, true); + expect(cli.setPushRuleEnabled).toHaveBeenCalledWith( + "global", + PushRuleKind.Override, + RuleId.SuppressNotices, + false, + ); + expect(cli.setPushRuleEnabled).toHaveBeenCalledWith( + "global", + PushRuleKind.Override, + RuleId.InviteToSelf, + true, + ); + + expect(cli.setPushRuleActions).toHaveBeenCalledWith( + "global", + PushRuleKind.Underride, + RuleId.EncryptedMessage, + StandardActions.ACTION_NOTIFY, + ); + expect(cli.setPushRuleActions).toHaveBeenCalledWith( + "global", + PushRuleKind.Underride, + RuleId.Message, + StandardActions.ACTION_NOTIFY, + ); + expect(cli.setPushRuleActions).toHaveBeenCalledWith( + "global", + PushRuleKind.Underride, + RuleId.EncryptedDM, + StandardActions.ACTION_NOTIFY_DEFAULT_SOUND, + ); + expect(cli.setPushRuleActions).toHaveBeenCalledWith( + "global", + PushRuleKind.Underride, + RuleId.DM, + StandardActions.ACTION_NOTIFY_DEFAULT_SOUND, + ); + expect(cli.setPushRuleActions).toHaveBeenCalledWith( + "global", + PushRuleKind.Override, + RuleId.SuppressNotices, + StandardActions.ACTION_DONT_NOTIFY, + ); + expect(cli.setPushRuleActions).toHaveBeenCalledWith( + "global", + PushRuleKind.Override, + RuleId.InviteToSelf, + StandardActions.ACTION_NOTIFY_DEFAULT_SOUND, + ); + + for (const pattern of keywords) { + expect(cli.deletePushRule).toHaveBeenCalledWith("global", PushRuleKind.ContentSpecific, pattern); + } + }); + }); + + describe("pusher settings", () => { + it("can create email pushers", async () => { + cli.getPushers = jest.fn(cli.getPushers).mockResolvedValue({ + pushers: [ + { + app_display_name: "Element", + app_id: "im.vector.app", + data: {}, + device_display_name: "My EyeFon", + kind: "http", + lang: "en", + pushkey: "", + enabled: true, + }, + ], + }); + cli.getThreePids = jest.fn(cli.getThreePids).mockResolvedValue({ + threepids: [ + { + medium: ThreepidMedium.Email, + address: "test@example.tld", + validated_at: 1656633600, + added_at: 1656633600, + }, + ], + }); + + const label = "test@example.tld"; + const user = userEvent.setup(); + const screen = render( + + + , + ); + await act(waitForUpdate); + expect(screen.getByLabelText(label)).not.toBeDisabled(); + await act(async () => { + await user.click(screen.getByLabelText(label)); + await waitForUpdate(); + }); + expect(cli.setPusher).toHaveBeenCalledWith({ + app_display_name: "Email Notifications", + app_id: "m.email", + append: true, + data: { brand: "Element" }, + device_display_name: "test@example.tld", + kind: "email", + lang: "en-US", + pushkey: "test@example.tld", + }); + }); + + it("can remove email pushers", async () => { + cli.getPushers = jest.fn(cli.getPushers).mockResolvedValue({ + pushers: [ + { + app_display_name: "Element", + app_id: "im.vector.app", + data: {}, + device_display_name: "My EyeFon", + kind: "http", + lang: "en", + pushkey: "abctest", + }, + { + app_display_name: "Email Notifications", + app_id: "m.email", + data: { brand: "Element" }, + device_display_name: "test@example.tld", + kind: "email", + lang: "en-US", + pushkey: "test@example.tld", + }, + ], + }); + cli.getThreePids = jest.fn(cli.getThreePids).mockResolvedValue({ + threepids: [ + { + medium: ThreepidMedium.Email, + address: "test@example.tld", + validated_at: 1656633600, + added_at: 1656633600, + }, + ], + }); + + const label = "test@example.tld"; + const user = userEvent.setup(); + const screen = render( + + + , + ); + await act(waitForUpdate); + expect(screen.getByLabelText(label)).not.toBeDisabled(); + await act(async () => { + await user.click(screen.getByLabelText(label)); + await waitForUpdate(); + }); + expect(cli.removePusher).toHaveBeenCalledWith("test@example.tld", "m.email"); + }); + }); + + describe("clear all notifications", () => { + it("is hidden when no notifications exist", async () => { + const room = new Room("room123", cli, "@alice:example.org"); + cli.getRooms = jest.fn(cli.getRooms).mockReturnValue([room]); + + const { container } = render( + + + , + ); + await waitForUpdate(); + expect( + queryByRole(container, "button", { + name: "Mark all messages as read", + }), + ).not.toBeInTheDocument(); + }); + + it("clears all notifications", async () => { + const room = new Room("room123", cli, "@alice:example.org"); + cli.getRooms = jest.fn(cli.getRooms).mockReturnValue([room]); + + const message = mkMessage({ + event: true, + room: "room123", + user: "@alice:example.org", + ts: 1, + }); + room.addLiveEvents([message]); + room.setUnreadNotificationCount(NotificationCountType.Total, 1); + + const user = userEvent.setup(); + const { container } = render( + + + , + ); + await waitForUpdate(); + const clearNotificationEl = await findByRole(container, "button", { + name: "Mark all messages as read", + }); + + await act(async () => { + await user.click(clearNotificationEl); + await waitForUpdate(); + }); + expect(cli.sendReadReceipt).toHaveBeenCalled(); + + await waitFor(() => { + expect(clearNotificationEl).not.toBeInTheDocument(); + }); + }); + }); +}); diff --git a/test/components/views/settings/notifications/__snapshots__/Notifications2-test.tsx.snap b/test/components/views/settings/notifications/__snapshots__/Notifications2-test.tsx.snap new file mode 100644 index 00000000000..9eef11fbaea --- /dev/null +++ b/test/components/views/settings/notifications/__snapshots__/Notifications2-test.tsx.snap @@ -0,0 +1,1604 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` correctly handles the loading/disabled state 1`] = ` +
+
+
+

+ Notifications +

+
+
+
+ +
+ Enable notifications for this account +
+
+
+
+
+
+
+ +
+ Enable desktop notifications for this session +
+
+
+
+
+
+
+ +
+ Show message preview in desktop notification +
+
+
+
+
+
+
+ +
+ Enable audible notifications for this session +
+
+
+
+
+
+
+
+
+

+ I want to be notified for (Default Setting) +

+
+
+
+ This setting will be applied by default to all your rooms. +
+
+
+