GYU: Account Notification Settings (#11008)

* Implement new notification settings UI

* Sort new keywords at the front

* Make ts-strict happier

* Make ts-strict happier

* chore: fixed lint issues

* update beta card

* Fix issue with the user settings test

* chore: fixed lint issues

* Add tests for notification settings

* chore: fixed lint issues

* fix: spurious text failures

* improve tests further

* make ts-strict happier

* improve tests further

* Reduce uncovered conditions

* Correct snapshot

* even more test coverage

* Fix an issue with inverted rules

* Update res/css/views/settings/tabs/_SettingsIndent.pcss

Co-authored-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Fix license headers

* Improve i18n

* make linters happier

* Improve beta labels

* improve i18n

* chore: fixed lint issues

* fix: more lint issues

* Update snapshots to match changed text

* Update text as requested

* Remove labs image

* Update snapshots

* Correct an issue with one of the tests

* fix: keyword reconcilation code

* Determine mute status more accurately

* Address review comments

* Prevent duplicate updates

* Fix missing license header

* slight change to avoid ts-strict complaining

* fix test issue caused by previous merge

---------

Co-authored-by: Šimon Brandner <simon.bra.ag@gmail.com>
This commit is contained in:
Janne Mareike Koschinski 2023-06-29 17:46:31 +02:00 committed by GitHub
parent 95283d21bb
commit f62fe2626c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 3797 additions and 33 deletions

View file

@ -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";

View file

@ -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;
}
}

View file

@ -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;
}
}
}
}
}

View file

@ -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;
}
}

View file

@ -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;
}

View file

@ -0,0 +1,4 @@
<svg viewBox="0 0 24 24" width="24" height="24" xmlns="http://www.w3.org/2000/svg">
<path fill="#0dbd8b" fill-opacity="0.1" d="m 24,12 a 12,12 0 0 1 -12,12 12,12 0 0 1 -12,-12 12,12 0 0 1 12,-12 12,12 0 0 1 12,12 z" />
<path fill="#0dbd8b" d="m 20,12 a 8,8 0 0 1 -8,8 8,8 0 0 1 -8,-8 8,8 0 0 1 8,-8 8,8 0 0 1 8,8 z" />
</svg>

After

Width:  |  Height:  |  Size: 334 B

View file

@ -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<HTMLAttributes<HTMLDivElement>, HTMLDivElement> {
icon?: () => JSX.Element;
label: string;
onDeleteClick?: () => void;
disabled?: boolean;
}
export const Tag: React.FC<IProps> = ({ icon, label, onDeleteClick, disabled = false }) => {
export const Tag: React.FC<IProps> = ({ icon, label, onDeleteClick, disabled = false, ...other }) => {
return (
<div className="mx_Tag">
<div className="mx_Tag" {...other}>
{icon?.()}
{label}
{onDeleteClick && (

View file

@ -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<IProps, IState> {
public render(): React.ReactNode {
return (
<div className="mx_TagComposer">
<div
className={classNames("mx_TagComposer", {
mx_TagComposer_disabled: this.props.disabled,
})}
>
<form className="mx_TagComposer_input" onSubmit={this.onAdd}>
<Field
id={this.props.id ? this.props.id + "_field" : undefined}
value={this.state.newTag}
onChange={this.onInputChange}
label={this.props.label || _t("Keyword")}
@ -81,13 +88,14 @@ export default class TagComposer extends React.PureComponent<IProps, IState> {
{_t("Add")}
</AccessibleButton>
</form>
<div className="mx_TagComposer_tags">
<div className="mx_TagComposer_tags" role="list">
{this.props.tags.map((t, i) => (
<Tag
label={t}
key={t}
onDeleteClick={this.onRemove.bind(this, t)}
disabled={this.props.disabled}
role="listitem"
/>
))}
</div>

View file

@ -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 (
<AccessibleButton
kind="link_inline"
onClick={() => {
dispatcher.dispatch({
action: Action.ViewUserSettings,
initialTabId: UserTab.General,
});
}}
>
{content}
</AccessibleButton>
);
}
export function NotificationPusherSettings(): JSX.Element {
const EmailPusherTemplate: Omit<IPusher, "pushkey" | "device_display_name" | "append"> = 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 (
<>
<SettingsSubsection className="mx_NotificationPusherSettings" heading={_t("Email summary")}>
<SettingsSubsectionText className="mx_NotificationPusherSettings_description">
{_t("Receive an email summary of missed notifications")}
</SettingsSubsectionText>
<div className="mx_SettingsSubsection_description mx_NotificationPusherSettings_detail">
<SettingsSubsectionText>
{_t(
"Select which emails you want to send summaries to. Manage your emails in <button>General</button>.",
{},
{ button: generalTabButton },
)}
</SettingsSubsectionText>
</div>
<SettingsIndent>
{threepids
.filter((t) => t.medium === ThreepidMedium.Email)
.map((email) => (
<LabelledCheckbox
key={email.address}
label={email.address}
value={pushers.find((it) => it.pushkey === email.address) !== undefined}
onChange={(value) => setEmailEnabled(email.address, value)}
/>
))}
</SettingsIndent>
</SettingsSubsection>
{notificationTargets.length > 0 && (
<SettingsSubsection heading={_t("Notification targets")}>
<ul>
{pushers
.filter((it) => it.kind !== "email")
.map((pusher) => (
<li key={pusher.pushkey}>{pusher.device_display_name || pusher.app_display_name}</li>
))}
</ul>
</SettingsSubsection>
)}
</>
);
}

View file

@ -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 <strong>{text}</strong>;
}
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<boolean>("notificationsEnabled");
const desktopShowBody = useSettingValue<boolean>("notificationBodyEnabled");
const audioNotifications = useSettingValue<boolean>("audioNotificationsEnabled");
const { model, hasPendingChanges, reconcile } = useNotificationSettings(cli);
const disabled = model === null || hasPendingChanges;
const settings = model ?? DefaultNotificationSettings;
const [updatingUnread, setUpdatingUnread] = useState<boolean>(false);
const hasUnreadNotifications = useHasUnreadNotifications();
return (
<div className="mx_NotificationSettings2">
{hasPendingChanges && model !== null && (
<SettingsBanner
icon={<img src={NewAndImprovedIcon} alt="" width={12} />}
action={_t("Switch now")}
onAction={() => reconcile(model!)}
>
{_t(
"<strong>Update:</strong> We have updated our notification settings. This wont affect your previously selected settings.",
{},
{ strong: boldText },
)}
</SettingsBanner>
)}
<SettingsSection heading={_t("Notifications")}>
<div className="mx_SettingsSubsection_content mx_NotificationSettings2_flags">
<LabelledToggleSwitch
label={_t("Enable notifications for this account")}
value={!settings.globalMute}
disabled={disabled}
onChange={(value) => {
reconcile({
...model!,
globalMute: !value,
});
}}
/>
<LabelledToggleSwitch
label={_t("Enable desktop notifications for this session")}
value={desktopNotifications}
onChange={(value) =>
SettingsStore.setValue("notificationsEnabled", null, SettingLevel.DEVICE, value)
}
/>
<LabelledToggleSwitch
label={_t("Show message preview in desktop notification")}
value={desktopShowBody}
onChange={(value) =>
SettingsStore.setValue("notificationBodyEnabled", null, SettingLevel.DEVICE, value)
}
/>
<LabelledToggleSwitch
label={_t("Enable audible notifications for this session")}
value={audioNotifications}
onChange={(value) =>
SettingsStore.setValue("audioNotificationsEnabled", null, SettingLevel.DEVICE, value)
}
/>
</div>
<SettingsSubsection
heading={_t("I want to be notified for (Default Setting)")}
description={_t("This setting will be applied by default to all your rooms.")}
>
<StyledRadioGroup
name="defaultNotificationLevel"
value={toDefaultLevels(settings.defaultLevels)}
disabled={disabled}
definitions={NotificationOptions}
onChange={(value) => {
reconcile({
...model!,
defaultLevels: {
...model!.defaultLevels,
dm:
value !== NotificationDefaultLevels.MentionsKeywords
? RoomNotifState.AllMessages
: RoomNotifState.MentionsOnly,
room:
value === NotificationDefaultLevels.AllMessages
? RoomNotifState.AllMessages
: RoomNotifState.MentionsOnly,
},
});
}}
/>
</SettingsSubsection>
<SettingsSubsection
heading={_t("Play a sound for")}
description={_t("Applied by default to all rooms on all devices.")}
>
<LabelledCheckbox
label="People"
value={settings.sound.people !== undefined}
disabled={disabled}
onChange={(value) => {
reconcile({
...model!,
sound: {
...model!.sound,
people: value ? "default" : undefined,
},
});
}}
/>
<LabelledCheckbox
label={_t("Mentions and Keywords")}
value={settings.sound.mentions !== undefined}
disabled={disabled}
onChange={(value) => {
reconcile({
...model!,
sound: {
...model!.sound,
mentions: value ? "default" : undefined,
},
});
}}
/>
<LabelledCheckbox
label={_t("Audio and Video calls")}
value={settings.sound.calls !== undefined}
disabled={disabled}
onChange={(value) => {
reconcile({
...model!,
sound: {
...model!.sound,
calls: value ? "ring" : undefined,
},
});
}}
/>
</SettingsSubsection>
<SettingsSubsection heading={_t("Other things we think you might be interested in:")}>
<LabelledCheckbox
label={_t("Invited to a room")}
value={settings.activity.invite}
disabled={disabled}
onChange={(value) => {
reconcile({
...model!,
activity: {
...model!.activity,
invite: value,
},
});
}}
/>
<LabelledCheckbox
label={_t("New room activity, upgrades and status messages occur")}
value={settings.activity.status_event}
disabled={disabled}
onChange={(value) => {
reconcile({
...model!,
activity: {
...model!.activity,
status_event: value,
},
});
}}
/>
<LabelledCheckbox
label={_t("Messages sent by bots")}
value={settings.activity.bot_notices}
disabled={disabled}
onChange={(value) => {
reconcile({
...model!,
activity: {
...model!.activity,
bot_notices: value,
},
});
}}
/>
</SettingsSubsection>
<SettingsSubsection
heading={_t("Mentions and Keywords")}
description={_t(
"Show a badge <badge/> when keywords are used in a room.",
{},
{
badge: <StatelessNotificationBadge symbol="1" count={1} color={NotificationColor.Grey} />,
},
)}
>
<LabelledCheckbox
label={_t("Notify when someone mentions using @room")}
value={settings.mentions.room}
disabled={disabled}
onChange={(value) => {
reconcile({
...model!,
mentions: {
...model!.mentions,
room: value,
},
});
}}
/>
<LabelledCheckbox
label={_t("Notify when someone mentions using @displayname or %(mxid)s", {
mxid: cli.getUserId()!,
})}
value={settings.mentions.user}
disabled={disabled}
onChange={(value) => {
reconcile({
...model!,
mentions: {
...model!.mentions,
user: value,
},
});
}}
/>
<LabelledCheckbox
label={_t("Notify when someone uses a keyword")}
byline={_t("Enter keywords here, or use for spelling variations or nicknames")}
value={settings.mentions.keywords}
disabled={disabled}
onChange={(value) => {
reconcile({
...model!,
mentions: {
...model!.mentions,
keywords: value,
},
});
}}
/>
<TagComposer
id="mx_NotificationSettings2_Keywords"
tags={model?.keywords ?? []}
disabled={disabled}
onAdd={(keyword) => {
reconcile({
...model!,
keywords: [keyword, ...model!.keywords],
});
}}
onRemove={(keyword) => {
reconcile({
...model!,
keywords: model!.keywords.filter((it) => it !== keyword),
});
}}
label={_t("Keyword")}
placeholder={_t("New keyword")}
/>
</SettingsSubsection>
<NotificationPusherSettings />
<SettingsSubsection heading={_t("Quick Actions")}>
{hasUnreadNotifications && (
<AccessibleButton
kind="primary_outline"
disabled={updatingUnread}
onClick={async () => {
setUpdatingUnread(true);
await clearAllNotifications(cli);
setUpdatingUnread(false);
}}
>
{_t("Mark all messages as read")}
</AccessibleButton>
)}
<AccessibleButton
kind="danger_outline"
disabled={model === null}
onClick={() => {
reconcile(DefaultNotificationSettings);
}}
>
{_t("Reset to default settings")}
</AccessibleButton>
</SettingsSubsection>
</SettingsSection>
</div>
);
}

View file

@ -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<Props>): JSX.Element {
return (
<div className="mx_SettingsBanner">
{icon}
<div className="mx_SettingsBanner_content">{children}</div>
{action && (
<AccessibleButton kind="primary_outline" onClick={onAction ?? null}>
{action}
</AccessibleButton>
)}
</div>
);
}

View file

@ -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<HTMLDivElement> {
children?: React.ReactNode;
}
export const SettingsIndent: React.FC<SettingsIndentProps> = ({ children, ...rest }) => (
<div {...rest} className="mx_SettingsIndent">
{children}
</div>
);

View file

@ -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<HTMLDivElement> {
* </SettingsTab>
* ```
*/
export const SettingsSection: React.FC<SettingsSectionProps> = ({ heading, children, ...rest }) => (
<div {...rest} className="mx_SettingsSection">
export const SettingsSection: React.FC<SettingsSectionProps> = ({ className, heading, children, ...rest }) => (
<div {...rest} className={classnames("mx_SettingsSection", className)}>
{typeof heading === "string" ? <Heading size="2">{heading}</Heading> : <>{heading}</>}
<div className="mx_SettingsSection_subSections">{children}</div>
</div>

View file

@ -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 (
<SettingsTab>
<SettingsSection heading={_t("Notifications")}>
<Notifications />
</SettingsSection>
{newNotificationSettingsEnabled ? (
<NotificationSettings2 />
) : (
<SettingsSection heading={_t("Notifications")}>
<Notifications />
</SettingsSection>
)}
</SettingsTab>
);
}

View file

@ -44,6 +44,7 @@ type UseNotificationSettings = {
};
export function useNotificationSettings(cli: MatrixClient): UseNotificationSettings {
const run = useLinearisedPromise<void>();
const supportsIntentionalMentions = useMemo(() => cli.supportsIntentionalMentions(), [cli]);
const pushRules = useRef<IPushRules | null>(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<T>(): (fun: () => Promise<T>) => Promise<T> {
const lastPromise = useRef<Promise<T> | null>(null);
return useCallback((fun: () => Promise<T>): Promise<T> => {
let next: Promise<T>;
if (lastPromise.current === null) {
next = fun();
} else {
next = lastPromise.current.then(fun);
}
lastPromise.current = next;
return next;
}, []);
}

View file

@ -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 <button>General</button>.": "Select which emails you want to send summaries to. Manage your emails in <button>General</button>.",
"People, Mentions and Keywords": "People, Mentions and Keywords",
"Mentions and Keywords only": "Mentions and Keywords only",
"Switch now": "Switch now",
"<strong>Update:</strong> We have updated our notification settings. This wont affect your previously selected settings.": "<strong>Update:</strong> We have updated our notification settings. This wont 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 <badge/> when keywords are used in a room.": "Show a badge <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",

View file

@ -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,
});
}

View file

@ -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)]),
},

View file

@ -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: () => (
<>
<p>
{_t(
"Introducing a simpler way to change your notification settings. Customize your %(brand)s, just the way you like.",
{
brand: SdkConfig.get().brand,
},
)}
</p>
</>
),
},
},
"feature_exploring_public_spaces": {
isFeature: true,
labsGroup: LabGroup.Spaces,

View file

@ -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<void> => 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("<Notifications />", () => {
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(
<MatrixClientContext.Provider value={cli}>
<NotificationSettings2 />
</MatrixClientContext.Provider>,
);
await act(waitForUpdate);
expect(screen.container).toMatchSnapshot();
});
it("correctly handles the loading/disabled state", async () => {
(cli.getPushRules as Mock).mockReturnValue(new Promise<IPushRules>(() => {}));
const user = userEvent.setup();
const screen = render(
<MatrixClientContext.Provider value={cli}>
<NotificationSettings2 />
</MatrixClientContext.Provider>,
);
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(
<MatrixClientContext.Provider value={cli}>
<NotificationSettings2 />
</MatrixClientContext.Provider>,
);
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(
<MatrixClientContext.Provider value={cli}>
<NotificationSettings2 />
</MatrixClientContext.Provider>,
);
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(
<MatrixClientContext.Provider value={cli}>
<NotificationSettings2 />
</MatrixClientContext.Provider>,
);
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(
<MatrixClientContext.Provider value={cli}>
<NotificationSettings2 />
</MatrixClientContext.Provider>,
);
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(
<MatrixClientContext.Provider value={cli}>
<NotificationSettings2 />
</MatrixClientContext.Provider>,
);
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(
<MatrixClientContext.Provider value={cli}>
<NotificationSettings2 />
</MatrixClientContext.Provider>,
);
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(
<MatrixClientContext.Provider value={cli}>
<NotificationSettings2 />
</MatrixClientContext.Provider>,
);
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(
<MatrixClientContext.Provider value={cli}>
<NotificationSettings2 />
</MatrixClientContext.Provider>,
);
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(
<MatrixClientContext.Provider value={cli}>
<NotificationSettings2 />
</MatrixClientContext.Provider>,
);
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(
<MatrixClientContext.Provider value={cli}>
<NotificationSettings2 />
</MatrixClientContext.Provider>,
);
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(
<MatrixClientContext.Provider value={cli}>
<NotificationSettings2 />
</MatrixClientContext.Provider>,
);
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(
<MatrixClientContext.Provider value={cli}>
<NotificationSettings2 />
</MatrixClientContext.Provider>,
);
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(
<MatrixClientContext.Provider value={cli}>
<NotificationSettings2 />
</MatrixClientContext.Provider>,
);
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(
<MatrixClientContext.Provider value={cli}>
<NotificationSettings2 />
</MatrixClientContext.Provider>,
);
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(
<MatrixClientContext.Provider value={cli}>
<NotificationSettings2 />
</MatrixClientContext.Provider>,
);
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(
<MatrixClientContext.Provider value={cli}>
<NotificationSettings2 />
</MatrixClientContext.Provider>,
);
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(
<MatrixClientContext.Provider value={cli}>
<NotificationSettings2 />
</MatrixClientContext.Provider>,
);
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(
<MatrixClientContext.Provider value={cli}>
<NotificationSettings2 />
</MatrixClientContext.Provider>,
);
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();
});
});
});
});

View file

@ -79,6 +79,56 @@ exports[`<LabsUserSettingsTab /> renders settings marked as beta as beta cards 1
</div>
</div>
</div>
<div
class="mx_BetaCard"
>
<div
class="mx_BetaCard_columns"
>
<div
class="mx_BetaCard_columns_description"
>
<h3
class="mx_BetaCard_title"
>
<span>
Notification Settings
</span>
<span
class="mx_BetaCard_betaPill"
>
Beta
</span>
</h3>
<div
class="mx_BetaCard_caption"
>
<p>
Introducing a simpler way to change your notification settings. Customize your , just the way you like.
</p>
</div>
<div
class="mx_BetaCard_buttons"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
role="button"
tabindex="0"
>
Join the beta
</div>
</div>
</div>
<div
class="mx_BetaCard_columns_image_wrapper"
>
<img
alt=""
class="mx_BetaCard_columns_image"
/>
</div>
</div>
</div>
</div>
</div>
`;

View file

@ -157,4 +157,37 @@ describe("NotificationSettings", () => {
expect(roundtripPendingChanges.deleted).toHaveLength(0);
expect(roundtripPendingChanges.updated).toHaveLength(0);
});
it("handles the bot notice inversion correctly", async () => {
const pushRules = (await import("./pushrules_bug_botnotices.json")) as IPushRules;
const model = toNotificationSettings(pushRules, false);
const pendingChanges = reconcileNotificationSettings(pushRules, model, false);
const expectedModel: NotificationSettings = {
globalMute: false,
defaultLevels: {
dm: RoomNotifState.AllMessages,
room: RoomNotifState.MentionsOnly,
},
sound: {
calls: "ring",
mentions: "default",
people: undefined,
},
activity: {
bot_notices: true,
invite: true,
status_event: false,
},
mentions: {
user: true,
room: true,
keywords: true,
},
keywords: ["janne"],
};
expect(model).toEqual(expectedModel);
expect(pendingChanges.added).toHaveLength(0);
expect(pendingChanges.deleted).toHaveLength(0);
expect(pendingChanges.updated).toHaveLength(0);
});
});

View file

@ -0,0 +1,407 @@
{
"global": {
"underride": [
{
"conditions": [
{
"kind": "event_match",
"key": "type",
"pattern": "m.call.invite"
}
],
"actions": [
"notify",
{
"set_tweak": "sound",
"value": "ring"
},
{
"set_tweak": "highlight",
"value": false
}
],
"rule_id": ".m.rule.call",
"default": true,
"enabled": true
},
{
"conditions": [
{
"kind": "event_match",
"key": "type",
"pattern": "m.room.message"
},
{
"kind": "room_member_count",
"is": "2"
}
],
"actions": [
"notify",
{
"set_tweak": "highlight",
"value": false
}
],
"rule_id": ".m.rule.room_one_to_one",
"default": true,
"enabled": true
},
{
"conditions": [
{
"kind": "event_match",
"key": "type",
"pattern": "m.room.encrypted"
},
{
"kind": "room_member_count",
"is": "2"
}
],
"actions": [
"notify",
{
"set_tweak": "highlight",
"value": false
}
],
"rule_id": ".m.rule.encrypted_room_one_to_one",
"default": true,
"enabled": true
},
{
"conditions": [
{
"kind": "event_match",
"key": "type",
"pattern": "m.room.message"
}
],
"actions": ["dont_notify"],
"rule_id": ".m.rule.message",
"default": true,
"enabled": true
},
{
"conditions": [
{
"kind": "event_match",
"key": "type",
"pattern": "m.room.encrypted"
}
],
"actions": ["dont_notify"],
"rule_id": ".m.rule.encrypted",
"default": true,
"enabled": true
},
{
"conditions": [
{
"kind": "event_match",
"key": "type",
"pattern": "im.vector.modular.widgets"
},
{
"kind": "event_match",
"key": "content.type",
"pattern": "jitsi"
},
{
"kind": "event_match",
"key": "state_key",
"pattern": "*"
}
],
"actions": [
"notify",
{
"set_tweak": "highlight",
"value": false
}
],
"rule_id": ".im.vector.jitsi",
"default": true,
"enabled": true
},
{
"rule_id": ".org.matrix.msc3914.rule.room.call",
"default": true,
"enabled": true,
"conditions": [
{
"kind": "event_match",
"key": "type",
"pattern": "org.matrix.msc3401.call"
},
{
"kind": "call_started"
}
],
"actions": [
"notify",
{
"set_tweak": "sound",
"value": "default"
}
]
}
],
"sender": [],
"room": [],
"content": [
{
"actions": [
"notify",
{
"set_tweak": "sound",
"value": "default"
},
{
"set_tweak": "highlight"
}
],
"pattern": "janne",
"rule_id": "janne",
"default": false,
"enabled": true
},
{
"actions": [
"notify",
{
"set_tweak": "sound",
"value": "default"
},
{
"set_tweak": "highlight"
}
],
"rule_id": ".m.rule.contains_user_name",
"default": true,
"pattern": "jannemk",
"enabled": true
}
],
"override": [
{
"conditions": [],
"actions": ["dont_notify"],
"rule_id": ".m.rule.master",
"default": true,
"enabled": false
},
{
"conditions": [
{
"kind": "event_match",
"key": "content.msgtype",
"pattern": "m.notice"
}
],
"actions": ["dont_notify"],
"rule_id": ".m.rule.suppress_notices",
"default": true,
"enabled": false
},
{
"conditions": [
{
"kind": "event_match",
"key": "type",
"pattern": "m.room.member"
},
{
"kind": "event_match",
"key": "content.membership",
"pattern": "invite"
},
{
"kind": "event_match",
"key": "state_key",
"pattern": "@jannemk:element.io"
}
],
"actions": [
"notify",
{
"set_tweak": "highlight",
"value": false
}
],
"rule_id": ".m.rule.invite_for_me",
"default": true,
"enabled": true
},
{
"conditions": [
{
"kind": "event_match",
"key": "type",
"pattern": "m.room.member"
}
],
"actions": ["dont_notify"],
"rule_id": ".m.rule.member_event",
"default": true,
"enabled": true
},
{
"conditions": [
{
"kind": "contains_display_name"
}
],
"actions": [
"notify",
{
"set_tweak": "sound",
"value": "default"
},
{
"set_tweak": "highlight"
}
],
"rule_id": ".m.rule.contains_display_name",
"default": true,
"enabled": true
},
{
"conditions": [
{
"kind": "sender_notification_permission",
"key": "room"
},
{
"kind": "event_match",
"key": "content.body",
"pattern": "@room"
}
],
"actions": [
"notify",
{
"set_tweak": "highlight",
"value": false
}
],
"rule_id": ".m.rule.roomnotif",
"default": true,
"enabled": true
},
{
"conditions": [
{
"kind": "event_match",
"key": "type",
"pattern": "m.room.tombstone"
},
{
"kind": "event_match",
"key": "state_key",
"pattern": ""
}
],
"actions": [
"notify",
{
"set_tweak": "highlight"
}
],
"rule_id": ".m.rule.tombstone",
"default": true,
"enabled": false
},
{
"conditions": [
{
"kind": "event_match",
"key": "type",
"pattern": "m.reaction"
}
],
"actions": ["dont_notify"],
"rule_id": ".m.rule.reaction",
"default": true,
"enabled": true
},
{
"conditions": [
{
"kind": "event_match",
"key": "type",
"pattern": "m.room.server_acl"
},
{
"kind": "event_match",
"key": "state_key",
"pattern": ""
}
],
"actions": [],
"rule_id": ".m.rule.room.server_acl",
"default": true,
"enabled": true
},
{
"rule_id": ".org.matrix.msc3952.is_user_mention",
"default": true,
"enabled": true,
"conditions": [
{
"kind": "event_property_contains",
"key": "content.org\\.matrix\\.msc3952\\.mentions.user_ids",
"value": "@jannemk:element.io"
}
],
"actions": [
"notify",
{
"set_tweak": "highlight"
}
]
},
{
"rule_id": ".org.matrix.msc3952.is_room_mention",
"default": true,
"enabled": true,
"conditions": [
{
"kind": "event_property_is",
"key": "content.org\\.matrix\\.msc3952\\.mentions.room",
"value": true
},
{
"kind": "sender_notification_permission",
"key": "room"
}
],
"actions": [
"notify",
{
"set_tweak": "highlight"
}
]
},
{
"rule_id": ".org.matrix.msc3786.rule.room.server_acl",
"default": true,
"enabled": true,
"conditions": [
{
"kind": "event_match",
"key": "type",
"pattern": "m.room.server_acl"
},
{
"kind": "event_match",
"key": "state_key",
"pattern": ""
}
],
"actions": []
}
]
}
}

34
test/predictableRandom.ts Normal file
View file

@ -0,0 +1,34 @@
/*
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.
*/
// Fake random strings to give a predictable snapshot for IDs
// Simple Xorshift random number generator with predictable ID
export class PredictableRandom {
private state: number;
constructor() {
this.state = 314159265;
}
get(): number {
this.state ^= this.state << 13;
this.state ^= this.state >> 17;
this.state ^= this.state << 5;
return this.state / 1073741823;
}
reset(): void {
this.state = 314159265;
}
}