diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 6dc2d57f3c..04d0644610 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -7,16 +7,17 @@ import React, { StrictMode } from 'react'; import '../stylesheets/manifest.scss'; import '../stylesheets/tailwind-config.css'; - import * as styles from './styles.scss'; import messages from '../_locales/en/messages.json'; -import { StorybookThemeContext } from './StorybookThemeContext.js'; -import { ThemeType } from '../ts/types/Util.js'; -import { setupI18n } from '../ts/util/setupI18n.js'; -import { HourCyclePreference } from '../ts/types/I18N.js'; + import { Provider } from 'react-redux'; import { Store, combineReducers, createStore } from 'redux'; import { Globals } from '@react-spring/web'; + +import { StorybookThemeContext } from './StorybookThemeContext.js'; +import { SystemThemeType, ThemeType } from '../ts/types/Util.js'; +import { setupI18n } from '../ts/util/setupI18n.js'; +import { HourCyclePreference } from '../ts/types/I18N.js'; import { AxoProvider } from '../ts/axo/AxoProvider.js'; import { StateType } from '../ts/state/reducer.js'; import { @@ -30,6 +31,10 @@ import { FunProvider } from '../ts/components/fun/FunProvider.js'; import { EmojiSkinTone } from '../ts/components/fun/data/emojis.js'; import { MOCK_GIFS_PAGINATED_ONE_PAGE } from '../ts/components/fun/mocks.js'; +import type { FunEmojiSelection } from '../ts/components/fun/panels/FunPanelEmojis.js'; +import type { FunGifSelection } from '../ts/components/fun/panels/FunPanelGifs.js'; +import type { FunStickerSelection } from '../ts/components/fun/panels/FunPanelStickers.js'; + setEnvironment(Environment.Development, true); const i18n = setupI18n('en', messages); @@ -105,10 +110,10 @@ window.SignalContext = { }, nativeThemeListener: { - getSystemTheme: () => 'light', + getSystemTheme: () => SystemThemeType.light, subscribe: noop, unsubscribe: noop, - update: () => 'light', + update: () => SystemThemeType.light, }, Settings: { themeSetting: { @@ -249,6 +254,15 @@ function withFunProvider(Story, context) { fetchGifsSearch={() => Promise.resolve(MOCK_GIFS_PAGINATED_ONE_PAGE)} fetchGifsFeatured={() => Promise.resolve(MOCK_GIFS_PAGINATED_ONE_PAGE)} fetchGif={() => Promise.resolve(new Blob([new Uint8Array(1)]))} + onSelectEmoji={function (emojiSelection: FunEmojiSelection): void { + console.log('onSelectEmoji', emojiSelection); + }} + onSelectSticker={function (stickerSelection: FunStickerSelection): void { + console.log('onSelectSticker', stickerSelection); + }} + onSelectGif={function (gifSelection: FunGifSelection): void { + console.log('onSelectGif', gifSelection); + }} > diff --git a/.storybook/styles.scss b/.storybook/styles.scss index aa277952f5..54ce6e7e84 100644 --- a/.storybook/styles.scss +++ b/.storybook/styles.scss @@ -3,6 +3,13 @@ @use '../stylesheets/variables'; +#storybook-root { + height: 100%; +} +#storybook-root > div { + height: 100%; +} + .container { align-content: stretch; align-items: stretch; diff --git a/ACKNOWLEDGMENTS.md b/ACKNOWLEDGMENTS.md index 37114991ba..f5e5fcdb1b 100644 --- a/ACKNOWLEDGMENTS.md +++ b/ACKNOWLEDGMENTS.md @@ -174,6 +174,210 @@ Signal Desktop makes use of the following open source projects. OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +## @internationalized/date + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2019 Adobe + + 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. + ## @popperjs/core The MIT License (MIT) diff --git a/_locales/en/messages.json b/_locales/en/messages.json index a30a5b0113..92785dd6a5 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -7116,6 +7116,334 @@ "messageformat": "Notification content", "description": "Label for the notification content setting select box" }, + "icu:NotificationProfile--moon-icon": { + "messageformat": "Moon icon", + "description": "Screenreader description for the moon icon used to signify notification profiles" + }, + "icu:NotificationProfile--copy-label": { + "messageformat": "Copy of {profileName}", + "description": "Like 'Copy of Work.' When turning notification profiles sync back on, profile copies may be made if a given profile diverged on multiple devices." + }, + "icu:NotificationProfile--am": { + "messageformat": "AM", + "description": "Text to be used for morning times in times; if your locale uses 24-hour time, this won't be used" + }, + "icu:NotificationProfile--pm": { + "messageformat": "PM", + "description": "Text to be used for afternoon times in times; if your locale uses 24-hour time, this won't be used" + }, + "icu:NotificationProfile--time-separator": { + "messageformat": ":", + "description": "The symbol used to separate hour and minutes in a time representation" + }, + "icu:NotificationProfileMenuItem": { + "messageformat": "Notification profile", + "description": "Item in the main chats view 'more actions' menu allowing quick control of notification profiles" + }, + "icu:NotificationProfileMenu--header": { + "messageformat": "Notification Profile", + "description": "Header for the popop meny to control notification profile overrides" + }, + "icu:NotificationProfileMenu--on": { + "messageformat": "On", + "description": "Shown when a notification profile is enabled, but with no overrided end time" + }, + "icu:NotificationProfileMenu--on-with-end": { + "messageformat": "On until {endTime}", + "description": "Shown when a notification profile is enabled, with an override that has an end time" + }, + "icu:NotificationProfileMenu--for-one-hour": { + "messageformat": "For one hour", + "description": "Label for an item that will turn on a notification profile for one hour" + }, + "icu:NotificationProfileMenu--until-time": { + "messageformat": "Until {time}", + "description": "Label for an item that will turn on a notification profile until a certain time" + }, + "icu:NotificationProfileMenu--settings": { + "messageformat": "Settings", + "description": "Label for an item that will take the user to the Notification Profiles list in the preferences tab" + }, + "icu:NotificationProfilesToast--enabled": { + "messageformat": "“{name}” on", + "description": "Shown in toast announcing that the named notification profile is now enabled" + }, + "icu:NotificationProfilesToast--disabled": { + "messageformat": "“{name}” off", + "description": "Shown in toast announcing that the named notification profile is now disabled, and another has not replaced it as active" + }, + "icu:NotificationProfilesToast--enabled--label": { + "messageformat": "Notification profile now enabled", + "description": "Shown in toast announcing that the named notification profile is now enabled" + }, + "icu:NotificationProfilesToast--disabled--label": { + "messageformat": "Notification profile now disabled", + "description": "Shown in toast announcing that the named notification profile is now disabled, and another has not replaced it as active" + }, + "icu:NotificationProfiles--setting": { + "messageformat": "Notification profiles", + "description": "Label for the notification profiles setting button" + }, + "icu:NotificationProfiles--setup": { + "messageformat": "Set up", + "description": "Button text to setup notification profiles" + }, + "icu:NotificationProfiles--title": { + "messageformat": "Notification Profiles", + "description": "Header text for notification profiles" + }, + "icu:NotificationProfiles--setup-description": { + "messageformat": "Create a profile to receive notifications and calls only from the people and groups you choose", + "description": "Description text shown above create button when users have no notification profiles set up." + }, + "icu:NotificationProfiles--setup-continue": { + "messageformat": "Continue", + "description": "Button on Notification Profile onboarding dialog, shown their first time on the profile list screen, if user has no profiles." + }, + "icu:NotificationProfiles--manage-description": { + "messageformat": "Add or edit notification profiles", + "description": "Description text on the Notifications preferences page when the user already has profiles" + }, + "icu:NotificationProfiles--manage-profiles": { + "messageformat": "{profileCount, plural, one {One profile} other {# profiles}}", + "description": "Button text to take user to profile list page when they already have notification profiles" + }, + "icu:NotificationProfiles--create": { + "messageformat": "Create profile", + "description": "Button text to create a user's first notification profile" + }, + "icu:NotificationProfiles--name-title": { + "messageformat": "Name your profile", + "description": "Title on page where users can type in a name for a notification profile." + }, + "icu:NotificationProfiles--name-title--editing": { + "messageformat": "Name", + "description": "Title on page where user can edit the name/emoji for their notification profile." + }, + "icu:NotificationProfiles--name-placeholder": { + "messageformat": "Profile name", + "description": "Placeholder text for notification profile name text input box" + }, + "icu:NotificationProfiles--avatar-title": { + "messageformat": "Select an avatar", + "description": "Text shown above list of avatars that users can select for a notification profile" + }, + "icu:NotificationProfiles--sample-name__work": { + "messageformat": "Work", + "description": "Sample notification profile name, shown next to a flexed biceps emoji" + }, + "icu:NotificationProfiles--sample-name__sleep": { + "messageformat": "Sleep", + "description": "Sample notification profile name, shown next to a sleeping face emoji" + }, + "icu:NotificationProfiles--sample-name__driving": { + "messageformat": "Driving", + "description": "Sample notification profile name, shown next to a car emoji" + }, + "icu:NotificationProfiles--sample-name__downtime": { + "messageformat": "Downtime", + "description": "Sample notification profile name, shown next to a blushing smiley face emoji" + }, + "icu:NotificationProfiles--sample-name__focus": { + "messageformat": "Focus", + "description": "Sample notification profile name, shown next to a lightbulb emoji" + }, + "icu:NotificationProfiles--allowed-title": { + "messageformat": "Allowed notifications", + "description": "Header text above area where users select people or groups to include in the notification profile, who will still recevive nofications" + }, + "icu:NotificationProfiles--allowed-modal-title": { + "messageformat": "Allowed Notifications", + "description": "Title text for modal where users select people or groups to include in the notification profile, who will still recevive nofications" + }, + "icu:NotificationProfiles--allowed-description": { + "messageformat": "Add people and groups who you want notications from when this profile is on", + "description": "Description text for selecting people or groups that you still want to get notifications from when this profile is active" + }, + "icu:NotificationProfiles--allowed-add-label": { + "messageformat": "Add people or groups", + "description": "Label text for a button to add people or groups to the allowed notification list" + }, + "icu:NotificationProfiles--exceptions": { + "messageformat": "Exceptions", + "description": "Section header on text 'allowed notifications' screen, with checkboxes inside of it" + }, + "icu:NotificationProfiles--exceptions--allow-all-calls": { + "messageformat": "Allow all calls", + "description": "Label text for a checkbox in the 'allowed notifications' page Exceptions section" + }, + "icu:NotificationProfiles--exceptions--notify-for-mentions": { + "messageformat": "Notify for all mentions", + "description": "Label text for a checkbox in the 'allowed notifications' page Exceptions section" + }, + "icu:NotificationProfiles--schedule-title": { + "messageformat": "Add a schedule", + "description": "Title of page where users can add a schedule for the profile to be active" + }, + "icu:NotificationProfiles--schedule-title--editing": { + "messageformat": "Schedule", + "description": "Title of page where users change the schedule of their profile" + }, + "icu:NotificationProfiles--schedule-description": { + "messageformat": "Enable and edit your schedule to automate your notification profile.", + "description": "More information about the schedule screen when creating a notification profile" + }, + "icu:NotificationProfiles--schedule-enable": { + "messageformat": "Turn on automatically", + "description": "Label text for checkbox to enable / disable schedule" + }, + "icu:NotificationProfiles--schedule": { + "messageformat": "Schedule", + "description": "Shown above area where users can edit a notification profile schedule" + }, + "icu:NotificationProfiles--schedule-from": { + "messageformat": "From", + "description": "Label for dropdown to select time to start a notification profile schedule" + }, + "icu:NotificationProfiles--schedule-until": { + "messageformat": "Until", + "description": "Label for dropdown to select time to end a notification profile schedule" + }, + "icu:NotificationProfiles--open-time-picker": { + "messageformat": "Open time picker", + "description": "Label for button to open popup to make time choosing easier" + }, + "icu:NotificationProfiles--schedule-sunday": { + "messageformat": "Sunday", + "description": "Label for the schedule checkbox for Sunday" + }, + "icu:NotificationProfiles--schedule-monday": { + "messageformat": "Monday", + "description": "Label for the schedule checkbox for Monday" + }, + "icu:NotificationProfiles--schedule-tuesday": { + "messageformat": "Tuesday", + "description": "Label for the schedule checkbox for Tuesday" + }, + "icu:NotificationProfiles--schedule-wednesday": { + "messageformat": "Wednesday", + "description": "Label for the schedule checkbox for Wednesday" + }, + "icu:NotificationProfiles--schedule-thursday": { + "messageformat": "Thursday", + "description": "Label for the schedule checkbox for Thursday" + }, + "icu:NotificationProfiles--schedule-friday": { + "messageformat": "Friday", + "description": "Label for the schedule checkbox for Friday" + }, + "icu:NotificationProfiles--schedule-saturday": { + "messageformat": "Saturday", + "description": "Label for the schedule checkbox for Saturday" + }, + "icu:NotificationProfiles--done-title": { + "messageformat": "Profile Created", + "description": "Title text for the final screen in the notification profile creation flow" + }, + "icu:NotificationProfiles--done-description": { + "messageformat": "If you’ve added a schedule, your profile will turn on and off automatically. You can also click the ··· icon at the top of the chat list to turn it on or off manually.", + "description": "Description for the final screen in the notification profile creation flow" + }, + "icu:NotificationProfiles--schedule-sunday-short": { + "messageformat": "Sun", + "description": "Abbreviation for Sunday" + }, + "icu:NotificationProfiles--schedule-monday-short": { + "messageformat": "Mon", + "description": "Abbreviation for Monday" + }, + "icu:NotificationProfiles--schedule-tuesday-short": { + "messageformat": "Tue", + "description": "Abbreviation for Tuesday" + }, + "icu:NotificationProfiles--schedule-wednesday-short": { + "messageformat": "Wed", + "description": "Abbreviation for Wednesday" + }, + "icu:NotificationProfiles--schedule-thursday-short": { + "messageformat": "Thu", + "description": "Abbreviation for Thursday" + }, + "icu:NotificationProfiles--schedule-friday-short": { + "messageformat": "Fri", + "description": "Abbreviation for Friday" + }, + "icu:NotificationProfiles--schedule-saturday-short": { + "messageformat": "Sat", + "description": "Abbreviation for Saturday" + }, + "icu:NotificationProfiles--schedule-weekdays": { + "messageformat": "Weekdays", + "description": "Shown when someone has selected all of the weekdays for a notification profile schedule" + }, + "icu:NotificationProfiles--schedule-weekends": { + "messageformat": "Weekends", + "description": "Shown when someone has selected only weekend days for a notification profile schedule" + }, + "icu:NotificationProfiles--schedule-daily": { + "messageformat": "Daily", + "description": "Shown when someone has selected every day for a notification profile schedule" + }, + "icu:NotificationProfiles--schedule-separator": { + "messageformat": ", ", + "description": "Shown when user chose something other than weekdays/weekends/daily for a notification profile schedule. Short versions of all days chosen are shown, separated by this character. Like 'Mon, Thu, Fri'" + }, + "icu:NotificationProfiles--list--header": { + "messageformat": "Profiles", + "description": "Shown right above the list of notification profiles on the main list page" + }, + "icu:NotificationProfiles--list--sync": { + "messageformat": "Sync across devices", + "description": "Shown below the notification profile list; label for checkbox which controls whether notification profile behavior is always the same across all devices" + }, + "icu:NotificationProfiles--list--sync--description": { + "messageformat": "Your profiles will be synced across your devices. Turning on a profile for one device will turn it on for the the others.", + "description": "More details for the 'Sync across devices' setting" + }, + "icu:NotificationProfiles--new": { + "messageformat": "New profile", + "description": "Button label text to create a new notification profile" + }, + "icu:NotificationProfiles--delete": { + "messageformat": "Delete profile", + "description": "Button label text to delete a notification profile" + }, + "icu:NotificationProfiles--delete-confirmation": { + "messageformat": "Permanently delete this notification profile?", + "description": "Text of notification profile delete confirmation modal" + }, + "icu:NotificationProfiles--delete-button": { + "messageformat": "Delete", + "description": "Button label text in the delete confirmation modal" + }, + "icu:NotificationProfiles--edit--is-active": { + "messageformat": "On", + "description": "Shorthand description for a notification profile that is currently active" + }, + "icu:NotificationProfiles--edit--is-not-active": { + "messageformat": "Off", + "description": "Shorthand description for a notification profile that is NOT currently active" + }, + "icu:NotificationProfiles--edit--edit-name-label": { + "messageformat": "Edit this profile's name", + "description": "Label for pen icon next to notification profile's name on the edit page" + }, + "icu:NotificationProfiles--edit--allowed": { + "messageformat": "{allowedCount, plural, zero {No allowed notifications} one {One allowed notification} other {# allowed notifications}}", + "description": "Header on notification profile edit page, showing number of contacts and groups in the allowed list" + }, + "icu:NotificationProfiles--edit--schedule-timing": { + "messageformat": "{startTime} to {endTime}", + "description": "A summary of a notification profile's schedule - times will be rendered like 9:00 AM to 5:00 PM" + }, + "icu:NotificationProfiles--edit--schedule-enabled": { + "messageformat": "On", + "description": "Shorthand description for an enabled notification profile schedule" + }, + "icu:NotificationProfiles--edit--schedule-disabled": { + "messageformat": "Off", + "description": "Shorthand description for a disabled notification profile schedule" + }, "icu:Preferences--blocked": { "messageformat": "Blocked", "description": "Label for blocked contacts setting" diff --git a/images/notifications-moon.svg b/images/notifications-moon.svg new file mode 100644 index 0000000000..f13c3a0d7b --- /dev/null +++ b/images/notifications-moon.svg @@ -0,0 +1,3 @@ + + + diff --git a/package.json b/package.json index 8b35f59975..ad5b01ae3d 100644 --- a/package.json +++ b/package.json @@ -124,6 +124,7 @@ "@indutny/range-finder": "1.3.4", "@indutny/simple-windows-notifications": "2.0.16", "@indutny/sneequals": "4.0.0", + "@internationalized/date": "3.7.0", "@popperjs/core": "2.11.8", "@radix-ui/react-tooltip": "1.2.7", "@react-aria/focus": "3.19.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d96dad40fc..dc3dd3a978 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -104,6 +104,9 @@ importers: '@indutny/sneequals': specifier: 4.0.0 version: 4.0.0 + '@internationalized/date': + specifier: 3.7.0 + version: 3.7.0 '@popperjs/core': specifier: 2.11.8 version: 2.11.8 diff --git a/protos/SignalStorage.proto b/protos/SignalStorage.proto index 1b2f37bb91..3dc7dc5209 100644 --- a/protos/SignalStorage.proto +++ b/protos/SignalStorage.proto @@ -300,6 +300,7 @@ message AccountRecord { IAPSubscriberData backupSubscriberData = 41; optional AvatarColor avatarColor = 42; NotificationProfileManualOverride notificationProfileManualOverride = 44; + bool notificationProfileSyncDisabled = 45; } message StoryDistributionListRecord { diff --git a/stylesheets/components/NotificationProfiles.scss b/stylesheets/components/NotificationProfiles.scss new file mode 100644 index 0000000000..1dd0bea18a --- /dev/null +++ b/stylesheets/components/NotificationProfiles.scss @@ -0,0 +1,22 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +@use 'sass:map'; + +@use '../mixins'; +@use '../variables'; + +// These are needed to override Input styling +.NotificationProfiles__NamePage__input { + margin-top: 6px; + margin-inline-start: 2px; +} + +.NotificationProfiles__NamePage__container { + max-width: 328px; + margin-inline-start: auto; + margin-inline-end: auto; + width: 100%; + margin-bottom: 12px; + border-width: 1px; +} diff --git a/stylesheets/components/Preferences.scss b/stylesheets/components/Preferences.scss index 1e05124145..399ff8a829 100644 --- a/stylesheets/components/Preferences.scss +++ b/stylesheets/components/Preferences.scss @@ -24,7 +24,9 @@ $secondary-text-color: light-dark( display: flex; overflow: hidden; user-select: none; - width: 100vw; + height: 100%; + width: 100%; + align-items: stretch; @include mixins.light-theme { background: variables.$color-white; @@ -343,6 +345,7 @@ $secondary-text-color: light-dark( width: 100%; padding-top: 8px; max-width: 750px; + position: relative; &::-webkit-scrollbar-corner { background: transparent; @@ -1467,8 +1470,36 @@ $secondary-text-color: light-dark( } } +.Preferences__EditChatFolderPage__SelectChatsDialog { + height: 60vw; + + .module-conversation-list::-webkit-scrollbar-thumb { + border-color: light-dark(variables.$color-white, variables.$color-gray-80); + } +} + +.Preferences__EditChatFolderPage__SelectChatsDialog__body { + flex-grow: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.Preferences__EditChatFolderPage__SelectChatsDialog__body_inner { + display: flex; + flex-direction: column; + height: 100%; +} + .Preferences__ReadonlySqlPlayground__Textarea { &__input { font-family: variables.$monospace; } } + +.TimePickerPopup { + ::-webkit-scrollbar { + width: 0px; + background: transparent; + } +} diff --git a/stylesheets/components/fun/FunEmoji.scss b/stylesheets/components/fun/FunEmoji.scss index ea029fd5b0..1cc4bb43c7 100644 --- a/stylesheets/components/fun/FunEmoji.scss +++ b/stylesheets/components/fun/FunEmoji.scss @@ -50,6 +50,13 @@ $emoji-sprite-sheet-grid-item-count: 62; vertical-align: baseline; } +.FunStaticEmoji--Size12 { + width: 12px; + height: 12px; + // Use 32px variant even on smaller sizes to avoid shipping the 16px sheet + @include emoji-sprite($sheet: 32, $margin: 1px, $scale: calc(12 / 32)); +} + .FunStaticEmoji--Size16 { width: 16px; height: 16px; diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index 91812b7c5a..4574cb5e6f 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -145,6 +145,7 @@ @use 'components/MyStories.scss'; @use 'components/NavSidebar.scss'; @use 'components/NavTabs.scss'; +@use 'components/NotificationProfiles.scss'; @use 'components/OutgoingGiftBadgeModal.scss'; @use 'components/PermissionsPopup.scss'; @use 'components/PlaybackButton.scss'; diff --git a/stylesheets/tailwind-config.css b/stylesheets/tailwind-config.css index 061f88791a..a5e423a28a 100644 --- a/stylesheets/tailwind-config.css +++ b/stylesheets/tailwind-config.css @@ -54,6 +54,7 @@ --color-background-primary: light-dark(/* */ #FFFFFF /* */, /* */ #1A1A1A /* */); --color-background-secondary: light-dark(/* */ #F6F6F6 /* */, /* */ #262626 /* */); --color-background-overlay: light-dark(--alpha(#000000 / 20%), --alpha(#000000 / 40%)); + --color-background-overlay-secondary: light-dark(--alpha(#000000 / 15%), --alpha(#FFFFFF / 15%)); /* Colors/Elevated Background */ --color-elevated-background-primary: light-dark(#FAFAFA, #2A2A2A); @@ -145,6 +146,7 @@ --color-background-primary: light-dark(/* */ #FFFFFF /* */, /* */ #121212 /* */); --color-background-secondary: light-dark(/* */ #F6F6F6 /* */, /* */ #1E1E1E /* */); --color-background-overlay: light-dark(--alpha(#000000 / 40%), --alpha(#000000 / 60%)); + --color-background-overlay-secondary: light-dark(--alpha(#000000 / 15%), --alpha(#FFFFFF / 15%)); /* Colors/Elevated Background */ --color-elevated-background-primary: light-dark(#FFFFFF, #222222); @@ -310,6 +312,8 @@ @theme { /* box-shadow */ --shadow-*: initial; /* reset defaults */ + --shadow-legacy-outline: + 0 0 0 2px #2c6bed; --shadow-elevation-0: 0 1px 2px 0 var(--color-shadow-elevation-1); --shadow-elevation-1: diff --git a/ts/Crypto.ts b/ts/Crypto.ts index d6c6c317d0..b46552a529 100644 --- a/ts/Crypto.ts +++ b/ts/Crypto.ts @@ -19,6 +19,7 @@ import { Environment, getEnvironment } from './environment.js'; import { toWebSafeBase64 } from './util/webSafeBase64.js'; import type { AciString, PniString } from './types/ServiceId.js'; +import type { AvatarColorType } from './types/Colors.js'; const { sample } = lodash; @@ -710,7 +711,7 @@ export function generateAvatarColor({ e164: string | undefined; pni: PniString | undefined; groupId: string | undefined; -}): string { +}): AvatarColorType { const hashValue = getIdentifierHash({ aci, e164, pni, groupId }); if (hashValue == null) { diff --git a/ts/axo/AxoDropdownMenu.tsx b/ts/axo/AxoDropdownMenu.tsx index d661d8d9e2..1839654c40 100644 --- a/ts/axo/AxoDropdownMenu.tsx +++ b/ts/axo/AxoDropdownMenu.tsx @@ -57,13 +57,17 @@ export namespace AxoDropdownMenu { * --------------------------------- */ - export type RootProps = AxoBaseMenu.MenuRootProps; + export type RootProps = AxoBaseMenu.MenuRootProps & { + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; + }; /** * Contains all the parts of a dropdown menu. */ export const Root: FC = memo(props => { - return {props.children}; + return {props.children}; }); Root.displayName = `${Namespace}.Root`; @@ -121,7 +125,9 @@ export namespace AxoDropdownMenu { * --------------------------------- */ - export type ItemProps = AxoBaseMenu.MenuItemProps; + export type ItemProps = AxoBaseMenu.MenuItemProps & { + customIcon?: React.ReactNode; + }; /** * The component that contains the dropdown menu items. @@ -145,6 +151,11 @@ export namespace AxoDropdownMenu { )} + {props.customIcon && ( + + {props.customIcon} + + )} {props.children} {props.keyboardShortcut && ( diff --git a/ts/axo/AxoSymbol.tsx b/ts/axo/AxoSymbol.tsx index 4bbb613162..4877a32aef 100644 --- a/ts/axo/AxoSymbol.tsx +++ b/ts/axo/AxoSymbol.tsx @@ -65,15 +65,17 @@ export namespace AxoSymbol { */ export type IconName = AxoSymbolIconName; - export type IconSize = 14 | 16 | 20 | 24; + export type IconSize = 12 | 14 | 16 | 20 | 24 | 48; type IconSizeConfig = { size: number; fontSize: number }; const IconSizes: Record = { + 12: { size: 12, fontSize: 11 }, 14: { size: 14, fontSize: 12 }, 16: { size: 16, fontSize: 14 }, 20: { size: 20, fontSize: 18 }, 24: { size: 24, fontSize: 22 }, + 48: { size: 48, fontSize: 44 }, }; export function _getAllIconSizes(): ReadonlyArray { diff --git a/ts/background.ts b/ts/background.ts index 3dbf1cc3da..09862ddfbd 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -48,7 +48,7 @@ import { } from './services/expiringMessagesDeletion.js'; import { initialize as initializeNotificationProfilesService, - update as updateNotificationProfileService, + fastUpdate as updateNotificationProfileService, } from './services/notificationProfilesService.js'; import { tapToViewMessagesDeletionService } from './services/tapToViewMessagesDeletionService.js'; import { senderCertificateService } from './services/senderCertificate.js'; diff --git a/ts/components/Button.tsx b/ts/components/Button.tsx index d19e251969..7f3ff15709 100644 --- a/ts/components/Button.tsx +++ b/ts/components/Button.tsx @@ -48,6 +48,7 @@ export type PropsType = { size?: ButtonSize; style?: CSSProperties; tabIndex?: number; + testId?: string; theme?: Theme; variant?: ButtonVariant; 'aria-disabled'?: boolean; @@ -110,6 +111,7 @@ export const Button = React.forwardRef( icon, style, tabIndex, + testId, theme, variant = ButtonVariant.Primary, size = variant === ButtonVariant.Details @@ -150,6 +152,7 @@ export const Button = React.forwardRef( className, className && discouraged ? `${className}--discouraged` : undefined )} + data-testid={testId} disabled={disabled} onClick={onClick} form={form} diff --git a/ts/components/LeftPane.stories.tsx b/ts/components/LeftPane.stories.tsx index fbd40baa90..17f699dbe1 100644 --- a/ts/components/LeftPane.stories.tsx +++ b/ts/components/LeftPane.stories.tsx @@ -192,6 +192,7 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => { usernameCorrupted: false, usernameLinkCorrupted: false, isUpdateDownloaded, + isNotificationProfileActive: false, navTabsCollapsed: false, setChallengeStatus: action('setChallengeStatus'), @@ -237,6 +238,9 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => { {...props} /> ), + renderNotificationProfilesMenu: ({ trigger }) => ( +
{trigger}
+ ), renderRelinkDialog: props => ( JSX.Element; renderExpiredBuildDialog: (_: DialogExpiredBuildPropsType) => JSX.Element; renderLeftPaneChatFolders: () => JSX.Element; + renderNotificationProfilesMenu: ( + props: NotificationProfilesMenuProps + ) => JSX.Element; renderToastManager: (_: { containerWidthBreakpoint: WidthBreakpoint; }) => JSX.Element; @@ -227,6 +234,7 @@ export function LeftPane({ i18n, lookupConversationWithoutServiceId, isMacOS, + isNotificationProfileActive, isOnline, isUpdateDownloaded, modeSpecificProps, @@ -246,6 +254,7 @@ export function LeftPane({ renderMessageSearchResult, renderConversationListItemContextMenu, renderNetworkStatus, + renderNotificationProfilesMenu, renderUnsupportedOSDialog, renderRelinkDialog, renderUpdateDialog, @@ -729,6 +738,21 @@ export function LeftPane({ const hasDialogs = dialogs.length ? !hideHeader : false; + // The notification profile menu shows in two places - under its own icon and + // under the more actions context menu. + const [isNotificationProfilesMenuOpen, setIsNotificationProfilesMenuOpen] = + React.useState(false); + const [ + isNotificationProfilesSubMenuOpen, + setIsNotificationProfilesSubMenuOpen, + ] = React.useState(false); + + React.useEffect(() => { + if (!isNotificationProfileActive) { + setIsNotificationProfilesMenuOpen(false); + } + }, [isNotificationProfileActive, setIsNotificationProfilesMenuOpen]); + return ( + {isNotificationProfileActive && + renderNotificationProfilesMenu({ + isOpen: isNotificationProfilesMenuOpen, + onClose: () => { + setIsNotificationProfilesMenuOpen(false); + }, + trigger: ( + + ), + })} } @@ -757,6 +804,10 @@ export function LeftPane({ label: i18n('icu:avatarMenuViewArchive'), onClick: showArchivedConversations, }, + { + label: i18n('icu:NotificationProfileMenuItem'), + onClick: () => setIsNotificationProfilesSubMenuOpen(true), + }, ]} popperOptions={{ placement: 'bottom', @@ -764,17 +815,25 @@ export function LeftPane({ }} portalToRoot > - {({ onClick, onKeyDown, ref }) => { - return ( - } - label="More Actions" - /> - ); - }} + {({ onClick, onKeyDown, ref }) => + renderNotificationProfilesMenu({ + isOpen: isNotificationProfilesSubMenuOpen, + onClose: () => { + setIsNotificationProfilesSubMenuOpen(false); + }, + trigger: ( + + } + label="More Actions" + /> + ), + }) + } } diff --git a/ts/components/NotificationProfilesMenu.stories.tsx b/ts/components/NotificationProfilesMenu.stories.tsx new file mode 100644 index 0000000000..c237a8d72c --- /dev/null +++ b/ts/components/NotificationProfilesMenu.stories.tsx @@ -0,0 +1,188 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; +import { shuffle } from 'lodash'; +import type { Meta, StoryFn } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; + +import { NotificationProfilesMenu } from './NotificationProfilesMenu.js'; +import type { Props } from './NotificationProfilesMenu.js'; +import { + getDefaultConversation, + getDefaultGroup, +} from '../test-helpers/getDefaultConversation.js'; +import { DayOfWeek } from '../types/NotificationProfile.js'; +import type { NotificationProfileIdString } from '../types/NotificationProfile.js'; +import { HOUR } from '../util/durations/index.js'; + +const { i18n } = window.SignalContext; + +const conversations = shuffle([ + ...Array.from(Array(20), getDefaultGroup), + ...Array.from(Array(20), getDefaultConversation), +]); + +const threeProfiles = [ + { + id: 'Weekday' as NotificationProfileIdString, + name: 'Weekday', + emoji: '😬', + color: 0xffe3e3fe, + + createdAtMs: Date.now(), + + allowAllCalls: true, + allowAllMentions: true, + + allowedMembers: new Set([conversations[0].id, conversations[1].id]), + scheduleEnabled: true, + + scheduleStartTime: 1800, + scheduleEndTime: 2300, + + scheduleDaysEnabled: { + [DayOfWeek.SUNDAY]: false, + [DayOfWeek.MONDAY]: true, + [DayOfWeek.TUESDAY]: true, + [DayOfWeek.WEDNESDAY]: true, + [DayOfWeek.THURSDAY]: true, + [DayOfWeek.FRIDAY]: true, + [DayOfWeek.SATURDAY]: false, + }, + deletedAtTimestampMs: undefined, + storageNeedsSync: true, + }, + { + id: 'Weekend' as NotificationProfileIdString, + name: 'Weekend', + emoji: '❤️‍🔥', + color: 0xffd7d7d9, + + createdAtMs: Date.now(), + + allowAllCalls: true, + allowAllMentions: true, + + allowedMembers: new Set([conversations[0].id, conversations[1].id]), + scheduleEnabled: true, + + scheduleStartTime: 1800, + scheduleEndTime: 2300, + + scheduleDaysEnabled: { + [DayOfWeek.SUNDAY]: true, + [DayOfWeek.MONDAY]: false, + [DayOfWeek.TUESDAY]: false, + [DayOfWeek.WEDNESDAY]: false, + [DayOfWeek.THURSDAY]: false, + [DayOfWeek.FRIDAY]: false, + [DayOfWeek.SATURDAY]: true, + }, + deletedAtTimestampMs: undefined, + storageNeedsSync: true, + }, + { + id: 'Random' as NotificationProfileIdString, + name: 'Random', + emoji: undefined, + color: 0xfffef5d0, + + createdAtMs: Date.now(), + + allowAllCalls: true, + allowAllMentions: true, + + allowedMembers: new Set([conversations[0].id, conversations[1].id]), + scheduleEnabled: true, + + scheduleStartTime: 1800, + scheduleEndTime: 2300, + + scheduleDaysEnabled: { + [DayOfWeek.SUNDAY]: true, + [DayOfWeek.MONDAY]: false, + [DayOfWeek.TUESDAY]: true, + [DayOfWeek.WEDNESDAY]: false, + [DayOfWeek.THURSDAY]: true, + [DayOfWeek.FRIDAY]: false, + [DayOfWeek.SATURDAY]: true, + }, + deletedAtTimestampMs: undefined, + storageNeedsSync: true, + }, +]; + +export default { + title: 'Components/NotificationProfilesMenu', + component: NotificationProfilesMenu, + args: { + activeProfileId: undefined, + allProfiles: threeProfiles, + currentOverride: undefined, + i18n, + isOpen: true, + onClose: action('onClose'), + onGoToSettings: action('onGoToSettings'), + setProfileOverride: action('setProfileOverride'), + trigger:
This is the trigger
, + }, +} satisfies Meta; + +function createProps(args: Partial) { + return { + activeProfileId: undefined, + allProfiles: threeProfiles, + currentOverride: undefined, + i18n, + onClose: action('onClose'), + onGoToSettings: action('onGoToSettings'), + setProfileOverride: action('setProfileOverride'), + ...args, + }; +} + +// eslint-disable-next-line react/function-component-definition +const Template: StoryFn = args => { + return ; +}; + +export const Default = Template.bind({}); + +export const WithNoProfiles = Template.bind({}); +WithNoProfiles.args = createProps({ + allProfiles: [], +}); + +export const WithProfileActive = Template.bind({}); +WithProfileActive.args = createProps({ + activeProfileId: threeProfiles[0].id, +}); + +export const WithEmojiLessProfileActive = Template.bind({}); +WithEmojiLessProfileActive.args = createProps({ + activeProfileId: threeProfiles[2].id, +}); + +export const WithEnabledOverride = Template.bind({}); +WithEnabledOverride.args = createProps({ + activeProfileId: threeProfiles[0].id, + currentOverride: { + disabledAtMs: undefined, + enabled: { + profileId: threeProfiles[0].id, + }, + }, +}); + +export const WithEnabledOverrideAndEndsAtMs = Template.bind({}); +WithEnabledOverrideAndEndsAtMs.args = createProps({ + activeProfileId: threeProfiles[0].id, + currentOverride: { + disabledAtMs: undefined, + enabled: { + profileId: threeProfiles[0].id, + endsAtMs: Date.now() + HOUR, + }, + }, +}); diff --git a/ts/components/NotificationProfilesMenu.tsx b/ts/components/NotificationProfilesMenu.tsx new file mode 100644 index 0000000000..73afec8c59 --- /dev/null +++ b/ts/components/NotificationProfilesMenu.tsx @@ -0,0 +1,213 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; + +import type { LocalizerType } from '../types/Util.js'; +import { + getMidnight, + type NotificationProfileIdString, + type NotificationProfileOverride, + type NotificationProfileType, +} from '../types/NotificationProfile.js'; +import { DAY, HOUR, SECOND } from '../util/durations/index.js'; +import { formatTimestamp } from '../util/formatTimestamp.js'; +import { AxoDropdownMenu } from '../axo/AxoDropdownMenu.js'; +import { tw } from '../axo/tw.js'; +import { ProfileAvatar } from './PreferencesNotificationProfiles.js'; +import { AxoSymbol } from '../axo/AxoSymbol.js'; + +export type Props = Readonly<{ + activeProfileId: NotificationProfileIdString | undefined; + allProfiles: ReadonlyArray; + currentOverride: NotificationProfileOverride | undefined; + i18n: LocalizerType; + isOpen: boolean; + loading: boolean; + onClose: () => void; + onGoToSettings: () => void; + trigger: React.ReactNode; + setProfileOverride: ( + id: NotificationProfileIdString, + enabled: boolean, + endsAtMs?: number + ) => void; +}>; + +function getSixPm() { + const midnight = getMidnight(Date.now()); + return midnight + 18 * HOUR; +} +function getEightAm() { + const midnight = getMidnight(Date.now()); + return midnight + 8 * HOUR; +} +function getEightAmTomorrow() { + const midnight = getMidnight(Date.now() + DAY); + return midnight + 8 * HOUR; +} + +export function NotificationProfilesMenu({ + activeProfileId, + allProfiles, + currentOverride, + i18n, + isOpen, + loading, + onClose, + onGoToSettings, + trigger, + setProfileOverride, +}: Props): JSX.Element { + const enabledOverrideEndTime = currentOverride?.enabled?.endsAtMs; + const [now, setNow] = React.useState(Date.now()); + const [cachedProfiles, setCachedProfiles] = React.useState< + ReadonlyArray + >([]); + + React.useEffect(() => { + if (!loading) { + setCachedProfiles(allProfiles); + } + }, [loading, allProfiles]); + + let enabledLabel; + if (activeProfileId && enabledOverrideEndTime) { + enabledLabel = ( +
+ {i18n('icu:NotificationProfileMenu--on-with-end', { + endTime: formatTimestamp(enabledOverrideEndTime, { + timeStyle: 'short', + }), + })} +
+ ); + } else if (activeProfileId) { + enabledLabel =
{i18n('icu:NotificationProfileMenu--on')}
; + } + + const profilesToRender = loading ? cachedProfiles : allProfiles; + + const sixPm = getSixPm(); + const eightAm = getEightAm(); + const eightAmTomorrow = getEightAmTomorrow(); + + let targetTime = sixPm; + + if (now < eightAm) { + targetTime = eightAm; + } else if (now > sixPm) { + targetTime = eightAmTomorrow; + } + + React.useEffect(() => { + const interval = setInterval(() => { + setNow(Date.now()); + }, 30 * SECOND); + + return () => { + clearInterval(interval); + }; + }, []); + + return ( + { + if (!open) { + onClose(); + } + }} + > + {trigger} + +
+
+ {i18n('icu:NotificationProfileMenu--header')} +
+
+ {enabledLabel} +
+
+ {profilesToRender.length > 0 ? ( + + ) : undefined} + {profilesToRender.map((profile, index) => { + const isActive = activeProfileId && profile.id === activeProfileId; + + return ( + + {index > 0 && ( +
+ )} + + } + onSelect={event => { + event.preventDefault(); + setProfileOverride(profile.id, !isActive); + }} + > + {profile.name} + + {isActive ? ( + { + event.preventDefault(); + setProfileOverride(profile.id, true, Date.now() + HOUR); + }} + > + {i18n('icu:NotificationProfileMenu--for-one-hour')} + + ) : null} + {isActive ? ( + { + event.preventDefault(); + setProfileOverride(profile.id, true, targetTime); + }} + > + {i18n('icu:NotificationProfileMenu--until-time', { + time: formatTimestamp(targetTime, { + timeStyle: 'short', + }), + })} + + ) : null} + + ); + })} + + { + event.preventDefault(); + onGoToSettings(); + }} + customIcon={ + + + + } + > + {i18n('icu:NotificationProfileMenu--settings')} + + + + ); +} diff --git a/ts/components/Preferences.stories.tsx b/ts/components/Preferences.stories.tsx index f377dd065d..d396cb8396 100644 --- a/ts/components/Preferences.stories.tsx +++ b/ts/components/Preferences.stories.tsx @@ -4,9 +4,9 @@ import type { Meta, StoryFn } from '@storybook/react'; import React, { useState } from 'react'; import type { MutableRefObject } from 'react'; - import { action } from '@storybook/addon-actions'; import lodash from 'lodash'; + import { Preferences } from './Preferences.js'; import { DEFAULT_CONVERSATION_COLOR } from '../types/Colors.js'; import { PhoneNumberSharingMode } from '../types/PhoneNumberSharingMode.js'; @@ -29,6 +29,14 @@ import type { SettingsLocation } from '../types/Nav.js'; import { NavTab, ProfileEditorPage, SettingsPage } from '../types/Nav.js'; import { PreferencesDonations } from './PreferencesDonations.js'; import { strictAssert } from '../util/assert.js'; +import { PreferencesChatFoldersPage } from './preferences/chatFolders/PreferencesChatFoldersPage.js'; +import { PreferencesEditChatFolderPage } from './preferences/chatFolders/PreferencesEditChatFoldersPage.js'; +import { CHAT_FOLDER_DEFAULTS } from '../types/ChatFolder.js'; +import { + NotificationProfilesHome, + NotificationProfilesCreateFlow, +} from './PreferencesNotificationProfiles.js'; +import { DayOfWeek } from '../types/NotificationProfile.js'; import type { LocalizerType } from '../types/Util.js'; import type { PropsType } from './Preferences.js'; @@ -41,10 +49,9 @@ import type { } from '../types/Donations.js'; import type { AnyToast } from '../types/Toast.js'; import type { SmartPreferencesChatFoldersPageProps } from '../state/smart/PreferencesChatFoldersPage.js'; -import { PreferencesChatFoldersPage } from './preferences/chatFolders/PreferencesChatFoldersPage.js'; import type { SmartPreferencesEditChatFolderPageProps } from '../state/smart/PreferencesEditChatFolderPage.js'; -import { PreferencesEditChatFolderPage } from './preferences/chatFolders/PreferencesEditChatFoldersPage.js'; -import { CHAT_FOLDER_DEFAULTS } from '../types/ChatFolder.js'; +import type { ExternalProps as SmartNotificationProfilesProps } from '../state/smart/PreferencesNotificationProfiles.js'; +import type { NotificationProfileIdString } from '../types/NotificationProfile.js'; const { shuffle } = lodash; @@ -316,6 +323,49 @@ function renderPreferencesEditChatFolderPage( ); } +function renderNotificationProfilesCreateFlow( + props: SmartNotificationProfilesProps +): JSX.Element { + return ( + undefined} + setSettingsLocation={props.setSettingsLocation} + theme={ThemeType.light} + /> + ); +} + +function renderNotificationProfilesHome( + props: SmartNotificationProfilesProps +): JSX.Element { + return ( + undefined} + setHasOnboardingBeenSeen={action('setHasOnboardingBeenSeen')} + setIsSyncEnabled={action('setIsSyncEnabled')} + setSettingsLocation={props.setSettingsLocation} + setProfileOverride={action('setProfileOverride')} + theme={ThemeType.light} + updateProfile={action('updateProfile')} + /> + ); +} + export default { title: 'Components/Preferences', component: Preferences, @@ -405,6 +455,7 @@ export default { me, navTabsCollapsed: false, notificationContent: 'name', + notificationProfileCount: 0, otherTabsUnreadStats: { unreadCount: 0, unreadMentionsCount: 0, @@ -456,6 +507,8 @@ export default { }, showToast: action('showToast'), }), + renderNotificationProfilesCreateFlow, + renderNotificationProfilesHome, renderProfileEditor, renderToastManager, renderUpdateDialog, @@ -633,6 +686,142 @@ Donations.args = { donationsFeatureEnabled: true, settingsLocation: { page: SettingsPage.Donations }, }; + +export const NotificationsPageWithThreeProfiles = Template.bind({}); +const threeProfiles = [ + { + id: 'Weekday' as NotificationProfileIdString, + name: 'Weekday', + emoji: '😬', + color: 0xffe3e3fe, + + createdAtMs: Date.now(), + + allowAllCalls: true, + allowAllMentions: true, + + allowedMembers: new Set([conversations[0].id, conversations[1].id]), + scheduleEnabled: true, + + scheduleStartTime: 1800, + scheduleEndTime: 2300, + + scheduleDaysEnabled: { + [DayOfWeek.SUNDAY]: false, + [DayOfWeek.MONDAY]: true, + [DayOfWeek.TUESDAY]: true, + [DayOfWeek.WEDNESDAY]: true, + [DayOfWeek.THURSDAY]: true, + [DayOfWeek.FRIDAY]: true, + [DayOfWeek.SATURDAY]: false, + }, + deletedAtTimestampMs: undefined, + storageNeedsSync: true, + }, + { + id: 'Weekend' as NotificationProfileIdString, + name: 'Weekend', + emoji: '❤️‍🔥', + color: 0xffd7d7d9, + + createdAtMs: Date.now(), + + allowAllCalls: true, + allowAllMentions: true, + + allowedMembers: new Set([conversations[0].id, conversations[1].id]), + scheduleEnabled: true, + + scheduleStartTime: 100, + scheduleEndTime: 1200, + + scheduleDaysEnabled: { + [DayOfWeek.SUNDAY]: true, + [DayOfWeek.MONDAY]: false, + [DayOfWeek.TUESDAY]: false, + [DayOfWeek.WEDNESDAY]: false, + [DayOfWeek.THURSDAY]: false, + [DayOfWeek.FRIDAY]: false, + [DayOfWeek.SATURDAY]: true, + }, + deletedAtTimestampMs: undefined, + storageNeedsSync: true, + }, + { + id: 'Random' as NotificationProfileIdString, + name: 'Random', + emoji: undefined, + color: 0xfffef5d0, + + createdAtMs: Date.now(), + + allowAllCalls: true, + allowAllMentions: true, + + allowedMembers: new Set([conversations[0].id, conversations[1].id]), + scheduleEnabled: true, + + scheduleStartTime: 1800, + scheduleEndTime: 2300, + + scheduleDaysEnabled: { + [DayOfWeek.SUNDAY]: true, + [DayOfWeek.MONDAY]: false, + [DayOfWeek.TUESDAY]: true, + [DayOfWeek.WEDNESDAY]: false, + [DayOfWeek.THURSDAY]: true, + [DayOfWeek.FRIDAY]: false, + [DayOfWeek.SATURDAY]: true, + }, + deletedAtTimestampMs: undefined, + storageNeedsSync: true, + }, +]; + +NotificationsPageWithThreeProfiles.args = { + settingsLocation: { page: SettingsPage.Notifications }, + notificationProfileCount: threeProfiles.length, + renderNotificationProfilesCreateFlow: ( + props: SmartNotificationProfilesProps + ) => { + return ( + undefined} + theme={ThemeType.light} + /> + ); + }, + renderNotificationProfilesHome: (props: SmartNotificationProfilesProps) => { + return ( + undefined} + setHasOnboardingBeenSeen={action('setHasOnboardingBeenSeen')} + setIsSyncEnabled={action('setIsSyncEnabled')} + setSettingsLocation={props.setSettingsLocation} + setProfileOverride={action('setProfileOverride)')} + theme={ThemeType.light} + updateProfile={action('updateProfile')} + /> + ); + }, +}; + export const DonationsDonateFlow = Template.bind({}); DonationsDonateFlow.args = { donationsFeatureEnabled: true, diff --git a/ts/components/Preferences.tsx b/ts/components/Preferences.tsx index aae573c3f1..38aaa52ad9 100644 --- a/ts/components/Preferences.tsx +++ b/ts/components/Preferences.tsx @@ -15,6 +15,7 @@ import classNames from 'classnames'; import * as LocaleMatcher from '@formatjs/intl-localematcher'; import type { MutableRefObject, ReactNode } from 'react'; import type { RowType } from '@signalapp/sqlcipher'; + import { Button, ButtonVariant } from './Button.js'; import { ChatColorPicker } from './ChatColorPicker.js'; import { Checkbox } from './Checkbox.js'; @@ -39,10 +40,7 @@ import { removeDiacritics } from '../util/removeDiacritics.js'; import { assertDev } from '../util/assert.js'; import { I18n } from './I18n.js'; import { FunSkinTonesList } from './fun/FunSkinTones.js'; -import { - EMOJI_PARENT_KEY_CONSTANTS, - type EmojiSkinTone, -} from './fun/data/emojis.js'; +import { EMOJI_PARENT_KEY_CONSTANTS } from './fun/data/emojis.js'; import { SettingsControl as Control, FlowingSettingsControl as FlowingControl, @@ -56,7 +54,12 @@ import { Avatar, AvatarSize } from './Avatar.js'; import { NavSidebar } from './NavSidebar.js'; import type { SettingsLocation } from '../types/Nav.js'; import { SettingsPage, ProfileEditorPage, NavTab } from '../types/Nav.js'; +import { tw } from '../axo/tw.js'; +import { isBackupPage } from '../types/PreferencesBackupPage.js'; +import { isChatFoldersEnabled } from '../types/ChatFolder.js'; +import { FullWidthButton } from './PreferencesNotificationProfiles.js'; +import type { EmojiSkinTone } from './fun/data/emojis.js'; import type { MediaDeviceSettings } from '../types/Calling.js'; import type { ValidationResultType as BackupValidationResultType } from '../services/backups/index.js'; import type { @@ -88,7 +91,6 @@ import type { UnreadStats } from '../util/countUnreadStats.js'; import type { BadgeType } from '../badges/types.js'; import type { MessageCountBySchemaVersionType } from '../sql/Interface.js'; import type { MessageAttributesType } from '../model-types.js'; -import { isBackupPage } from '../types/PreferencesBackupPage.js'; import type { PreferencesBackupPage } from '../types/PreferencesBackupPage.js'; import type { PromptOSAuthReasonType, @@ -96,9 +98,9 @@ import type { } from '../util/os/promptOSAuthMain.js'; import type { DonationReceipt } from '../types/Donations.js'; import type { ChatFolderId } from '../types/ChatFolder.js'; -import { isChatFoldersEnabled } from '../types/ChatFolder.js'; import type { SmartPreferencesEditChatFolderPageProps } from '../state/smart/PreferencesEditChatFolderPage.js'; import type { SmartPreferencesChatFoldersPageProps } from '../state/smart/PreferencesChatFoldersPage.js'; +import type { ExternalProps as SmartNotificationProfilesProps } from '../state/smart/PreferencesNotificationProfiles.js'; const { isNumber, noop, partition } = lodash; @@ -180,6 +182,7 @@ export type PropsDataType = { preferredWidthFromStorage: number; shouldShowUpdateDialog: boolean; theme: ThemeType; + notificationProfileCount: number; // Limited support features isAutoDownloadUpdatesSupported: boolean; @@ -208,6 +211,13 @@ type PropsFunctionType = { settingsLocation: SettingsLocation; setSettingsLocation: (settingsLocation: SettingsLocation) => void; }) => JSX.Element; + renderNotificationProfilesHome: ( + props: SmartNotificationProfilesProps + ) => JSX.Element; + renderNotificationProfilesCreateFlow: ( + props: SmartNotificationProfilesProps + ) => JSX.Element; + renderProfileEditor: (options: { contentsRef: MutableRefObject; }) => JSX.Element; @@ -434,6 +444,7 @@ export function Preferences({ me, navTabsCollapsed, notificationContent, + notificationProfileCount, onAudioNotificationsChange, onAutoConvertEmojiChange, onAutoDownloadAttachmentChange, @@ -483,6 +494,8 @@ export function Preferences({ removeCustomColor, removeCustomColorOnConversations, renderDonationsPane, + renderNotificationProfilesCreateFlow, + renderNotificationProfilesHome, renderProfileEditor, renderToastManager, renderUpdateDialog, @@ -1486,6 +1499,59 @@ export function Preferences({ onChange={onMessageAudioChange} /> + {notificationProfileCount > 0 ? ( + + setSettingsLocation({ + page: SettingsPage.NotificationProfilesHome, + }) + } + > +
+
{i18n('icu:NotificationProfiles--setting')}
+
+ {i18n('icu:NotificationProfiles--manage-description')} +
+
+ + {i18n('icu:NotificationProfiles--manage-profiles', { + profileCount: notificationProfileCount, + })} + +
+ ) : ( + + +
{i18n('icu:NotificationProfiles--setting')}
+
+ {i18n('icu:NotificationProfiles--setup-description')} +
+ + } + right={ + + } + /> +
+ )} ); content = ( @@ -2136,6 +2202,18 @@ export function Preferences({ title={pageTitle} /> ); + } else if (settingsLocation.page === SettingsPage.NotificationProfilesHome) { + content = renderNotificationProfilesHome({ + setSettingsLocation, + contentsRef: settingsPaneRef, + }); + } else if ( + settingsLocation.page === SettingsPage.NotificationProfilesCreateFlow + ) { + content = renderNotificationProfilesCreateFlow({ + setSettingsLocation, + contentsRef: settingsPaneRef, + }); } else if (settingsLocation.page === SettingsPage.Internal) { content = ( ); } - return (
diff --git a/ts/components/PreferencesNotificationProfiles.tsx b/ts/components/PreferencesNotificationProfiles.tsx new file mode 100644 index 0000000000..56cf351123 --- /dev/null +++ b/ts/components/PreferencesNotificationProfiles.tsx @@ -0,0 +1,2154 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; +import type { MutableRefObject } from 'react'; +import { DateInput, DateSegment, TimeField } from 'react-aria-components'; +import { Time } from '@internationalized/date'; +import { sample, isEqual, noop, range } from 'lodash'; +import classNames from 'classnames'; +import { Popper } from 'react-popper'; + +import { + isEmojiVariantValue, + getEmojiVariantByKey, + getEmojiVariantKeyByValue, +} from './fun/data/emojis.js'; +import { FunStaticEmoji } from './fun/FunEmoji.js'; +import { FunEmojiPicker } from './fun/FunEmojiPicker.js'; +import { useFunEmojiLocalizer } from './fun/useFunEmojiLocalizer.js'; +import { FunEmojiPickerButton } from './fun/FunButton.js'; +import { tw } from '../axo/tw.js'; +import { AxoButton } from '../axo/AxoButton.js'; +import { AxoSelect } from '../axo/AxoSelect.js'; +import { AxoSwitch } from '../axo/AxoSwitch.js'; +import { AxoSymbol } from '../axo/AxoSymbol.js'; +import { Input } from './Input.js'; +import { Checkbox } from './Checkbox.js'; +import { AvatarColorMap, AvatarColors } from '../types/Colors.js'; +import { PreferencesSelectChatsDialog } from './preferences/PreferencesSelectChatsDialog.js'; +import { + DayOfWeek, + getMidnight, + scheduleToTime, +} from '../types/NotificationProfile.js'; +import { Avatar } from './Avatar.js'; +import { missingCaseError } from '../util/missingCaseError.js'; +import { formatTimestamp } from '../util/formatTimestamp.js'; +import { strictAssert } from '../util/assert.js'; +import { ConfirmationDialog } from './ConfirmationDialog.js'; +import { SettingsPage } from '../types/Nav.js'; +import { useConfirmDiscard } from '../hooks/useConfirmDiscard.js'; +import { AriaClickable } from '../axo/AriaClickable.js'; +import { offsetDistanceModifier } from '../util/popperUtil.js'; +import { themeClassName2 } from '../util/theme.js'; +import { useRefMerger } from '../hooks/useRefMerger.js'; +import { handleOutsideClick } from '../util/handleOutsideClick.js'; +import { useEscapeHandling } from '../hooks/useEscapeHandling.js'; +import { Modal } from './Modal.js'; + +import type { EmojiVariantKey } from './fun/data/emojis.js'; +import type { LocalizerType } from '../types/I18N.js'; +import type { ThemeType } from '../types/Util.js'; +import type { ConversationType } from '../state/ducks/conversations.js'; +import type { GetConversationByIdType } from '../state/selectors/conversations.js'; +import type { PreferredBadgeSelectorType } from '../state/selectors/badges.js'; +import type { + NotificationProfileIdString, + NotificationProfileType, + ScheduleDays, +} from '../types/NotificationProfile.js'; +import type { SettingsLocation } from '../types/Nav.js'; + +enum CreateFlowPage { + Name = 'Name', + Allowed = 'Allowed', + Schedule = 'Schedule', + Done = 'Done', +} + +enum HomePage { + List = 'List', + Edit = 'Edit', + Name = 'Name', + Schedule = 'Schedule', +} + +const DEFAULT_ALLOW_CALLS = true; +const DEFAULT_ALLOW_MENTIONS = false; + +const NINE_AM = 900; +const FIVE_PM = 1700; + +const DEFAULT_ENABLED = false; +const DEFAULT_START = NINE_AM; +const DEFAULT_END = FIVE_PM; + +const WEEKDAY_SCHEDULE: ScheduleDays = { + [DayOfWeek.MONDAY]: true, + [DayOfWeek.TUESDAY]: true, + [DayOfWeek.WEDNESDAY]: true, + [DayOfWeek.THURSDAY]: true, + [DayOfWeek.FRIDAY]: true, + [DayOfWeek.SATURDAY]: false, + [DayOfWeek.SUNDAY]: false, +}; +const WEEKEND_SCHEDULE: ScheduleDays = { + [DayOfWeek.MONDAY]: false, + [DayOfWeek.TUESDAY]: false, + [DayOfWeek.WEDNESDAY]: false, + [DayOfWeek.THURSDAY]: false, + [DayOfWeek.FRIDAY]: false, + [DayOfWeek.SATURDAY]: true, + [DayOfWeek.SUNDAY]: true, +}; +const DAILY_SCHEDULE: ScheduleDays = { + [DayOfWeek.MONDAY]: true, + [DayOfWeek.TUESDAY]: true, + [DayOfWeek.WEDNESDAY]: true, + [DayOfWeek.THURSDAY]: true, + [DayOfWeek.FRIDAY]: true, + [DayOfWeek.SATURDAY]: true, + [DayOfWeek.SUNDAY]: true, +}; + +const DEFAULT_SCHEDULE = WEEKDAY_SCHEDULE; + +type CreateFlowProps = { + contentsRef: MutableRefObject; + conversations: ReadonlyArray; + conversationSelector: GetConversationByIdType; + createProfile: (profile: Omit) => void; + i18n: LocalizerType; + setSettingsLocation: (location: SettingsLocation) => void; + preferredBadgeSelector: PreferredBadgeSelectorType; + theme: ThemeType; +}; + +type HomeProps = { + activeProfileId: NotificationProfileIdString | undefined; + allProfiles: ReadonlyArray; + contentsRef: MutableRefObject; + conversations: ReadonlyArray; + conversationSelector: GetConversationByIdType; + hasOnboardingBeenSeen: boolean; + i18n: LocalizerType; + isSyncEnabled: boolean; + loading: boolean; + markProfileDeleted: (id: string) => void; + preferredBadgeSelector: PreferredBadgeSelectorType; + setHasOnboardingBeenSeen: (value: boolean) => void; + setIsSyncEnabled: (value: boolean) => void; + setSettingsLocation: (location: SettingsLocation) => void; + setProfileOverride: ( + id: NotificationProfileIdString, + enabled: boolean + ) => void; + theme: ThemeType; + updateProfile: (profile: NotificationProfileType) => void; +}; + +function formatTimeForDisplay(time: number): string { + const midnight = getMidnight(Date.now()); + const ms = scheduleToTime(midnight, time); + return formatTimestamp(ms, { timeStyle: 'short' }); +} + +function need24HourTime(): boolean { + const formatted = formatTimeForDisplay(FIVE_PM); + return formatted.includes('17'); +} + +function formatTimeForInput(time: number): Time { + const { hours, minutes } = getTimeDetails(time, true); + return new Time(hours, minutes); +} +function addLeadingZero(minutes: number): string { + if (minutes < 10) { + return `0${minutes}`; + } + return minutes.toString(); +} + +function parseTimeFromInput(time: Time): number { + return time.hour * 100 + time.minute; +} + +type PERIOD = 'AM' | 'PM'; +function hourTo24HourTime(hours: number, period: PERIOD) { + if (period === 'AM' && hours === 12) { + return 0; + } + if (period === 'AM') { + return hours; + } + if (period === 'PM' && hours < 12) { + return hours + 12; + } + + return hours; +} +function hourFrom24HourTime(hours: number): { hours: number; period: PERIOD } { + if (hours === 0) { + return { + hours: 12, + period: 'AM', + }; + } + if (hours === 12) { + return { + hours: 12, + period: 'PM', + }; + } + if (hours > 12) { + return { + hours: hours - 12, + period: 'PM', + }; + } + return { + hours, + period: 'AM', + }; +} +function makeTime( + rawHours: number, + minutes: number, + period: PERIOD | undefined +): number { + if (!period) { + return rawHours * 100 + minutes; + } + + const hours = hourTo24HourTime(rawHours, period); + return hours * 100 + minutes; +} + +function getTimeDetails( + time: number, + use24HourTime: boolean +): { hours: number; minutes: number; period: PERIOD | undefined } { + const rawHours = Math.floor(time / 100); + const minutes = time % 100; + + if (use24HourTime) { + return { hours: rawHours, minutes, period: undefined }; + } + + const { hours, period } = hourFrom24HourTime(rawHours); + return { + hours, + minutes, + period, + }; +} + +const ARGB_BITS = 0xff000000; +const A100_BACKGROUND_ARGB = 0xffe3e3fe; + +function getRandomColor(): number { + const colorName = sample(AvatarColors) || AvatarColors[0]; + const color = AvatarColorMap.get(colorName); + if (!color) { + return A100_BACKGROUND_ARGB; // A100, background, with bits for ARGB + } + + const rgb = parseInt(color.bg.slice(1), 16); + const argb = rgb + ARGB_BITS; + + return argb; +} + +export function getColorFromProfile(argb: number): string { + const rgb = argb - ARGB_BITS; + return `#${rgb.toString(16)}`; +} + +function getEmojiVariantKey(value: string): EmojiVariantKey | undefined { + if (isEmojiVariantValue(value)) { + return getEmojiVariantKeyByValue(value); + } + + return undefined; +} + +type ProfileToSave = Omit; + +export function NotificationProfilesCreateFlow({ + contentsRef, + conversations, + conversationSelector, + createProfile, + i18n, + preferredBadgeSelector, + setSettingsLocation, + theme, +}: CreateFlowProps): JSX.Element { + const [page, setPage] = React.useState(CreateFlowPage.Name); + + const [name, setName] = React.useState(); + const [emoji, setEmoji] = React.useState(); + const [allowedMembers, setAllowedMembers] = React.useState< + ReadonlySet + >(new Set()); + const [allowAllCalls, setAllowAllCalls] = React.useState(DEFAULT_ALLOW_CALLS); + const [allowAllMentions, setAllowAllMentions] = React.useState( + DEFAULT_ALLOW_MENTIONS + ); + const [isEnabled, setIsEnabled] = React.useState(DEFAULT_ENABLED); + const [scheduleDays, setScheduledDays] = + React.useState(DEFAULT_SCHEDULE); + const [startTime, setStartTime] = React.useState(DEFAULT_START); + const [endTime, setEndTime] = React.useState(DEFAULT_END); + const [color] = React.useState(getRandomColor()); + + const tryClose = React.useRef<() => void | undefined>(); + const [confirmDiscardModal, confirmDiscardIf] = useConfirmDiscard({ + i18n, + name: 'NotificationProfilesCreateFlow', + tryClose, + }); + + const onTryClose = React.useCallback(() => { + const isDirty = + page !== CreateFlowPage.Done && (Boolean(name) || Boolean(emoji)); + const discardChanges = noop; + + confirmDiscardIf(isDirty, discardChanges); + }, [confirmDiscardIf, emoji, name, page]); + tryClose.current = onTryClose; + + function makeNotificationProfile(): ProfileToSave { + return { + name: name || '', + emoji, + + color, + + createdAtMs: Date.now(), + + allowAllCalls, + allowAllMentions, + + allowedMembers, + scheduleEnabled: isEnabled, + + scheduleStartTime: startTime, + scheduleEndTime: endTime, + scheduleDaysEnabled: scheduleDays, + + deletedAtTimestampMs: undefined, + storageNeedsSync: true, + }; + } + + const goToNotificationsProfilesHome = React.useCallback(() => { + setSettingsLocation({ page: SettingsPage.NotificationProfilesHome }); + }, [setSettingsLocation]); + + function getPageContents() { + switch (page) { + case CreateFlowPage.Name: + return ( + { + setPage(CreateFlowPage.Allowed); + }} + onUpdate={({ name: newName, emoji: newEmoji }) => { + setEmoji(newEmoji); + setName(newName); + }} + theme={theme} + /> + ); + case CreateFlowPage.Allowed: + return ( + setPage(CreateFlowPage.Name)} + onNext={() => setPage(CreateFlowPage.Schedule)} + onSetAllowedMembers={(members: ReadonlyArray) => + setAllowedMembers(new Set(members)) + } + onSetAllowAllCalls={(value: boolean) => setAllowAllCalls(value)} + onSetAllowAllMentions={() => + setAllowAllMentions( + existingallowAllMentions => !existingallowAllMentions + ) + } + preferredBadgeSelector={preferredBadgeSelector} + theme={theme} + /> + ); + case CreateFlowPage.Schedule: + return ( + setPage(CreateFlowPage.Allowed)} + onNext={() => { + const profile = makeNotificationProfile(); + createProfile(profile); + setPage(CreateFlowPage.Done); + }} + onSetIsEnabled={(value: boolean) => setIsEnabled(value)} + onSetScheduleDays={(schedule: ScheduleDays) => + setScheduledDays(schedule) + } + onSetStartTime={(value: number) => setStartTime(value)} + onSetEndTime={(value: number) => setEndTime(value)} + theme={theme} + /> + ); + case CreateFlowPage.Done: + return ( + + ); + default: + throw missingCaseError(page); + } + } + return ( +
+ {confirmDiscardModal} + {getPageContents()} +
+ ); +} + +export function NotificationProfilesHome({ + activeProfileId, + allProfiles, + contentsRef, + conversations, + conversationSelector, + hasOnboardingBeenSeen, + i18n, + isSyncEnabled, + loading, + markProfileDeleted, + preferredBadgeSelector, + setHasOnboardingBeenSeen, + setIsSyncEnabled, + setSettingsLocation, + setProfileOverride, + theme, + updateProfile, +}: HomeProps): JSX.Element { + const [page, setPage] = React.useState(HomePage.List); + const [profile, setProfile] = React.useState< + NotificationProfileType | undefined + >(); + const [isShowingOnboardModal, setIsShowingOnboardModal] = + React.useState(false); + + const goBackToNotifications = React.useCallback(() => { + setSettingsLocation({ page: SettingsPage.Notifications }); + }, [setSettingsLocation]); + const goToNotificationsProfilesCreateFlow = React.useCallback(() => { + setSettingsLocation({ page: SettingsPage.NotificationProfilesCreateFlow }); + }, [setSettingsLocation]); + + React.useEffect(() => { + if (page === HomePage.List && !hasOnboardingBeenSeen) { + if (allProfiles.length === 0) { + setIsShowingOnboardModal(true); + } else { + setHasOnboardingBeenSeen(true); + } + } + + if ( + profile && + (page === HomePage.Name || + page === HomePage.Schedule || + page === HomePage.Edit) + ) { + const newProfile = allProfiles.find(item => item.id === profile.id); + if (newProfile) { + setProfile(newProfile); + } else { + setProfile(undefined); + setPage(HomePage.List); + } + } + }, [ + allProfiles, + hasOnboardingBeenSeen, + page, + profile, + setHasOnboardingBeenSeen, + setPage, + setProfile, + ]); + + function getPageContents() { + switch (page) { + case HomePage.List: + return ( + { + setProfile(profileToEdit); + setPage(HomePage.Edit); + }} + onBack={goBackToNotifications} + setIsSyncEnabled={setIsSyncEnabled} + /> + ); + case HomePage.Name: + strictAssert(profile, 'HomePage.Name: Need a profile to edit!'); + + return ( + setPage(HomePage.Edit)} + onNext={() => { + setPage(HomePage.Edit); + }} + onUpdate={({ emoji, name }) => { + const newProfile = { + ...profile, + emoji, + name, + }; + updateProfile(newProfile); + setProfile(newProfile); + }} + theme={theme} + /> + ); + case HomePage.Schedule: + strictAssert(profile, 'HomePage.Schedule: Need a profile to edit!'); + + return ( + setPage(HomePage.Edit)} + onNext={() => setPage(HomePage.Edit)} // TODO: probably don't show Next button? + onSetIsEnabled={(scheduleEnabled: boolean) => { + const newProfile = { + ...profile, + scheduleEnabled, + }; + updateProfile(newProfile); + setProfile(newProfile); + }} + onSetScheduleDays={(scheduleDaysEnabled: ScheduleDays) => { + const newProfile = { + ...profile, + scheduleDaysEnabled, + }; + updateProfile(newProfile); + setProfile(newProfile); + }} + onSetStartTime={(scheduleStartTime: number) => { + const newProfile = { + ...profile, + scheduleStartTime, + }; + updateProfile(newProfile); + setProfile(newProfile); + }} + onSetEndTime={(scheduleEndTime: number) => { + const newProfile = { + ...profile, + scheduleEndTime, + }; + updateProfile(newProfile); + setProfile(newProfile); + }} + theme={theme} + /> + ); + case HomePage.Edit: + strictAssert(profile, 'HomePage.Edit: Need a profile to edit!'); + + return ( + setPage(HomePage.List)} + onDeleteProfile={() => { + markProfileDeleted(profile.id); + setPage(HomePage.List); + }} + onEditName={() => setPage(HomePage.Name)} + onEditProfile={newProfile => { + setProfile(newProfile); + updateProfile(newProfile); + }} + onEditSchedule={() => setPage(HomePage.Schedule)} + onUpdateOverrideState={value => + setProfileOverride(profile.id, value) + } + preferredBadgeSelector={preferredBadgeSelector} + theme={theme} + /> + ); + default: + throw missingCaseError(page); + } + } + return ( +
+ {isShowingOnboardModal ? ( + { + setHasOnboardingBeenSeen(true); + setIsShowingOnboardModal(false); + }} + /> + ) : null} + {getPageContents()} +
+ ); +} + +function NotificationProfilesOnboardingDialog({ + i18n, + onDismiss, +}: { + i18n: LocalizerType; + onDismiss: VoidFunction; +}) { + return ( + +
+
+ +
+ + <p className={tw('mt-4 mb-12 max-w-[340px] text-center leading-5')}> + {i18n('icu:NotificationProfiles--setup-description')} + </p> + <AxoButton.Root variant="primary" onClick={onDismiss} size="large"> + {i18n('icu:NotificationProfiles--setup-continue')} + </AxoButton.Root> + </div> + </Modal> + ); +} + +function NotificationProfilesNamePage({ + contentsRef, + i18n, + initialEmoji, + initialName, + isEditing, + onBack, + onNext, + onUpdate, + theme, +}: { + contentsRef: MutableRefObject<HTMLDivElement | null>; + i18n: LocalizerType; + initialEmoji: string | undefined; + initialName?: string; + isEditing: boolean; + onBack: VoidFunction; + onNext: () => void; + onUpdate: (data: { emoji: string | undefined; name: string }) => void; + theme: ThemeType; +}) { + const [emojiPickerOpen, setEmojiPickerOpen] = React.useState(false); + const [name, setName] = React.useState(initialName); + const [emoji, setEmoji] = React.useState<string | undefined>(initialEmoji); + const emojiLocalizer = useFunEmojiLocalizer(); + + const isValid = Boolean(name); + const sampleProfileNames = React.useMemo(() => { + return [ + { + emoji: '💪', + text: i18n('icu:NotificationProfiles--sample-name__work'), + }, + { + emoji: '😴', + text: i18n('icu:NotificationProfiles--sample-name__sleep'), + }, + { + emoji: '🚗', + text: i18n('icu:NotificationProfiles--sample-name__driving'), + }, + { + emoji: '😊', + text: i18n('icu:NotificationProfiles--sample-name__downtime'), + }, + { + emoji: '💡', + text: i18n('icu:NotificationProfiles--sample-name__focus'), + }, + ] as const; + }, [i18n]); + + const handleFunEmojiPickerOpenChange = React.useCallback((open: boolean) => { + setEmojiPickerOpen(open); + }, []); + + const handleInputChange = React.useCallback( + (newName: string) => { + setName(newName); + + if (newName === '') { + setEmoji(undefined); + } else { + onUpdate({ name: newName, emoji }); + } + }, + [emoji, setEmoji, setName, onUpdate] + ); + + const emojiKey = emoji ? getEmojiVariantKey(emoji) : null; + + return ( + <> + <Header + onBack={onBack} + title={ + isEditing + ? i18n('icu:NotificationProfiles--name-title--editing') + : undefined + } + i18n={i18n} + /> + <Container contentsRef={contentsRef}> + {!isEditing ? ( + <Title title={i18n('icu:NotificationProfiles--name-title')} /> + ) : undefined} + <div className={tw('mt-9 w-full grow')}> + <Input + expandable + hasClearButton + i18n={i18n} + icon={ + <FunEmojiPicker + open={emojiPickerOpen} + onOpenChange={handleFunEmojiPickerOpenChange} + placement="bottom" + onSelectEmoji={data => { + const newEmoji = getEmojiVariantByKey(data.variantKey)?.value; + + setEmoji(newEmoji); + if (name) { + onUpdate({ name, emoji: newEmoji }); + } + }} + closeOnSelect + theme={theme} + > + <FunEmojiPickerButton i18n={i18n} selectedEmoji={emojiKey} /> + </FunEmojiPicker> + } + maxLengthCount={140} + maxByteCount={512} + moduleClassName="NotificationProfiles__NamePage" + onChange={handleInputChange} + ref={undefined} + placeholder={i18n('icu:NotificationProfiles--name-placeholder')} + value={name} + whenToShowRemainingCount={40} + /> + <div className={tw('mx-auto w-full max-w-[320px]')}> + {sampleProfileNames.map(item => { + const itemEmojiKey = getEmojiVariantKey(item.emoji); + strictAssert( + itemEmojiKey, + 'Emoji for name defaults should exist' + ); + const itemEmojiData = getEmojiVariantByKey(itemEmojiKey); + + return ( + <FullWidthButton + key={item.text} + className={tw('ms-[-4px] min-h-[52px] gap-4 ps-4 pe-3')} + onClick={() => { + const newName = item.text; + const newEmoji = item.emoji; + + setName(newName); + setEmoji(newEmoji); + onUpdate({ emoji: newEmoji, name: newName }); + }} + > + <FunStaticEmoji + role="img" + aria-label={emojiLocalizer.getLocaleShortName( + itemEmojiData.key + )} + size={24} + emoji={itemEmojiData} + /> + {item.text} + </FullWidthButton> + ); + })} + </div> + </div> + </Container> + <ButtonContainer> + <AxoButton.Root + variant="primary" + size="large" + type="button" + form="notificationProfileName" + disabled={!isValid} + onClick={onNext} + > + {isEditing ? i18n('icu:done') : i18n('icu:next2')} + </AxoButton.Root> + </ButtonContainer> + </> + ); +} + +function NotificationProfilesAllowedPage({ + allowAllCalls, + allowedMembers, + contentsRef, + conversations, + conversationSelector, + i18n, + allowAllMentions, + onBack, + onNext, + onSetAllowedMembers, + onSetAllowAllCalls, + onSetAllowAllMentions, + preferredBadgeSelector, + theme, +}: { + allowAllCalls: boolean; + allowedMembers: ReadonlyArray<string>; + contentsRef: MutableRefObject<HTMLDivElement | null>; + conversations: ReadonlyArray<ConversationType>; + conversationSelector: GetConversationByIdType; + i18n: LocalizerType; + allowAllMentions: boolean; + onBack: VoidFunction; + onNext: VoidFunction; + onSetAllowedMembers: (members: ReadonlyArray<string>) => void; + onSetAllowAllCalls: (value: boolean) => void; + onSetAllowAllMentions: (value: boolean) => void; + preferredBadgeSelector: PreferredBadgeSelectorType; + theme: ThemeType; +}) { + return ( + <> + <Header onBack={onBack} i18n={i18n} /> + <Container contentsRef={contentsRef}> + <Title title={i18n('icu:NotificationProfiles--allowed-title')} /> + <p className={tw('mt-4 mb-13 max-w-[335px] text-center leading-5')}> + {i18n('icu:NotificationProfiles--allowed-description')} + </p> + <AllowedMembersSection + allowedMembers={allowedMembers} + conversations={conversations} + conversationSelector={conversationSelector} + i18n={i18n} + onSetAllowedMembers={onSetAllowedMembers} + preferredBadgeSelector={preferredBadgeSelector} + theme={theme} + title={i18n('icu:NotificationProfiles--allowed-title')} + /> + <ExceptionsSection + allowAllCalls={allowAllCalls} + allowAllMentions={allowAllMentions} + i18n={i18n} + onSetAllowAllCalls={onSetAllowAllCalls} + onSetAllowAllMentions={onSetAllowAllMentions} + /> + </Container> + <ButtonContainer> + <form + onSubmit={e => { + e.preventDefault(); + onNext(); + }} + > + <AxoButton.Root variant="primary" size="large" type="submit"> + {i18n('icu:next2')} + </AxoButton.Root> + </form> + </ButtonContainer> + </> + ); +} + +function NotificationProfilesSchedulePage({ + isEnabled, + scheduleDays, + startTime, + endTime, + contentsRef, + i18n, + isEditing, + onBack, + onNext, + onSetIsEnabled, + onSetScheduleDays, + onSetStartTime, + onSetEndTime, + theme, +}: { + isEnabled: boolean; + scheduleDays: ScheduleDays; + startTime: number; + endTime: number; + contentsRef: MutableRefObject<HTMLDivElement | null>; + i18n: LocalizerType; + isEditing: boolean; + onBack: () => void; + onNext: () => void; + onSetIsEnabled: (value: boolean) => void; + onSetScheduleDays: (value: ScheduleDays) => void; + onSetStartTime: (value: number) => void; + onSetEndTime: (value: number) => void; + theme: ThemeType; +}) { + const daysInUIOrder = React.useMemo(() => { + return [ + { + dayOfWeek: DayOfWeek.SUNDAY, + label: i18n('icu:NotificationProfiles--schedule-sunday'), + }, + { + dayOfWeek: DayOfWeek.MONDAY, + label: i18n('icu:NotificationProfiles--schedule-monday'), + }, + { + dayOfWeek: DayOfWeek.TUESDAY, + label: i18n('icu:NotificationProfiles--schedule-tuesday'), + }, + { + dayOfWeek: DayOfWeek.WEDNESDAY, + label: i18n('icu:NotificationProfiles--schedule-wednesday'), + }, + { + dayOfWeek: DayOfWeek.THURSDAY, + label: i18n('icu:NotificationProfiles--schedule-thursday'), + }, + { + dayOfWeek: DayOfWeek.FRIDAY, + label: i18n('icu:NotificationProfiles--schedule-friday'), + }, + { + dayOfWeek: DayOfWeek.SATURDAY, + label: i18n('icu:NotificationProfiles--schedule-saturday'), + }, + ]; + }, [i18n]); + + return ( + <> + <Header + onBack={onBack} + title={ + isEditing + ? i18n('icu:NotificationProfiles--schedule-title--editing') + : undefined + } + i18n={i18n} + /> + <Container contentsRef={contentsRef}> + {!isEditing && ( + <> + <Title title={i18n('icu:NotificationProfiles--schedule-title')} /> + <FullWidthRow> + <p + className={tw( + 'mx-auto mt-2 mb-4 max-w-[335px] text-center leading-5' + )} + > + {i18n('icu:NotificationProfiles--schedule-description')} + </p> + </FullWidthRow> + </> + )} + <FullWidthRow + className={tw('mt-4 flex min-h-[40px] w-full items-center py-2')} + > + <div className={tw('grow type-body-large')}> + {i18n('icu:NotificationProfiles--schedule-enable')} + </div> + <div className={tw('ms-4')}> + <AxoSwitch.Root + checked={isEnabled} + onCheckedChange={onSetIsEnabled} + /> + </div> + </FullWidthRow> + <FullWidthRow className={tw('mt-3 min-h-[40px] w-full pt-3 pb-2')}> + <h2 className={tw('type-title-small')}> + {i18n('icu:NotificationProfiles--schedule')} + </h2> + </FullWidthRow> + <FullWidthRow className={tw('flex min-h-[40px] items-center')}> + <span id="start-label" className={tw('grow')}> + {i18n('icu:NotificationProfiles--schedule-from')} + </span> + <span className={tw('shrink-0')}> + <TimePicker + i18n={i18n} + isDisabled={!isEnabled} + labelId="start-label" + onUpdateTime={onSetStartTime} + theme={theme} + time={startTime} + /> + </span> + </FullWidthRow> + <FullWidthRow className={tw('flex min-h-[40px] items-center')}> + <span id="end-label" className={tw('grow')}> + {i18n('icu:NotificationProfiles--schedule-until')} + </span> + <span className={tw('shrink-0')}> + <TimePicker + i18n={i18n} + isDisabled={!isEnabled} + labelId="end-label" + onUpdateTime={onSetEndTime} + theme={theme} + time={endTime} + /> + </span> + </FullWidthRow> + <FullWidthRow className={tw('mt-3')}> + {daysInUIOrder.map(day => { + return ( + <DayCheckbox + key={day.label} + label={day.label} + dayOfWeek={day.dayOfWeek} + isEnabled={isEnabled} + scheduleDays={scheduleDays} + onSetScheduleDays={onSetScheduleDays} + /> + ); + })} + </FullWidthRow> + </Container> + <ButtonContainer> + <AxoButton.Root + variant="primary" + size="large" + type="button" + onClick={onNext} + > + {isEditing ? i18n('icu:done') : i18n('icu:next2')} + </AxoButton.Root> + </ButtonContainer> + </> + ); +} + +function NotificationProfilesDonePage({ + contentsRef, + i18n, + onNext, + profile, +}: { + contentsRef: MutableRefObject<HTMLDivElement | null>; + i18n: LocalizerType; + onNext: () => void; + profile: ProfileToSave; +}): JSX.Element { + return ( + <> + <Header i18n={i18n} /> + <MidFloatingContainer contentsRef={contentsRef}> + <div className={tw('mb-4')}> + <ProfileAvatar i18n={i18n} profile={profile} size="large" /> + </div> + <Title title={i18n('icu:NotificationProfiles--done-title')} /> + <p className={tw('mt-4 mb-6 max-w-[350px] text-center leading-5')}> + {i18n('icu:NotificationProfiles--done-description')} + </p> + <AxoButton.Root + variant="primary" + size="large" + type="button" + onClick={onNext} + > + {i18n('icu:done')} + </AxoButton.Root> + </MidFloatingContainer> + </> + ); +} + +function NotificationProfilesListPage({ + allProfiles, + contentsRef, + i18n, + isSyncEnabled, + loading, + onBack, + onCreateProfile, + onEditProfile, + setIsSyncEnabled, +}: { + allProfiles: ReadonlyArray<NotificationProfileType>; + contentsRef: MutableRefObject<HTMLDivElement | null>; + i18n: LocalizerType; + isSyncEnabled: boolean; + loading: boolean; + onBack: () => void; + onCreateProfile: () => void; + onEditProfile: (profileToEdit: NotificationProfileType) => void; + setIsSyncEnabled: (value: boolean) => void; +}) { + const [cachedProfiles, setCachedProfiles] = React.useState< + ReadonlyArray<NotificationProfileType> + >([]); + React.useEffect(() => { + if (!loading) { + setCachedProfiles(allProfiles); + } + }, [loading, allProfiles]); + + const profilesToRender = loading ? cachedProfiles : allProfiles; + + return ( + <> + <Header + onBack={onBack} + title={i18n('icu:NotificationProfiles--title')} + i18n={i18n} + /> + <Container contentsRef={contentsRef}> + <FullWidthRow className={tw('mt-3 min-h-[40px] pt-3')}> + <h2 className={tw('type-title-small')}> + {i18n('icu:NotificationProfiles--list--header')} + </h2> + </FullWidthRow> + <FullWidthButton + className={tw('min-h-[52px]')} + onClick={() => onCreateProfile()} + > + <PlusIconInCircle /> + <span>{i18n('icu:NotificationProfiles--create')}</span> + </FullWidthButton> + {profilesToRender.map(profile => { + return ( + <FullWidthButton + key={profile.id} + className={tw('min-h-[52px]')} + onClick={() => onEditProfile(profile)} + testId={`EditProfile--${profile.name}`} + > + <ProfileAvatar i18n={i18n} profile={profile} size="medium" /> + <span className={tw('ms-4')}>{profile.name}</span> + </FullWidthButton> + ); + })} + <FullWidthDivider /> + <FullWidthRow className={tw('flex min-h-[40px] items-start pt-1')}> + <div className={tw('grow')}> + <div className={tw('type-body-large text-label-primary')}> + {i18n('icu:NotificationProfiles--list--sync')} + </div> + <div className={tw('mt-1 type-body-small text-label-secondary')}> + {i18n('icu:NotificationProfiles--list--sync--description')} + </div> + </div> + <div className={tw('ms-4')}> + <AxoSwitch.Root + checked={isSyncEnabled} + onCheckedChange={value => { + setIsSyncEnabled(value); + }} + /> + </div> + </FullWidthRow> + </Container> + </> + ); +} + +function NotificationProfilesEditPage({ + activeProfileId, + contentsRef, + conversations, + conversationSelector, + i18n, + onBack, + onDeleteProfile, + onEditName, + onEditSchedule, + onEditProfile, + onUpdateOverrideState, + preferredBadgeSelector, + profile, + theme, +}: { + activeProfileId: NotificationProfileIdString | undefined; + contentsRef: MutableRefObject<HTMLDivElement | null>; + conversations: ReadonlyArray<ConversationType>; + conversationSelector: GetConversationByIdType; + i18n: LocalizerType; + onBack: () => void; + onDeleteProfile: () => void; + onEditName: () => void; + onEditSchedule: () => void; + onEditProfile: (profile: NotificationProfileType) => void; + onUpdateOverrideState: (value: boolean) => void; + preferredBadgeSelector: PreferredBadgeSelectorType; + profile: NotificationProfileType; + theme: ThemeType; +}) { + const [isConfirmingDelete, setIsConfirmingDelete] = React.useState(false); + + const activeString = i18n('icu:NotificationProfiles--edit--is-active'); + const notActiveString = i18n('icu:NotificationProfiles--edit--is-not-active'); + const isProfileActive = activeProfileId === profile.id; + const currentActiveString = isProfileActive ? activeString : notActiveString; + + const allowedMembersArray = React.useMemo(() => { + return Array.from(profile.allowedMembers); + }, [profile.allowedMembers]); + + return ( + <> + {isConfirmingDelete ? ( + <ConfirmationDialog + dialogName="NotificationProfileDelete" + actions={[ + { + action: onDeleteProfile, + text: i18n('icu:NotificationProfiles--delete-button'), + style: 'affirmative', + }, + ]} + i18n={i18n} + onClose={() => setIsConfirmingDelete(false)} + > + {i18n('icu:NotificationProfiles--delete-confirmation')} + </ConfirmationDialog> + ) : null} + <Header onBack={onBack} title={profile.name} i18n={i18n} /> + <Container contentsRef={contentsRef}> + <AriaClickable.Root + className={tw( + 'group mb-3 flex min-h-[80px] w-full items-center rounded-md border-[2.5px] border-transparent px-[11.5px] outline-none data-[focused]:border-color-label-light' + )} + > + <ProfileAvatar i18n={i18n} profile={profile} size="medium" /> + <span className={tw('ms-3 text-start')}>{profile.name}</span> + <span + id="edit-icon" + className={tw( + 'ms-2 opacity-0 group-hover:opacity-100 group-data-[focused]:opacity-100' + )} + > + <AxoSymbol.Icon + size={20} + symbol="pencil" + label={i18n('icu:NotificationProfiles--edit--edit-name-label')} + /> + <AriaClickable.HiddenTrigger + onClick={onEditName} + aria-labelledby="edit-icon" + /> + </span> + + <span className={tw('grow')} /> + <span> + <AriaClickable.SubWidget> + <AxoSelect.Root + value={currentActiveString} + onValueChange={stringValue => { + const value = stringValue === activeString; + onUpdateOverrideState(value); + }} + > + <AxoSelect.Trigger placeholder={currentActiveString}> + {currentActiveString} + </AxoSelect.Trigger> + <AxoSelect.Content> + <AxoSelect.Item + key="isActive" + value={activeString} + textValue={activeString} + > + <AxoSelect.ItemText>{activeString}</AxoSelect.ItemText> + </AxoSelect.Item> + <AxoSelect.Item + key="isNotActive" + value={notActiveString} + textValue={notActiveString} + > + <AxoSelect.ItemText>{notActiveString}</AxoSelect.ItemText> + </AxoSelect.Item> + </AxoSelect.Content> + </AxoSelect.Root> + </AriaClickable.SubWidget> + </span> + </AriaClickable.Root> + + <AllowedMembersSection + allowedMembers={allowedMembersArray} + conversations={conversations} + conversationSelector={conversationSelector} + i18n={i18n} + onSetAllowedMembers={allowedMembers => { + onEditProfile({ + ...profile, + allowedMembers: new Set(allowedMembers), + }); + }} + preferredBadgeSelector={preferredBadgeSelector} + theme={theme} + title={i18n('icu:NotificationProfiles--edit--allowed', { + allowedCount: allowedMembersArray.length, + })} + /> + <FullWidthRow className={tw('mt-8 mb-2')}> + <h2 className={tw('type-title-small')}> + {i18n('icu:NotificationProfiles--schedule')} + </h2> + </FullWidthRow> + <FullWidthButton + testId="EditSchedule" + onClick={onEditSchedule} + className={tw('min-h-[55px] py-2')} + > + <div className={tw('grow text-start')}> + <div> + {i18n('icu:NotificationProfiles--edit--schedule-timing', { + startTime: formatTimeForDisplay( + profile.scheduleStartTime ?? DEFAULT_START + ), + endTime: formatTimeForDisplay( + profile.scheduleEndTime ?? DEFAULT_END + ), + })} + </div> + <div className={tw('mt-0.5 type-body-small text-label-secondary')}> + <ScheduleSummary + i18n={i18n} + scheduleDays={profile.scheduleDaysEnabled ?? DEFAULT_SCHEDULE} + /> + </div> + </div> + <div className={tw('ms-5')}> + {profile.scheduleEnabled + ? i18n('icu:NotificationProfiles--edit--schedule-enabled') + : i18n('icu:NotificationProfiles--edit--schedule-disabled')} + </div> + </FullWidthButton> + <ExceptionsSection + allowAllCalls={profile.allowAllCalls} + allowAllMentions={profile.allowAllMentions} + i18n={i18n} + onSetAllowAllCalls={allowAllCalls => { + onEditProfile({ ...profile, allowAllCalls }); + }} + onSetAllowAllMentions={allowAllMentions => { + onEditProfile({ ...profile, allowAllMentions }); + }} + /> + <FullWidthButton + className={tw('mt-6 min-h-[52px]')} + onClick={() => setIsConfirmingDelete(true)} + > + <div className={tw('me-4 text-color-label-destructive')}> + <AxoSymbol.Icon size={24} symbol="trash" label={null} /> + </div> + <span className={tw('grow text-start text-color-label-destructive')}> + {i18n('icu:NotificationProfiles--delete')} + </span> + </FullWidthButton> + </Container> + <ButtonContainer> + <AxoButton.Root + variant="primary" + size="large" + type="button" + onClick={onBack} + > + {i18n('icu:done')} + </AxoButton.Root> + </ButtonContainer> + </> + ); +} + +// Utility components + +export function FullWidthButton({ + children, + className, + onClick, + testId, +}: { + children: React.ReactNode; + className?: string; + onClick: () => void; + testId?: string; +}): JSX.Element { + return ( + <button + className={classNames( + tw( + 'flex w-full items-center rounded-md border-[2.5px] border-transparent px-[11.5px] outline-none focus-visible:border-color-label-light' + ), + className + )} + data-testid={testId} + onClick={onClick} + type="button" + > + {children} + </button> + ); +} + +function FullWidthRow({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return ( + <div className={classNames(tw('w-full px-[14px]'), className)}> + {children} + </div> + ); +} + +function FullWidthDivider() { + return ( + <div className={tw('my-3 w-full px-[14px]')}> + <hr + aria-orientation="horizontal" + className={tw('border-t-[0.5px] border-label-secondary')} + /> + </div> + ); +} + +function Header({ + onBack, + title, + i18n, +}: { + onBack?: VoidFunction; + title?: string; + i18n: LocalizerType; +}) { + return ( + <div className="Preferences__title"> + {onBack ? ( + <button + aria-label={i18n('icu:goBack')} + className="Preferences__back-icon" + onClick={onBack} + type="button" + /> + ) : null} + {title ? <div className="Preferences__title--header">{title}</div> : null} + </div> + ); +} + +function Container({ + children, + contentsRef, +}: { + children: React.ReactNode; + contentsRef: MutableRefObject<HTMLDivElement | null>; +}) { + return ( + <div className={tw('relative flex grow overflow-y-scroll')}> + <div className={tw('grow')} /> + <div + ref={contentsRef} + className={tw( + 'flex w-full max-w-[798px] grow flex-col items-center px-[10px]' + )} + > + {children} + </div> + <div className={tw('grow')} /> + </div> + ); +} + +function Title({ title }: { title: string }) { + return <h1 className={tw('type-title-medium')}>{title}</h1>; +} + +function ButtonContainer({ children }: { children: React.ReactNode }) { + return ( + <div + className={tw( + 'mx-auto flex w-full max-w-[798px] justify-end p-6 pe-[33px]' + )} + > + {children} + </div> + ); +} + +function MidFloatingContainer({ + children, + contentsRef, +}: { + children: React.ReactNode; + contentsRef: MutableRefObject<HTMLDivElement | null>; +}) { + return ( + <div className={tw('relative h-full grow')}> + <div + className={tw( + 'absolute top-4/10 flex w-full transform-[translateY(-40%)] flex-col items-center px-4' + )} + ref={contentsRef} + > + {children} + </div> + </div> + ); +} + +function DayCheckbox({ + label, + dayOfWeek, + isEnabled, + scheduleDays, + onSetScheduleDays, +}: { + label: string; + dayOfWeek: DayOfWeek; + isEnabled: boolean; + scheduleDays: ScheduleDays; + onSetScheduleDays: (value: ScheduleDays) => void; +}) { + return ( + <div className={tw('py-[3px]')}> + <Checkbox + label={label} + disabled={!isEnabled} + checked={scheduleDays[dayOfWeek]} + name={`dayEnabled-${dayOfWeek}`} + onChange={value => { + onSetScheduleDays({ + ...scheduleDays, + [dayOfWeek]: value, + }); + }} + /> + </div> + ); +} + +type IconSize = 'large' | 'medium' | 'medium-small' | 'small'; + +function EmojiOrMoon({ + emoji, + forceLightTheme, + i18n, + size, +}: { + emoji?: EmojiVariantKey | undefined; + forceLightTheme?: boolean; + i18n: LocalizerType; + size: IconSize; +}) { + const emojiLocalizer = useFunEmojiLocalizer(); + const sizeMap = React.useMemo( + () => ({ + large: 48 as const, + medium: 20 as const, + 'medium-small': 16 as const, + small: 12 as const, + }), + [] + ); + + if (!emoji) { + return ( + <div + className={tw( + 'absolute start-1/2 top-1/2 -translate-1/2 leading-none text-color-label-primary' + )} + style={ + forceLightTheme + ? { + colorScheme: 'light', + } + : {} + } + > + <AxoSymbol.Icon + label={i18n('icu:NotificationProfile--moon-icon')} + size={sizeMap[size]} + symbol="moon-fill" + /> + </div> + ); + } + + const emojiData = getEmojiVariantByKey(emoji); + + return ( + <span className={tw('absolute start-1/2 top-1/2 -translate-1/2 leading-0')}> + <FunStaticEmoji + role="img" + aria-label={emojiLocalizer.getLocaleShortName(emojiData.key)} + size={sizeMap[size]} + emoji={emojiData} + /> + </span> + ); +} + +function PlusIconInCircle() { + return ( + <div + className={tw( + 'me-3 flex size-[36px] items-center justify-center rounded-full bg-background-secondary' + )} + > + <AxoSymbol.Icon size={20} symbol="plus" label={null} /> + </div> + ); +} + +function AllowedMembersSection({ + allowedMembers, + conversations, + conversationSelector, + i18n, + onSetAllowedMembers, + preferredBadgeSelector, + theme, + title, +}: { + allowedMembers: ReadonlyArray<string>; + conversations: ReadonlyArray<ConversationType>; + conversationSelector: GetConversationByIdType; + i18n: LocalizerType; + onSetAllowedMembers: (members: ReadonlyArray<string>) => void; + preferredBadgeSelector: PreferredBadgeSelectorType; + theme: ThemeType; + title: string; +}) { + const [showingMemberChooser, setShowingMemberChooser] = React.useState(false); + + return ( + <> + {showingMemberChooser ? ( + <PreferencesSelectChatsDialog + i18n={i18n} + title={i18n('icu:NotificationProfiles--allowed-title')} + conversations={conversations} + conversationSelector={conversationSelector} + onClose={({ selectedRecipientIds }) => { + onSetAllowedMembers(selectedRecipientIds); + setShowingMemberChooser(false); + }} + preferredBadgeSelector={preferredBadgeSelector} + theme={theme} + initialSelection={{ + selectedRecipientIds: Array.from(allowedMembers), + selectAllIndividualChats: false, + selectAllGroupChats: false, + }} + showChatTypes={false} + /> + ) : null} + <FullWidthRow> + <h2 className={tw('mb-1 type-title-small')}>{title}</h2> + </FullWidthRow> + <FullWidthButton + onClick={() => setShowingMemberChooser(true)} + className={tw('min-h-[52px]')} + > + <PlusIconInCircle /> + <span>{i18n('icu:NotificationProfiles--allowed-add-label')}</span> + </FullWidthButton> + {allowedMembers.map(member => { + const conversation = conversationSelector(member); + const badge = preferredBadgeSelector(conversation.badges); + + return ( + <FullWidthButton + key={conversation.id} + onClick={() => setShowingMemberChooser(true)} + className={tw('min-h-[52px]')} + > + <div + className={tw( + 'me-3 flex size-[36px] items-center justify-center rounded-full bg-background-secondary' + )} + > + <Avatar + {...conversation} + badge={badge} + conversationType={conversation.type} + i18n={i18n} + size={36} + theme={theme} + /> + </div> + <span>{conversation.title}</span> + </FullWidthButton> + ); + })} + </> + ); +} + +function ExceptionsSection({ + allowAllCalls, + allowAllMentions, + i18n, + onSetAllowAllMentions, + onSetAllowAllCalls, +}: { + allowAllCalls: boolean; + allowAllMentions: boolean; + i18n: LocalizerType; + onSetAllowAllMentions: (value: boolean) => void; + onSetAllowAllCalls: (value: boolean) => void; +}) { + return ( + <> + <FullWidthRow className={tw('mt-8 mb-1')}> + <h2 className={tw('type-title-small')}> + {i18n('icu:NotificationProfiles--exceptions')} + </h2> + </FullWidthRow> + <FullWidthRow className={tw('flex min-h-[40px] items-center')}> + <div className={tw('grow type-body-large')}> + {i18n('icu:NotificationProfiles--exceptions--allow-all-calls')} + </div> + <div className={tw('ms-4')}> + <AxoSwitch.Root + checked={allowAllCalls} + onCheckedChange={onSetAllowAllCalls} + /> + </div> + </FullWidthRow> + <FullWidthRow className={tw('flex min-h-[40px] items-center')}> + <div className={tw('grow type-body-large')}> + {i18n('icu:NotificationProfiles--exceptions--notify-for-mentions')} + </div> + <div className={tw('ms-4')}> + <AxoSwitch.Root + checked={allowAllMentions} + onCheckedChange={onSetAllowAllMentions} + /> + </div> + </FullWidthRow> + </> + ); +} + +export function ProfileAvatar({ + i18n, + isActive, + profile, + size, +}: { + i18n: LocalizerType; + isActive?: boolean; + profile?: ProfileToSave; + size: IconSize; +}): React.ReactNode { + const emoji = profile?.emoji ? getEmojiVariantKey(profile.emoji) : undefined; + const backgroundColor = profile?.color + ? getColorFromProfile(profile.color) + : undefined; + const forceLightTheme = profile && !profile.emoji; + + const sizeMap = React.useMemo( + () => ({ + large: tw('size-[80px]'), + medium: tw('size-[36px]'), + 'medium-small': tw('size-[28px]'), + small: tw('size-[20px]'), + }), + [] + ); + const sizeClass = sizeMap[size]; + + return ( + <div + className={classNames( + tw('relative rounded-full'), + sizeClass, + isActive ? tw('border-2 border-border-selected') : undefined, + !backgroundColor ? tw('bg-color-label-light-disabled') : undefined + )} + style={{ backgroundColor }} + > + <EmojiOrMoon + emoji={emoji} + forceLightTheme={forceLightTheme} + i18n={i18n} + size={size} + /> + </div> + ); +} + +function ScheduleSummary({ + i18n, + scheduleDays, +}: { + i18n: LocalizerType; + scheduleDays: ScheduleDays; +}): string { + const daysInUIOrder = React.useMemo(() => { + return [ + { + dayOfWeek: DayOfWeek.SUNDAY, + label: i18n('icu:NotificationProfiles--schedule-sunday-short'), + }, + { + dayOfWeek: DayOfWeek.MONDAY, + label: i18n('icu:NotificationProfiles--schedule-monday-short'), + }, + { + dayOfWeek: DayOfWeek.TUESDAY, + label: i18n('icu:NotificationProfiles--schedule-tuesday-short'), + }, + { + dayOfWeek: DayOfWeek.WEDNESDAY, + label: i18n('icu:NotificationProfiles--schedule-wednesday-short'), + }, + { + dayOfWeek: DayOfWeek.THURSDAY, + label: i18n('icu:NotificationProfiles--schedule-thursday-short'), + }, + { + dayOfWeek: DayOfWeek.FRIDAY, + label: i18n('icu:NotificationProfiles--schedule-friday-short'), + }, + { + dayOfWeek: DayOfWeek.SATURDAY, + label: i18n('icu:NotificationProfiles--schedule-saturday-short'), + }, + ]; + }, [i18n]); + + if (isEqual(scheduleDays, DEFAULT_SCHEDULE)) { + return i18n('icu:NotificationProfiles--schedule-weekdays'); + } + if (isEqual(scheduleDays, WEEKEND_SCHEDULE)) { + return i18n('icu:NotificationProfiles--schedule-weekends'); + } + if (isEqual(scheduleDays, DAILY_SCHEDULE)) { + return i18n('icu:NotificationProfiles--schedule-daily'); + } + + let result = ''; + daysInUIOrder.forEach(day => { + if (!scheduleDays[day.dayOfWeek]) { + return; + } + + if (result) { + result += i18n('icu:NotificationProfiles--schedule-separator'); + } + + result += day.label; + }); + + return result; +} + +const HOURS_24 = range(0, 24); +const HOURS_12 = range(1, 13); +const MINUTES = range(0, 60); + +function TimePicker({ + i18n, + isDisabled, + labelId, + theme, + time, + onUpdateTime, +}: { + i18n: LocalizerType; + isDisabled: boolean; + labelId: string; + theme: ThemeType; + time: number; + onUpdateTime: (value: number) => void; +}) { + const [isShowingPopup, setIsShowingPopup] = React.useState(false); + const use24HourTime = need24HourTime(); + const AM_PM: Array<PERIOD> = ['AM', 'PM']; + const periodLookup = React.useMemo(() => { + return { + AM: i18n('icu:NotificationProfile--am'), + PM: i18n('icu:NotificationProfile--pm'), + }; + }, [i18n]); + const [timeFieldElement, setTimeFieldElement] = React.useState< + HTMLDivElement | undefined + >(); + const [popupElement, setPopupElement] = React.useState< + HTMLDivElement | undefined + >(); + const { minutes, hours, period } = getTimeDetails(time, use24HourTime); + const refMerger = useRefMerger(); + const selectedHour = React.useRef<HTMLButtonElement | null>(null); + const selectedMinute = React.useRef<HTMLButtonElement | null>(null); + + React.useEffect(() => { + if (!isShowingPopup || !popupElement) { + return noop; + } + return handleOutsideClick( + (_target, event) => { + event.preventDefault(); + event.stopImmediatePropagation(); + setIsShowingPopup(false); + return true; + }, + { + containerElements: [popupElement], + name: 'TimePicker.popup', + } + ); + }, [isShowingPopup, popupElement, setIsShowingPopup]); + + React.useEffect(() => { + if (!isShowingPopup || !popupElement) { + return; + } + if (selectedHour.current) { + selectedHour.current.focus(); + } + if (selectedMinute.current) { + selectedMinute.current.scrollIntoView(); + } + }, [isShowingPopup, popupElement, setIsShowingPopup]); + + useEscapeHandling( + isShowingPopup ? () => setIsShowingPopup(false) : undefined + ); + + return ( + <> + {isShowingPopup && ( + <Popper + placement="bottom-end" + modifiers={[offsetDistanceModifier(6)]} + referenceElement={timeFieldElement} + > + {({ ref, style }) => ( + <div + ref={refMerger(ref, (element: HTMLDivElement | null) => + setPopupElement(element ?? undefined) + )} + style={style} + className={classNames( + 'TimePickerPopup', + tw( + 'flex h-[244px] rounded-[10px] bg-background-secondary p-1 shadow-elevation-1' + ), + use24HourTime ? tw('w-[102px]') : tw('w-[150px]'), + theme ? themeClassName2(theme) : undefined + )} + > + <div className={tw('w-[46px] overflow-y-scroll')}> + {(use24HourTime ? HOURS_24 : HOURS_12).map(hour => { + const isSelected = hour === hours; + + return ( + <button + key={hour.toString()} + ref={isSelected ? selectedHour : null} + className={classNames( + tw('w-[46px] rounded-sm py-[7px] type-body-medium'), + isSelected ? tw('bg-fill-secondary') : null + )} + type="button" + onClick={() => { + const newTime = makeTime(hour, minutes, period); + onUpdateTime(newTime); + }} + > + {hour} + </button> + ); + })} + </div> + <div className={tw('ms-0.5 w-[46px] overflow-y-scroll')}> + {MINUTES.map(minute => { + const isSelected = minute === minutes; + + return ( + <button + key={minute.toString()} + ref={isSelected ? selectedMinute : null} + className={classNames( + tw('w-[46px] rounded-sm py-[7px] type-body-medium'), + isSelected ? tw('bg-fill-secondary') : null + )} + type="button" + onClick={() => { + const newTime = makeTime(hours, minute, period); + onUpdateTime(newTime); + }} + > + {addLeadingZero(minute)} + </button> + ); + })} + </div> + {!use24HourTime ? ( + <div className={tw('ms-0.5 w-[46px] overflow-y-scroll')}> + {AM_PM.map(item => { + const isSelected = item === period; + + return ( + <button + key={item} + className={classNames( + tw('w-[46px] rounded-sm py-[7px] type-body-medium'), + isSelected ? tw('bg-fill-secondary') : null + )} + type="button" + onClick={() => { + const newTime = makeTime(hours, minutes, item); + onUpdateTime(newTime); + }} + > + {periodLookup[item]} + </button> + ); + })} + </div> + ) : null} + </div> + )} + </Popper> + )} + <TimeField + ref={element => { + setTimeFieldElement(element ?? undefined); + }} + className={tw( + 'flex items-center rounded-lg border-[2.5px] border-transparent bg-fill-secondary px-2 py-0.5 focus-within:border-border-focused' + )} + aria-labelledby={labelId} + hourCycle={use24HourTime ? 24 : 12} + isDisabled={isDisabled} + minValue={new Time(0, 0)} + maxValue={new Time(23, 59)} + onChange={value => { + if (!value) { + return; + } + onUpdateTime(parseTimeFromInput(value)); + }} + value={formatTimeForInput(time)} + > + <DateInput + className={tw('inline-flex min-w-[5em] items-center leading-none')} + > + {segment => { + // We don't need the space between the time and the am/pm + if (segment.type === 'literal' && segment.text === ' ') { + return <span />; + } + if (segment.type === 'literal') { + // eslint-disable-next-line no-param-reassign + segment.text = i18n('icu:NotificationProfile--time-separator'); + } + return ( + <DateSegment + className={classNames( + tw( + 'inline-block px-[1px] type-body-medium outline-none focus:bg-fill-selected' + ), + segment.type === 'literal' ? tw('px-[3px]') : null, + segment.type === 'dayPeriod' ? tw('ps-[2px]') : null, + segment.type === 'hour' ? tw('flex-grow text-end') : null, + isDisabled ? tw('text-label-placeholder') : null + )} + segment={segment} + /> + ); + }} + </DateInput> + <button + className={classNames( + tw( + 'ms-3 p-0.5 leading-none outline-0 focus-visible:bg-background-overlay-secondary' + ), + isDisabled ? tw('text-label-placeholder') : null + )} + type="button" + onClick={() => { + if (isDisabled) { + return; + } + setIsShowingPopup(!isShowingPopup); + }} + > + <AxoSymbol.Icon + size={14} + symbol="chevron-down" + label={i18n('icu:NotificationProfiles--open-time-picker')} + /> + </button> + </TimeField> + </> + ); +} diff --git a/ts/components/ToastManager.stories.tsx b/ts/components/ToastManager.stories.tsx index 9b669c0d47..2e52e4b6c0 100644 --- a/ts/components/ToastManager.stories.tsx +++ b/ts/components/ToastManager.stories.tsx @@ -165,6 +165,11 @@ function getToast(toastType: ToastType): AnyToast { return { toastType: ToastType.MessageBodyTooLong }; case ToastType.MessageLoop: return { toastType: ToastType.MessageLoop }; + case ToastType.NotificationProfileUpdate: + return { + toastType: ToastType.NotificationProfileUpdate, + parameters: { name: 'Focus', enabled: true }, + }; case ToastType.OriginalMessageNotFound: return { toastType: ToastType.OriginalMessageNotFound }; case ToastType.PinnedConversationsFull: diff --git a/ts/components/ToastManager.tsx b/ts/components/ToastManager.tsx index d93854c1b4..67231433e3 100644 --- a/ts/components/ToastManager.tsx +++ b/ts/components/ToastManager.tsx @@ -14,12 +14,13 @@ import { missingCaseError } from '../util/missingCaseError.js'; import { ToastType } from '../types/Toast.js'; import { MegaphoneType } from '../types/Megaphone.js'; import { NavTab, SettingsPage } from '../types/Nav.js'; +import { AxoSymbol } from '../axo/AxoSymbol.js'; +import { tw } from '../axo/tw.js'; import type { LocalizerType } from '../types/Util.js'; import type { AnyToast } from '../types/Toast.js'; import type { AnyActionableMegaphone } from '../types/Megaphone.js'; import type { Location } from '../types/Nav.js'; -import { tw } from '../axo/tw.js'; export type PropsType = { changeLocation: (newLocation: Location) => unknown; @@ -581,6 +582,26 @@ export function renderToast({ ); } + if (toastType === ToastType.NotificationProfileUpdate) { + const { name, enabled } = toast.parameters; + const text = enabled + ? i18n('icu:NotificationProfilesToast--enabled', { name }) + : i18n('icu:NotificationProfilesToast--disabled', { name }); + const label = enabled + ? i18n('icu:NotificationProfilesToast--enabled--label') + : i18n('icu:NotificationProfilesToast--disabled--label'); + const symbol = enabled ? 'moon-fill' : 'moon-slash-fill'; + + return ( + <Toast onClose={hideToast}> + <div className={tw('flex items-center')}> + <AxoSymbol.Icon symbol={symbol} size={12} label={label} /> + <span className={tw('mx-[10px]')}>{text}</span> + </div> + </Toast> + ); + } + if (toastType === ToastType.OriginalMessageNotFound) { return ( <Toast onClose={hideToast}>{i18n('icu:originalMessageNotFound')}</Toast> diff --git a/ts/components/conversation/MessageDetail.stories.tsx b/ts/components/conversation/MessageDetail.stories.tsx index 927771bca3..2c6d20cce4 100644 --- a/ts/components/conversation/MessageDetail.stories.tsx +++ b/ts/components/conversation/MessageDetail.stories.tsx @@ -111,7 +111,7 @@ export function DeliveredIncoming(args: Props): JSX.Element { contacts={[ { ...getDefaultConversation({ - color: 'forest', + color: 'A100', title: 'Max', }), status: undefined, diff --git a/ts/components/fun/FunEmoji.tsx b/ts/components/fun/FunEmoji.tsx index ddb30bf6d8..8ac3b8b604 100644 --- a/ts/components/fun/FunEmoji.tsx +++ b/ts/components/fun/FunEmoji.tsx @@ -30,6 +30,7 @@ function getEmojiJumboBackground( } export type FunStaticEmojiSize = + | 12 | 16 | 18 | 20 @@ -52,6 +53,7 @@ export enum FunJumboEmojiSize { } const funStaticEmojiSizeClasses = { + 12: 'FunStaticEmoji--Size12', 16: 'FunStaticEmoji--Size16', 18: 'FunStaticEmoji--Size18', 20: 'FunStaticEmoji--Size20', diff --git a/ts/components/preferences/chatFolders/PreferencesEditChatFoldersSelectChatsDialog.tsx b/ts/components/preferences/PreferencesSelectChatsDialog.tsx similarity index 69% rename from ts/components/preferences/chatFolders/PreferencesEditChatFoldersSelectChatsDialog.tsx rename to ts/components/preferences/PreferencesSelectChatsDialog.tsx index f5f304dce6..f2486c62f5 100644 --- a/ts/components/preferences/chatFolders/PreferencesEditChatFoldersSelectChatsDialog.tsx +++ b/ts/components/preferences/PreferencesSelectChatsDialog.tsx @@ -2,27 +2,30 @@ // SPDX-License-Identifier: AGPL-3.0-only import type { ChangeEvent } from 'react'; import React, { useCallback, useMemo, useState } from 'react'; -import type { ConversationType } from '../../../state/ducks/conversations.js'; -import type { PreferredBadgeSelectorType } from '../../../state/selectors/badges.js'; -import type { LocalizerType } from '../../../types/I18N.js'; -import type { ThemeType } from '../../../types/Util.js'; -import { filterAndSortConversations } from '../../../util/filterAndSortConversations.js'; -import { ContactPills } from '../../ContactPills.js'; -import { ContactPill } from '../../ContactPill.js'; + +import type { ConversationType } from '../../state/ducks/conversations.js'; +import type { PreferredBadgeSelectorType } from '../../state/selectors/badges.js'; +import type { LocalizerType } from '../../types/I18N.js'; +import type { ThemeType } from '../../types/Util.js'; +import { filterAndSortConversations } from '../../util/filterAndSortConversations.js'; +import { ContactPills } from '../ContactPills.js'; +import { ContactPill } from '../ContactPill.js'; import { asyncShouldNeverBeCalled, shouldNeverBeCalled, -} from '../../../util/shouldNeverBeCalled.js'; -import { SearchInput } from '../../SearchInput.js'; -import { Button, ButtonVariant } from '../../Button.js'; -import { Modal } from '../../Modal.js'; -import type { Row } from '../../ConversationList.js'; +} from '../../util/shouldNeverBeCalled.js'; +import { SearchInput } from '../SearchInput.js'; +import { Button, ButtonVariant } from '../Button.js'; +import { Modal } from '../Modal.js'; +import type { Row } from '../ConversationList.js'; import { ConversationList, GenericCheckboxRowIcon, RowType, -} from '../../ConversationList.js'; -import type { GetConversationByIdType } from '../../../state/selectors/conversations.js'; +} from '../ConversationList.js'; +import type { GetConversationByIdType } from '../../state/selectors/conversations.js'; +import { SizeObserver } from '../../hooks/useSizeObserver.js'; +import { tw } from '../../axo/tw.js'; export type ChatFolderSelection = Readonly<{ selectedRecipientIds: ReadonlyArray<string>; @@ -30,7 +33,7 @@ export type ChatFolderSelection = Readonly<{ selectAllGroupChats: boolean; }>; -export type PreferencesEditChatFoldersSelectChatsDialogProps = Readonly<{ +export type PreferencesSelectChatsDialogProps = Readonly<{ i18n: LocalizerType; title: string; conversations: ReadonlyArray<ConversationType>; @@ -42,8 +45,8 @@ export type PreferencesEditChatFoldersSelectChatsDialogProps = Readonly<{ showChatTypes: boolean; }>; -export function PreferencesEditChatFoldersSelectChatsDialog( - props: PreferencesEditChatFoldersSelectChatsDialogProps +export function PreferencesSelectChatsDialog( + props: PreferencesSelectChatsDialogProps ): JSX.Element { const { i18n, @@ -199,7 +202,7 @@ export function PreferencesEditChatFoldersSelectChatsDialog( onClose={handleClose} padded={false} noMouseClose - noEscapeClose + hasXButton modalFooter={ <Button variant={ButtonVariant.Primary} onClick={handleClose}> {i18n( @@ -240,35 +243,42 @@ export function PreferencesEditChatFoldersSelectChatsDialog( })} </ContactPills> )} - <ConversationList - dimensions={{ - width: 360, - height: 404, + <SizeObserver> + {(ref, size) => { + return ( + <div ref={ref} className={tw('min-h-[100px] w-full flex-grow')}> + {size != null && ( + <ConversationList + dimensions={size} + i18n={i18n} + getPreferredBadge={props.preferredBadgeSelector} + getRow={index => rows[index]} + onClickContactCheckbox={handleToggleSelectedConversation} + rowCount={rows.length} + shouldRecomputeRowHeights={false} + theme={props.theme} + // never called: + blockConversation={shouldNeverBeCalled} + lookupConversationWithoutServiceId={asyncShouldNeverBeCalled} + onClickArchiveButton={shouldNeverBeCalled} + onClickClearFilterButton={shouldNeverBeCalled} + onOutgoingAudioCallInConversation={shouldNeverBeCalled} + onOutgoingVideoCallInConversation={shouldNeverBeCalled} + onPreloadConversation={shouldNeverBeCalled} + onSelectConversation={shouldNeverBeCalled} + removeConversation={shouldNeverBeCalled} + setIsFetchingUUID={shouldNeverBeCalled} + showChooseGroupMembers={shouldNeverBeCalled} + showConversation={shouldNeverBeCalled} + showFindByPhoneNumber={shouldNeverBeCalled} + showFindByUsername={shouldNeverBeCalled} + showUserNotFoundModal={shouldNeverBeCalled} + /> + )} + </div> + ); }} - i18n={i18n} - getPreferredBadge={props.preferredBadgeSelector} - getRow={index => rows[index]} - onClickContactCheckbox={handleToggleSelectedConversation} - rowCount={rows.length} - shouldRecomputeRowHeights={false} - theme={props.theme} - // never called: - blockConversation={shouldNeverBeCalled} - lookupConversationWithoutServiceId={asyncShouldNeverBeCalled} - onClickArchiveButton={shouldNeverBeCalled} - onClickClearFilterButton={shouldNeverBeCalled} - onOutgoingAudioCallInConversation={shouldNeverBeCalled} - onOutgoingVideoCallInConversation={shouldNeverBeCalled} - onPreloadConversation={shouldNeverBeCalled} - onSelectConversation={shouldNeverBeCalled} - removeConversation={shouldNeverBeCalled} - setIsFetchingUUID={shouldNeverBeCalled} - showChooseGroupMembers={shouldNeverBeCalled} - showConversation={shouldNeverBeCalled} - showFindByPhoneNumber={shouldNeverBeCalled} - showFindByUsername={shouldNeverBeCalled} - showUserNotFoundModal={shouldNeverBeCalled} - /> + </SizeObserver> </Modal> ); } diff --git a/ts/components/preferences/chatFolders/PreferencesEditChatFoldersPage.tsx b/ts/components/preferences/chatFolders/PreferencesEditChatFoldersPage.tsx index 3d0d5d3664..1160f1cf30 100644 --- a/ts/components/preferences/chatFolders/PreferencesEditChatFoldersPage.tsx +++ b/ts/components/preferences/chatFolders/PreferencesEditChatFoldersPage.tsx @@ -2,15 +2,11 @@ // SPDX-License-Identifier: AGPL-3.0-only import type { MutableRefObject } from 'react'; import React, { useCallback, useMemo, useRef, useState } from 'react'; -import type { ConversationType } from '../../../state/ducks/conversations.js'; -import type { PreferredBadgeSelectorType } from '../../../state/selectors/badges.js'; -import type { LocalizerType } from '../../../types/I18N.js'; -import type { ThemeType } from '../../../types/Util.js'; + import { Input } from '../../Input.js'; import { Button, ButtonVariant } from '../../Button.js'; import { ConfirmationDialog } from '../../ConfirmationDialog.js'; -import type { ChatFolderSelection } from './PreferencesEditChatFoldersSelectChatsDialog.js'; -import { PreferencesEditChatFoldersSelectChatsDialog } from './PreferencesEditChatFoldersSelectChatsDialog.js'; +import { PreferencesSelectChatsDialog } from '../PreferencesSelectChatsDialog.js'; import { SettingsRow } from '../../PreferencesUtil.js'; import { Checkbox } from '../../Checkbox.js'; import { Avatar, AvatarSize } from '../../Avatar.js'; @@ -21,17 +17,23 @@ import { isSameChatFolderParams, validateChatFolderParams, } from '../../../types/ChatFolder.js'; +import { strictAssert } from '../../../util/assert.js'; +import { parseStrict } from '../../../util/schemas.js'; +import { BeforeNavigateResponse } from '../../../services/BeforeNavigate.js'; +import { useNavBlocker } from '../../../hooks/useNavBlocker.js'; +import { DeleteChatFolderDialog } from './DeleteChatFolderDialog.js'; + +import type { ConversationType } from '../../../state/ducks/conversations.js'; +import type { PreferredBadgeSelectorType } from '../../../state/selectors/badges.js'; +import type { LocalizerType } from '../../../types/I18N.js'; +import type { ThemeType } from '../../../types/Util.js'; +import type { ChatFolderSelection } from '../PreferencesSelectChatsDialog.js'; import type { ChatFolderId, ChatFolderParams, } from '../../../types/ChatFolder.js'; import type { GetConversationByIdType } from '../../../state/selectors/conversations.js'; -import { strictAssert } from '../../../util/assert.js'; -import { parseStrict } from '../../../util/schemas.js'; -import { BeforeNavigateResponse } from '../../../services/BeforeNavigate.js'; -import { type Location } from '../../../types/Nav.js'; -import { useNavBlocker } from '../../../hooks/useNavBlocker.js'; -import { DeleteChatFolderDialog } from './DeleteChatFolderDialog.js'; +import type { Location } from '../../../types/Nav.js'; export type PreferencesEditChatFolderPageProps = Readonly<{ i18n: LocalizerType; @@ -395,7 +397,7 @@ export function PreferencesEditChatFolderPage( </SettingsRow> )} {showInclusionsDialog && ( - <PreferencesEditChatFoldersSelectChatsDialog + <PreferencesSelectChatsDialog i18n={i18n} title={i18n( 'icu:Preferences__EditChatFolderPage__SelectChatsDialog--IncludedChats__Title' @@ -415,7 +417,7 @@ export function PreferencesEditChatFolderPage( /> )} {showExclusionsDialog && ( - <PreferencesEditChatFoldersSelectChatsDialog + <PreferencesSelectChatsDialog i18n={i18n} title={i18n( 'icu:Preferences__EditChatFolderPage__SelectChatsDialog--ExcludedChats__Title' diff --git a/ts/services/notificationProfilesService.ts b/ts/services/notificationProfilesService.ts index c816d06d06..881012c941 100644 --- a/ts/services/notificationProfilesService.ts +++ b/ts/services/notificationProfilesService.ts @@ -13,15 +13,22 @@ import { DataReader, DataWriter } from '../sql/Client.js'; import { findNextProfileEvent, redactNotificationProfileId, - type NotificationProfileType, } from '../types/NotificationProfile.js'; import { + getActiveProfile, getCurrentState, getDeletedProfiles, getOverride, getProfiles, } from '../state/selectors/notificationProfiles.js'; import { safeSetTimeout } from '../util/timeout.js'; +import { ToastType } from '../types/Toast.js'; +import { toLogFormat } from '../types/errors.js'; + +import type { + NextProfileEvent, + NotificationProfileType, +} from '../types/NotificationProfile.js'; const { debounce, isEqual, isNumber } = lodash; @@ -35,15 +42,25 @@ export class NotificationProfilesService { drop(this.#debouncedRefreshNextEvent()); } - async #refreshNextEvent() { + fastUpdate(): void { + drop(this.#refreshNextEvent()); + } + + async #refreshNextEvent(): Promise<void> { log.info('notificationProfileService: starting'); const { updateCurrentState, updateOverride, profileWasRemoved } = window.reduxActions.notificationProfiles; const state = window.reduxStore.getState(); + + // This gets everything, even if it's not being shown to user + const allProfilesIncludingRemoteOnly = state.notificationProfiles.profiles; + + // These fetches are limited to what user can see (local-only items, if sync=OFF) const profiles = getProfiles(state); const previousCurrentState = getCurrentState(state); + const previousActiveProfile = getActiveProfile(state); const deletedProfiles = getDeletedProfiles(state); let override = getOverride(state); @@ -88,7 +105,7 @@ export class NotificationProfilesService { ) { log.info('notificationProfileService: Clearing manual enable override'); override = undefined; - updateOverride(undefined); + updateOverride(undefined, { fromStorageService: false }); } else { log.info( 'notificationProfileService: Tried to clear manual enable override, but it did not match previous override' @@ -107,7 +124,7 @@ export class NotificationProfilesService { 'notificationProfileService: Clearing manual disable override' ); override = undefined; - updateOverride(undefined); + updateOverride(undefined, { fromStorageService: false }); } else { log.info( 'notificationProfileService: Tried to clear manual disable override, but it did not match previous override' @@ -115,18 +132,88 @@ export class NotificationProfilesService { } } - log.info('notificationProfileService: finding next profile event'); - const currentState = findNextProfileEvent({ - override, - profiles, - time, - }); + let currentState: NextProfileEvent; + try { + log.info('notificationProfileService: finding next profile event'); + currentState = findNextProfileEvent({ + override, + profiles, + time, + }); + } catch (error) { + log.warn('notificationProfileService:', toLogFormat(error)); + if (override) { + log.warn( + 'notificationProfileService: Clearing override because something went wrong' + ); + + // This will kick off another profile update when it completes + updateOverride(undefined, { fromStorageService: false }); + } + + return; + } + + const currentActiveProfileId = + currentState.type === 'willDisable' || currentState.type === 'noChange' + ? currentState.activeProfile + : undefined; + const currentActiveProfile = currentActiveProfileId + ? allProfilesIncludingRemoteOnly.find( + item => item.id === currentActiveProfileId + ) + : undefined; if (!isEqual(previousCurrentState, currentState)) { log.info( 'notificationProfileService: next profile event has changed, updating redux' ); - updateCurrentState(currentState); + updateCurrentState(currentState, currentActiveProfile); + } + + if (previousActiveProfile?.id === currentActiveProfileId) { + // do nothing! + // Something has changed, but it's still the same profile + } else if ( + previousActiveProfile && + currentActiveProfile && + previousActiveProfile.name === currentActiveProfile.name && + // This off-by-one timestamp is created in prepareForDisabledNotificationProfileSync + (previousActiveProfile.createdAtMs === + currentActiveProfile.createdAtMs + 1 || + previousActiveProfile.createdAtMs + 1 === + currentActiveProfile.createdAtMs) + ) { + // do nothing! + // We're switching to a different profile, but it's a remote/local copy. This will + // happen whenever there's an override enabling a profile and notification profiles + // sync is turned on/off. + } else if (!currentActiveProfileId) { + if (previousActiveProfile) { + window.reduxActions.toast.showToast({ + toastType: ToastType.NotificationProfileUpdate, + parameters: { + enabled: false, + name: previousActiveProfile.name, + }, + }); + } else { + log.warn( + 'refreshNextEvent: Unable to find just-disabled profile for toast' + ); + } + } else if (currentActiveProfile) { + window.reduxActions.toast.showToast({ + toastType: ToastType.NotificationProfileUpdate, + parameters: { + enabled: true, + name: currentActiveProfile.name, + }, + }); + } else { + log.warn( + 'refreshNextEvent: Unable to find just-enabled profile for toast' + ); } let nextCheck: number | undefined; @@ -146,7 +233,7 @@ export class NotificationProfilesService { return; } - const wait = Date.now() - nextCheck; + const wait = nextCheck - Date.now(); log.info( `notificationProfileService: next check ${new Date(nextCheck).toISOString()};` + ` waiting ${wait}ms` @@ -159,18 +246,26 @@ export class NotificationProfilesService { } export function initialize(): void { - // if (instance) { - // log.warn('NotificationProfileService is already initialized!'); - // return; - // } - // instance = new NotificationProfilesService(); + if (instance) { + log.warn('NotificationProfileService is already initialized!'); + return; + } + instance = new NotificationProfilesService(); } export function update(): void { - // if (!instance) { - // throw new Error('NotificationProfileService not yet initialized!'); - // } - // instance.update(); + if (!instance) { + throw new Error('update: NotificationProfileService not yet initialized!'); + } + instance.update(); +} +export function fastUpdate(): void { + if (!instance) { + throw new Error( + 'fastUpdate: NotificationProfileService not yet initialized!' + ); + } + instance.fastUpdate(); } let cachedProfiles: ReadonlyArray<NotificationProfileType> | undefined; @@ -189,4 +284,4 @@ export function getCachedProfiles(): ReadonlyArray<NotificationProfileType> { return profiles; } -// let instance: NotificationProfilesService; +let instance: NotificationProfilesService; diff --git a/ts/services/storage.ts b/ts/services/storage.ts index 85a427b641..8f5fc04b7c 100644 --- a/ts/services/storage.ts +++ b/ts/services/storage.ts @@ -27,6 +27,7 @@ import { toContactRecord, toGroupV1Record, toGroupV2Record, + toNotificationProfileRecord, toStoryDistributionListRecord, toStickerPackRecord, toCallLinkRecord, @@ -34,6 +35,7 @@ import { toDefunctOrPendingCallLinkRecord, toChatFolderRecord, mergeChatFolderRecord, + mergeNotificationProfileRecord, } from './storageRecordOps.js'; import type { MergeResultType } from './storageRecordOps.js'; import { MAX_READ_KEYS } from './storageConstants.js'; @@ -88,7 +90,9 @@ import { isDone as isRegistrationDone } from '../util/registration.js'; import { callLinkRefreshJobQueue } from '../jobs/callLinkRefreshJobQueue.js'; import { isMockEnvironment } from '../environment.js'; import { validateConversation } from '../util/validateConversation.js'; -import { hasAllChatsChatFolder, type ChatFolder } from '../types/ChatFolder.js'; +import { hasAllChatsChatFolder } from '../types/ChatFolder.js'; +import type { ChatFolder } from '../types/ChatFolder.js'; +import type { NotificationProfileType } from '../types/NotificationProfile.js'; const { debounce, isNumber, chunk } = lodash; @@ -109,6 +113,7 @@ const uploadBucket: Array<number> = []; const ITEM_TYPE = Proto.ManifestRecord.Identifier.Type; +// Note: when updating this, update the switch downfile in mergeRecord() const validRecordTypes = new Set([ ITEM_TYPE.UNKNOWN, ITEM_TYPE.CONTACT, @@ -119,6 +124,7 @@ const validRecordTypes = new Set([ ITEM_TYPE.STICKER_PACK, ITEM_TYPE.CALL_LINK, ITEM_TYPE.CHAT_FOLDER, + ITEM_TYPE.NOTIFICATION_PROFILE, ]); const backOff = new BackOff([ @@ -189,6 +195,12 @@ async function generateManifest( await window.ConversationController.checkForConflicts(); + // Load at the beginning, so we use this one value through the whole process + const notificationProfileSyncDisabled = window.storage.get( + 'notificationProfileSyncDisabled', + false + ); + const postUploadUpdateFunctions: Array<() => unknown> = []; const insertKeys = new Set<string>(); const deleteKeys = new Set<string>(); @@ -263,7 +275,9 @@ async function generateManifest( if (conversationType === ConversationTypes.Me) { storageRecord = new Proto.StorageRecord(); // eslint-disable-next-line no-await-in-loop - storageRecord.account = await toAccountRecord(conversation); + storageRecord.account = await toAccountRecord(conversation, { + notificationProfileSyncDisabled, + }); identifierType = ITEM_TYPE.ACCOUNT; } else if (conversationType === ConversationTypes.Direct) { // Contacts must have UUID @@ -355,6 +369,7 @@ async function generateManifest( const { callLinkDbRecords, defunctCallLinks, + notificationProfiles, pendingCallLinks, storyDistributionLists, installedStickerPacks, @@ -419,6 +434,78 @@ async function generateManifest( } } + const notificationProfilesToUpload = notificationProfileSyncDisabled + ? notificationProfiles.filter(item => item.storageID) + : notificationProfiles; + if (notificationProfileSyncDisabled) { + const localOnlyCount = + notificationProfilesToUpload.length - notificationProfiles.length; + log.info( + `upload(${version}): ` + + `sync=OFF; adding notificationProfiles=${notificationProfilesToUpload.length}, excluding ${localOnlyCount} local profiles` + ); + } else { + log.info( + `upload(${version}): ` + + `sync=ON, adding notificationProfiles=${notificationProfilesToUpload.length}` + ); + } + for (const notificationProfile of notificationProfilesToUpload) { + const storageRecord = new Proto.StorageRecord(); + storageRecord.notificationProfile = + toNotificationProfileRecord(notificationProfile); + + if ( + notificationProfile.deletedAtTimestampMs != null && + notificationProfile.deletedAtTimestampMs !== 0 && + isOlderThan( + notificationProfile.deletedAtTimestampMs, + getMessageQueueTime() + ) + ) { + const droppedID = notificationProfile.storageID; + const droppedVersion = notificationProfile.storageVersion; + if (!droppedID) { + continue; + } + + const recordID = redactStorageID(droppedID, droppedVersion); + + log.info( + `generateManifest(${version}): ` + + `dropping notificationProfile=${recordID} ` + + `due to expired deleted timestamp=${notificationProfile.deletedAtTimestampMs}` + ); + deleteKeys.add(droppedID); + + const { id } = notificationProfile; + drop(DataWriter.deleteNotificationProfileById(id)); + window.reduxActions.notificationProfiles.profileWasRemoved(id); + continue; + } + + const { isNewItem, storageID } = processStorageRecord({ + currentStorageID: notificationProfile.storageID, + currentStorageVersion: notificationProfile.storageVersion, + identifierType: ITEM_TYPE.NOTIFICATION_PROFILE, + storageNeedsSync: notificationProfile.storageNeedsSync, + storageRecord, + }); + + if (isNewItem) { + postUploadUpdateFunctions.push(() => { + const updated = { + ...notificationProfile, + storageID, + storageVersion: version, + storageNeedsSync: false, + }; + drop(DataWriter.updateNotificationProfile(updated)); + window.reduxActions.notificationProfiles.profileWasUpdated(updated); + }); + } + } + const uninstalledStickerPackIds = new Set<string>(); let newlyUninstalledPacks = 0; @@ -1146,6 +1233,7 @@ async function mergeRecord( const needProfileFetch = new Array<ConversationModel>(); try { + // Note: when updating this switch, update the validRecordTypes set upfile if (itemType === ITEM_TYPE.UNKNOWN) { log.warn('mergeRecord: Unknown item type', redactedStorageID); } else if (itemType === ITEM_TYPE.CONTACT && storageRecord.contact) { @@ -1202,10 +1290,20 @@ async function mergeRecord( storageVersion, storageRecord.chatFolder ); + } else if ( + itemType === ITEM_TYPE.NOTIFICATION_PROFILE && + storageRecord.notificationProfile + ) { + mergeResult = await mergeNotificationProfileRecord( + storageID, + storageVersion, + storageRecord.notificationProfile + ); } else { isUnsupported = true; log.warn(`merge(${redactedStorageID}): unknown item type=${itemType}`); } + // Note: when updating this switch, update the validRecordTypes set upfile const redactedID = redactStorageID( storageID, @@ -1254,6 +1352,7 @@ async function mergeRecord( type NonConversationRecordsResultType = Readonly<{ callLinkDbRecords: ReadonlyArray<CallLinkRecord>; defunctCallLinks: ReadonlyArray<DefunctCallLinkType>; + notificationProfiles: ReadonlyArray<NotificationProfileType>; pendingCallLinks: ReadonlyArray<PendingCallLinkType>; installedStickerPacks: ReadonlyArray<StickerPackType>; uninstalledStickerPacks: ReadonlyArray<UninstalledStickerPackType>; @@ -1266,6 +1365,7 @@ async function getNonConversationRecords(): Promise<NonConversationRecordsResult const [ callLinkDbRecords, defunctCallLinks, + notificationProfiles, pendingCallLinks, storyDistributionLists, uninstalledStickerPacks, @@ -1274,6 +1374,7 @@ async function getNonConversationRecords(): Promise<NonConversationRecordsResult ] = await Promise.all([ DataReader.getAllCallLinkRecordsWithAdminKey(), DataReader.getAllDefunctCallLinksWithAdminKey(), + DataReader.getAllNotificationProfiles(), callLinkRefreshJobQueue.getPendingAdminCallLinks(), DataReader.getAllStoryDistributionsWithMembers(), DataReader.getUninstalledStickerPacks(), @@ -1284,6 +1385,7 @@ async function getNonConversationRecords(): Promise<NonConversationRecordsResult return { callLinkDbRecords, defunctCallLinks, + notificationProfiles, pendingCallLinks, storyDistributionLists, uninstalledStickerPacks, @@ -1325,6 +1427,7 @@ async function processManifest( const { callLinkDbRecords, defunctCallLinks, + notificationProfiles, pendingCallLinks, storyDistributionLists, installedStickerPacks, @@ -1349,6 +1452,9 @@ async function processManifest( defunctCallLinks.forEach(collectLocalKeysFromFields); localRecordCount += defunctCallLinks.length; + notificationProfiles.forEach(collectLocalKeysFromFields); + localRecordCount += notificationProfiles.length; + pendingCallLinks.forEach(collectLocalKeysFromFields); localRecordCount += pendingCallLinks.length; @@ -1815,9 +1921,18 @@ async function processRemoteRecords( } let accountItem: MergeableItemType | undefined; + const recordsNeedingAllContacts: Array<MergeableItemType> = []; let prunedStorageItems = decryptedItems.filter(item => { const { itemType, storageID, storageRecord } = item; + if ( + itemType === ITEM_TYPE.NOTIFICATION_PROFILE || + itemType === ITEM_TYPE.NOTIFICATION_PROFILE + ) { + recordsNeedingAllContacts.push(item); + return false; + } + if (itemType === ITEM_TYPE.ACCOUNT) { if (accountItem !== undefined) { log.warn( @@ -1829,6 +1944,15 @@ async function processRemoteRecords( } accountItem = item; + const record = accountItem?.storageRecord.account; + + if (!record) { + log.warn( + `process(${storageVersion}): account record had no account data` + ); + return false; + } + return false; } @@ -1910,19 +2034,34 @@ async function processRemoteRecords( ); }; + const mergedPrunedStorageItems = + await mergeWithConcurrency(prunedStorageItems); + + // Merge split PNI contacts after processing remote records. If original + // e164+ACI+PNI contact is unregistered - it is going to be split so we + // have to make that happen first. Otherwise we will ignore ContactRecord + // changes on these since there is already a parent "merged" contact. + const mergedSplitPNIContacts = await mergeWithConcurrency(splitPNIContacts); + + // Merge records that need all contacts already processed beforehand. Records like + // Chat Folders and Notification Profiles need all contacts in place since they might + // refer to any contact. + const mergedRecordsNeedingAllContacts = await mergeWithConcurrency( + recordsNeedingAllContacts + ); + + // Merge Account record last since it contains references to records that need + // to be processed first - things like pinned conversations or the user's notification + // profile manual override. + const mergedAccountRecord = accountItem + ? await mergeRecord(storageVersion, accountItem) + : undefined; + const mergedRecords = [ - ...(await mergeWithConcurrency(prunedStorageItems)), - - // Merge split PNI contacts after processing remote records. If original - // e164+ACI+PNI contact is unregistered - it is going to be split so we - // have to make that happen first. Otherwise we will ignore ContactRecord - // changes on these since there is already a parent "merged" contact. - ...(await mergeWithConcurrency(splitPNIContacts)), - - // Merge Account records last since it contains the pinned conversations - // and we need all other records merged first before we can find the pinned - // records in our db - ...(accountItem ? [await mergeRecord(storageVersion, accountItem)] : []), + ...mergedPrunedStorageItems, + ...mergedSplitPNIContacts, + ...mergedRecordsNeedingAllContacts, + ...(mergedAccountRecord ? [mergedAccountRecord] : []), ]; log.info( diff --git a/ts/services/storageRecordOps.ts b/ts/services/storageRecordOps.ts index 9f575fa0b1..8f78cb0644 100644 --- a/ts/services/storageRecordOps.ts +++ b/ts/services/storageRecordOps.ts @@ -1,7 +1,7 @@ // Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import lodash from 'lodash'; +import lodash, { omit, partition, without } from 'lodash'; import Long from 'long'; import { ServiceId } from '@signalapp/libsignal-client'; @@ -112,6 +112,20 @@ import { import { deriveGroupID, deriveGroupSecretParams } from '../util/zkgroup.js'; import { chatFolderCleanupService } from './expiring/chatFolderCleanupService.js'; import { signalProtocolStore } from '../SignalProtocolStore.js'; +import type { + NotificationProfileOverride, + NotificationProfileType, +} from '../types/NotificationProfile.js'; +import { + DEFAULT_PROFILE_COLOR, + fromDayOfWeekArray, + redactNotificationProfileId, + toDayOfWeekArray, +} from '../types/NotificationProfile.js'; +import { + generateNotificationProfileId, + normalizeNotificationProfileId, +} from '../types/NotificationProfile-node.js'; const { isEqual } = lodash; @@ -211,7 +225,8 @@ function applyAvatarColor( }); } -function addUnknownFields( +// Conversation stores a base64-encoded storageUnknownFields field +function addUnknownFieldsToConversation( record: RecordClass, conversation: ConversationModel, details: Array<string> @@ -231,7 +246,7 @@ function addUnknownFields( } } -function applyUnknownFields( +function applyConversationUnknownFieldsToRecord( record: RecordClass, conversation: ConversationModel ): void { @@ -246,6 +261,26 @@ function applyUnknownFields( } } +// Other records save a UInt8Array to the database +function toStorageUnknownFields( + unknownFields: ReadonlyArray<Uint8Array> | undefined +): Uint8Array | null { + if (!unknownFields) { + return null; + } + + return Bytes.concatenate(unknownFields); +} +function fromStorageUnknownFields( + storageUnknownFields: Uint8Array | null +): ReadonlyArray<Uint8Array> | undefined { + if (!storageUnknownFields) { + return undefined; + } + + return [storageUnknownFields]; +} + export async function toContactRecord( conversation: ConversationModel ): Promise<Proto.ContactRecord> { @@ -345,13 +380,16 @@ export async function toContactRecord( contactRecord.avatarColor = avatarColor; } - applyUnknownFields(contactRecord, conversation); + applyConversationUnknownFieldsToRecord(contactRecord, conversation); return contactRecord; } export function toAccountRecord( - conversation: ConversationModel + conversation: ConversationModel, + { + notificationProfileSyncDisabled, + }: { notificationProfileSyncDisabled: boolean } ): Proto.AccountRecord { const accountRecord = new Proto.AccountRecord(); @@ -584,7 +622,39 @@ export function toAccountRecord( accountRecord.avatarColor = avatarColor; } - applyUnknownFields(accountRecord, conversation); + accountRecord.notificationProfileSyncDisabled = + notificationProfileSyncDisabled; + + const override = notificationProfileSyncDisabled + ? window.storage.get('notificationProfileOverrideFromPrimary') + : window.storage.get('notificationProfileOverride'); + + if (override?.disabledAtMs && override?.disabledAtMs > 0) { + const overrideProto = + new Proto.AccountRecord.NotificationProfileManualOverride(); + + overrideProto.disabledAtTimestampMs = Long.fromNumber( + override.disabledAtMs + ); + + accountRecord.notificationProfileManualOverride = overrideProto; + } else if (override?.enabled) { + const { profileId, endsAtMs } = override.enabled; + + const overrideProto = + new Proto.AccountRecord.NotificationProfileManualOverride(); + overrideProto.enabled = + new Proto.AccountRecord.NotificationProfileManualOverride.ManuallyEnabled(); + + overrideProto.enabled.id = Bytes.fromHex(profileId); + if (endsAtMs && endsAtMs > 0) { + overrideProto.enabled.endAtTimestampMs = Long.fromNumber(endsAtMs); + } + + accountRecord.notificationProfileManualOverride = overrideProto; + } + + applyConversationUnknownFieldsToRecord(accountRecord, conversation); return accountRecord; } @@ -596,7 +666,7 @@ export function toGroupV1Record( groupV1Record.id = Bytes.fromBinary(String(conversation.get('groupId'))); - applyUnknownFields(groupV1Record, conversation); + applyConversationUnknownFieldsToRecord(groupV1Record, conversation); return groupV1Record; } @@ -640,7 +710,7 @@ export function toGroupV2Record( groupV2Record.avatarColor = avatarColor; } - applyUnknownFields(groupV2Record, conversation); + applyConversationUnknownFieldsToRecord(groupV2Record, conversation); return groupV2Record; } @@ -675,9 +745,9 @@ export function toStoryDistributionListRecord( } if (storyDistributionList.storageUnknownFields) { - storyDistributionListRecord.$unknownFields = [ - storyDistributionList.storageUnknownFields, - ]; + storyDistributionListRecord.$unknownFields = fromStorageUnknownFields( + storyDistributionList.storageUnknownFields + ); } return storyDistributionListRecord; @@ -702,7 +772,9 @@ export function toStickerPackRecord( } if (stickerPack.storageUnknownFields) { - stickerPackRecord.$unknownFields = [stickerPack.storageUnknownFields]; + stickerPackRecord.$unknownFields = fromStorageUnknownFields( + stickerPack.storageUnknownFields + ); } return stickerPackRecord; @@ -734,7 +806,9 @@ export function toCallLinkRecord( } if (callLinkDbRecord.storageUnknownFields) { - callLinkRecord.$unknownFields = [callLinkDbRecord.storageUnknownFields]; + callLinkRecord.$unknownFields = fromStorageUnknownFields( + callLinkDbRecord.storageUnknownFields + ); } return callLinkRecord; @@ -762,26 +836,31 @@ export function toDefunctOrPendingCallLinkRecord( } if (callLink.storageUnknownFields) { - callLinkRecord.$unknownFields = [callLink.storageUnknownFields]; + callLinkRecord.$unknownFields = fromStorageUnknownFields( + callLink.storageUnknownFields + ); } return callLinkRecord; } -function toRecipient(conversationId: string): Proto.Recipient { +function toRecipient( + conversationId: string, + logPrefix: string +): Proto.Recipient { const conversation = window.ConversationController.get(conversationId); if (conversation == null) { - throw new Error('toRecipient: Missing conversation'); + throw new Error(`${logPrefix}/toRecipient: Missing conversation`); } - const logPrefix = `toRecipient(${conversation.idForLogging()})`; + const logId = `${logPrefix}/toRecipient(${conversation.idForLogging()})`; if (isDirectConversation(conversation.attributes)) { const serviceId = conversation.getServiceId(); strictAssert( serviceId, - `${logPrefix}: Missing serviceId on direct conversation` + `${logId}: Missing serviceId on direct conversation` ); const serviceIdBinary = ServiceId.parseFromServiceIdString(serviceId).getServiceIdBinary(); @@ -798,7 +877,7 @@ function toRecipient(conversationId: string): Proto.Recipient { const masterKey = conversation.get('masterKey'); strictAssert( masterKey, - `${logPrefix}: Missing masterKey on groupV2 conversation` + `${logId}: Missing masterKey on groupV2 conversation` ); return new Proto.Recipient({ groupMasterKey: Bytes.fromBase64(masterKey), @@ -815,10 +894,11 @@ function toRecipient(conversationId: string): Proto.Recipient { } function toRecipients( - conversationIds: ReadonlyArray<string> + conversationIds: ReadonlyArray<string>, + logPrefix: string ): Array<Proto.Recipient> { return conversationIds.map(conversationId => { - return toRecipient(conversationId); + return toRecipient(conversationId, logPrefix); }); } @@ -837,6 +917,8 @@ function toChatFolderRecordFolderType( export function toChatFolderRecord( chatFolder: ChatFolder ): Proto.ChatFolderRecord { + const logId = `toChatFolderRecord(${chatFolder.id})`; + const chatFolderRecord = new Proto.ChatFolderRecord({ id: uuidToBytes(chatFolder.id), name: chatFolder.name, @@ -846,8 +928,8 @@ export function toChatFolderRecord( includeAllIndividualChats: chatFolder.includeAllIndividualChats, includeAllGroupChats: chatFolder.includeAllGroupChats, folderType: toChatFolderRecordFolderType(chatFolder.folderType), - includedRecipients: toRecipients(chatFolder.includedConversationIds), - excludedRecipients: toRecipients(chatFolder.excludedConversationIds), + includedRecipients: toRecipients(chatFolder.includedConversationIds, logId), + excludedRecipients: toRecipients(chatFolder.excludedConversationIds, logId), deletedAtTimestampMs: Long.fromNumber(chatFolder.deletedAtTimestampMs), }); @@ -858,6 +940,59 @@ export function toChatFolderRecord( return chatFolderRecord; } +export function toNotificationProfileRecord( + profile: NotificationProfileType +): Proto.NotificationProfile { + const { + id, + name, + emoji, + color, + createdAtMs, + deletedAtTimestampMs, + allowAllCalls, + allowAllMentions, + allowedMembers, + scheduleEnabled, + scheduleStartTime, + scheduleEndTime, + scheduleDaysEnabled, + storageUnknownFields, + } = profile; + const logId = `toNotificationProfileRecord(${redactNotificationProfileId(id)})`; + const proto = new Proto.NotificationProfile(); + + proto.id = Bytes.fromHex(id); + proto.name = name; + if (emoji) { + proto.emoji = emoji; + } + proto.color = color; + proto.createdAtMs = Long.fromNumber(createdAtMs); + if (deletedAtTimestampMs) { + proto.deletedAtTimestampMs = Long.fromNumber(deletedAtTimestampMs); + } + proto.allowAllCalls = allowAllCalls; + proto.allowAllMentions = allowAllMentions; + proto.scheduleEnabled = scheduleEnabled; + + if (scheduleStartTime) { + proto.scheduleStartTime = scheduleStartTime; + } + if (scheduleEndTime) { + proto.scheduleEndTime = scheduleEndTime; + } + proto.scheduleDaysEnabled = toDayOfWeekArray(scheduleDaysEnabled) ?? []; + + proto.allowedMembers = toRecipients(Array.from(allowedMembers), logId); + + if (storageUnknownFields) { + proto.$unknownFields = fromStorageUnknownFields(storageUnknownFields); + } + + return proto; +} + type MessageRequestCapableRecord = Proto.IContactRecord | Proto.IGroupV2Record; function applyMessageRequestState( @@ -1074,7 +1209,7 @@ export async function mergeGroupV1Record( }); if (isGroupV1(conversation.attributes)) { - addUnknownFields(groupV1Record, conversation, details); + addUnknownFieldsToConversation(groupV1Record, conversation, details); } else { // We cannot preserve unknown fields if local group is V2 and the remote is // still V1, because the storageItem that we'll put into manifest will have @@ -1209,7 +1344,7 @@ export async function mergeGroupV2Record( applyAvatarColor(conversation, groupV2Record.avatarColor); - addUnknownFields(groupV2Record, conversation, details); + addUnknownFieldsToConversation(groupV2Record, conversation, details); if (isGroupV1(conversation.attributes)) { // If we found a GroupV1 conversation from this incoming GroupV2 record, we need to @@ -1405,7 +1540,7 @@ export async function mergeContactRecord( applyMessageRequestState(contactRecord, conversation); - addUnknownFields(contactRecord, conversation, details); + addUnknownFieldsToConversation(contactRecord, conversation, details); const oldStorageID = conversation.get('storageID'); const oldStorageVersion = conversation.get('storageVersion'); @@ -1500,13 +1635,17 @@ export async function mergeAccountRecord( storyViewReceiptsEnabled, username, usernameLink, + notificationProfileManualOverride, + notificationProfileSyncDisabled, } = accountRecord; const conversation = window.ConversationController.getOurConversationOrThrow(); const details = logRecordChanges( - toAccountRecord(conversation), + toAccountRecord(conversation, { + notificationProfileSyncDisabled: Boolean(notificationProfileSyncDisabled), + }), accountRecord ); @@ -1808,7 +1947,60 @@ export async function mergeAccountRecord( ]); } - addUnknownFields(accountRecord, conversation, details); + const previousSyncDisabled = window.storage.get( + 'notificationProfileSyncDisabled', + false + ); + if (previousSyncDisabled !== notificationProfileSyncDisabled) { + log.info( + `process(${storageVersion}): Account just flipped from notificationProfileSyncDisabled=${previousSyncDisabled} to ${notificationProfileSyncDisabled}` + ); + await window.reduxActions.notificationProfiles.setIsSyncEnabled( + !notificationProfileSyncDisabled, + { fromStorageService: true } + ); + } + + const override = notificationProfileManualOverride; + let overrideToSave: NotificationProfileOverride | undefined; + if (override) { + if (override.enabled?.id) { + overrideToSave = { + disabledAtMs: undefined, + enabled: { + profileId: normalizeNotificationProfileId( + Bytes.toHex(override.enabled.id), + 'mergeAccountRecord' + ), + endsAtMs: override.enabled.endAtTimestampMs?.toNumber(), + }, + }; + } else if (override.disabledAtTimestampMs) { + overrideToSave = { + disabledAtMs: override.disabledAtTimestampMs.toNumber(), + enabled: undefined, + }; + } else { + log.warn( + 'mergeAccountRecord: notificationProfileManualOverride had neither enabled nor disabledAtTimestamp. Clearing local override.' + ); + overrideToSave = undefined; + } + } else { + overrideToSave = undefined; + } + + if (notificationProfileSyncDisabled) { + await window.storage.put( + 'notificationProfileOverrideFromPrimary', + overrideToSave + ); + } else { + const { updateOverride } = window.reduxActions.notificationProfiles; + updateOverride(overrideToSave, { fromStorageService: true }); + } + + addUnknownFieldsToConversation(accountRecord, conversation, details); const oldStorageID = conversation.get('storageID'); const oldStorageVersion = conversation.get('storageVersion'); @@ -1935,9 +2127,9 @@ export async function mergeStoryDistributionListRecord( storageID, storageVersion, - storageUnknownFields: storyDistributionListRecord.$unknownFields - ? Bytes.concatenate(storyDistributionListRecord.$unknownFields) - : null, + storageUnknownFields: toStorageUnknownFields( + storyDistributionListRecord.$unknownFields + ), storageNeedsSync: false, }; @@ -2036,9 +2228,9 @@ export async function mergeStickerPackRecord( if (stickerPackRecord.$unknownFields) { details.push('adding unknown fields'); } - const storageUnknownFields = stickerPackRecord.$unknownFields - ? Bytes.concatenate(stickerPackRecord.$unknownFields) - : null; + const storageUnknownFields = toStorageUnknownFields( + stickerPackRecord.$unknownFields + ); let stickerPack: StickerPackInfoType; if (stickerPackRecord.deletedAtTimestamp?.toNumber()) { @@ -2200,9 +2392,7 @@ export async function mergeCallLinkRecord( storageID, storageVersion, - storageUnknownFields: callLinkRecord.$unknownFields - ? Bytes.concatenate(callLinkRecord.$unknownFields) - : null, + storageUnknownFields: toStorageUnknownFields(callLinkRecord.$unknownFields), storageNeedsSync: 0, }; @@ -2310,7 +2500,10 @@ function protoToChatFolderType(folderType: Proto.ChatFolderRecord.FolderType) { return ChatFolderType.UNKNOWN; } -function recipientToConversationId(recipient: Proto.Recipient): string { +function recipientToConversationId( + recipient: Proto.Recipient, + logPrefix: string +): string { let match: ConversationModel | undefined; if (recipient.contact != null) { match = window.ConversationController.get(recipient.contact.serviceId); @@ -2331,15 +2524,16 @@ function recipientToConversationId(recipient: Proto.Recipient): string { } else { throw new Error('Unexpected type of recipient'); } - strictAssert(match, 'Missing conversation for recipient'); + strictAssert(match, `${logPrefix}: Missing conversation for recipient`); return match.id; } function recipientsToConversationIds( - recipients: ReadonlyArray<Proto.Recipient> + recipients: ReadonlyArray<Proto.Recipient>, + logPrefix: string ): ReadonlyArray<string> { return recipients.map(recipient => { - return recipientToConversationId(recipient); + return recipientToConversationId(recipient, logPrefix); }); } @@ -2353,14 +2547,15 @@ export async function mergeChatFolderRecord( storageVersion, }); - const logPrefix = `mergeChatFolderRecord(${redactedStorageID})`; - if (remoteChatFolderRecord.id == null) { return { shouldDrop: true, details: ['no id'] }; } + const idString = bytesToUuid(remoteChatFolderRecord.id) as ChatFolderId; + const logPrefix = `mergeChatFolderRecord(${redactedStorageID}, idString)`; + const remoteChatFolder: ChatFolder = { - id: bytesToUuid(remoteChatFolderRecord.id) as ChatFolderId, + id: idString, folderType: protoToChatFolderType( remoteChatFolderRecord.folderType ?? Proto.ChatFolderRecord.FolderType.UNKNOWN @@ -2373,10 +2568,12 @@ export async function mergeChatFolderRecord( remoteChatFolderRecord.includeAllIndividualChats ?? false, includeAllGroupChats: remoteChatFolderRecord.includeAllGroupChats ?? false, includedConversationIds: recipientsToConversationIds( - remoteChatFolderRecord.includedRecipients ?? [] + remoteChatFolderRecord.includedRecipients ?? [], + logPrefix ), excludedConversationIds: recipientsToConversationIds( - remoteChatFolderRecord.excludedRecipients ?? [] + remoteChatFolderRecord.excludedRecipients ?? [], + logPrefix ), deletedAtTimestampMs: remoteChatFolderRecord.deletedAtTimestampMs?.toNumber() ?? 0, @@ -2466,3 +2663,305 @@ export async function mergeChatFolderRecord( oldStorageVersion: localChatFolder?.storageVersion ?? undefined, }; } + +function cleanNotificationProfileForComparision( + profile: NotificationProfileType +): Omit<NotificationProfileType, 'id'> & { + id: null; +} { + return { + ...profile, + // Color and id are randomly assigned; profiles made on different devices will differ + id: null, + color: 0, + // If we really just care about structure, then we shouldn't consider this + createdAtMs: 0, + // Storage services details could easily get out of date + storageID: null, + storageNeedsSync: false, + storageVersion: null, + storageUnknownFields: undefined, + }; +} + +export function prepareForDisabledNotificationProfileSync(): { + toAdd: Array<NotificationProfileType>; + newOverride: NotificationProfileOverride | undefined; +} { + const logId = 'prepareForDisabledNotificationProfileSync'; + const state = window.reduxStore.getState(); + const { profiles } = state.notificationProfiles; + let newOverride: NotificationProfileOverride | undefined = window.storage.get( + 'notificationProfileOverride' + ); + + const notDeletedProfiles = profiles.filter( + profile => + (profile.storageID && profile.deletedAtTimestampMs == null) || + profile.deletedAtTimestampMs === 0 + ); + + const toAdd: Array<NotificationProfileType> = []; + + notDeletedProfiles.forEach(profile => { + const localId = generateNotificationProfileId(); + toAdd.push({ + ...omit(profile, 'storageID', 'storageVersion', 'storageUnknownFields'), + id: localId, + storageNeedsSync: true, + // Note: we check for createdAtMs + 1 downfile for conflict detection + createdAtMs: profile.createdAtMs + 1, + }); + + if (newOverride?.enabled?.profileId === profile.id) { + log.info( + `${logId}: Override referenced now-remote match; updating to local profile` + ); + newOverride = { + disabledAtMs: undefined, + enabled: { + endsAtMs: newOverride.enabled.endsAtMs, + profileId: localId, + }, + }; + } + }); + + log.info(`${logId}: Duplicated ${toAdd.length} profiles`); + return { + newOverride, + toAdd, + }; +} + +export function prepareForEnabledNotificationProfileSync(): { + newOverride: NotificationProfileOverride | undefined; + toAdd: Array<NotificationProfileType>; + toRemove: Array<NotificationProfileType>; +} { + const logId = 'prepareForEnabledNotificationProfileSync'; + const state = window.reduxStore.getState(); + const { profiles } = state.notificationProfiles; + let newOverride: NotificationProfileOverride | undefined = window.storage.get( + 'notificationProfileOverride' + ); + + const notDeletedProfiles = profiles.filter( + profile => + profile.deletedAtTimestampMs == null || profile.deletedAtTimestampMs === 0 + ); + const withCleaned = notDeletedProfiles.map(profile => ({ + clean: cleanNotificationProfileForComparision(profile), + profile, + })); + const result = partition(withCleaned, item => item.profile.storageID); + const remoteProfiles = result[0]; + let localProfiles = result[1]; + + const toRemove: Array<NotificationProfileType> = []; + + remoteProfiles.forEach(remote => { + const localMatch = localProfiles.find(local => + isEqual(remote.clean, local.clean) + ); + + if (localMatch) { + log.info( + `${logId}: Found local record that matches. Dropping local in favor of remote` + ); + toRemove.push(localMatch.profile); + localProfiles = without(localProfiles, localMatch); + + if (newOverride?.enabled?.profileId === localMatch.profile.id) { + log.info( + `${logId}: Override referenced local match; updating to remote profile` + ); + newOverride = { + disabledAtMs: undefined, + enabled: { + endsAtMs: newOverride.enabled.endsAtMs, + profileId: remote.profile.id, + }, + }; + } + } + }); + + const toAdd: Array<NotificationProfileType> = []; + localProfiles.forEach(local => { + if ( + remoteProfiles.some( + remote => + remote.profile.name === local.profile.name && + // Note: when we create local copies above, we use original.createdAtMs + 1 + remote.profile.createdAtMs + 1 === local.profile.createdAtMs + ) + ) { + log.info( + `${logId}: Found local record that indicates divergence; adding copy label` + ); + toRemove.push(local.profile); + toAdd.push({ + ...local.profile, + name: window.i18n('icu:NotificationProfile--copy-label', { + profileName: local.profile.name, + }), + }); + } + }); + + log.info( + `${logId}: Removed ${toRemove.length} profiles, added ${toAdd.length} profiles` + ); + return { + newOverride, + toAdd, + toRemove, + }; +} + +export async function mergeNotificationProfileRecord( + storageID: string, + storageVersion: number, + profileRecord: Proto.INotificationProfile +): Promise<MergeResultType> { + const redactedStorageID = redactExtendedStorageID({ + storageID, + storageVersion, + }); + const { + id, + name, + color, + emoji, + createdAtMs, + allowAllCalls, + allowAllMentions, + allowedMembers, + scheduleEnabled, + scheduleStartTime, + scheduleEndTime, + scheduleDaysEnabled, + deletedAtTimestampMs, + } = profileRecord; + // NotificationProfile records must have id + if (!id) { + return { shouldDrop: true, details: ['no id'] }; + } + // NotificationProfile records must have name + if (!name) { + return { shouldDrop: true, details: ['no name'] }; + } + + const details: Array<string> = []; + + const idString = normalizeNotificationProfileId( + Bytes.toHex(id), + 'storage service merge', + log + ); + const logId = `mergeNotificationProfileRecord(${redactedStorageID}, ${redactNotificationProfileId(idString)})`; + const localProfile = await DataReader.getNotificationProfileById(idString); + + // Note deletedAtTimestampMs can be 0 + const deletedAt = deletedAtTimestampMs?.toNumber() || null; + const shouldDrop = Boolean( + deletedAt && isOlderThan(deletedAt, getMessageQueueTime()) + ); + if (shouldDrop) { + details.push( + `expired deleted notification profile deletedAt=${deletedAt}; scheduling for removal` + ); + } + + const allowedMemberConversationIds = recipientsToConversationIds( + allowedMembers || [], + logId + ); + + if (localProfile?.storageNeedsSync) { + log.warn( + `${logId}: Local record had storageNeedsSync=true, but we're updating from remote` + ); + } + + const localDeletedAt = localProfile?.deletedAtTimestampMs; + const newProfile: NotificationProfileType = { + id: idString, + name, + emoji: dropNull(emoji), + color: dropNull(color) ?? DEFAULT_PROFILE_COLOR, + createdAtMs: createdAtMs?.toNumber() ?? Date.now(), + allowAllCalls: Boolean(allowAllCalls), + allowAllMentions: Boolean(allowAllMentions), + allowedMembers: new Set(allowedMemberConversationIds), + scheduleEnabled: Boolean(scheduleEnabled), + scheduleStartTime: dropNull(scheduleStartTime), + scheduleEndTime: dropNull(scheduleEndTime), + scheduleDaysEnabled: fromDayOfWeekArray(scheduleDaysEnabled), + deletedAtTimestampMs: localDeletedAt + ? Math.min(localDeletedAt, deletedAt ?? Number.MAX_SAFE_INTEGER) + : dropNull(deletedAt), + storageID, + storageVersion, + storageUnknownFields: + toStorageUnknownFields(profileRecord.$unknownFields) ?? undefined, + storageNeedsSync: false, + }; + + const { profileWasCreated, profileWasUpdated } = + window.reduxActions.notificationProfiles; + + if (!localProfile) { + if (deletedAt) { + details.push( + `skipping deleted notification profile with no matching local record deletedAt=${deletedAt}` + ); + } else { + details.push('created new notification profile'); + await DataWriter.createNotificationProfile(newProfile); + profileWasCreated(newProfile); + } + + return { + details, + shouldDrop, + }; + } + + const oldStorageID = localProfile.storageID || undefined; + const oldStorageVersion = localProfile.storageVersion || undefined; + + const needsToClearUnknownFields = + !profileRecord.$unknownFields && localProfile.storageUnknownFields; + if (needsToClearUnknownFields) { + details.push('clearing unknown fields'); + } + + const changeDetails = logRecordChanges( + toNotificationProfileRecord(newProfile), + profileRecord + ); + + // First update local record + details.push('updated'); + await DataWriter.updateNotificationProfile(newProfile); + profileWasUpdated(newProfile); + + if (deletedAt && !localProfile.deletedAtTimestampMs) { + log.info(`${logId}: Discovered profile deleted remotely.`); + } else if (!deletedAt && localProfile.deletedAtTimestampMs) { + log.info( + `${logId}: Notification profile deleted locally, but not remotely.` + ); + } else if (deletedAt && localProfile.deletedAtTimestampMs) { + // No need to do anything - deleted before, and deleted now + } + + return { + details: [...details, ...changeDetails], + shouldDrop, + oldStorageID, + oldStorageVersion, + }; +} diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index 7c47b480cd..a9aca67617 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -7770,7 +7770,9 @@ function markNotificationProfileDeleted( const [query, parameters] = sql` UPDATE notificationProfiles - SET deletedAtTimestampMs = ${now} + SET + deletedAtTimestampMs = ${now}, + storageNeedsSync = 1 WHERE id = ${id} AND deletedAtTimestampMs IS NULL @@ -8041,6 +8043,14 @@ function eraseStorageServiceState(db: WritableDB): void { -- Chat Folders UPDATE chatFolders + SET + storageID = null, + storageVersion = null, + storageUnknownFields = null, + storageNeedsSync = 0; + + -- Notification Profiles + UPDATE notificationProfiles SET storageID = null, storageVersion = null, diff --git a/ts/state/ducks/notificationProfiles.ts b/ts/state/ducks/notificationProfiles.ts index 7ade99366c..dd8917ce1c 100644 --- a/ts/state/ducks/notificationProfiles.ts +++ b/ts/state/ducks/notificationProfiles.ts @@ -1,23 +1,44 @@ // Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only +import { debounce, difference } from 'lodash'; + import type { ReadonlyDeep } from 'type-fest'; import type { ThunkAction } from 'redux-thunk'; -import { update as updateProfileService } from '../../services/notificationProfilesService.js'; +import { createLogger } from '../../logging/log.js'; +import { + update as updateProfileService, + fastUpdate as fastUpdateProfileService, +} from '../../services/notificationProfilesService.js'; import { strictAssert } from '../../util/assert.js'; import { type BoundActionCreatorsMapObject, useBoundActions, } from '../../hooks/useBoundActions.js'; import { DataWriter } from '../../sql/Client.js'; -import { sortProfiles } from '../../types/NotificationProfile.js'; +import { + redactNotificationProfileId, + sortProfiles, +} from '../../types/NotificationProfile.js'; +import { generateNotificationProfileId } from '../../types/NotificationProfile-node.js'; +import { getOverride } from '../selectors/notificationProfiles.js'; +import { getItems } from '../selectors/items.js'; +import { + prepareForDisabledNotificationProfileSync, + prepareForEnabledNotificationProfileSync, +} from '../../services/storageRecordOps.js'; +import { SECOND } from '../../util/durations/constants.js'; import type { NextProfileEvent, + NotificationProfileIdString, NotificationProfileOverride, NotificationProfileType, } from '../../types/NotificationProfile.js'; +import type { StateType } from '../reducer.js'; + +const log = createLogger('ducks/notificationProfiles'); const { updateNotificationProfile, @@ -28,7 +49,9 @@ const { // State export type NotificationProfilesStateType = ReadonlyDeep<{ + activeProfile: NotificationProfileType | undefined; currentState: NextProfileEvent; + loading: boolean; override: NotificationProfileOverride | undefined; profiles: ReadonlyArray<NotificationProfileType>; }>; @@ -36,9 +59,11 @@ export type NotificationProfilesStateType = ReadonlyDeep<{ // Actions const CREATE_PROFILE = 'NotificationProfiles/CREATE_PROFILE'; +const GLOBAL_UPDATE = 'NotificationProfiles/GLOBAL_UPDATE'; const MARK_PROFILE_DELETED = 'NotificationProfiles/MARK_PROFILE_DELETED'; const REMOVE_PROFILE = 'NotificationProfiles/REMOVE_PROFILE'; const UPDATE_CURRENT_STATE = 'NotificationProfiles/UPDATE_CURRENT_STATE'; +const UPDATE_LOADING = 'NotificationProfiles/UPDATE_LOADING'; const UPDATE_OVERRIDE = 'NotificationProfiles/UPDATE_OVERRIDE'; const UPDATE_PROFILE = 'NotificationProfiles/UPDATE_PROFILE'; @@ -47,9 +72,13 @@ export type CreateProfile = ReadonlyDeep<{ payload: NotificationProfileType; }>; -export type RemoveProfile = ReadonlyDeep<{ - type: typeof REMOVE_PROFILE; - payload: string; +export type GlobalUpdate = ReadonlyDeep<{ + type: typeof GLOBAL_UPDATE; + payload: { + toAdd: Array<NotificationProfileType>; + toRemove: Array<NotificationProfileType>; + newOverride: NotificationProfileOverride | undefined; + }; }>; export type MarkProfileDeleted = ReadonlyDeep<{ @@ -60,9 +89,22 @@ export type MarkProfileDeleted = ReadonlyDeep<{ }; }>; +export type RemoveProfile = ReadonlyDeep<{ + type: typeof REMOVE_PROFILE; + payload: string; +}>; + export type UpdateCurrentState = ReadonlyDeep<{ type: typeof UPDATE_CURRENT_STATE; - payload: NextProfileEvent; + payload: { + currentState: NextProfileEvent; + activeProfile: NotificationProfileType | undefined; + }; +}>; + +export type UpdateLoading = ReadonlyDeep<{ + type: typeof UPDATE_LOADING; + payload: boolean; }>; export type UpdateOverride = ReadonlyDeep<{ @@ -77,9 +119,11 @@ export type UpdateProfile = ReadonlyDeep<{ type NotificationProfilesActionType = ReadonlyDeep< | CreateProfile + | GlobalUpdate | MarkProfileDeleted | RemoveProfile | UpdateCurrentState + | UpdateLoading | UpdateOverride | UpdateProfile >; @@ -92,6 +136,8 @@ export const actions = { profileWasRemoved, profileWasUpdated, markProfileDeleted, + setIsSyncEnabled, + setProfileOverride, updateCurrentState, updateOverride, updateProfile, @@ -101,16 +147,36 @@ export const useNotificationProfilesActions = (): BoundActionCreatorsMapObject< typeof actions > => useBoundActions(actions); +const updateStorageService = debounce( + (reason: string, options: { force?: boolean } = {}) => { + const disabled = window.storage.get('notificationProfileSyncDisabled'); + if (disabled && !options.force) { + return; + } + + window.Signal.Services.storage.storageServiceUploadJob({ + reason, + }); + }, + SECOND +); + function createProfile( - payload: NotificationProfileType + profile: Omit<NotificationProfileType, 'id'> ): ThunkAction<void, unknown, unknown, CreateProfile> { return async dispatch => { + // We must generate this id here, because we need crypto to generate random bytes, and + // don't want to load that in our UI components. + const id = generateNotificationProfileId(); + const payload = { ...profile, id }; + await createNotificationProfile(payload); dispatch({ type: CREATE_PROFILE, payload, }); - updateProfileService(); + fastUpdateProfileService(); + updateStorageService(`createProfile/${redactNotificationProfileId(id)}`); }; } @@ -132,28 +198,168 @@ function markProfileDeleted( deletedAtTimestampMs, }, }); - updateProfileService(); + fastUpdateProfileService(); + updateStorageService( + `markProfileDeleted/${redactNotificationProfileId(id)}` + ); }; } -function updateCurrentState(payload: NextProfileEvent): UpdateCurrentState { - // No need for a thunk - redux is the source of truth, and it's only kept in memory - return { - type: UPDATE_CURRENT_STATE, - payload, +// If called based on a local change, this function is run before the storage service +// upload. If called based on a storage service update, it is called at the end of +// processing, as the AccountRecord is processed. All profiles have been processed at +// that point, and the override from AccountRecord has been processed as well. +function setIsSyncEnabled( + enabled: boolean, + { fromStorageService }: { fromStorageService: boolean } +): ThunkAction<void, StateType, unknown, GlobalUpdate | UpdateLoading> { + return async (dispatch, getState) => { + const logId = `setIsSyncEnabled/enabled=${enabled}`; + const items = getItems(getState()); + const disabled = !enabled; + + if (items.notificationProfileSyncDisabled === disabled) { + log.warn('No change to current sync state, returning early'); + return; + } + + // Because we can't update everything (window.storage and our redux slice), there is + // the risk of a flash of content on the list page when enabling/disabling sync. So + // we set this loading flag and show something else until everything is ready. + try { + dispatch({ + type: UPDATE_LOADING, + payload: true, + }); + + await window.storage.put('notificationProfileSyncDisabled', disabled); + if (disabled) { + if (!fromStorageService) { + const globalOverride = await window.storage.get( + 'notificationProfileOverride' + ); + + await window.storage.put( + 'notificationProfileOverrideFromPrimary', + globalOverride + ); + } + const { toAdd, newOverride } = + prepareForDisabledNotificationProfileSync(); + dispatch({ + type: GLOBAL_UPDATE, + payload: { + toAdd, + toRemove: [], + newOverride, + }, + }); + await window.storage.put('notificationProfileOverride', newOverride); + await Promise.all( + toAdd.map(async profile => { + await DataWriter.createNotificationProfile(profile); + }) + ); + } else { + await window.storage.put( + 'notificationProfileOverrideFromPrimary', + undefined + ); + const { toAdd, toRemove, newOverride } = + prepareForEnabledNotificationProfileSync(); + dispatch({ + type: GLOBAL_UPDATE, + payload: { + toAdd, + toRemove, + newOverride, + }, + }); + await window.storage.put('notificationProfileOverride', newOverride); + await Promise.all( + toRemove.map(async profile => { + await DataWriter.deleteNotificationProfileById(profile.id); + }) + ); + await Promise.all( + toAdd.map(async profile => { + await DataWriter.createNotificationProfile(profile); + }) + ); + } + } finally { + dispatch({ + type: UPDATE_LOADING, + payload: false, + }); + } + + if (!fromStorageService) { + const me = window.ConversationController.getOurConversationOrThrow(); + me.captureChange(logId); + // We need to force because we don't need to update storage service with sync + // disabled - except in the case where we just disabled it. + updateStorageService(logId, { force: true }); + } + + fastUpdateProfileService(); }; } -function updateOverride( - payload: NotificationProfileOverride | undefined -): ThunkAction<void, unknown, unknown, UpdateOverride> { - return async dispatch => { - await window.storage.put('notificationProfileOverride', payload); +function setProfileOverride( + id: NotificationProfileIdString, + enabled: boolean, + endsAtMs?: number +): ThunkAction<void, StateType, unknown, UpdateOverride | UpdateLoading> { + return async (dispatch, getState) => { + const logId = `setProfileOverride/${redactNotificationProfileId(id)}/enabled=${enabled}`; + const state = getState(); + const currentOverride = getOverride(state); + + const me = window.ConversationController.getOurConversationOrThrow(); + me.captureChange(logId); + + if (enabled) { + if ( + currentOverride?.enabled && + currentOverride.enabled.profileId === id && + currentOverride.enabled.endsAtMs === endsAtMs + ) { + log.info( + `${logId}: Requested override is already in place; doing nothing.` + ); + return; + } + + const newOverride: NotificationProfileOverride = { + disabledAtMs: undefined, + enabled: { + profileId: id, + endsAtMs, + }, + }; + await window.storage.put('notificationProfileOverride', newOverride); + dispatch({ + type: UPDATE_OVERRIDE, + payload: newOverride, + }); + fastUpdateProfileService(); + updateStorageService(logId); + + return; + } + + const newOverride: NotificationProfileOverride = { + disabledAtMs: Date.now(), + enabled: undefined, + }; + await window.storage.put('notificationProfileOverride', newOverride); dispatch({ type: UPDATE_OVERRIDE, - payload, + payload: newOverride, }); - updateProfileService(); + fastUpdateProfileService(); + updateStorageService(logId); }; } @@ -161,12 +367,59 @@ function updateProfile( payload: NotificationProfileType ): ThunkAction<void, unknown, unknown, UpdateProfile> { return async dispatch => { - await updateNotificationProfile(payload); + const newProfile = { + ...payload, + storageNeedsSync: true, + }; + await updateNotificationProfile(newProfile); dispatch({ type: UPDATE_PROFILE, + payload: newProfile, + }); + fastUpdateProfileService(); + updateStorageService( + `updateProfile/${redactNotificationProfileId(newProfile.id)}` + ); + }; +} + +function updateOverride( + payload: NotificationProfileOverride | undefined, + { fromStorageService }: { fromStorageService: boolean } +): ThunkAction<void, unknown, unknown, UpdateOverride> { + return async dispatch => { + const id = payload?.enabled?.profileId; + const enabled = payload?.enabled; + await window.storage.put('notificationProfileOverride', payload); + + const logId = `updateOverride/${id ? redactNotificationProfileId(id) : 'undefined'}/enabled=${enabled}`; + + dispatch({ + type: UPDATE_OVERRIDE, payload, }); - updateProfileService(); + + if (!fromStorageService) { + const me = window.ConversationController.getOurConversationOrThrow(); + me.captureChange(logId); + updateStorageService(logId); + } + + fastUpdateProfileService(); + }; +} + +function updateCurrentState( + currentState: NextProfileEvent, + activeProfile: NotificationProfileType | undefined +): UpdateCurrentState { + // No need for a thunk - redux is the source of truth, and it's only kept in memory + return { + type: UPDATE_CURRENT_STATE, + payload: { + activeProfile, + currentState, + }, }; } @@ -201,7 +454,9 @@ function profileWasRemoved(payload: string): RemoveProfile { export function getEmptyState(): NotificationProfilesStateType { return { + activeProfile: undefined, currentState: { type: 'noChange', activeProfile: undefined }, + loading: false, override: undefined, profiles: [], }; @@ -219,6 +474,18 @@ export function reducer( }; } + if (action.type === GLOBAL_UPDATE) { + const { toAdd, toRemove, newOverride } = action.payload; + + return { + ...state, + profiles: sortProfiles( + difference(state.profiles, toRemove).concat(toAdd) + ), + override: newOverride, + }; + } + if (action.type === MARK_PROFILE_DELETED) { const { payload } = action; const { id, deletedAtTimestampMs } = payload; @@ -227,7 +494,11 @@ export function reducer( ...state, profiles: state.profiles.map(item => { if (item.id === id) { - return { ...item, deletedAtTimestampMs }; + return { + ...item, + deletedAtTimestampMs, + storageNeedsSync: true, + }; } return item; }), @@ -245,9 +516,18 @@ export function reducer( if (action.type === UPDATE_CURRENT_STATE) { const { payload } = action; + const { activeProfile, currentState } = payload; return { ...state, - currentState: payload, + activeProfile, + currentState, + }; + } + + if (action.type === UPDATE_LOADING) { + return { + ...state, + loading: action.payload, }; } diff --git a/ts/state/selectors/items.ts b/ts/state/selectors/items.ts index 945fd87ce7..f884cd332d 100644 --- a/ts/state/selectors/items.ts +++ b/ts/state/selectors/items.ts @@ -214,6 +214,12 @@ export const getHasStoryViewReceiptSetting = createSelector( ) ); +export const getNotificationProfileSyncDisabled = createSelector( + getItems, + (state: ItemsStateType): boolean => + Boolean(state.notificationProfileSyncDisabled) +); + export const getRemoteBuildExpiration = createSelector( getItems, (state: ItemsStateType): number | undefined => diff --git a/ts/state/selectors/notificationProfiles.ts b/ts/state/selectors/notificationProfiles.ts index a7ca44c684..1af783a3b5 100644 --- a/ts/state/selectors/notificationProfiles.ts +++ b/ts/state/selectors/notificationProfiles.ts @@ -5,19 +5,16 @@ import { createSelector } from 'reselect'; -import { createLogger } from '../../logging/log.js'; +import { getNotificationProfileSyncDisabled } from './items.js'; import type { StateType } from '../reducer.js'; import type { NotificationProfilesStateType } from '../ducks/notificationProfiles.js'; -import { - redactNotificationProfileId, - type NextProfileEvent, - type NotificationProfileOverride, - type NotificationProfileType, +import type { + NextProfileEvent, + NotificationProfileOverride, + NotificationProfileType, } from '../../types/NotificationProfile.js'; -const log = createLogger('notificationProfiles'); - export const getNotificationProfileData = ( state: StateType ): NotificationProfilesStateType => { @@ -25,24 +22,51 @@ export const getNotificationProfileData = ( }; export const getProfiles = createSelector( + getNotificationProfileSyncDisabled, getNotificationProfileData, ( + syncDisabled: boolean, state: NotificationProfilesStateType ): ReadonlyArray<NotificationProfileType> => { - return state.profiles.filter( - profile => profile.deletedAtTimestampMs == null + const notDeleted = state.profiles.filter( + profile => + profile.deletedAtTimestampMs == null || + profile.deletedAtTimestampMs === 0 ); + + if (syncDisabled) { + return notDeleted.filter(profile => !profile.storageID); + } + + return notDeleted; } ); export const getDeletedProfiles = createSelector( + getNotificationProfileSyncDisabled, getNotificationProfileData, ( + syncDisabled: boolean, state: NotificationProfilesStateType ): ReadonlyArray<NotificationProfileType> => { - return state.profiles.filter( - profile => profile.deletedAtTimestampMs != null + const deleted = state.profiles.filter( + profile => + profile.deletedAtTimestampMs != null && + profile.deletedAtTimestampMs !== 0 ); + + if (syncDisabled) { + return deleted.filter(profile => !profile.storageID); + } + + return deleted; + } +); + +export const getLoading = createSelector( + getNotificationProfileData, + (state: NotificationProfilesStateType): boolean => { + return state.loading; } ); @@ -63,29 +87,10 @@ export const getCurrentState = createSelector( ); export const getActiveProfile = createSelector( - getCurrentState, - getProfiles, + getNotificationProfileData, ( - state: NextProfileEvent, - profiles: ReadonlyArray<NotificationProfileType> + state: NotificationProfilesStateType ): NotificationProfileType | undefined => { - let profileId: string; - - if (state.type === 'noChange' && state.activeProfile) { - profileId = state.activeProfile; - } else if (state.type === 'willDisable') { - profileId = state.activeProfile; - } else { - return undefined; - } - - const profile = profiles.find(item => item.id === profileId); - if (!profile) { - log.warn( - `getActiveProfile: currentState referred to profileId ${redactNotificationProfileId(profileId)} not in the list` - ); - } - - return profile; + return state.activeProfile; } ); diff --git a/ts/state/smart/LeftPane.tsx b/ts/state/smart/LeftPane.tsx index ec2fa01a1b..a9b740130f 100644 --- a/ts/state/smart/LeftPane.tsx +++ b/ts/state/smart/LeftPane.tsx @@ -113,6 +113,9 @@ import { useNavActions } from '../ducks/nav.js'; import { SmartLeftPaneChatFolders } from './LeftPaneChatFolders.js'; import { SmartLeftPaneConversationListItemContextMenu } from './LeftPaneConversationListItemContextMenu.js'; import type { RenderConversationListItemContextMenuProps } from '../../components/conversationList/BaseConversationListItem.js'; +import { SmartNotificationProfilesMenu } from './NotificationProfilesMenu.js'; +import type { ExternalProps as NotificationProfilesMenuProps } from './NotificationProfilesMenu.js'; +import { getActiveProfile } from '../selectors/notificationProfiles.js'; function renderMessageSearchResult(id: string): JSX.Element { return <SmartMessageSearchResult id={id} />; @@ -168,6 +171,12 @@ function renderToastManagerWithoutMegaphone(props: { return <SmartToastManager disableMegaphone {...props} />; } +function renderNotificationProfilesMenu( + props: NotificationProfilesMenuProps +): JSX.Element { + return <SmartNotificationProfilesMenu {...props} />; +} + const getModeSpecificProps = ( state: StateType ): LeftPanePropsType['modeSpecificProps'] => { @@ -385,6 +394,7 @@ export const SmartLeftPane = memo(function SmartLeftPane({ : renderToastManagerWithoutMegaphone; const targetedMessageId = targetedMessage?.id; + const isNotificationProfileActive = Boolean(useSelector(getActiveProfile)); return ( <LeftPane @@ -416,6 +426,7 @@ export const SmartLeftPane = memo(function SmartLeftPane({ i18n={i18n} isMacOS={isMacOS} isOnline={isOnline} + isNotificationProfileActive={isNotificationProfileActive} isUpdateDownloaded={isUpdateDownloaded} lookupConversationWithoutServiceId={lookupConversationWithoutServiceId} modeSpecificProps={modeSpecificProps} @@ -437,6 +448,7 @@ export const SmartLeftPane = memo(function SmartLeftPane({ renderConversationListItemContextMenu } renderNetworkStatus={renderNetworkStatus} + renderNotificationProfilesMenu={renderNotificationProfilesMenu} renderRelinkDialog={renderRelinkDialog} renderToastManager={renderToastManager} renderUnsupportedOSDialog={renderUnsupportedOSDialog} diff --git a/ts/state/smart/NotificationProfilesMenu.tsx b/ts/state/smart/NotificationProfilesMenu.tsx new file mode 100644 index 0000000000..2579602ede --- /dev/null +++ b/ts/state/smart/NotificationProfilesMenu.tsx @@ -0,0 +1,63 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; +import { useSelector } from 'react-redux'; + +import { getIntl } from '../selectors/user.js'; +import { NotificationProfilesMenu } from '../../components/NotificationProfilesMenu.js'; +import { useNotificationProfilesActions } from '../ducks/notificationProfiles.js'; +import { + getActiveProfile, + getLoading, + getOverride, + getProfiles, +} from '../selectors/notificationProfiles.js'; +import { useNavActions } from '../ducks/nav.js'; +import { NavTab, SettingsPage } from '../../types/Nav.js'; + +export type ExternalProps = { + isOpen: boolean; + onClose: () => void; + trigger?: React.ReactNode; +}; + +export function SmartNotificationProfilesMenu({ + isOpen, + onClose, + trigger, +}: ExternalProps): JSX.Element { + const i18n = useSelector(getIntl); + + const allProfiles = useSelector(getProfiles); + const activeProfile = useSelector(getActiveProfile); + const currentOverride = useSelector(getOverride); + const loading = useSelector(getLoading); + + const { changeLocation } = useNavActions(); + const { setProfileOverride } = useNotificationProfilesActions(); + + const goToSettings = () => { + changeLocation({ + tab: NavTab.Settings, + details: { + page: SettingsPage.NotificationProfilesHome, + }, + }); + }; + + return ( + <NotificationProfilesMenu + activeProfileId={activeProfile?.id} + allProfiles={allProfiles} + currentOverride={currentOverride} + i18n={i18n} + isOpen={isOpen} + loading={loading} + onClose={onClose} + onGoToSettings={goToSettings} + setProfileOverride={setProfileOverride} + trigger={trigger} + /> + ); +} diff --git a/ts/state/smart/Preferences.tsx b/ts/state/smart/Preferences.tsx index 338b557f16..f2f58889a5 100644 --- a/ts/state/smart/Preferences.tsx +++ b/ts/state/smart/Preferences.tsx @@ -93,6 +93,12 @@ import type { SmartPreferencesEditChatFolderPageProps } from './PreferencesEditC import { SmartPreferencesEditChatFolderPage } from './PreferencesEditChatFolderPage.js'; import { isProduction } from '../../util/version.js'; import { AxoProvider } from '../../axo/AxoProvider.js'; +import { + SmartNotificationProfilesCreateFlow, + SmartNotificationProfilesHome, +} from './PreferencesNotificationProfiles.js'; +import type { ExternalProps as SmartNotificationProfilesProps } from './PreferencesNotificationProfiles.js'; +import { getProfiles } from '../selectors/notificationProfiles.js'; const DEFAULT_NOTIFICATION_SETTING = 'message'; @@ -114,6 +120,18 @@ function renderPreferencesEditChatFolderPage( return <SmartPreferencesEditChatFolderPage {...props} />; } +function renderNotificationProfilesHome( + props: SmartNotificationProfilesProps +): JSX.Element { + return <SmartNotificationProfilesHome {...props} />; +} + +function renderNotificationProfilesCreateFlow( + props: SmartNotificationProfilesProps +): JSX.Element { + return <SmartNotificationProfilesCreateFlow {...props} />; +} + function renderProfileEditor(options: { contentsRef: MutableRefObject<HTMLDivElement | null>; }): JSX.Element { @@ -200,20 +218,21 @@ export function SmartPreferences(): JSX.Element | null { getConversationsWithCustomColorSelector ); const i18n = useSelector(getIntl); + const dialogType = useSelector(getUpdateDialogType); const items = useSelector(getItems); const hasFailedStorySends = useSelector(getHasAnyFailedStorySends); - const dialogType = useSelector(getUpdateDialogType); const me = useSelector(getMe); const navTabsCollapsed = useSelector(getNavTabsCollapsed); const otherTabsUnreadStats = useSelector(getOtherTabsUnreadStats); const preferredWidthFromStorage = useSelector(getPreferredLeftPaneWidth); + const getPreferredBadge = useSelector(getPreferredBadgeSelector); const theme = useSelector(getTheme); const donationReceipts = useSelector( (state: StateType) => state.donations.receipts ); + const notificationProfileCount = useSelector(getProfiles).length; const shouldShowUpdateDialog = dialogType !== DialogType.None; - const getPreferredBadge = useSelector(getPreferredBadgeSelector); const badge = getPreferredBadge(me.badges); // The weird ones @@ -820,6 +839,7 @@ export function SmartPreferences(): JSX.Element | null { me={me} navTabsCollapsed={navTabsCollapsed} notificationContent={notificationContent} + notificationProfileCount={notificationProfileCount} onAudioNotificationsChange={onAudioNotificationsChange} onAutoConvertEmojiChange={onAutoConvertEmojiChange} onAutoDownloadAttachmentChange={onAutoDownloadAttachmentChange} @@ -871,6 +891,10 @@ export function SmartPreferences(): JSX.Element | null { removeCustomColorOnConversations={removeCustomColorOnConversations} removeCustomColor={removeCustomColor} renderDonationsPane={renderDonationsPane} + renderNotificationProfilesHome={renderNotificationProfilesHome} + renderNotificationProfilesCreateFlow={ + renderNotificationProfilesCreateFlow + } renderProfileEditor={renderProfileEditor} renderToastManager={renderToastManager} renderUpdateDialog={renderUpdateDialog} diff --git a/ts/state/smart/PreferencesNotificationProfiles.tsx b/ts/state/smart/PreferencesNotificationProfiles.tsx new file mode 100644 index 0000000000..35f156cd6b --- /dev/null +++ b/ts/state/smart/PreferencesNotificationProfiles.tsx @@ -0,0 +1,127 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { memo } from 'react'; +import type { MutableRefObject } from 'react'; +import { useSelector } from 'react-redux'; + +import { getIntl, getTheme } from '../selectors/user.js'; +import { + NotificationProfilesCreateFlow, + NotificationProfilesHome, +} from '../../components/PreferencesNotificationProfiles.js'; +import { + getAllComposableConversations, + getConversationSelector, +} from '../selectors/conversations.js'; +import { getPreferredBadgeSelector } from '../selectors/badges.js'; +import { useNotificationProfilesActions } from '../ducks/notificationProfiles.js'; +import { + getActiveProfile, + getLoading, + getProfiles, +} from '../selectors/notificationProfiles.js'; +import type { SettingsLocation } from '../../types/Nav.js'; +import { getItems } from '../selectors/items.js'; +import { useItemsActions } from '../ducks/items.js'; + +export type ExternalProps = { + contentsRef: MutableRefObject<HTMLDivElement | null>; + setSettingsLocation: (location: SettingsLocation) => void; +}; + +export const SmartNotificationProfilesHome = memo( + function SmartNotificationProfilesHome({ + contentsRef, + setSettingsLocation, + }: ExternalProps) { + const i18n = useSelector(getIntl); + const theme = useSelector(getTheme); + const items = useSelector(getItems); + + const allProfiles = useSelector(getProfiles); + const activeProfile = useSelector(getActiveProfile); + const loading = useSelector(getLoading); + + const conversations = useSelector(getAllComposableConversations); + const conversationSelector = useSelector(getConversationSelector); + const preferredBadgeSelector = useSelector(getPreferredBadgeSelector); + + const isSyncEnabled = !items.notificationProfileSyncDisabled; + const hasOnboardingBeenSeen = Boolean( + items.hasSeenNotificationProfileOnboarding + ); + + const { + markProfileDeleted, + setIsSyncEnabled: originalSetIsSyncEnabled, + setProfileOverride, + updateProfile, + } = useNotificationProfilesActions(); + const { putItem } = useItemsActions(); + + const setIsSyncEnabled = React.useCallback( + (value: boolean) => { + originalSetIsSyncEnabled(value, { fromStorageService: false }); + }, + [originalSetIsSyncEnabled] + ); + const setHasOnboardingBeenSeen = React.useCallback( + (value: boolean) => { + putItem('hasSeenNotificationProfileOnboarding', value); + }, + [putItem] + ); + + return ( + <NotificationProfilesHome + activeProfileId={activeProfile?.id} + allProfiles={allProfiles} + contentsRef={contentsRef} + conversations={conversations} + conversationSelector={conversationSelector} + i18n={i18n} + isSyncEnabled={isSyncEnabled} + hasOnboardingBeenSeen={hasOnboardingBeenSeen} + loading={loading} + markProfileDeleted={markProfileDeleted} + preferredBadgeSelector={preferredBadgeSelector} + setHasOnboardingBeenSeen={setHasOnboardingBeenSeen} + setIsSyncEnabled={setIsSyncEnabled} + setSettingsLocation={setSettingsLocation} + setProfileOverride={setProfileOverride} + theme={theme} + updateProfile={updateProfile} + /> + ); + } +); + +export const SmartNotificationProfilesCreateFlow = memo( + function SmartNotificationProfilesCreateFlow({ + contentsRef, + setSettingsLocation, + }: ExternalProps) { + const i18n = useSelector(getIntl); + const theme = useSelector(getTheme); + + const conversations = useSelector(getAllComposableConversations); + const conversationSelector = useSelector(getConversationSelector); + const preferredBadgeSelector = useSelector(getPreferredBadgeSelector); + + const { createProfile } = useNotificationProfilesActions(); + + return ( + <NotificationProfilesCreateFlow + contentsRef={contentsRef} + conversations={conversations} + conversationSelector={conversationSelector} + createProfile={createProfile} + i18n={i18n} + preferredBadgeSelector={preferredBadgeSelector} + setSettingsLocation={setSettingsLocation} + theme={theme} + /> + ); + } +); diff --git a/ts/test-mock/backups/backups_test.ts b/ts/test-mock/backups/backups_test.ts index dcd19c4a85..550c411fb4 100644 --- a/ts/test-mock/backups/backups_test.ts +++ b/ts/test-mock/backups/backups_test.ts @@ -11,6 +11,7 @@ import { assert } from 'chai'; import { expect } from 'playwright/test'; import Long from 'long'; +import * as Bytes from '../../Bytes.js'; import { generateStoryDistributionId } from '../../types/StoryDistributionId.js'; import { MY_STORY_ID } from '../../types/Stories.js'; import { generateAci } from '../../types/ServiceId.js'; @@ -28,6 +29,7 @@ import { import { toBase64 } from '../../Bytes.js'; import { strictAssert } from '../../util/assert.js'; import { BackupLevel } from '../../services/backups/types.js'; +import { generateNotificationProfileId } from '../../types/NotificationProfile-node.js'; export const debug = createDebug('mock:test:backups'); @@ -123,6 +125,35 @@ describe('backups', function (this: Mocha.Suite) { }, }); + const notificationProfileName1 = 'Work'; + const now = Date.now(); + state = state.addRecord({ + type: IdentifierType.NOTIFICATION_PROFILE, + record: { + notificationProfile: { + id: Bytes.fromHex(generateNotificationProfileId()), + name: notificationProfileName1, + color: 0xffff0000, + createdAtMs: Long.fromNumber(now), + allowAllCalls: true, + }, + }, + }); + + const notificationProfileName2 = 'Driving'; + state = state.addRecord({ + type: IdentifierType.NOTIFICATION_PROFILE, + record: { + notificationProfile: { + id: Bytes.fromHex(generateNotificationProfileId()), + name: notificationProfileName2, + color: 0xff00ff00, + createdAtMs: Long.fromNumber(now + 1), + allowAllMentions: true, + }, + }, + }); + await phone.setStorageState(state); app = await bootstrap.link(); @@ -304,6 +335,24 @@ describe('backups', function (this: Mocha.Suite) { ).toHaveCSS('opacity', '1'); await snapshot('story privacy'); + + debug('Closing story privacy dialog'); + await window.locator('.module-Modal__close-button').click(); + + debug('Switching to settings tab'); + await window.getByTestId('NavTabsItem--Settings').click(); + + debug('Opening Notification Profiles list screen'); + await window.getByRole('button', { name: 'Notifications' }).click(); + await window.getByTestId('ManageNotificationProfiles').click(); + await expect( + window.getByTestId(`EditProfile--${notificationProfileName1}`) + ).toBeVisible(); + await expect( + window.getByTestId(`EditProfile--${notificationProfileName2}`) + ).toBeVisible(); + + await snapshot('notification profile list'); }, thisVal.test ); diff --git a/ts/test-mock/storage/notification_profiles_test.ts b/ts/test-mock/storage/notification_profiles_test.ts new file mode 100644 index 0000000000..46ba909a5b --- /dev/null +++ b/ts/test-mock/storage/notification_profiles_test.ts @@ -0,0 +1,515 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import assert from 'node:assert'; +import { Proto, StorageState } from '@signalapp/mock-server'; +import { expect } from 'playwright/test'; +import Long from 'long'; + +import * as Bytes from '../../Bytes.js'; +import * as durations from '../../util/durations/index.js'; +import { dropNull } from '../../util/dropNull.js'; +import { constantTimeEqual } from '../../Crypto.js'; +import { generateNotificationProfileId } from '../../types/NotificationProfile-node.js'; +import { Bootstrap, debug } from './fixtures.js'; +import { typeIntoInput } from '../helpers.js'; + +import type { App } from './fixtures.js'; +import { DayOfWeek } from '../../types/NotificationProfile.js'; + +const IdentifierType = Proto.ManifestRecord.Identifier.Type; + +describe('storage service/notification profiles', function (this: Mocha.Suite) { + this.timeout(durations.MINUTE); + + let bootstrap: Bootstrap; + let app: App; + + beforeEach(async () => { + bootstrap = new Bootstrap({ contactCount: 0 }); + await bootstrap.init(); + + const { phone } = bootstrap; + + let state = StorageState.getEmpty(); + + state = state.updateAccount({ + profileKey: phone.profileKey.serialize(), + givenName: phone.profileName, + }); + + await phone.setStorageState(state); + + app = await bootstrap.link(); + }); + + afterEach(async function (this: Mocha.Context) { + if (!bootstrap) { + return; + } + + await bootstrap.maybeSaveLogs(this.currentTest, app); + if (app) { + await app.close(); + } + await bootstrap.teardown(); + }); + + it('updates storage service on initial onboard on desktop', async () => { + const { phone } = bootstrap; + const window = await app.getWindow(); + + const state = await phone.expectStorageState('initial state'); + + // wait for initial sync storage service update + const secondState = await phone.waitForStorageState({ after: state }); + + debug('Opening settings tab'); + await window.locator('[data-testid="NavTabsItem--Settings"]').click(); + + debug('Opening Notifications page'); + await window.getByRole('button', { name: 'Notifications' }).click(); + + const profileName = 'NewProfile'; + debug('Starting Notification Profiles onboarding'); + await window.getByTestId('OnboardNotificationProfiles').click(); + + debug('Dismiss onboarding dialog'); + await window.getByRole('button', { name: 'Continue' }).click(); + + debug('Start the create flow'); + await window.getByRole('button', { name: 'Create profile' }).click(); + + debug('Name page'); + const nameInput = window.locator('.Input__input'); + await typeIntoInput(nameInput, profileName, ''); + await window.getByRole('button', { name: 'Next' }).click(); + + debug('Allowed page'); + await window.getByRole('button', { name: 'Next' }).click(); + + debug('Schedule page'); + await window.locator('button[role="switch"]').click(); + await window.getByRole('button', { name: 'Next' }).click(); + + debug('Done page'); + await window.getByRole('button', { name: 'Done' }).click(); + + debug('List page'); + await expect( + window.getByTestId(`EditProfile--${profileName}`) + ).toBeVisible(); + + // finally, this storage service update should include the new notification profile + const thirdState = await phone.waitForStorageState({ + after: secondState, + }); + + let profileId: Uint8Array | undefined; + const profilewasAdded = thirdState.hasRecord(record => { + const isMatch = + record.type === IdentifierType.NOTIFICATION_PROFILE && + record.record?.notificationProfile?.name === profileName && + record.record?.notificationProfile?.scheduleEnabled === true; + if (isMatch) { + profileId = dropNull(record.record?.notificationProfile?.id); + } + + return isMatch; + }); + if (!profilewasAdded) { + throw new Error('Did not find new profile in storage service'); + } + if (!profileId || !profileId.length) { + throw new Error('No profileId found on new notification record'); + } + + debug('Open edit page for profile'); + await window.getByTestId(`EditProfile--${profileName}`).click(); + + debug('Open edit schedule page'); + await window.getByTestId('EditSchedule').click(); + await window.locator('button[role="switch"]').click(); + + debug('Done page'); + await window.getByRole('button', { name: 'Done' }).click(); + + debug('Done page'); + await window.getByRole('button', { name: 'Done' }).click(); + + debug('List page'); + await expect( + window.getByTestId(`EditProfile--${profileName}`) + ).toBeVisible(); + + // finally, this storage service update should include the new notification profile + const fourthState = await phone.waitForStorageState({ + after: secondState, + }); + + const profileScheduleIsOff = fourthState.hasRecord(record => { + return ( + record.type === IdentifierType.NOTIFICATION_PROFILE && + record.record?.notificationProfile?.name === profileName && + record.record?.notificationProfile?.scheduleEnabled === false + ); + }); + if (!profileScheduleIsOff) { + throw new Error('Profile schedule was not disabled in storage service'); + } + + debug('Opening chats tab'); + await window.locator('[data-testid="NavTabsItem--Chats"]').click(); + + debug('Click triple-dot button'); + await window.getByRole('button', { name: 'More Actions' }).click(); + await window + .getByRole('button', { name: 'Notification profile', exact: true }) + .click(); + + debug('Click to add enabled=true override'); + await window.getByRole('menuitem', { name: profileName }).click(); + + // finally, this storage service update should have the new override + const fifthState = await phone.waitForStorageState({ + after: secondState, + }); + + const acountRecordHasOverride = fifthState.hasRecord(record => { + const id = + record.record?.account?.notificationProfileManualOverride?.enabled?.id; + + return Boolean( + record.type === IdentifierType.ACCOUNT && + id && + id.length && + profileId && + constantTimeEqual(id, profileId) + ); + }); + if (!acountRecordHasOverride) { + throw new Error('Did not find matching override in storage service'); + } + }); + + it('reconciles profiles from storage service when sync is reenabled', async () => { + const { phone } = bootstrap; + const window = await app.getWindow(); + + const starting = await phone.expectStorageState('initial state'); + + const firstState = await phone.waitForStorageState({ after: starting }); + + debug('Opening settings tab'); + await window.locator('[data-testid="NavTabsItem--Settings"]').click(); + + debug('Opening Notifications page'); + await window.getByRole('button', { name: 'Notifications' }).click(); + + debug('Open Notification Profiles list page'); + await window.getByTestId('OnboardNotificationProfiles').click(); + + debug('Dismiss onboarding dialog'); + await window.getByRole('button', { name: 'Continue' }).click(); + + debug('Adding two profiles and an override to storage service'); + const now = Date.now(); + const notificationProfileName1 = 'One'; + const notificationProfileId1 = Bytes.fromHex( + generateNotificationProfileId() + ); + const notificationProfileName2 = 'Two'; + const notificationProfileId2 = Bytes.fromHex( + generateNotificationProfileId() + ); + const notificationProfileName3 = 'Three'; + const notificationProfileId3 = Bytes.fromHex( + generateNotificationProfileId() + ); + const notificationProfileName4 = 'Four'; + const notificationProfileId4 = Bytes.fromHex( + generateNotificationProfileId() + ); + + const DEFAULT_PROFILE = { + allowAllCalls: true, + allowAllMentions: false, + scheduleStartTime: 900, + scheduleEndTime: 1700, + scheduleEnabled: false, + scheduleDaysEnabled: [ + DayOfWeek.MONDAY, + DayOfWeek.TUESDAY, + DayOfWeek.WEDNESDAY, + DayOfWeek.THURSDAY, + DayOfWeek.FRIDAY, + ], + }; + + { + let newState = firstState.addRecord({ + type: IdentifierType.NOTIFICATION_PROFILE, + record: { + notificationProfile: { + id: notificationProfileId1, + name: notificationProfileName1, + color: 0xffff0000, + createdAtMs: Long.fromNumber(now + 1), + ...DEFAULT_PROFILE, + }, + }, + }); + + newState = newState.addRecord({ + type: IdentifierType.NOTIFICATION_PROFILE, + record: { + notificationProfile: { + id: notificationProfileId2, + name: notificationProfileName2, + color: 0xff00ff00, + createdAtMs: Long.fromNumber(now + 2), + ...DEFAULT_PROFILE, + allowAllCalls: false, + }, + }, + }); + + newState = newState.updateAccount({ + notificationProfileManualOverride: { + enabled: { + id: notificationProfileId1, + }, + }, + }); + + await phone.setStorageState(newState); + } + + debug('Waiting for desktop to process storage service updates'); + await phone.sendFetchStorage({ + timestamp: bootstrap.getTimestamp(), + }); + await app.waitForManifestVersion(firstState.version + 1); + + debug('Now we should be on the Notification Profiles list page'); + await expect( + window.getByTestId(`EditProfile--${notificationProfileName1}`) + ).toBeVisible(); + await expect( + window.getByTestId(`EditProfile--${notificationProfileName2}`) + ).toBeVisible(); + + debug('Turn off Notification Profiles sync'); + await window.locator('button[role="switch"]').click(); + + const secondState = await phone.waitForStorageState({ + after: firstState, + }); + + debug( + 'We should still see the same items on the Notification Profiles list page' + ); + await expect( + window.getByTestId(`EditProfile--${notificationProfileName1}`) + ).toBeVisible(); + await expect( + window.getByTestId(`EditProfile--${notificationProfileName2}`) + ).toBeVisible(); + + { + const accountRecord = secondState.getAccountRecord(); + if (!accountRecord?.notificationProfileSyncDisabled) { + throw new Error('Notification profile sync is disabled!'); + } + + assert.deepEqual(accountRecord?.notificationProfileManualOverride, { + enabled: { + id: notificationProfileId1, + }, + }); + + let countOfProfiles = 0; + secondState.hasRecord(record => { + const deletedTimestamp = + record.record.notificationProfile?.deletedAtTimestampMs; + if ( + record.type === IdentifierType.NOTIFICATION_PROFILE && + (!deletedTimestamp || deletedTimestamp.isZero()) + ) { + countOfProfiles += 1; + } + return false; + }); + + assert.strictEqual( + countOfProfiles, + 2, + 'Expect the original two still in storage service' + ); + } + + debug('Open edit page for existing profile'); + await window + .getByTestId(`EditProfile--${notificationProfileName1}`) + .click(); + + debug('Open edit schedule page, enable schedule'); + await window.getByTestId('EditSchedule').click(); + await window.locator('button[role="switch"]').click(); + + debug('Done page'); + await window.getByRole('button', { name: 'Done' }).click(); + + debug('Done page'); + await window.getByRole('button', { name: 'Done' }).click(); + + debug('List page'); + await expect( + window.getByTestId(`EditProfile--${notificationProfileName1}`) + ).toBeVisible(); + + debug('Now create a new Notification Profile'); + await window.getByRole('button', { name: 'Create profile' }).click(); + + debug('Name page'); + const nameInput = window.locator('.Input__input'); + await typeIntoInput(nameInput, notificationProfileName3, ''); + await window.getByRole('button', { name: 'Next' }).click(); + + debug('Allowed page'); + await window.getByRole('button', { name: 'Next' }).click(); + + debug('Schedule page'); + await window.getByRole('button', { name: 'Next' }).click(); + + debug('Done page'); + await window.getByRole('button', { name: 'Done' }).click(); + + debug('List page'); + await expect( + window.getByTestId(`EditProfile--${notificationProfileName3}`) + ).toBeVisible(); + + debug('Turn on sync on storage service, and add two new profiles'); + { + let newState = secondState.addRecord({ + type: IdentifierType.NOTIFICATION_PROFILE, + record: { + notificationProfile: { + id: notificationProfileId1, + name: notificationProfileName1, + color: 0xffff0000, + createdAtMs: Long.fromNumber(now + 1), + ...DEFAULT_PROFILE, + }, + }, + }); + + newState = newState.addRecord({ + type: IdentifierType.NOTIFICATION_PROFILE, + record: { + notificationProfile: { + id: notificationProfileId2, + name: notificationProfileName2, + color: 0xff00ff00, + createdAtMs: Long.fromNumber(now + 2), + ...DEFAULT_PROFILE, + allowAllCalls: false, + }, + }, + }); + + newState = newState.addRecord({ + type: IdentifierType.NOTIFICATION_PROFILE, + record: { + notificationProfile: { + id: notificationProfileId3, + name: notificationProfileName3, + color: 0xff0000ff, + createdAtMs: Long.fromNumber(now + 3), + ...DEFAULT_PROFILE, + }, + }, + }); + + newState = newState.addRecord({ + type: IdentifierType.NOTIFICATION_PROFILE, + record: { + notificationProfile: { + id: notificationProfileId4, + name: notificationProfileName4, + color: 0xff0000ff, + createdAtMs: Long.fromNumber(now + 4), + ...DEFAULT_PROFILE, + allowAllCalls: false, + scheduleStartTime: 1000, + scheduleEndTime: 1100, + scheduleDaysEnabled: [DayOfWeek.MONDAY], + }, + }, + }); + + newState = newState.updateAccount({ + notificationProfileManualOverride: { + enabled: { + id: notificationProfileId1, + }, + }, + notificationProfileSyncDisabled: false, + }); + + await phone.setStorageState(newState); + } + + // now desktop will see the off->on flip for sync, and reconcile profiles: + // #1: was modified on Desktop, so will be duplicated + // #2: same on both, should not be duplicated + // #3: created separately on both sides with sync off, structurally similar, no dupe + // #4: new via storage service after sync + debug('Waiting for desktop to process storage service updates'); + await phone.sendFetchStorage({ + timestamp: bootstrap.getTimestamp(), + }); + await app.waitForManifestVersion(secondState.version + 1); + + debug('Check what is on the list page now'); + await expect( + window.getByTestId(`EditProfile--${notificationProfileName1}`) + ).toBeVisible(); + await expect( + window.getByTestId(`EditProfile--Copy of ${notificationProfileName1}`) + ).toBeVisible(); + await expect( + window.getByTestId(`EditProfile--${notificationProfileName2}`) + ).toBeVisible(); + await expect( + window.getByTestId(`EditProfile--${notificationProfileName3}`) + ).toBeVisible(); + await expect( + window.getByTestId(`EditProfile--${notificationProfileName4}`) + ).toBeVisible(); + + const thirdState = await phone.waitForStorageState({ + after: secondState, + }); + + let countOfProfiles = 0; + thirdState.hasRecord(record => { + const deletedTimestamp = + record.record.notificationProfile?.deletedAtTimestampMs; + if ( + record.type === IdentifierType.NOTIFICATION_PROFILE && + (!deletedTimestamp || deletedTimestamp.isZero()) + ) { + countOfProfiles += 1; + } + return false; + }); + + assert.strictEqual( + countOfProfiles, + 5, + 'Expect all profiles in storage service' + ); + }); +}); diff --git a/ts/test-node/types/NotificationProfile_test.ts b/ts/test-node/types/NotificationProfile_test.ts index 79f5216014..bda70fc35a 100644 --- a/ts/test-node/types/NotificationProfile_test.ts +++ b/ts/test-node/types/NotificationProfile_test.ts @@ -9,7 +9,9 @@ import { DayOfWeek, findNextProfileEvent, getDayOfWeek, + getEndTime, getMidnight, + getStartTime, loopThroughWeek, sortProfiles, } from '../../types/NotificationProfile.js'; @@ -86,7 +88,7 @@ describe('NotificationProfile', () => { profiles, time: now, }); - assert.deepEqual(expected, actual); + assert.deepEqual(actual, expected); }); it('should return noChange with no profiles with schedules', () => { @@ -106,7 +108,7 @@ describe('NotificationProfile', () => { profiles, time: now, }); - assert.deepEqual(expected, actual); + assert.deepEqual(actual, expected); }); it('should return noChange if manual enable override w/o end time and profile has no schedule', () => { @@ -132,7 +134,7 @@ describe('NotificationProfile', () => { profiles, time: now, }); - assert.deepEqual(expected, actual); + assert.deepEqual(actual, expected); }); it('should return willDisable if manual enable override w/ end time and profile has no schedule', () => { @@ -163,7 +165,7 @@ describe('NotificationProfile', () => { profiles, time: now, }); - assert.deepEqual(expected, actual); + assert.deepEqual(actual, expected); }); it('should return willDisable if manual enable override w/ end time overlaps with scheduled time', () => { @@ -206,7 +208,7 @@ describe('NotificationProfile', () => { profiles, time: now, }); - assert.deepEqual(expected, actual); + assert.deepEqual(actual, expected); }); it('should return willDisable if manual enable override w/ end time if different profile enables at end time', () => { const newProfile = createBasicProfile({ @@ -261,7 +263,7 @@ describe('NotificationProfile', () => { profiles, time: now, }); - assert.deepEqual(expected, actual); + assert.deepEqual(actual, expected); }); it('should return willDisable if manual enable override w/o end time refers to profile with schedule, enabled now', () => { @@ -303,7 +305,48 @@ describe('NotificationProfile', () => { profiles, time: now, }); - assert.deepEqual(expected, actual); + assert.deepEqual(actual, expected); + }); + it('should return willDisable if manual enable override w/o end time refers to profile with schedule, enabled via previous day schedule', () => { + const defaultProfile = createBasicProfile({ + scheduleEnabled: true, + scheduleDaysEnabled: { + [DayOfWeek.MONDAY]: false, + [DayOfWeek.TUESDAY]: false, + [DayOfWeek.WEDNESDAY]: false, + [DayOfWeek.THURSDAY]: false, + [DayOfWeek.FRIDAY]: false, + [DayOfWeek.SATURDAY]: false, + [DayOfWeek.SUNDAY]: true, + }, + scheduleStartTime: 2000, + scheduleEndTime: 1100, + }); + + const expected: NextProfileEvent = { + type: 'willDisable', + activeProfile: defaultProfile.id, + willDisableAt: now + HOUR, + clearEnableOverride: true, + }; + const profiles: ReadonlyArray<NotificationProfileType> = [ + defaultProfile, + createBasicProfile({ + name: 'Work', + }), + ]; + + const actual = findNextProfileEvent({ + override: { + disabledAtMs: undefined, + enabled: { + profileId: defaultProfile.id, + }, + }, + profiles, + time: now, + }); + assert.deepEqual(actual, expected); }); it('should return willDisable if manual enable override w/o end time refers to profile with schedule, not enabled now', () => { const defaultProfile = createBasicProfile({ @@ -344,7 +387,7 @@ describe('NotificationProfile', () => { profiles, time: now, }); - assert.deepEqual(expected, actual); + assert.deepEqual(actual, expected); }); it('should return willDisable if profile should be active right now', () => { @@ -380,7 +423,7 @@ describe('NotificationProfile', () => { profiles, time: now, }); - assert.deepEqual(expected, actual); + assert.deepEqual(actual, expected); }); it('should return willDisable if profile should be active right now, with earlier preempt time', () => { @@ -449,7 +492,7 @@ describe('NotificationProfile', () => { profiles, time: now, }); - assert.deepEqual(expected, actual); + assert.deepEqual(actual, expected); }); it('should return willDisable with newer profile if two profiles should be active right now, different start time', () => { @@ -499,7 +542,7 @@ describe('NotificationProfile', () => { profiles, time: now, }); - assert.deepEqual(expected, actual); + assert.deepEqual(actual, expected); }); it('should return willDisable with newer profile if two profiles should be active right now, same start time', () => { const oldProfile = createBasicProfile({ @@ -548,7 +591,43 @@ describe('NotificationProfile', () => { profiles, time: now, }); - assert.deepEqual(expected, actual); + assert.deepEqual(actual, expected); + }); + + it('should return willDisable if profile has end before start, and is scheduled to end soon', () => { + const defaultProfile = createBasicProfile({ + scheduleEnabled: true, + scheduleDaysEnabled: { + [DayOfWeek.MONDAY]: false, + [DayOfWeek.TUESDAY]: false, + [DayOfWeek.WEDNESDAY]: false, + [DayOfWeek.THURSDAY]: false, + [DayOfWeek.FRIDAY]: false, + [DayOfWeek.SATURDAY]: false, + [DayOfWeek.SUNDAY]: true, + }, + scheduleStartTime: 2000, + scheduleEndTime: 1100, + }); + + const expected: NextProfileEvent = { + type: 'willDisable', + activeProfile: defaultProfile.id, + willDisableAt: now + HOUR, + }; + const profiles: ReadonlyArray<NotificationProfileType> = [ + defaultProfile, + createBasicProfile({ + name: 'Work', + }), + ]; + + const actual = findNextProfileEvent({ + override: undefined, + profiles, + time: now, + }); + assert.deepEqual(actual, expected); }); it('should return willEnable if profile is scheduled to start soon', () => { @@ -584,7 +663,43 @@ describe('NotificationProfile', () => { profiles, time: now, }); - assert.deepEqual(expected, actual); + assert.deepEqual(actual, expected); + }); + + it('should return willEnable if profile has end before start, and is scheduled to start soon', () => { + const defaultProfile = createBasicProfile({ + scheduleEnabled: true, + scheduleDaysEnabled: { + [DayOfWeek.MONDAY]: true, + [DayOfWeek.TUESDAY]: false, + [DayOfWeek.WEDNESDAY]: false, + [DayOfWeek.THURSDAY]: false, + [DayOfWeek.FRIDAY]: false, + [DayOfWeek.SATURDAY]: false, + [DayOfWeek.SUNDAY]: false, + }, + scheduleStartTime: 2000, + scheduleEndTime: 1100, + }); + + const expected: NextProfileEvent = { + type: 'willEnable', + toEnable: defaultProfile.id, + willEnableAt: now + 10 * HOUR, + }; + const profiles: ReadonlyArray<NotificationProfileType> = [ + defaultProfile, + createBasicProfile({ + name: 'Work', + }), + ]; + + const actual = findNextProfileEvent({ + override: undefined, + profiles, + time: now, + }); + assert.deepEqual(actual, expected); }); it('should return willEnable w/ manual disable override if profile will start soon', () => { @@ -624,7 +739,7 @@ describe('NotificationProfile', () => { profiles, time: now, }); - assert.deepEqual(expected, actual); + assert.deepEqual(actual, expected); }); it('should return willEnable w/ manual disable override if profile should be active now, another starts tomorrow', () => { @@ -679,7 +794,7 @@ describe('NotificationProfile', () => { profiles, time: now, }); - assert.deepEqual(expected, actual); + assert.deepEqual(actual, expected); }); it('should return willEnable if profile schedule starts in six days', () => { @@ -715,7 +830,7 @@ describe('NotificationProfile', () => { profiles, time: now, }); - assert.deepEqual(expected, actual); + assert.deepEqual(actual, expected); }); it('should return willEnable for newer profile if there is a conflict', () => { @@ -766,7 +881,7 @@ describe('NotificationProfile', () => { profiles, time: now, }); - assert.deepEqual(expected, actual); + assert.deepEqual(actual, expected); }); it('should return willEnable for older profile if it will activate first', () => { @@ -817,7 +932,7 @@ describe('NotificationProfile', () => { profiles, time: now, }); - assert.deepEqual(expected, actual); + assert.deepEqual(actual, expected); }); }); @@ -853,7 +968,7 @@ describe('NotificationProfile', () => { }, }); - assert.strictEqual(count, 6); + assert.strictEqual(count, 7); }); it('loops through entire week if check returns false', () => { let count = 0; @@ -868,9 +983,9 @@ describe('NotificationProfile', () => { }, }); - assert.strictEqual(count, 7); + assert.strictEqual(count, 8); }); - it('loops from monday to sunday', () => { + it('loops from sunday (yesterday) to next sunday', () => { let count = 0; const startingDay = getDayOfWeek(now); @@ -879,16 +994,16 @@ describe('NotificationProfile', () => { startingDay, check: ({ day }) => { count += 1; - if (day === DayOfWeek.SUNDAY) { + if (day === DayOfWeek.SUNDAY && count !== 1) { return true; } return false; }, }); - assert.strictEqual(count, 7); + assert.strictEqual(count, 8); }); - it('loops from sunday to saturday', () => { + it('loops from saturday (yesterday) to next saturday', () => { const sundayAt10 = now + 6 * DAY; let count = 0; const startingDay = getDayOfWeek(sundayAt10); @@ -898,14 +1013,14 @@ describe('NotificationProfile', () => { startingDay, check: ({ day }) => { count += 1; - if (day === DayOfWeek.SATURDAY) { + if (day === DayOfWeek.SATURDAY && count !== 1) { return true; } return false; }, }); - assert.strictEqual(count, 7); + assert.strictEqual(count, 8); }); }); @@ -926,4 +1041,42 @@ describe('NotificationProfile', () => { assert.strictEqual(actual[2].name, 'old'); }); }); + + describe('getStartTime', () => { + it('returns start time for today, without considering end time', () => { + const scheduleStartTime = 1100; + + const expected = now + HOUR; + const actual = getStartTime(midnight, { + scheduleStartTime, + }); + assert.strictEqual(actual, expected); + }); + }); + + describe('getEndTime', () => { + it('returns end time for today if it is later', () => { + const scheduleStartTime = 1100; + const scheduleEndTime = 1200; + + const expected = now + 2 * HOUR; + const actual = getEndTime(midnight, { + scheduleStartTime, + scheduleEndTime, + }); + assert.strictEqual(actual, expected); + }); + + it('returns start time tomorrow if it start is later', () => { + const scheduleStartTime = 1200; + const scheduleEndTime = 1100; + + const expected = now + DAY + HOUR; + const actual = getEndTime(midnight, { + scheduleStartTime, + scheduleEndTime, + }); + assert.strictEqual(actual, expected); + }); + }); }); diff --git a/ts/types/Avatar.ts b/ts/types/Avatar.ts index 209a34249b..0f687c3b2e 100644 --- a/ts/types/Avatar.ts +++ b/ts/types/Avatar.ts @@ -113,7 +113,7 @@ const groupIconColors = [ 'A110', 'A130', 'A210', -]; +] as const; const personalIconColors = [ 'A130', @@ -128,7 +128,7 @@ const personalIconColors = [ 'A180', 'A210', 'A100', -]; +] as const; strictAssert( groupIconColors.length === GroupAvatarIcons.length && @@ -136,17 +136,20 @@ strictAssert( 'colors.length !== icons.length' ); -const groupDefaultAvatars = GroupAvatarIcons.map((icon, index) => ({ - id: index, - color: groupIconColors[index], - icon, -})); +const groupDefaultAvatars: ReadonlyArray<AvatarDataType> = GroupAvatarIcons.map( + (icon, index) => ({ + id: index, + color: groupIconColors[index], + icon, + }) +); -const personalDefaultAvatars = PersonalAvatarIcons.map((icon, index) => ({ - id: index, - color: personalIconColors[index], - icon, -})); +const personalDefaultAvatars: ReadonlyArray<AvatarDataType> = + PersonalAvatarIcons.map((icon, index) => ({ + id: index, + color: personalIconColors[index], + icon, + })); export function getDefaultAvatars( isGroup?: boolean diff --git a/ts/types/Colors.ts b/ts/types/Colors.ts index 01510d7171..5211b8b097 100644 --- a/ts/types/Colors.ts +++ b/ts/types/Colors.ts @@ -86,7 +86,7 @@ export const AvatarColorMap = new Map([ fg: '#5c5c5c', }, ], -]); +] as const); export const AvatarColors = Array.from(AvatarColorMap.keys()).sort(); diff --git a/ts/types/Nav.ts b/ts/types/Nav.ts index c0c5d70ed6..118bcd7069 100644 --- a/ts/types/Nav.ts +++ b/ts/types/Nav.ts @@ -57,6 +57,8 @@ export enum SettingsPage { DonationsDonateFlow = 'DonationsDonateFlow', DonationsReceiptList = 'DonationsReceiptList', EditChatFolder = 'EditChatFolder', + NotificationProfilesHome = 'NotificationProfilesHome', + NotificationProfilesCreateFlow = 'NotificationProfilesCreateFlow', PNP = 'PNP', BackupsDetails = 'BackupsDetails', LocalBackups = 'LocalBackups', diff --git a/ts/types/NotificationProfile-node.ts b/ts/types/NotificationProfile-node.ts index eb88d2ea45..83daf9a73e 100644 --- a/ts/types/NotificationProfile-node.ts +++ b/ts/types/NotificationProfile-node.ts @@ -15,9 +15,10 @@ import type { LoggerType } from './Logging.js'; const log = createLogger('NotificationProfile-node'); export function generateNotificationProfileId(): NotificationProfileIdString { - return Bytes.toHex( - getRandomBytes(NOTIFICATION_PROFILE_ID_LENGTH) - ) as NotificationProfileIdString; + return normalizeNotificationProfileId( + Bytes.toHex(getRandomBytes(NOTIFICATION_PROFILE_ID_LENGTH)), + 'generateNotificationProfileId' + ); } export function isNotificationProfileId( @@ -36,7 +37,7 @@ export function normalizeNotificationProfileId( context: string, logger: Pick<LoggerType, 'warn'> = log ): NotificationProfileIdString { - const result = id.toUpperCase(); + const result = id.toLowerCase(); if (!isNotificationProfileId(result)) { logger.warn( diff --git a/ts/types/NotificationProfile.ts b/ts/types/NotificationProfile.ts index 318402499d..70ca0e23a2 100644 --- a/ts/types/NotificationProfile.ts +++ b/ts/types/NotificationProfile.ts @@ -11,8 +11,6 @@ import type { StorageServiceFieldsType } from '../sql/Interface.js'; const { isNumber, orderBy } = lodash; // Note: this must match the Backup and Storage Service protos for NotificationProfile -// This variable is separate so we aren't forced to add it to ScheduleDays object below -export const DayOfWeekUnknown = 0; export enum DayOfWeek { MONDAY = 1, TUESDAY = 2, @@ -24,6 +22,9 @@ export enum DayOfWeek { } export type ScheduleDays = { [key in DayOfWeek]: boolean }; +// This variable is separate so we aren't forced to add it to ScheduleDays type +export const DayOfWeekUnknown = 0; + export type NotificationProfileIdString = string & { __notification_profile_id: never; }; @@ -148,7 +149,17 @@ export function findNextProfileEvent({ if (override?.enabled) { const profile = getProfileById(override.enabled.profileId, profiles); - const isEnabled = isProfileEnabledBySchedule({ time, profile }); + const isEnabled = + isProfileEnabledBySchedule({ + time, + timeForSchedule: time - DAY, + profile, + }) || + isProfileEnabledBySchedule({ + time, + timeForSchedule: time, + profile, + }); if (isEnabled) { const willDisableAt = findNextScheduledDisable({ time, profile }); strictAssert( @@ -257,13 +268,15 @@ export function findNextProfileEvent({ // Should this profile be active right now, based on its schedule? export function isProfileEnabledBySchedule({ time, + timeForSchedule, profile, }: { time: number; + timeForSchedule: number; profile: NotificationProfileType; }): boolean { - const day = getDayOfWeek(time); - const midnight = getMidnight(time); + const day = getDayOfWeek(timeForSchedule); + const midnight = getMidnight(timeForSchedule); const { scheduleEnabled, @@ -281,8 +294,13 @@ export function isProfileEnabledBySchedule({ return false; } - const scheduleStart = scheduleToTime(midnight, scheduleStartTime); - const scheduleEnd = scheduleToTime(midnight, scheduleEndTime); + const scheduleStart = getStartTime(midnight, { + scheduleStartTime, + }); + const scheduleEnd = getEndTime(midnight, { + scheduleEndTime, + scheduleStartTime, + }); if (time >= scheduleStart && time <= scheduleEnd) { return true; } @@ -290,6 +308,37 @@ export function isProfileEnabledBySchedule({ return false; } +// For a schedule like start: 8pm, end: 8am, it's an overnight schedule. But we still just +// start with the start time. +export function getStartTime( + midnight: number, + { scheduleStartTime }: { scheduleStartTime: number } +): number { + const scheduleStart = scheduleToTime(midnight, scheduleStartTime); + + return scheduleStart; +} + +// For a schedule like start: 8pm, end: 8am, it's an overnight schedule. It ends with +// the stated start time, 24 hours added. +export function getEndTime( + midnight: number, + { + scheduleStartTime, + scheduleEndTime, + }: { scheduleStartTime: number; scheduleEndTime: number } +): number { + const scheduleStart = scheduleToTime(midnight, scheduleStartTime); + const scheduleEnd = scheduleToTime(midnight, scheduleEndTime); + + // The normal case, where the end comes after the start. + if (scheduleEnd > scheduleStart) { + return scheduleEnd; + } + + return scheduleEnd + DAY; +} + // Find the profile that should be active right, based on schedules export function areAnyProfilesEnabledBySchedule({ time, @@ -300,8 +349,21 @@ export function areAnyProfilesEnabledBySchedule({ }): NotificationProfileType | undefined { // We find the first match, assuming the array is sorted, newest to oldest for (const profile of profiles) { - const result = isProfileEnabledBySchedule({ time, profile }); - if (result) { + const enabledYesterday = isProfileEnabledBySchedule({ + time, + timeForSchedule: time - DAY, + profile, + }); + if (enabledYesterday) { + return profile; + } + + const enabledNow = isProfileEnabledBySchedule({ + time, + timeForSchedule: time, + profile, + }); + if (enabledNow) { return profile; } } @@ -329,12 +391,21 @@ export function findNextScheduledDisable({ time, startingDay, check: ({ startOfDay, day }) => { - const { scheduleDaysEnabled, scheduleEndTime } = profile; - if (!scheduleDaysEnabled?.[day] || !isNumber(scheduleEndTime)) { + const { scheduleDaysEnabled, scheduleEndTime, scheduleStartTime } = + profile; + if ( + !scheduleDaysEnabled?.[day] || + !isNumber(scheduleEndTime) || + !isNumber(scheduleStartTime) + ) { return false; } - const scheduleEnd = scheduleToTime(startOfDay, scheduleEndTime); + const scheduleEnd = getEndTime(startOfDay, { + scheduleEndTime, + scheduleStartTime, + }); + if (time < scheduleEnd) { result = scheduleEnd; return true; @@ -367,13 +438,20 @@ export function findNextScheduledEnable({ time, startingDay, check: ({ startOfDay, day }) => { - const { scheduleDaysEnabled, scheduleStartTime } = profile; + const { scheduleDaysEnabled, scheduleEndTime, scheduleStartTime } = + profile; - if (!scheduleDaysEnabled?.[day] || !isNumber(scheduleStartTime)) { + if ( + !scheduleDaysEnabled?.[day] || + !isNumber(scheduleEndTime) || + !isNumber(scheduleStartTime) + ) { return false; } - const scheduleStart = scheduleToTime(startOfDay, scheduleStartTime); + const scheduleStart = getStartTime(startOfDay, { + scheduleStartTime, + }); if (time < scheduleStart) { result = scheduleStart; return true; @@ -386,8 +464,8 @@ export function findNextScheduledEnable({ return result; } -// This is specifically about finding schedule that will enable later. It will not return -// a schedule enabled right now unless it also has the next scheduled start. +// This is specifically about finding a schedule that will enable later. It will not +// return a schedule enabled right now unless it also has the next scheduled start. export function findNextScheduledEnableForAll({ profiles, time, @@ -456,10 +534,13 @@ export function loopThroughWeek({ check: (options: { startOfDay: number; day: DayOfWeek }) => boolean; }): void { const todayAtMidnight = getMidnight(time); - let index = 0; + let index = -1; while (index < DayOfWeek.SUNDAY) { let indexDay = startingDay + index; + if (indexDay <= 0) { + indexDay += DayOfWeek.SUNDAY; + } if (indexDay > DayOfWeek.SUNDAY) { indexDay -= DayOfWeek.SUNDAY; } diff --git a/ts/types/Storage.d.ts b/ts/types/Storage.d.ts index 854ae6ad5b..4f9afbd52e 100644 --- a/ts/types/Storage.d.ts +++ b/ts/types/Storage.d.ts @@ -86,6 +86,7 @@ export type StorageAccessType = { hasCompletedUsernameLinkOnboarding: boolean; hasCompletedSafetyNumberOnboarding: boolean; hasSeenGroupStoryEducationSheet: boolean; + hasSeenNotificationProfileOnboarding: boolean; hasViewedOnboardingStory: boolean; hasStoriesDisabled: boolean; storyViewReceiptsEnabled: boolean | undefined; @@ -200,6 +201,10 @@ export type StorageAccessType = { needOrphanedAttachmentCheck: boolean; needProfileMovedModal: boolean; notificationProfileOverride: NotificationProfileOverride | undefined; + notificationProfileOverrideFromPrimary: + | NotificationProfileOverride + | undefined; + notificationProfileSyncDisabled: boolean; observedCapabilities: { attachmentBackfill?: true; diff --git a/ts/types/Toast.tsx b/ts/types/Toast.tsx index e2cf6aeeb8..e9e024aa1c 100644 --- a/ts/types/Toast.tsx +++ b/ts/types/Toast.tsx @@ -59,6 +59,7 @@ export enum ToastType { MediaNoLongerAvailable = 'MediaNoLongerAvailable', MessageBodyTooLong = 'MessageBodyTooLong', MessageLoop = 'MessageLoop', + NotificationProfileUpdate = 'NotificationProfileUpdate', OriginalMessageNotFound = 'OriginalMessageNotFound', PinnedConversationsFull = 'PinnedConversationsFull', ReactionFailed = 'ReactionFailed', @@ -174,6 +175,10 @@ export type AnyToast = | { toastType: ToastType.MediaNoLongerAvailable } | { toastType: ToastType.MessageBodyTooLong } | { toastType: ToastType.MessageLoop } + | { + toastType: ToastType.NotificationProfileUpdate; + parameters: { enabled: boolean; name: string }; + } | { toastType: ToastType.OriginalMessageNotFound } | { toastType: ToastType.PinnedConversationsFull } | { toastType: ToastType.ReactionFailed } diff --git a/ts/util/getColorForCallLink.ts b/ts/util/getColorForCallLink.ts index 825e699c54..f470671f19 100644 --- a/ts/util/getColorForCallLink.ts +++ b/ts/util/getColorForCallLink.ts @@ -2,12 +2,13 @@ // SPDX-License-Identifier: AGPL-3.0-only import { AVATAR_COLOR_COUNT, AvatarColors } from '../types/Colors.js'; +import type { AvatarColorType } from '../types/Colors.js'; // See https://github.com/signalapp/ringrtc/blob/49b4b8a16f997c7fa9a429e96aa83f80b2065c63/src/rust/src/lite/call_links/base16.rs#L8 const BASE_16_CONSONANT_ALPHABET = 'bcdfghkmnpqrstxz'; // See https://github.com/signalapp/ringrtc/blob/49b4b8a16f997c7fa9a429e96aa83f80b2065c63/src/rust/src/lite/call_links/base16.rs#L127-L139 -export function getColorForCallLink(rootKey: string): string { +export function getColorForCallLink(rootKey: string): AvatarColorType { const rootKeyStart = rootKey.slice(0, 2); const upper = (BASE_16_CONSONANT_ALPHABET.indexOf(rootKeyStart[0]) || 0) * 16; diff --git a/ts/util/handleOutsideClick.ts b/ts/util/handleOutsideClick.ts index 97e81fada0..18c7aa2313 100644 --- a/ts/util/handleOutsideClick.ts +++ b/ts/util/handleOutsideClick.ts @@ -6,7 +6,7 @@ import { createLogger } from '../logging/log.js'; const log = createLogger('handleOutsideClick'); -export type HandlerType = (target: Node) => boolean; +export type HandlerType = (target: Node, event: MouseEvent) => boolean; export type HandlersType = { name: string; handleClick: HandlerType; @@ -26,13 +26,13 @@ export type HandleOutsideClickOptionsType = Readonly<{ function handleGlobalPointerDown(event: MouseEvent) { for (const handlers of fakeHandlers) { // continue even if handled, so that we can detect if the click was inside - handlers.handlePointerDown(event.target as Node); + handlers.handlePointerDown(event.target as Node, event); } } function handleGlobalClick(event: MouseEvent) { for (const handlers of fakeHandlers.slice().reverse()) { - const handled = handlers.handleClick(event.target as Node); + const handled = handlers.handleClick(event.target as Node, event); if (handled) { log.info(`${handlers.name} handled click`); break; @@ -65,14 +65,14 @@ export const handleOutsideClick = ( return false; } - function handleClick(target: Node) { + function handleClick(target: Node, event: MouseEvent) { const endedInside = isInside(target); // Clicked inside of one of container elements - stop processing if (startedInside || endedInside) { return true; } // Stop processing if requested by handler function - return handler(target); + return handler(target, event); } const fakeHandler = { diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index bffaa2e6e0..dce51c13e0 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -1515,6 +1515,30 @@ "updated": "2025-05-30T22:48:14.420Z", "reasonDetail": "For focusing the settings backup key viewer textarea" }, + { + "rule": "React-useRef", + "path": "ts/components/PreferencesNotificationProfiles.tsx", + "line": " const tryClose = React.useRef<() => void | undefined>();", + "reasonCategory": "usageTrusted", + "updated": "2025-08-25T21:04:21.643Z", + "reasonDetail": "Holding on to a close function" + }, + { + "rule": "React-useRef", + "path": "ts/components/PreferencesNotificationProfiles.tsx", + "line": " const selectedHour = React.useRef<HTMLButtonElement | null>(null);", + "reasonCategory": "usageTrusted", + "updated": "2025-09-05T21:41:09.931Z", + "reasonDetail": "Holding on to element for focus" + }, + { + "rule": "React-useRef", + "path": "ts/components/PreferencesNotificationProfiles.tsx", + "line": " const selectedMinute = React.useRef<HTMLButtonElement | null>(null);", + "reasonCategory": "usageTrusted", + "updated": "2025-09-05T21:41:09.931Z", + "reasonDetail": "Holding on to element for scrolling into view" + }, { "rule": "React-useRef", "path": "ts/components/ProfileEditor.tsx", diff --git a/ts/util/migrateColor.ts b/ts/util/migrateColor.ts index c5ffa7d38f..f5cea99974 100644 --- a/ts/util/migrateColor.ts +++ b/ts/util/migrateColor.ts @@ -12,8 +12,8 @@ export function migrateColor( color: string | undefined, options: Parameters<typeof generateAvatarColor>[0] ): AvatarColorType { - if (color && NEW_COLOR_NAMES.has(color)) { - return color; + if (color && NEW_COLOR_NAMES.has(color as AvatarColorType)) { + return color as AvatarColorType; } return generateAvatarColor(options); diff --git a/ts/window.d.ts b/ts/window.d.ts index c48b9173a9..07ce426ade 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -26,6 +26,7 @@ import type { import type { Receipt } from './types/Receipt.js'; import type { ConversationController } from './ConversationController.js'; import type { ReduxActions } from './state/types.js'; +import type * as StorageService from './services/storage.js'; import type { BatcherType } from './util/batcher.js'; import type { ScreenShareStatus } from './types/Calling.js'; import type { MessageCache } from './services/MessageCache.js'; @@ -135,6 +136,7 @@ export type SignalCoreType = { PermissionsWindowProps?: PermissionsWindowPropsType; ScreenShareWindowProps?: ScreenShareWindowPropsType; Services: { + storage: typeof StorageService; // Only for development backups: unknown; calling: unknown;