From 94fa10ed52db4b3aff5cc435d222d8ed733d20dc Mon Sep 17 00:00:00 2001 From: trevor-signal <131492920+trevor-signal@users.noreply.github.com> Date: Mon, 24 Aug 2026 09:37:22 -0400 Subject: [PATCH] Updated conversation mute options Co-authored-by: jamiebuilds-signal --- .storybook/preview.tsx | 10 + _locales/en/messages.json | 56 +++ ts/components/DatePicker.dom.tsx | 205 +++++++++ ts/components/MuteNotificationsMenu.dom.tsx | 222 ++++++++++ ts/components/MuteUntilDialog.dom.stories.tsx | 26 ++ ts/components/MuteUntilDialog.dom.tsx | 141 +++++++ .../PreferencesNotificationProfiles.dom.tsx | 394 +----------------- ts/components/TimePicker.dom.tsx | 356 ++++++++++++++++ .../conversation/ConversationHeader.dom.tsx | 32 +- .../ConversationDetails.dom.tsx | 84 ++-- .../ConversationNotificationsModal.dom.tsx | 88 ---- .../ConversationNotificationsSettings.dom.tsx | 31 +- .../leftPane/LeftPaneChatFolders.dom.tsx | 76 ++-- ...aneConversationListItemContextMenu.dom.tsx | 51 +-- ts/state/smart/App.preload.tsx | 39 +- ts/test-node/util/getMuteOptions_test.node.ts | 219 ++++++++-- ts/util/getMuteOptions.std.ts | 68 ++- 17 files changed, 1376 insertions(+), 722 deletions(-) create mode 100644 ts/components/DatePicker.dom.tsx create mode 100644 ts/components/MuteNotificationsMenu.dom.tsx create mode 100644 ts/components/MuteUntilDialog.dom.stories.tsx create mode 100644 ts/components/MuteUntilDialog.dom.tsx create mode 100644 ts/components/TimePicker.dom.tsx delete mode 100644 ts/components/conversation/conversation-details/ConversationNotificationsModal.dom.tsx diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 52b2c8d6d7..e00de3ff08 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -28,6 +28,7 @@ import { Environment, setEnvironment } from '../ts/environment.std.ts'; import { parseUnknown } from '../ts/util/schemas.std.ts'; import { LocaleEmojiListSchema } from '../ts/types/emoji.std.ts'; import { FunProvider } from '../ts/components/fun/FunProvider.dom.tsx'; +import { MuteUntilDialogProvider } from '../ts/components/MuteNotificationsMenu.dom.tsx'; import { MOCK_GIFS_PAGINATED_ONE_PAGE } from '../ts/test-helpers/funPickerMocks.dom.tsx'; import { NavTab } from '../ts/types/Nav.std.ts'; @@ -309,7 +310,16 @@ function withAppProvider(Story, context) { ); } +function withMutedUntilDialogProvider(Story, context) { + return ( + + + + ); +} + export const decorators = [ + withMutedUntilDialogProvider, withAppProvider, withGlobalTypesProvider, withMockStoreProvider, diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 9d8573545f..4f608b78eb 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -5298,6 +5298,62 @@ "messageformat": "Unmute", "description": "Label for unmuting the conversation" }, + "icu:MuteMenu__label": { + "messageformat": "Mute this chat for…", + "description": "Header shown at the top of the mute notifications menu when the chat is not currently muted" + }, + "icu:MuteMenu__labelChatFolder": { + "messageformat": "Mute these chats for…", + "description": "Header shown at the top of the mute notifications menu for a chat folder, which mutes every chat in the folder" + }, + "icu:MuteMenu__hour": { + "messageformat": "1 hour", + "description": "Label for muting the conversation for one hour" + }, + "icu:MuteMenu__eightHours": { + "messageformat": "8 hours", + "description": "Label for muting the conversation for eight hours" + }, + "icu:MuteMenu__day": { + "messageformat": "1 day", + "description": "Label for muting the conversation for one day" + }, + "icu:MuteMenu__week": { + "messageformat": "1 week", + "description": "Label for muting the conversation for one week" + }, + "icu:MuteMenu__until": { + "messageformat": "Until…", + "description": "Label for muting the conversation until a date and time the user picks. Opens a dialog." + }, + "icu:MuteMenu__always": { + "messageformat": "Always", + "description": "Label for muting the conversation forever" + }, + "icu:DatePicker__popupTitle": { + "messageformat": "Choose a date", + "description": "Title of the popup for choosing a date, only read out by screen readers" + }, + "icu:TimePicker__popupTitle": { + "messageformat": "Choose a time", + "description": "Title of the popup for choosing a time, only read out by screen readers" + }, + "icu:MuteUntilDialog__title": { + "messageformat": "Mute this chat until…", + "description": "Title of the dialog where a date and time to mute the conversation until is picked" + }, + "icu:MuteUntilDialog__dateLabel": { + "messageformat": "Date", + "description": "Accessible label for the date field in the dialog where a date and time to mute the conversation until is picked" + }, + "icu:MuteUntilDialog__timeLabel": { + "messageformat": "Time", + "description": "Accessible label for the time field in the dialog where a date and time to mute the conversation until is picked" + }, + "icu:MuteUntilDialog__timeZoneNote": { + "messageformat": "All times in {timeZone}", + "description": "Note below the date and time fields telling the user which time zone the times are in. {timeZone} is a time zone name, like 'Eastern Time'." + }, "icu:muteExpirationLabelAlways": { "messageformat": "Muted always", "description": "Shown in the mute notifications submenu whenever a conversation has been muted" diff --git a/ts/components/DatePicker.dom.tsx b/ts/components/DatePicker.dom.tsx new file mode 100644 index 0000000000..035567ad5b --- /dev/null +++ b/ts/components/DatePicker.dom.tsx @@ -0,0 +1,205 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { useState, type JSX } from 'react'; +import { + Button, + Calendar, + CalendarCell, + CalendarGrid, + DateInput, + DatePicker as AriaDatePicker, + DateSegment, + Group, + Heading, + Popover, + CalendarGridBody, + CalendarGridHeader, + CalendarHeaderCell, +} from 'react-aria-components'; +import { Dialog as RadixDialog } from 'radix-ui'; +import type { CalendarDate } from '@internationalized/date'; +import classNames from 'classnames'; + +import type { LocalizerType } from '../types/Util.std.ts'; +import { AxoSymbol } from '../axo/AxoSymbol.dom.tsx'; +import { tw } from '../axo/tw.dom.tsx'; + +type AriaLabelPropsType = + | { 'aria-label': string; 'aria-labelledby'?: never } + | { 'aria-label'?: never; 'aria-labelledby': string }; + +export type PropsType = Readonly<{ + i18n: LocalizerType; + isDisabled?: boolean; + minValue?: CalendarDate; + value: CalendarDate | null; + onUpdateDate: (value: CalendarDate | null) => void; +}> & + AriaLabelPropsType; + +export function DatePicker(props: PropsType): JSX.Element { + const { i18n, isDisabled = false, minValue, value, onUpdateDate } = props; + const [open, setOpen] = useState(false); + return ( + + + + {segment => ( + + )} + + + + + {/** + * Radix UI and React Aria both have their own focus scope logic and + * scroll locking behavior. We're sorta using Radix UI as a "decorator" + * here to inform it of this other portaled element. + * + * React Aria does not appear to pass through all props the way that + * Radix UI wants it to, but it does give Radix UI a ref and merges event + * handlers well enough that this just works + */} + + modal + > + {/** + * We need to render even though asChild+null prevents it + * from creating any element because it contains the logic to break + * out of scroll locking. + */} + {null} + with react-aria's + asChild + // Remove extra radix attributes that don't do anything + aria-labelledby={undefined} + data-state={undefined} + > + + + {i18n('icu:DatePicker__popupTitle')} + + +
+ + + +
+
+ + + {day => ( + + {day} + + )} + + + {date => ( + + )} + + +
+
+
+
+
+
+ ); +} + +type ArrowButtonProps = Readonly<{ + slot: 'previous' | 'next'; +}>; + +function ArrowButton(props: ArrowButtonProps) { + return ( + + ); +} diff --git a/ts/components/MuteNotificationsMenu.dom.tsx b/ts/components/MuteNotificationsMenu.dom.tsx new file mode 100644 index 0000000000..15dd67dc50 --- /dev/null +++ b/ts/components/MuteNotificationsMenu.dom.tsx @@ -0,0 +1,222 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { FC, JSX, ReactNode } from 'react'; +import { + createContext, + memo, + useCallback, + useContext, + useMemo, + useState, +} from 'react'; +import { AxoContextMenu } from '../axo/AxoContextMenu.dom.tsx'; +import { AxoDropdownMenu } from '../axo/AxoDropdownMenu.dom.tsx'; +import type { AxoMenuBuilder } from '../axo/AxoMenuBuilder.dom.tsx'; +import type { LocalizerType } from '../types/Util.std.ts'; +import { strictAssert } from '../util/assert.std.ts'; +import type { MuteOption } from '../util/getMuteOptions.std.ts'; +import { missingCaseError } from '../util/missingCaseError.std.ts'; +import { MuteUntilDialog } from './MuteUntilDialog.dom.tsx'; + +function getMenuComponents(renderer: AxoMenuBuilder.Renderer) { + switch (renderer) { + case 'AxoDropdownMenu': + return AxoDropdownMenu; + case 'AxoContextMenu': + return AxoContextMenu; + default: + throw missingCaseError(renderer); + } +} + +type MuteNotificationsMenuValue = Readonly<{ + i18n: LocalizerType; + onMuteUntilClick: (onSubmit: (durationMs: number) => void) => void; +}>; + +const MuteUntilDialogContext = createContext( + null +); + +function useMuteUntilDialog(): MuteNotificationsMenuValue { + const value = useContext(MuteUntilDialogContext); + strictAssert( + value != null, + 'Missing around the menu' + ); + return value; +} + +export type MuteUntilDialogProviderProps = { + i18n: LocalizerType; + children: ReactNode; +}; + +export function MuteUntilDialogProvider( + props: MuteUntilDialogProviderProps +): JSX.Element { + const [muteUntilDialog, setMuteUntilDialog] = useState< + false | { show: true; onSubmit: (durationMs: number) => void } + >(false); + + const handleMuteUntilClose = useCallback(() => { + setMuteUntilDialog(false); + }, []); + + const handleMuteUntilSubmit = useCallback( + (durationMs: number) => { + if (muteUntilDialog === false) { + return; + } + setMuteUntilDialog(false); + muteUntilDialog.onSubmit(durationMs); + }, + [muteUntilDialog] + ); + + const value = useMemo((): MuteNotificationsMenuValue => { + return { + i18n: props.i18n, + onMuteUntilClick: (onSubmit: (durationMs: number) => void) => + setMuteUntilDialog({ show: true, onSubmit }), + }; + }, [props.i18n]); + + return ( + + {props.children} + + + ); +} + +type MuteNotificationsMenuItemsProps = Readonly<{ + i18n: LocalizerType; + renderer: AxoMenuBuilder.Renderer; + label?: string; + options: ReadonlyArray; + onMuteDuration: (durationMs: number) => void; + onMuteUntilClick: () => void; +}>; + +const MuteNotificationsMenuItems: FC = memo( + function MuteNotificationsMenuItems(props) { + const { label, options, onMuteDuration, onMuteUntilClick } = props; + const Menu = getMenuComponents(props.renderer); + + return ( + <> + {label ? {label} : null} + {options.map(option => { + const { value } = option; + return ( + { + if (value === 'custom') { + onMuteUntilClick(); + } else { + onMuteDuration(value); + } + }} + > + {option.name} + + ); + })} + + ); + } +); + +export type MuteNotificationsSubMenuProps = Readonly<{ + i18n: LocalizerType; + renderer: AxoMenuBuilder.Renderer; + title: string; + label?: string; + options: ReadonlyArray; + children?: ReactNode; + onMuteDuration: (durationMs: number) => void; +}>; + +/** + * Requires a MuteUntilDialogProvider to show the mute until dialog even after the menu is unmounted + */ +export const MuteNotificationsSubMenu: FC = memo( + function MuteNotificationsSubMenu(props) { + const { onMuteUntilClick } = useMuteUntilDialog(); + const Menu = getMenuComponents(props.renderer); + + return ( + + {props.title} + + {props.children} + onMuteUntilClick(props.onMuteDuration)} + /> + + + ); + } +); + +export type MuteNotificationsDropdownMenuProps = Readonly<{ + i18n: LocalizerType; + label: string; + options: ReadonlyArray; + onMuteDuration: (durationMs: number) => void; + /** The button that opens the menu. */ + children: ReactNode; +}>; + +export const MuteNotificationsDropdownMenu: FC = + memo(function MuteNotificationsDropdownMenu(props) { + const { i18n, options, onMuteDuration } = props; + const [isShowingMuteUntilDialog, setIsShowingMuteUntilDialog] = + useState(false); + + const handleMuteUntilSubmit = useCallback( + (durationMs: number) => { + setIsShowingMuteUntilDialog(false); + onMuteDuration(durationMs); + }, + [onMuteDuration] + ); + + return ( + <> + + {props.children} + + setIsShowingMuteUntilDialog(true)} + /> + + + setIsShowingMuteUntilDialog(false)} + /> + + ); + }); diff --git a/ts/components/MuteUntilDialog.dom.stories.tsx b/ts/components/MuteUntilDialog.dom.stories.tsx new file mode 100644 index 0000000000..166bca3677 --- /dev/null +++ b/ts/components/MuteUntilDialog.dom.stories.tsx @@ -0,0 +1,26 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { JSX } from 'react'; + +import { action } from '@storybook/addon-actions'; +import type { Meta } from '@storybook/react'; +import type { PropsType } from './MuteUntilDialog.dom.tsx'; +import { MuteUntilDialog } from './MuteUntilDialog.dom.tsx'; + +export default { + title: 'Components/MuteUntilDialog', +} satisfies Meta; + +const { i18n } = window.SignalContext; + +export function Default(): JSX.Element { + return ( + + ); +} diff --git a/ts/components/MuteUntilDialog.dom.tsx b/ts/components/MuteUntilDialog.dom.tsx new file mode 100644 index 0000000000..2133f4a188 --- /dev/null +++ b/ts/components/MuteUntilDialog.dom.tsx @@ -0,0 +1,141 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { useCallback, useMemo, useState, type JSX } from 'react'; +import type { CalendarDate } from '@internationalized/date'; +import { + getLocalTimeZone, + Time, + toCalendarDateTime, + today, +} from '@internationalized/date'; +import type { LocalizerType } from '../types/Util.std.ts'; +import { AxoDialog } from '../axo/AxoDialog.dom.tsx'; +import { tw } from '../axo/tw.dom.tsx'; +import { getDateTimeFormatter } from '../util/formatTimestamp.dom.ts'; +import { DatePicker } from './DatePicker.dom.tsx'; +import { getTimeDetails, TimePicker } from './TimePicker.dom.tsx'; + +const DEFAULT_DAYS_IN_FUTURE = 1; +const DEFAULT_TIME = 800; + +export type PropsType = Readonly<{ + open: boolean; + i18n: LocalizerType; + onSubmit: (durationMs: number) => void; + onClose: () => void; +}>; + +export function MuteUntilDialog({ + open, + i18n, + onSubmit, + onClose, +}: PropsType): JSX.Element { + const [date, setDate] = useState(() => { + return today(getLocalTimeZone()).add({ days: DEFAULT_DAYS_IN_FUTURE }); + }); + const [time, setTime] = useState(DEFAULT_TIME); + + const earliestDate = useMemo(() => { + return today(getLocalTimeZone()); + }, []); + + const muteExpiresAt = useMemo(() => { + return getTimestamp(date, time); + }, [date, time]); + + const isValid = muteExpiresAt && muteExpiresAt > Date.now(); + + const timeZoneNote = useMemo(() => { + return i18n('icu:MuteUntilDialog__timeZoneNote', { + timeZone: getTimeZoneDisplayName(), + }); + }, [i18n]); + + const handleSubmit = useCallback(() => { + if (muteExpiresAt == null) { + return; + } + onSubmit(Math.max(0, muteExpiresAt - Date.now())); + }, [muteExpiresAt, onSubmit]); + + return ( + { + if (!isOpen) { + onClose(); + } + }} + > + + + + {i18n('icu:MuteUntilDialog__title')} + + + + +
+ + +
+
+ {timeZoneNote} +
+
+ + + + {i18n('icu:cancel')} + + + {i18n('icu:mute')} + + + +
+
+ ); +} + +function getTimestamp(date: CalendarDate | null, time: number): number | null { + if (date == null) { + return null; + } + + const { hours, minutes } = getTimeDetails(time, true); + return toCalendarDateTime(date, new Time(hours, minutes)) + .toDate(getLocalTimeZone()) + .valueOf(); +} + +function getTimeZoneDisplayName(): string { + const formatter = getDateTimeFormatter({ timeZoneName: 'longGeneric' }); + const timeZoneName = formatter + .formatToParts(Date.now()) + .find(part => part.type === 'timeZoneName'); + + return timeZoneName?.value ?? formatter.resolvedOptions().timeZone; +} diff --git a/ts/components/PreferencesNotificationProfiles.dom.tsx b/ts/components/PreferencesNotificationProfiles.dom.tsx index 27514e4c19..94ae27727a 100644 --- a/ts/components/PreferencesNotificationProfiles.dom.tsx +++ b/ts/components/PreferencesNotificationProfiles.dom.tsx @@ -1,13 +1,17 @@ // Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import { useState, useRef, useCallback, useEffect, useMemo } from 'react'; +import { + useState, + useRef, + useCallback, + useEffect, + useId, + useMemo, +} from 'react'; import type { MutableRefObject, JSX, ReactNode } from 'react'; -import { DateInput, DateSegment, TimeField } from 'react-aria-components'; -import { Time } from '@internationalized/date'; -import { sample, isEqual, noop, range } from 'lodash'; +import { sample, isEqual, noop } from 'lodash'; import classNames from 'classnames'; -import { Popper } from 'react-popper'; import { FunStaticEmoji } from './fun/FunEmoji.dom.tsx'; import { FunEmojiPicker } from './fun/FunEmojiPicker.dom.tsx'; @@ -21,23 +25,13 @@ import { Input } from './Input.dom.tsx'; import { Checkbox } from './Checkbox.dom.tsx'; import { AvatarColorMap, AvatarColors } from '../types/Colors.std.ts'; import { PreferencesSelectChatsDialog } from './preferences/PreferencesSelectChatsDialog.dom.tsx'; -import { - DayOfWeek, - getMidnight, - scheduleToTime, -} from '../types/NotificationProfile.std.ts'; +import { DayOfWeek } from '../types/NotificationProfile.std.ts'; import { Avatar } from './Avatar.dom.tsx'; import { missingCaseError } from '../util/missingCaseError.std.ts'; -import { formatTimestamp } from '../util/formatTimestamp.dom.ts'; import { strictAssert } from '../util/assert.std.ts'; import { SettingsPage } from '../types/Nav.std.ts'; import { useConfirmDiscard } from '../hooks/useConfirmDiscard.dom.tsx'; import { AriaClickable } from '../axo/AriaClickable.dom.tsx'; -import { offsetDistanceModifier } from '../util/popperUtil.std.ts'; -import { themeClassName2 } from '../util/theme.std.ts'; -import { useRefMerger } from '../hooks/useRefMerger.std.ts'; -import { handleOutsideClick } from '../util/handleOutsideClick.dom.ts'; -import { useEscapeHandling } from '../hooks/useEscapeHandling.dom.ts'; import type { LocalizerType } from '../types/I18N.std.ts'; import type { ThemeType } from '../types/Util.std.ts'; import type { ConversationType } from '../state/ducks/conversations.preload.ts'; @@ -49,10 +43,10 @@ import type { ScheduleDays, } from '../types/NotificationProfile.std.ts'; import type { SettingsLocation } from '../types/Nav.std.ts'; -import { addLeadingZero } from '../util/timestamp.std.ts'; import { Emoji } from '../axo/emoji.std.ts'; import { AxoConfirmDialog } from '../axo/AxoConfirmDialog.dom.tsx'; import { NotificationProfilesOnboardingDialog } from './preferences/notificationProfiles/NotificationProfilesOnboardingDialog.dom.tsx'; +import { formatTimeForDisplay, TimePicker } from './TimePicker.dom.tsx'; enum CreateFlowPage { Name = 'Name', @@ -142,96 +136,6 @@ type HomeProps = { 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 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; @@ -884,6 +788,9 @@ function NotificationProfilesSchedulePage({ onSetEndTime: (value: number) => void; theme: ThemeType; }) { + const startLabelId = useId(); + const endLabelId = useId(); + const daysInUIOrder = useMemo(() => { return [ { @@ -962,14 +869,14 @@ function NotificationProfilesSchedulePage({ - + {i18n('icu:NotificationProfiles--schedule-from')} - + {i18n('icu:NotificationProfiles--schedule-until')} void; -}) { - const [isShowingPopup, setIsShowingPopup] = useState(false); - const use24HourTime = need24HourTime(); - const AM_PM: Array = ['AM', 'PM']; - const periodLookup = useMemo(() => { - return { - AM: i18n('icu:NotificationProfile--am'), - PM: i18n('icu:NotificationProfile--pm'), - }; - }, [i18n]); - const [timeFieldElement, setTimeFieldElement] = useState< - HTMLDivElement | undefined - >(); - const [popupElement, setPopupElement] = useState< - HTMLDivElement | undefined - >(); - const { minutes, hours, period } = getTimeDetails(time, use24HourTime); - const refMerger = useRefMerger(); - const selectedHour = useRef(null); - const selectedMinute = useRef(null); - - 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]); - - 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 && ( - - {({ ref, style }) => ( -
- setPopupElement(element ?? undefined) - )} - style={style} - className={classNames( - 'TimePickerPopup', - tw( - 'flex h-[244px] rounded-[10px] bg-surface-secondary p-1 shadow-elevation-1' - ), - use24HourTime ? tw('w-[102px]') : tw('w-[150px]'), - theme ? themeClassName2(theme) : undefined - )} - > -
- {(use24HourTime ? HOURS_24 : HOURS_12).map(hour => { - const isSelected = hour === hours; - - return ( - - ); - })} -
-
- {MINUTES.map(minute => { - const isSelected = minute === minutes; - - return ( - - ); - })} -
- {!use24HourTime ? ( -
- {AM_PM.map(item => { - const isSelected = item === period; - - return ( - - ); - })} -
- ) : null} -
- )} -
- )} - { - setTimeFieldElement(element ?? undefined); - }} - className={tw( - 'flex items-center rounded-lg border-[2.5px] border-transparent bg-primary px-2 py-0.5 keyboard-mode:focus-within:axo-focus-ring' - )} - 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)} - > - - {segment => { - // We don't need the space between the time and the am/pm - // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/formatToParts#using_formattoparts - // https://github.com/adobe/react-spectrum/blob/36fdd8bca2df281fa955117d946e6dd9718241e4/packages/react-stately/src/datepicker/useDateFieldState.ts#L443-L470 - if ( - segment.type === 'literal' && - (segment.text === ' ' || - segment.text === '\u2066' || - segment.text === '\u2069') - ) { - return ; - } - if (segment.type === 'literal') { - // oxlint-disable-next-line no-param-reassign - segment.text = i18n('icu:NotificationProfile--time-separator'); - } - return ( - - ); - }} - - - - - ); -} diff --git a/ts/components/TimePicker.dom.tsx b/ts/components/TimePicker.dom.tsx new file mode 100644 index 0000000000..7687520266 --- /dev/null +++ b/ts/components/TimePicker.dom.tsx @@ -0,0 +1,356 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { JSX } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { + DateInput, + DateSegment, + Popover, + TimeField, +} from 'react-aria-components'; +import { Dialog as RadixDialog } from 'radix-ui'; +import { Time } from '@internationalized/date'; +import { range } from 'lodash'; +import classNames from 'classnames'; + +import type { LocalizerType, ThemeType } from '../types/Util.std.ts'; +import { AxoSymbol } from '../axo/AxoSymbol.dom.tsx'; +import { tw } from '../axo/tw.dom.tsx'; +import { + getMidnight, + scheduleToTime, +} from '../types/NotificationProfile.std.ts'; +import { formatTimestamp } from '../util/formatTimestamp.dom.ts'; +import { addLeadingZero } from '../util/timestamp.std.ts'; +import { themeClassName2 } from '../util/theme.std.ts'; + +const FIVE_PM = 1700; +const HOURS_24 = range(0, 24); +const HOURS_12 = range(1, 13); +const MINUTES = range(0, 60); + +export 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 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; +} + +export 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, + }; +} + +type AriaLabelPropsType = + | { 'aria-label': string; 'aria-labelledby'?: never } + | { 'aria-label'?: never; 'aria-labelledby': string }; + +export type PropsType = Readonly<{ + i18n: LocalizerType; + isDisabled: boolean; + theme?: ThemeType; + time: number; + onUpdateTime: (value: number) => void; +}> & + AriaLabelPropsType; + +export function TimePicker(props: PropsType): JSX.Element { + const { i18n, isDisabled, theme, time, onUpdateTime } = props; + const [isShowingPopup, setIsShowingPopup] = useState(false); + const use24HourTime = need24HourTime(); + const AM_PM: Array = ['AM', 'PM']; + const periodLookup = useMemo(() => { + return { + AM: i18n('icu:NotificationProfile--am'), + PM: i18n('icu:NotificationProfile--pm'), + }; + }, [i18n]); + const timeFieldRef = useRef(null); + const { minutes, hours, period } = getTimeDetails(time, use24HourTime); + const selectedHour = useRef(null); + const selectedMinute = useRef(null); + + useEffect(() => { + if (!isShowingPopup) { + return; + } + if (selectedHour.current) { + selectedHour.current.focus(); + } + if (selectedMinute.current) { + selectedMinute.current.scrollIntoView(); + } + }, [isShowingPopup]); + + return ( + <> + {/* We wrap React Aria's Popover with a "dummy" RadixDialog to help them play nicely together. + See DatePicker for more info. */} + + {null} + + + {/* Radix warns without a title, and labels the popup with it */} + + {i18n('icu:TimePicker__popupTitle')} + +
+ {(use24HourTime ? HOURS_24 : HOURS_12).map(hour => { + const isSelected = hour === hours; + + return ( + + ); + })} +
+
+ {MINUTES.map(minute => { + const isSelected = minute === minutes; + + return ( + + ); + })} +
+ {!use24HourTime ? ( +
+ {AM_PM.map(item => { + const isSelected = item === period; + + return ( + + ); + })} +
+ ) : null} +
+
+
+ + { + if (!value) { + return; + } + onUpdateTime(parseTimeFromInput(value)); + }} + value={formatTimeForInput(time)} + > + + {segment => { + if (segment.type === 'literal') { + // We don't need the space between the time and the am/pm + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/formatToParts#using_formattoparts + if (segment.text === ' ') { + return ; + } + // https://github.com/adobe/react-spectrum/blob/36fdd8bca2df281fa955117d946e6dd9718241e4/packages/react-stately/src/datepicker/useDateFieldState.ts#L443-L470 + if (segment.text === '\u2066' || segment.text === '\u2069') { + return {segment.text}; + } + // oxlint-disable-next-line no-param-reassign + segment.text = i18n('icu:NotificationProfile--time-separator'); + } + return ( + + ); + }} + + + + + ); +} diff --git a/ts/components/conversation/ConversationHeader.dom.tsx b/ts/components/conversation/ConversationHeader.dom.tsx index 74f9312dad..f75d28d4b2 100644 --- a/ts/components/conversation/ConversationHeader.dom.tsx +++ b/ts/components/conversation/ConversationHeader.dom.tsx @@ -14,7 +14,7 @@ import type { HasStories } from '../../types/Stories.std.ts'; import type { LocalizerType, ThemeType } from '../../types/Util.std.ts'; import type { DurationInSeconds } from '../../util/durations/index.std.ts'; import * as expirationTimer from '../../util/expirationTimer.std.ts'; -import { getMuteOptions } from '../../util/getMuteOptions.std.ts'; +import { getConversationMuteMenu } from '../../util/getMuteOptions.std.ts'; import { isConversationMuted } from '../../util/isConversationMuted.std.ts'; import { isInSystemContacts } from '../../util/isInSystemContacts.std.ts'; import { missingCaseError } from '../../util/missingCaseError.std.ts'; @@ -33,6 +33,7 @@ import { InAnotherCallTooltip, } from './InAnotherCallTooltip.dom.tsx'; import { DeleteMessagesConfirmationDialog } from '../DeleteMessagesConfirmationDialog.dom.tsx'; +import { MuteNotificationsSubMenu } from '../MuteNotificationsMenu.dom.tsx'; import { AxoDropdownMenu } from '../../axo/AxoDropdownMenu.dom.tsx'; import { strictAssert } from '../../util/assert.std.ts'; import { @@ -663,7 +664,7 @@ function HeaderDropdownMenuContent({ onViewAllMedia: () => void; onViewConversationDetails: () => void; }) { - const muteOptions = getMuteOptions(conversation.muteExpiresAt, i18n); + const muteMenu = getConversationMuteMenu(conversation.muteExpiresAt, i18n); const isGroup = conversation.type === 'group'; const disableTimerChanges = !conversation.canChangeTimer || @@ -702,7 +703,6 @@ function HeaderDropdownMenuContent({ return null; } - const muteTitle = {i18n('icu:muteNotificationsTitle')}; const disappearingTitle = {i18n('icu:disappearingMessages')}; if (isSignalConversation) { @@ -840,24 +840,14 @@ function HeaderDropdownMenuContent({ )} - - - {muteTitle} - - - {muteOptions.map(item => ( - { - onChangeMuteExpiration(item.value); - }} - > - {item.name} - - ))} - - + {!isGroup || hasGV2AdminEnabled ? ( { + return getConversationMuteMenu(conversation.muteExpiresAt, i18n, { + canOnlyBeMutedAlways: canConversationOnlyBeMutedAlways(conversation), + }); + }, [conversation, i18n]); + + const handleMuteDuration = useCallback( + (durationMs: number) => { + setMuteDuration(conversation.id, durationMs); + }, + [setMuteDuration, conversation.id] + ); + let modalNode: ReactNode; switch (modalState) { case ModalState.NothingOpen: @@ -374,39 +386,6 @@ export function ConversationDetails({ ); break; - case ModalState.MuteNotifications: - modalNode = ( - - ); - break; - case ModalState.UnmuteNotifications: - modalNode = ( - - - setMuteDuration(conversation.id, 0)} - > - {i18n('icu:unmute')} - - - ); - break; - default: throw missingCaseError(modalState); } @@ -482,26 +461,17 @@ export function ConversationDetails({ )} - { - if (canConversationOnlyBeMutedAlways(conversation)) { - if (isMuted) { - setMuteDuration(conversation.id, 0); - } else { - setMuteDuration(conversation.id, Number.MAX_SAFE_INTEGER); - } - return; - } - - if (isMuted) { - setModalState(ModalState.UnmuteNotifications); - } else { - setModalState(ModalState.MuteNotifications); - } - }} - /> + + + {selectedNavTab !== NavTab.Calls && ( unknown; - setMuteDuration: ( - conversationId: string, - muteDuration: undefined | number - ) => unknown; -}; - -export function ConversationNotificationsModal({ - i18n, - id, - muteExpiresAt, - onClose, - setMuteDuration, -}: PropsType): JSX.Element { - const muteOptions = useMemo(() => { - return getMuteOptions(muteExpiresAt, i18n).filter(option => { - return option.value > 0; - }); - }, [i18n, muteExpiresAt]); - - const [value, setValue] = useState(); - - const onConfirm = useCallback(() => { - if (value == null) { - return; - } - const duration = safeParseInteger(value); - strictAssert(duration, `Could not parse value: ${value}`); - setMuteDuration(id, duration); - onClose(); - }, [id, value, setMuteDuration, onClose]); - - return ( - - - - - {i18n('icu:muteNotificationsTitle')} - - - - - - {muteOptions.map(option => { - return ( - - {option.name} - - ); - })} - - - - - - {i18n('icu:cancel')} - - - {i18n('icu:mute')} - - - - - - ); -} diff --git a/ts/components/conversation/conversation-details/ConversationNotificationsSettings.dom.tsx b/ts/components/conversation/conversation-details/ConversationNotificationsSettings.dom.tsx index 4229783d4a..98d6367e1e 100644 --- a/ts/components/conversation/conversation-details/ConversationNotificationsSettings.dom.tsx +++ b/ts/components/conversation/conversation-details/ConversationNotificationsSettings.dom.tsx @@ -12,7 +12,11 @@ import { } from './ConversationDetailsIcon.dom.tsx'; import { Select } from '../../Select.dom.tsx'; import { isConversationMuted } from '../../../util/isConversationMuted.std.ts'; -import { getMuteOptions } from '../../../util/getMuteOptions.std.ts'; +import { getMutedUntilText } from '../../../util/getMutedUntilText.std.ts'; +import { + getMuteOptions, + isMuteDurationOption, +} from '../../../util/getMuteOptions.std.ts'; import { parseIntOrThrow } from '../../../util/parseIntOrThrow.std.ts'; export type PropsType = { @@ -44,22 +48,21 @@ export function ConversationNotificationsSettings({ const mentionsSelectId = useId(); const muteOptions = useMemo( () => [ - ...(isConversationMuted({ muteExpiresAt }) - ? [] - : [ - { - disabled: true, - text: i18n('icu:notMuted'), - value: -1, - }, - ]), - ...getMuteOptions(muteExpiresAt, i18n).map( - ({ disabled, name, value }) => ({ + { + disabled: true, + text: + muteExpiresAt != null && isConversationMuted({ muteExpiresAt }) + ? getMutedUntilText(muteExpiresAt, i18n) + : i18n('icu:notMuted'), + value: -1, + }, + ...getMuteOptions(muteExpiresAt, i18n) + .filter(isMuteDurationOption) + .map(({ disabled, name, value }) => ({ disabled, text: name, value, - }) - ), + })), ], [i18n, muteExpiresAt] ); diff --git a/ts/components/leftPane/LeftPaneChatFolders.dom.tsx b/ts/components/leftPane/LeftPaneChatFolders.dom.tsx index c033088a29..7f85b95172 100644 --- a/ts/components/leftPane/LeftPaneChatFolders.dom.tsx +++ b/ts/components/leftPane/LeftPaneChatFolders.dom.tsx @@ -23,6 +23,7 @@ import { WidthBreakpoint } from '../_util.std.ts'; import { AxoSelect } from '../../axo/AxoSelect.dom.tsx'; import { AxoContextMenu } from '../../axo/AxoContextMenu.dom.tsx'; import { getMuteValuesOptions } from '../../util/getMuteOptions.std.ts'; +import { MuteNotificationsSubMenu } from '../MuteNotificationsMenu.dom.tsx'; import type { AllChatFoldersMutedStats, MutedStats, @@ -306,6 +307,10 @@ function ChatFolderSegmentedControlItemContextMenu(props: { [chatFolderId, onChatFolderUpdateMute] ); + const handleChatFolderUnmuteAll = useCallback(() => { + onChatFolderUpdateMute(chatFolderId, 0); + }, [chatFolderId, onChatFolderUpdateMute]); + const handleChatFolderOpenSettings = useCallback(() => { onChatFolderOpenSettings(chatFolderId); }, [chatFolderId, onChatFolderOpenSettings]); @@ -323,45 +328,31 @@ function ChatFolderSegmentedControlItemContextMenu(props: { )} {!showOnlyUnmuteAll && ( - - - {i18n( - 'icu:LeftPaneChatFolders__Item__ContextMenu__MuteNotifications' - )} - - - {someChatsMuted && ( - - {i18n( - 'icu:LeftPaneChatFolders__Item__ContextMenu__MuteNotifications__UnmuteAll' - )} - - )} - {muteValuesOptions.map(option => { - return ( - - {option.name} - - ); - })} - - + + {someChatsMuted && ( + + {i18n( + 'icu:LeftPaneChatFolders__Item__ContextMenu__MuteNotifications__UnmuteAll' + )} + + )} + )} {showOnlyUnmuteAll && ( - {i18n('icu:LeftPaneChatFolders__Item__ContextMenu__UnmuteAll')} - + )} {props.chatFolder.folderType === ChatFolderType.CUSTOM && ( ); } - -function ContextMenuMuteNotificationsItem(props: { - symbol?: AxoSymbol.Name; - value: number; - onSelect: (value: number) => void; - children: ReactNode; -}): JSX.Element { - const { value, onSelect } = props; - const handleSelect = useCallback(() => { - onSelect(value); - }, [onSelect, value]); - return ( - - {props.children} - - ); -} diff --git a/ts/components/leftPane/LeftPaneConversationListItemContextMenu.dom.tsx b/ts/components/leftPane/LeftPaneConversationListItemContextMenu.dom.tsx index ffbdc9ce81..9f810f28be 100644 --- a/ts/components/leftPane/LeftPaneConversationListItemContextMenu.dom.tsx +++ b/ts/components/leftPane/LeftPaneConversationListItemContextMenu.dom.tsx @@ -8,7 +8,8 @@ import type { ConversationType } from '../../state/ducks/conversations.preload.t import { isConversationUnread } from '../../util/isConversationUnread.std.ts'; import { drop } from '../../util/drop.std.ts'; import { DeleteMessagesConfirmationDialog } from '../DeleteMessagesConfirmationDialog.dom.tsx'; -import { getMuteOptions } from '../../util/getMuteOptions.std.ts'; +import { MuteNotificationsSubMenu } from '../MuteNotificationsMenu.dom.tsx'; +import { getConversationMuteMenu } from '../../util/getMuteOptions.std.ts'; import { CHAT_FOLDER_DEFAULTS, ChatFolderType, @@ -79,8 +80,8 @@ export const LeftPaneConversationListItemContextMenu: FC { - return getMuteOptions(muteExpiresAt, i18n, { + const muteMenu = useMemo(() => { + return getConversationMuteMenu(muteExpiresAt, i18n, { canOnlyBeMutedAlways: canConversationOnlyBeMutedAlways(conversation), }); }, [muteExpiresAt, i18n, conversation]); @@ -181,25 +182,14 @@ export const LeftPaneConversationListItemContextMenu: FC )} - - - {i18n('icu:muteNotificationsTitle')} - - - {muteOptions.map(muteOption => { - return ( - - {muteOption.name} - - ); - })} - - + {!props.isActivelySearching && isSelectedChatFolderAllChats && props.currentChatFolders.hasAnyCurrentCustomChatFolders && ( @@ -304,23 +294,6 @@ export const LeftPaneConversationListItemContextMenu: FC void; - children: ReactNode; -}): JSX.Element { - const { value, onSelect } = props; - const handleSelect = useCallback(() => { - onSelect(value); - }, [onSelect, value]); - return ( - - {props.children} - - ); -} - function ContextMenuCopyTextItem(props: { value: string; children: ReactNode; diff --git a/ts/state/smart/App.preload.tsx b/ts/state/smart/App.preload.tsx index a89bf8f9c6..805d989ee7 100644 --- a/ts/state/smart/App.preload.tsx +++ b/ts/state/smart/App.preload.tsx @@ -9,10 +9,12 @@ import { SmartGlobalModalContainer } from './GlobalModalContainer.preload.tsx'; import { SmartLightbox } from './Lightbox.preload.tsx'; import { SmartStoryViewer } from './StoryViewer.preload.tsx'; import { + getIntl, getIsMainWindowMaximized, getIsMainWindowFullScreen, getTheme, } from '../selectors/user.std.ts'; +import { MuteUntilDialogProvider } from '../../components/MuteNotificationsMenu.dom.tsx'; import { hasSelectedStoryData as getHasSelectedStoryData } from '../selectors/stories.preload.ts'; import { useConversationsActions } from '../ducks/conversations.preload.ts'; import { useStoriesActions } from '../ducks/stories.preload.ts'; @@ -65,6 +67,7 @@ function renderStoryViewer(closeView: () => unknown): JSX.Element { } export const SmartApp = memo(function SmartApp() { + const i18n = useSelector(getIntl); const state = useSelector(getApp); const isMaximized = useSelector(getIsMainWindowMaximized); const isFullScreen = useSelector(getIsMainWindowFullScreen); @@ -78,23 +81,25 @@ export const SmartApp = memo(function SmartApp() { return ( - + + + ); }); diff --git a/ts/test-node/util/getMuteOptions_test.node.ts b/ts/test-node/util/getMuteOptions_test.node.ts index 3ea7257dc8..fc76266bce 100644 --- a/ts/test-node/util/getMuteOptions_test.node.ts +++ b/ts/test-node/util/getMuteOptions_test.node.ts @@ -5,85 +5,218 @@ import { assert } from 'chai'; import * as sinon from 'sinon'; import i18n from './i18n.node.ts'; -import { getMuteOptions } from '../../util/getMuteOptions.std.ts'; +import type { MuteOption } from '../../util/getMuteOptions.std.ts'; +import { + getConversationMuteMenu, + getMuteOptions, + getMuteValuesOptions, + isMuteDurationOption, +} from '../../util/getMuteOptions.std.ts'; describe('getMuteOptions', () => { const HOUR = 3600000; const DAY = HOUR * 24; const WEEK = DAY * 7; - const EXPECTED_DEFAULT_OPTIONS = [ + + const UNMUTE_OPTION: MuteOption = { + name: 'Unmute', + value: 0, + }; + + const expectedAlwaysOption = ( + isCurrentlyMutedAlways = false + ): MuteOption => ({ + name: 'Always', + disabled: isCurrentlyMutedAlways, + value: Number.MAX_SAFE_INTEGER, + }); + + const expectedDefaultOptions = ({ + isCurrentlyMutedAlways = false, + }: { isCurrentlyMutedAlways?: boolean } = {}): Array => [ { - name: 'Mute for one hour', + name: '1 hour', value: HOUR, }, { - name: 'Mute for eight hours', + name: '8 hours', value: HOUR * 8, }, { - name: 'Mute for one day', + name: '1 day', value: DAY, }, { - name: 'Mute for one week', + name: '1 week', value: WEEK, }, { - name: 'Mute always', - value: Number.MAX_SAFE_INTEGER, + name: 'Until…', + value: 'custom', }, + expectedAlwaysOption(isCurrentlyMutedAlways), ]; - describe('when not muted', () => { - it('returns the 5 default options', () => { + let sandbox: sinon.SinonSandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + sandbox.useFakeTimers({ + now: new Date(2000, 3, 20, 12, 0, 0), + }); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('getMuteValuesOptions', () => { + it('returns the 6 default options', () => { assert.deepStrictEqual( - getMuteOptions(undefined, i18n), - EXPECTED_DEFAULT_OPTIONS + getMuteValuesOptions(i18n), + expectedDefaultOptions() + ); + }); + + it('disables "Always" when already muted always', () => { + assert.deepStrictEqual( + getMuteValuesOptions(i18n, { isCurrentlyMutedAlways: true }), + expectedDefaultOptions({ isCurrentlyMutedAlways: true }) + ); + }); + + it('returns only "Always" when that is the only allowed duration', () => { + assert.deepStrictEqual( + getMuteValuesOptions(i18n, { canOnlyBeMutedAlways: true }), + [expectedAlwaysOption()] + ); + }); + + it('disables the only option when muted always and only "Always" is allowed', () => { + assert.deepStrictEqual( + getMuteValuesOptions(i18n, { + canOnlyBeMutedAlways: true, + isCurrentlyMutedAlways: true, + }), + [expectedAlwaysOption(true)] ); }); }); - describe('when muted', () => { - let sandbox: sinon.SinonSandbox; + describe('getMuteOptions', () => { + describe('when not muted', () => { + it('returns the default options with no "Unmute"', () => { + assert.deepStrictEqual( + getMuteOptions(undefined, i18n), + expectedDefaultOptions() + ); + }); - beforeEach(() => { - sandbox = sinon.createSandbox(); - sandbox.useFakeTimers({ - now: new Date(2000, 3, 20, 12, 0, 0), + it('treats a null mute expiry as not muted', () => { + assert.deepStrictEqual( + getMuteOptions(null, i18n), + expectedDefaultOptions() + ); + }); + + it('treats an expired mute as not muted', () => { + assert.deepStrictEqual( + getMuteOptions(new Date(2000, 3, 20, 11, 0, 0).valueOf(), i18n), + expectedDefaultOptions() + ); }); }); - afterEach(() => { - sandbox.restore(); + describe('when muted', () => { + it('returns an "Unmute" option, and then the default options', () => { + assert.deepStrictEqual( + getMuteOptions(new Date(2000, 3, 20, 18, 30, 0).valueOf(), i18n), + [UNMUTE_OPTION, ...expectedDefaultOptions()] + ); + }); + + it('disables "Always" when muted always', () => { + assert.deepStrictEqual(getMuteOptions(Number.MAX_SAFE_INTEGER, i18n), [ + UNMUTE_OPTION, + ...expectedDefaultOptions({ isCurrentlyMutedAlways: true }), + ]); + }); + + it('returns "Unmute" and a disabled "Always" when only "Always" is allowed', () => { + assert.deepStrictEqual( + getMuteOptions(Number.MAX_SAFE_INTEGER, i18n, { + canOnlyBeMutedAlways: true, + }), + [UNMUTE_OPTION, expectedAlwaysOption(true)] + ); + }); + }); + }); + + describe('getConversationMuteMenu', () => { + describe('when not muted', () => { + it('returns the mute label and the default options', () => { + assert.deepStrictEqual(getConversationMuteMenu(undefined, i18n), { + label: 'Mute this chat for…', + options: expectedDefaultOptions(), + }); + }); + + it('returns only "Always" when that is the only allowed duration', () => { + assert.deepStrictEqual( + getConversationMuteMenu(null, i18n, { canOnlyBeMutedAlways: true }), + { + label: 'Mute this chat for…', + options: [expectedAlwaysOption()], + } + ); + }); }); - it('returns a current mute label, an "Unmute" option, and then the 5 default options', () => { - assert.deepStrictEqual( - getMuteOptions(new Date(2000, 3, 20, 18, 30, 0).valueOf(), i18n), - [ + describe('when muted', () => { + it('returns a "Muted until" label and only an "Unmute" option', () => { + assert.deepStrictEqual( + getConversationMuteMenu( + new Date(2000, 3, 20, 18, 30, 0).valueOf(), + i18n + ), { - disabled: true, - name: 'Muted until 6:30 PM', - value: -1, - }, + label: 'Muted until 6:30 PM', + options: [UNMUTE_OPTION], + } + ); + }); + + it("includes a date in the label if it's on a different day", () => { + assert.deepStrictEqual( + getConversationMuteMenu( + new Date(2000, 3, 21, 18, 30, 0).valueOf(), + i18n + ).label, + 'Muted until 04/21/2000, 6:30 PM' + ); + }); + + it('returns a "Muted always" label when muted always', () => { + assert.deepStrictEqual( + getConversationMuteMenu(Number.MAX_SAFE_INTEGER, i18n), { - name: 'Unmute', - value: 0, - }, - ...EXPECTED_DEFAULT_OPTIONS, - ] - ); + label: 'Muted always', + options: [UNMUTE_OPTION], + } + ); + }); + }); + }); + + describe('isMuteDurationOption', () => { + it('is true for numeric durations', () => { + assert.isTrue(isMuteDurationOption({ name: '1 hour', value: HOUR })); + assert.isTrue(isMuteDurationOption(UNMUTE_OPTION)); }); - it("renders the current mute label with a date if it's on a different day", () => { - assert.deepStrictEqual( - getMuteOptions(new Date(2000, 3, 21, 18, 30, 0).valueOf(), i18n)[0], - { - disabled: true, - name: 'Muted until 04/21/2000, 6:30 PM', - value: -1, - } - ); + it('is false for the "custom" option', () => { + assert.isFalse(isMuteDurationOption({ name: 'Until…', value: 'custom' })); }); }); }); diff --git a/ts/util/getMuteOptions.std.ts b/ts/util/getMuteOptions.std.ts index 0990b42445..3f6bbedb93 100644 --- a/ts/util/getMuteOptions.std.ts +++ b/ts/util/getMuteOptions.std.ts @@ -9,9 +9,17 @@ import { isConversationMuted } from './isConversationMuted.std.ts'; export type MuteOption = { name: string; disabled?: boolean; - value: number; + value: number | 'custom'; }; +export type MuteDurationOption = MuteOption & { value: number }; + +export function isMuteDurationOption( + option: MuteOption +): option is MuteDurationOption { + return typeof option.value === 'number'; +} + export function getMuteValuesOptions( i18n: LocalizerType, options: { @@ -20,36 +28,37 @@ export function getMuteValuesOptions( } = {} ): ReadonlyArray { const muteAlwaysOption: MuteOption = { - name: i18n('icu:muteAlways'), + name: i18n('icu:MuteMenu__always'), + disabled: options.isCurrentlyMutedAlways === true, value: Number.MAX_SAFE_INTEGER, }; - if (options.canOnlyBeMutedAlways && options.isCurrentlyMutedAlways) { - return []; - } - if (options.canOnlyBeMutedAlways) { return [muteAlwaysOption]; } return [ { - name: i18n('icu:muteHour'), + name: i18n('icu:MuteMenu__hour'), value: durations.HOUR, }, { - name: i18n('icu:muteEightHours'), + name: i18n('icu:MuteMenu__eightHours'), value: 8 * durations.HOUR, }, { - name: i18n('icu:muteDay'), + name: i18n('icu:MuteMenu__day'), value: durations.DAY, }, { - name: i18n('icu:muteWeek'), + name: i18n('icu:MuteMenu__week'), value: durations.WEEK, }, - ...(options.isCurrentlyMutedAlways ? [] : [muteAlwaysOption]), + { + name: i18n('icu:MuteMenu__until'), + value: 'custom' as const, + }, + muteAlwaysOption, ]; } @@ -63,11 +72,6 @@ export function getMuteOptions( return [ ...(muteExpiresAt && isConversationMuted({ muteExpiresAt }) ? [ - { - name: getMutedUntilText(muteExpiresAt, i18n), - disabled: true, - value: -1, - }, { name: i18n('icu:unmute'), value: 0, @@ -80,3 +84,35 @@ export function getMuteOptions( }), ]; } + +export type MuteMenu = Readonly<{ + label: string; + options: ReadonlyArray; +}>; + +export function getConversationMuteMenu( + muteExpiresAt: null | undefined | number, + i18n: LocalizerType, + options: { + canOnlyBeMutedAlways?: boolean; + } = {} +): MuteMenu { + if (muteExpiresAt != null && isConversationMuted({ muteExpiresAt })) { + return { + label: getMutedUntilText(muteExpiresAt, i18n), + options: [ + { + name: i18n('icu:unmute'), + value: 0, + }, + ], + }; + } + + return { + label: i18n('icu:MuteMenu__label'), + options: getMuteValuesOptions(i18n, { + canOnlyBeMutedAlways: options.canOnlyBeMutedAlways, + }), + }; +}