From 4db41a7efd76405fa0faed3ed3863774dd969076 Mon Sep 17 00:00:00 2001 From: Janne Mareike Koschinski Date: Wed, 14 Jun 2023 02:45:45 +0200 Subject: [PATCH] Implement new notification settings UI --- res/css/_components.pcss | 3 + .../settings/_NotificationSettings2.pcss | 80 ++++ .../views/settings/tabs/_SettingsBanner.pcss | 19 + .../views/settings/tabs/_SettingsIndent.pcss | 21 ++ res/img/element-icons/new-and-improved.svg | 4 + src/components/views/elements/TagComposer.tsx | 7 +- .../NotificationPusherSettings.tsx | 131 +++++++ .../notifications/NotificationSettings2.tsx | 348 ++++++++++++++++++ .../views/settings/shared/SettingsBanner.tsx | 39 ++ .../views/settings/shared/SettingsIndent.tsx | 27 ++ .../views/settings/shared/SettingsSection.tsx | 5 +- .../tabs/user/NotificationUserSettingsTab.tsx | 15 +- src/i18n/strings/en_EN.json | 29 ++ src/settings/Settings.tsx | 20 + 14 files changed, 742 insertions(+), 6 deletions(-) create mode 100644 res/css/views/settings/_NotificationSettings2.pcss create mode 100644 res/css/views/settings/tabs/_SettingsBanner.pcss create mode 100644 res/css/views/settings/tabs/_SettingsIndent.pcss create mode 100644 res/img/element-icons/new-and-improved.svg create mode 100644 src/components/views/settings/notifications/NotificationPusherSettings.tsx create mode 100644 src/components/views/settings/notifications/NotificationSettings2.tsx create mode 100644 src/components/views/settings/shared/SettingsBanner.tsx create mode 100644 src/components/views/settings/shared/SettingsIndent.tsx diff --git a/res/css/_components.pcss b/res/css/_components.pcss index 56628095f2f0..43b6dc26d2f5 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -322,6 +322,7 @@ @import "./views/settings/_JoinRuleSettings.pcss"; @import "./views/settings/_KeyboardShortcut.pcss"; @import "./views/settings/_LayoutSwitcher.pcss"; +@import "./views/settings/_NotificationSettings2.pcss"; @import "./views/settings/_Notifications.pcss"; @import "./views/settings/_PhoneNumbers.pcss"; @import "./views/settings/_ProfileSettings.pcss"; @@ -332,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/_NotificationSettings2.pcss b/res/css/views/settings/_NotificationSettings2.pcss new file mode 100644 index 000000000000..02edf23d380f --- /dev/null +++ b/res/css/views/settings/_NotificationSettings2.pcss @@ -0,0 +1,80 @@ +.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; + } + } + } + } +} + +.mx_NotificationPusherSettings { + .mx_NotificationPusherSettings_description { + color: $primary-content; + } + + .mx_NotificationPusherSettings_detail { + margin-top: -4px; + margin-bottom: 12px; + } +} diff --git a/res/css/views/settings/tabs/_SettingsBanner.pcss b/res/css/views/settings/tabs/_SettingsBanner.pcss new file mode 100644 index 000000000000..084bea6e3eb6 --- /dev/null +++ b/res/css/views/settings/tabs/_SettingsBanner.pcss @@ -0,0 +1,19 @@ +.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 000000000000..e95f0900b5e4 --- /dev/null +++ b/res/css/views/settings/tabs/_SettingsIndent.pcss @@ -0,0 +1,21 @@ +/* +Copyright 2023 New Vector Ltd + +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 000000000000..113dfe8d6bd0 --- /dev/null +++ b/res/img/element-icons/new-and-improved.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/components/views/elements/TagComposer.tsx b/src/components/views/elements/TagComposer.tsx index 0fdd5e98cdb0..c0fe8b7264ea 100644 --- a/src/components/views/elements/TagComposer.tsx +++ b/src/components/views/elements/TagComposer.tsx @@ -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"; @@ -67,7 +68,11 @@ export default class TagComposer extends React.PureComponent { public render(): React.ReactNode { return ( -
+
= { + kind: "email", + app_id: "m.email", + app_display_name: "Email Notifications", + lang: navigator.language, + data: { + brand: SdkConfig.get().brand, + }, +}; + +function generalTabButton(content: string): JSX.Element { + return ( + { + dispatcher.dispatch({ + action: Action.ViewUserSettings, + initialTabId: UserTab.General, + }); + }} + > + {content} + + ); +} + +export function NotificationPusherSettings(): JSX.Element { + 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(); + }, + [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 000000000000..f1be8d2c00f3 --- /dev/null +++ b/src/components/views/settings/notifications/NotificationSettings2.tsx @@ -0,0 +1,348 @@ +/* +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 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 { + ALL_MESSAGES = "ALL_MESSAGES", + PEOPLE_MENTIONS_KEYWORDS = "PEOPLE_MENTIONS_KEYWORDS", + MENTIONS_KEYWORDS = "MENTIONS_KEYWORDS", +} + +function toDefaultLevels(levels: NotificationSettings["defaultLevels"]): NotificationDefaultLevels { + if (levels.room === RoomNotifState.AllMessages) { + return NotificationDefaultLevels.ALL_MESSAGES; + } else if (levels.dm === RoomNotifState.AllMessages) { + return NotificationDefaultLevels.PEOPLE_MENTIONS_KEYWORDS; + } else { + return NotificationDefaultLevels.MENTIONS_KEYWORDS; + } +} + +const NotificationOptions = [ + { + value: NotificationDefaultLevels.ALL_MESSAGES, + label: _t("All messages"), + }, + { + value: NotificationDefaultLevels.PEOPLE_MENTIONS_KEYWORDS, + label: _t("People, Mentions and Keywords"), + }, + { + value: NotificationDefaultLevels.MENTIONS_KEYWORDS, + label: _t("Mentions and Keywords only"), + }, +]; + +function boldText(text: string): JSX.Element { + return {text}; +} + +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; + + return ( +
+ {hasPendingChanges && ( + } + 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.MENTIONS_KEYWORDS + ? RoomNotifState.AllMessages + : RoomNotifState.MentionsOnly, + room: + value === NotificationDefaultLevels.ALL_MESSAGES + ? 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: [...model.keywords, keyword], + }); + }} + onRemove={(keyword) => { + reconcile({ + ...model, + keywords: model.keywords.filter((it) => it !== keyword), + }); + }} + label={_t("Keyword")} + placeholder={_t("New keyword")} + /> + + + + { + await clearAllNotifications(cli); + }} + > + {_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 000000000000..fbc4a3e25017 --- /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 000000000000..049f2303f88c --- /dev/null +++ b/src/components/views/settings/shared/SettingsIndent.tsx @@ -0,0 +1,27 @@ +/* +Copyright 2022 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 1fc00905653c..33e54aa155fa 100644 --- a/src/components/views/settings/shared/SettingsSection.tsx +++ b/src/components/views/settings/shared/SettingsSection.tsx @@ -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 4e95220df1f0..02421d0a340e 100644 --- a/src/components/views/settings/tabs/user/NotificationUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/NotificationUserSettingsTab.tsx @@ -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/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 84ca822e85d6..50b7f0aafd26 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", + "A simpler way to customize notification settings in %(brand)s just the way you like.": "A simpler way to customize notification settings in %(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.", @@ -1768,6 +1771,32 @@ "%(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 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 are sent by a bot": "Messages are sent by a bot", + "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": "Notify when someone mentions using @displayname or @mxid", + "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/settings/Settings.tsx b/src/settings/Settings.tsx index 6f24491c57c1..9855b32b1718 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -93,6 +93,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", } @@ -220,6 +221,25 @@ 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("A simpler way to customize notification settings in %(brand)s just the way you like.", { + brand: SdkConfig.get().brand, + })} +

+ + ), + }, + }, "feature_exploring_public_spaces": { isFeature: true, labsGroup: LabGroup.Spaces,