Updated conversation mute options

Co-authored-by: jamiebuilds-signal <jamie@signal.org>
This commit is contained in:
trevor-signal
2026-08-24 09:37:22 -04:00
committed by GitHub
co-authored by jamiebuilds-signal
parent b8c94cbe37
commit 94fa10ed52
17 changed files with 1376 additions and 722 deletions
+10
View File
@@ -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 (
<MuteUntilDialogProvider i18n={window.SignalContext.i18n}>
<Story {...context} />
</MuteUntilDialogProvider>
);
}
export const decorators = [
withMutedUntilDialogProvider,
withAppProvider,
withGlobalTypesProvider,
withMockStoreProvider,
+56
View File
@@ -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"
+205
View File
@@ -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 (
<AriaDatePicker
className={tw('flex min-w-0 flex-1')}
aria-label={props['aria-label']}
aria-labelledby={props['aria-labelledby']}
isDisabled={isDisabled}
minValue={minValue}
shouldForceLeadingZeros
value={value}
onChange={onUpdateDate}
isOpen={open}
onOpenChange={setOpen}
>
<Group
className={tw(
'flex min-w-0 flex-1 items-center rounded-lg border-[2.5px] border-transparent bg-primary px-2 py-0.5 keyboard-mode:focus-within:axo-focus-ring'
)}
>
<DateInput className={tw('inline-flex items-center')}>
{segment => (
<DateSegment
className={classNames(
tw(
'inline-block px-px type-body-medium outline-none focus:bg-secondary'
),
isDisabled ? tw('text-placeholder') : null
)}
segment={segment}
/>
)}
</DateInput>
<Button
className={classNames(
tw('ms-auto p-0.5 outline-none focus-visible:bg-secondary'),
isDisabled ? tw('text-placeholder') : null
)}
>
<AxoSymbol.Icon size={14} symbol="calendar" label={null} />
</Button>
</Group>
{/**
* 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
*/}
<RadixDialog.Root
// This is not strictly necessary to sync open state, but it disables
// Radix UI from doing anything while the popover is not open which
// seems safer
open={open}
// We need this to act as a dialog or it won't render the <Overlay>
modal
>
{/**
* We need to render <Overlay> even though asChild+null prevents it
* from creating any element because it contains the logic to break
* <Content> out of scroll locking.
*/}
<RadixDialog.Overlay asChild>{null}</RadixDialog.Overlay>
<RadixDialog.Content
// Merge behavior of radix <Dialog.Content> with react-aria's <Popover>
asChild
// Remove extra radix attributes that don't do anything
aria-labelledby={undefined}
data-state={undefined}
>
<Popover
className={tw(
'overflow-auto',
'rounded-[10px] bg-surface-secondary shadow-elevation-1',
'outline-none keyboard-mode:data-focused:axo-focus-ring'
)}
>
<RadixDialog.Title className={tw('sr-only')}>
{i18n('icu:DatePicker__popupTitle')}
</RadixDialog.Title>
<Calendar className={tw('flex flex-col gap-2')}>
<header
className={tw(
'flex items-center justify-between gap-2 border-b border-primary p-2'
)}
>
<ArrowButton slot="previous" />
<Heading
className={tw('type-body-medium font-medium text-primary')}
/>
<ArrowButton slot="next" />
</header>
<div className={tw('p-1.5')}>
<CalendarGrid className={tw('w-full')} weekdayStyle="short">
<CalendarGridHeader>
{day => (
<CalendarHeaderCell
className={tw(
'type-body-small font-medium text-secondary'
)}
>
{day}
</CalendarHeaderCell>
)}
</CalendarGridHeader>
<CalendarGridBody>
{date => (
<CalendarCell
className={tw(
'flex h-8 w-9 items-center justify-center',
'rounded-lg text-center type-body-medium',
'text-primary',
'data-disabled:text-disabled',
'data-hovered:bg-primary',
'data-focused:bg-primary',
'data-today:font-semibold',
'data-selected:bg-secondary-pressed',
'outline-none keyboard-mode:data-focused:axo-focus-ring'
)}
date={date}
/>
)}
</CalendarGridBody>
</CalendarGrid>
</div>
</Calendar>
</Popover>
</RadixDialog.Content>
</RadixDialog.Root>
</AriaDatePicker>
);
}
type ArrowButtonProps = Readonly<{
slot: 'previous' | 'next';
}>;
function ArrowButton(props: ArrowButtonProps) {
return (
<Button
slot={props.slot}
className={tw(
'flex items-center justify-center',
'p-1',
'rounded-full text-center type-body-medium',
'font-medium text-primary',
'data-disabled:text-disabled',
'data-hovered:bg-primary',
'data-focused:bg-primary',
'data-selected:bg-secondary-pressed',
'outline-none keyboard-mode:data-focused:axo-focus-ring'
)}
>
<AxoSymbol.Icon
size={14}
symbol={props.slot === 'previous' ? 'chevron-[start]' : 'chevron-[end]'}
label={null}
/>
</Button>
);
}
+222
View File
@@ -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<MuteNotificationsMenuValue | null>(
null
);
function useMuteUntilDialog(): MuteNotificationsMenuValue {
const value = useContext(MuteUntilDialogContext);
strictAssert(
value != null,
'Missing <MuteUntilDialogProvider> 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 (
<MuteUntilDialogContext.Provider value={value}>
{props.children}
<MuteUntilDialog
i18n={props.i18n}
open={muteUntilDialog !== false}
onSubmit={handleMuteUntilSubmit}
onClose={handleMuteUntilClose}
/>
</MuteUntilDialogContext.Provider>
);
}
type MuteNotificationsMenuItemsProps = Readonly<{
i18n: LocalizerType;
renderer: AxoMenuBuilder.Renderer;
label?: string;
options: ReadonlyArray<MuteOption>;
onMuteDuration: (durationMs: number) => void;
onMuteUntilClick: () => void;
}>;
const MuteNotificationsMenuItems: FC<MuteNotificationsMenuItemsProps> = memo(
function MuteNotificationsMenuItems(props) {
const { label, options, onMuteDuration, onMuteUntilClick } = props;
const Menu = getMenuComponents(props.renderer);
return (
<>
{label ? <Menu.Label>{label}</Menu.Label> : null}
{options.map(option => {
const { value } = option;
return (
<Menu.Item
key={option.name}
disabled={option.disabled}
onSelect={() => {
if (value === 'custom') {
onMuteUntilClick();
} else {
onMuteDuration(value);
}
}}
>
{option.name}
</Menu.Item>
);
})}
</>
);
}
);
export type MuteNotificationsSubMenuProps = Readonly<{
i18n: LocalizerType;
renderer: AxoMenuBuilder.Renderer;
title: string;
label?: string;
options: ReadonlyArray<MuteOption>;
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<MuteNotificationsSubMenuProps> = memo(
function MuteNotificationsSubMenu(props) {
const { onMuteUntilClick } = useMuteUntilDialog();
const Menu = getMenuComponents(props.renderer);
return (
<Menu.Sub>
<Menu.SubTrigger symbol="bell-slash">{props.title}</Menu.SubTrigger>
<Menu.SubContent>
{props.children}
<MuteNotificationsMenuItems
i18n={props.i18n}
renderer={props.renderer}
label={props.label}
options={props.options}
onMuteDuration={props.onMuteDuration}
onMuteUntilClick={() => onMuteUntilClick(props.onMuteDuration)}
/>
</Menu.SubContent>
</Menu.Sub>
);
}
);
export type MuteNotificationsDropdownMenuProps = Readonly<{
i18n: LocalizerType;
label: string;
options: ReadonlyArray<MuteOption>;
onMuteDuration: (durationMs: number) => void;
/** The button that opens the menu. */
children: ReactNode;
}>;
export const MuteNotificationsDropdownMenu: FC<MuteNotificationsDropdownMenuProps> =
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 (
<>
<AxoDropdownMenu.Root>
<AxoDropdownMenu.Trigger>{props.children}</AxoDropdownMenu.Trigger>
<AxoDropdownMenu.Content>
<MuteNotificationsMenuItems
i18n={i18n}
renderer="AxoDropdownMenu"
label={props.label}
options={options}
onMuteDuration={onMuteDuration}
onMuteUntilClick={() => setIsShowingMuteUntilDialog(true)}
/>
</AxoDropdownMenu.Content>
</AxoDropdownMenu.Root>
<MuteUntilDialog
open={isShowingMuteUntilDialog}
i18n={i18n}
onSubmit={handleMuteUntilSubmit}
onClose={() => setIsShowingMuteUntilDialog(false)}
/>
</>
);
});
@@ -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<PropsType>;
const { i18n } = window.SignalContext;
export function Default(): JSX.Element {
return (
<MuteUntilDialog
i18n={i18n}
open
onSubmit={action('onSubmit')}
onClose={action('onClose')}
/>
);
}
+141
View File
@@ -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<CalendarDate | null>(() => {
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 (
<AxoDialog.Root
open={open}
onOpenChange={isOpen => {
if (!isOpen) {
onClose();
}
}}
>
<AxoDialog.Content
size="sm"
escape="cancel-is-noop"
disableMissingAriaDescriptionWarning
>
<AxoDialog.Header>
<AxoDialog.Title>
{i18n('icu:MuteUntilDialog__title')}
</AxoDialog.Title>
<AxoDialog.Close />
</AxoDialog.Header>
<AxoDialog.Body>
<div className={tw('flex items-center gap-2')}>
<DatePicker
i18n={i18n}
aria-label={i18n('icu:MuteUntilDialog__dateLabel')}
minValue={earliestDate}
value={date}
onUpdateDate={setDate}
/>
<TimePicker
i18n={i18n}
aria-label={i18n('icu:MuteUntilDialog__timeLabel')}
time={time}
isDisabled={false}
onUpdateTime={setTime}
/>
</div>
<div className={tw('mt-4 type-body-medium text-secondary')}>
{timeZoneNote}
</div>
</AxoDialog.Body>
<AxoDialog.Footer>
<AxoDialog.Actions>
<AxoDialog.Action variant="strong-secondary" onClick={onClose}>
{i18n('icu:cancel')}
</AxoDialog.Action>
<AxoDialog.Action
variant="strong-primary"
disabled={!isValid}
onClick={handleSubmit}
>
{i18n('icu:mute')}
</AxoDialog.Action>
</AxoDialog.Actions>
</AxoDialog.Footer>
</AxoDialog.Content>
</AxoDialog.Root>
);
}
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;
}
@@ -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({
</h2>
</FullWidthRow>
<FullWidthRow className={tw('flex min-h-[40px] items-center')}>
<span id="start-label" className={tw('grow')}>
<span id={startLabelId} className={tw('grow')}>
{i18n('icu:NotificationProfiles--schedule-from')}
</span>
<span className={tw('shrink-0')}>
<TimePicker
i18n={i18n}
isDisabled={!isEnabled}
labelId="start-label"
aria-labelledby={startLabelId}
onUpdateTime={onSetStartTime}
theme={theme}
time={startTime}
@@ -977,14 +884,14 @@ function NotificationProfilesSchedulePage({
</span>
</FullWidthRow>
<FullWidthRow className={tw('flex min-h-[40px] items-center')}>
<span id="end-label" className={tw('grow')}>
<span id={endLabelId} className={tw('grow')}>
{i18n('icu:NotificationProfiles--schedule-until')}
</span>
<span className={tw('shrink-0')}>
<TimePicker
i18n={i18n}
isDisabled={!isEnabled}
labelId="end-label"
aria-labelledby={endLabelId}
onUpdateTime={onSetEndTime}
theme={theme}
time={endTime}
@@ -1809,268 +1716,3 @@ function ScheduleSummary({
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] = useState(false);
const use24HourTime = need24HourTime();
const AM_PM: Array<PERIOD> = ['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<HTMLButtonElement | null>(null);
const selectedMinute = useRef<HTMLButtonElement | null>(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 && (
<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-surface-secondary p-1 shadow-elevation-1'
),
use24HourTime ? tw('w-[102px]') : tw('w-[150px]'),
theme ? themeClassName2(theme) : undefined
)}
>
<div
className={tw(
'w-[46px] scrollbar-width-none 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 border-[2.5px] border-transparent py-[7px] type-body-medium outline-none keyboard-mode:focus:axo-focus-ring'
),
isSelected ? tw('bg-primary') : null
)}
type="button"
onClick={() => {
const newTime = makeTime(hour, minutes, period);
onUpdateTime(newTime);
}}
>
{hour}
</button>
);
})}
</div>
<div
className={tw(
'ms-0.5 w-[46px] scrollbar-width-none 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 border-[2.5px] border-transparent py-[7px] type-body-medium outline-none keyboard-mode:focus:axo-focus-ring'
),
isSelected ? tw('bg-primary') : 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] scrollbar-width-none overflow-y-scroll'
)}
>
{AM_PM.map(item => {
const isSelected = item === period;
return (
<button
key={item}
className={classNames(
tw(
'w-[46px] rounded-sm border-[2.5px] border-transparent py-[7px] type-body-medium outline-none keyboard-mode:focus:axo-focus-ring'
),
isSelected ? tw('bg-primary') : 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-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)}
>
<DateInput className={tw('inline-flex min-w-[5em] items-center')}>
{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 <span />;
}
if (segment.type === 'literal') {
// oxlint-disable-next-line no-param-reassign
segment.text = i18n('icu:NotificationProfile--time-separator');
}
return (
<DateSegment
className={classNames(
tw(
'inline-block px-px type-body-medium outline-none focus:bg-secondary'
),
segment.type === 'literal' ? tw('px-[3px]') : null,
segment.type === 'dayPeriod' ? tw('ps-[2px]') : null,
segment.type === 'hour' ? tw('grow text-end') : null,
isDisabled ? tw('text-placeholder') : null
)}
segment={segment}
/>
);
}}
</DateInput>
<button
className={classNames(
tw('ms-3 p-0.5 outline-none focus-visible:bg-secondary'),
isDisabled ? tw('text-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>
</>
);
}
+356
View File
@@ -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<PERIOD> = ['AM', 'PM'];
const periodLookup = useMemo(() => {
return {
AM: i18n('icu:NotificationProfile--am'),
PM: i18n('icu:NotificationProfile--pm'),
};
}, [i18n]);
const timeFieldRef = useRef<HTMLDivElement | null>(null);
const { minutes, hours, period } = getTimeDetails(time, use24HourTime);
const selectedHour = useRef<HTMLButtonElement | null>(null);
const selectedMinute = useRef<HTMLButtonElement | null>(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. */}
<RadixDialog.Root open={isShowingPopup} modal>
<RadixDialog.Overlay asChild>{null}</RadixDialog.Overlay>
<RadixDialog.Content
asChild
aria-labelledby={undefined}
data-state={undefined}
>
<Popover
triggerRef={timeFieldRef}
isOpen={isShowingPopup}
onOpenChange={setIsShowingPopup}
placement="bottom end"
offset={6}
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
)}
>
{/* Radix warns without a title, and labels the popup with it */}
<RadixDialog.Title className={tw('sr-only')}>
{i18n('icu:TimePicker__popupTitle')}
</RadixDialog.Title>
<div
className={tw('w-[46px] scrollbar-width-none 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 border-[2.5px] border-transparent py-[7px] type-body-medium outline-none keyboard-mode:focus:axo-focus-ring'
),
isSelected ? tw('bg-primary') : null
)}
type="button"
onClick={() => {
const newTime = makeTime(hour, minutes, period);
onUpdateTime(newTime);
}}
>
{hour}
</button>
);
})}
</div>
<div
className={tw(
'ms-0.5 w-[46px] scrollbar-width-none 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 border-[2.5px] border-transparent py-[7px] type-body-medium outline-none keyboard-mode:focus:axo-focus-ring'
),
isSelected ? tw('bg-primary') : 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] scrollbar-width-none overflow-y-scroll'
)}
>
{AM_PM.map(item => {
const isSelected = item === period;
return (
<button
key={item}
className={classNames(
tw(
'w-[46px] rounded-sm border-[2.5px] border-transparent py-[7px] type-body-medium outline-none keyboard-mode:focus:axo-focus-ring'
),
isSelected ? tw('bg-primary') : null
)}
type="button"
onClick={() => {
const newTime = makeTime(hours, minutes, item);
onUpdateTime(newTime);
}}
>
{periodLookup[item]}
</button>
);
})}
</div>
) : null}
</Popover>
</RadixDialog.Content>
</RadixDialog.Root>
<TimeField
ref={timeFieldRef}
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-label={props['aria-label']}
aria-labelledby={props['aria-labelledby']}
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')}>
{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 <span />;
}
// 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 <span>{segment.text}</span>;
}
// oxlint-disable-next-line no-param-reassign
segment.text = i18n('icu:NotificationProfile--time-separator');
}
return (
<DateSegment
className={classNames(
tw(
'inline-block px-px type-body-medium outline-none focus:bg-secondary'
),
segment.type === 'literal' ? tw('px-[3px]') : null,
segment.type === 'dayPeriod' ? tw('ps-[2px]') : null,
segment.type === 'hour' ? tw('grow text-end') : null,
isDisabled ? tw('text-placeholder') : null
)}
segment={segment}
/>
);
}}
</DateInput>
<button
className={classNames(
tw('ms-3 p-0.5 outline-none focus-visible:bg-secondary'),
isDisabled ? tw('text-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>
</>
);
}
@@ -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 = <span>{i18n('icu:muteNotificationsTitle')}</span>;
const disappearingTitle = <span>{i18n('icu:disappearingMessages')}</span>;
if (isSignalConversation) {
@@ -840,24 +840,14 @@ function HeaderDropdownMenuContent({
</AxoDropdownMenu.SubContent>
</AxoDropdownMenu.Sub>
)}
<AxoDropdownMenu.Sub>
<AxoDropdownMenu.SubTrigger symbol="bell-slash">
{muteTitle}
</AxoDropdownMenu.SubTrigger>
<AxoDropdownMenu.SubContent>
{muteOptions.map(item => (
<AxoDropdownMenu.Item
key={item.name}
disabled={item.disabled}
onSelect={() => {
onChangeMuteExpiration(item.value);
}}
>
{item.name}
</AxoDropdownMenu.Item>
))}
</AxoDropdownMenu.SubContent>
</AxoDropdownMenu.Sub>
<MuteNotificationsSubMenu
i18n={i18n}
renderer="AxoDropdownMenu"
title={i18n('icu:muteNotificationsTitle')}
label={muteMenu.label}
options={muteMenu.options}
onMuteDuration={onChangeMuteExpiration}
/>
{!isGroup || hasGV2AdminEnabled ? (
<AxoDropdownMenu.Item
symbol="settings"
@@ -2,7 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
import type { ReactNode, JSX } from 'react';
import { useEffect, useState, useCallback } from 'react';
import { useEffect, useState, useCallback, useMemo } from 'react';
import classNames from 'classnames';
import type {
@@ -42,7 +42,6 @@ import { EditConversationAttributesModal } from './EditConversationAttributesMod
import { RequestState } from './util.std.ts';
import { getCustomColorStyle } from '../../../util/getCustomColorStyle.dom.ts';
import { openLinkInWebBrowser } from '../../../util/openLinkInWebBrowser.dom.ts';
import { ConversationNotificationsModal } from './ConversationNotificationsModal.dom.tsx';
import type {
AvatarDataType,
DeleteAvatarFromDiskActionType,
@@ -66,15 +65,15 @@ import { AxoConfirmDialog } from '../../../axo/AxoConfirmDialog.dom.tsx';
import { canConversationOnlyBeMutedAlways } from '../../../conversations/canConversationOnlyBeMutedAlways.dom.ts';
import { CONTACT_SUPPORT_URL } from '../../../util/contactSupport.dom.tsx';
import { AxoStackedButton } from '../../../axo/AxoStackedButton.dom.tsx';
import { getConversationMuteMenu } from '../../../util/getMuteOptions.std.ts';
import { MuteNotificationsDropdownMenu } from '../../MuteNotificationsMenu.dom.tsx';
enum ModalState {
AddingGroupMembers,
ConfirmDeleteNicknameAndNote,
EditingGroupDescription,
EditingGroupTitle,
MuteNotifications,
NothingOpen,
UnmuteNotifications,
}
export type StateProps = {
@@ -265,6 +264,19 @@ export function ConversationDetails({
setEditGroupAttributesRequestState(RequestState.Inactive);
}, []);
const muteMenu = useMemo(() => {
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({
</AxoConfirmDialog.Root>
);
break;
case ModalState.MuteNotifications:
modalNode = (
<ConversationNotificationsModal
i18n={i18n}
id={conversation.id}
muteExpiresAt={conversation.muteExpiresAt}
onClose={onCloseModal}
setMuteDuration={setMuteDuration}
/>
);
break;
case ModalState.UnmuteNotifications:
modalNode = (
<AxoConfirmDialog.Root
open
onOpenChange={onCloseModal}
title={i18n('icu:ConversationDetails__unmute--title')}
description={getMutedUntilText(
Number(conversation.muteExpiresAt),
i18n
)}
>
<AxoConfirmDialog.Cancel />
<AxoConfirmDialog.Action
variant="strong-primary"
onClick={() => setMuteDuration(conversation.id, 0)}
>
{i18n('icu:unmute')}
</AxoConfirmDialog.Action>
</AxoConfirmDialog.Root>
);
break;
default:
throw missingCaseError(modalState);
}
@@ -482,26 +461,17 @@ export function ConversationDetails({
</>
)}
<AxoStackedButton.Root
symbol={isMuted ? 'bell-slash' : 'bell'}
label={isMuted ? i18n('icu:unmute') : i18n('icu:mute')}
onClick={() => {
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);
}
}}
/>
<MuteNotificationsDropdownMenu
i18n={i18n}
label={muteMenu.label}
options={muteMenu.options}
onMuteDuration={handleMuteDuration}
>
<AxoStackedButton.Root
symbol={isMuted ? 'bell-slash' : 'bell'}
label={isMuted ? i18n('icu:unmute') : i18n('icu:mute')}
/>
</MuteNotificationsDropdownMenu>
{selectedNavTab !== NavTab.Calls && (
<AxoStackedButton.Root
@@ -1,88 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { useCallback, useMemo, useState, type JSX } from 'react';
import type { LocalizerType } from '../../../types/Util.std.ts';
import { getMuteOptions } from '../../../util/getMuteOptions.std.ts';
import { AxoDialog } from '../../../axo/AxoDialog.dom.tsx';
import { AxoRadioGroup } from '../../../axo/controls/AxoRadioGroup.dom.tsx';
import { safeParseInteger } from '../../../util/numbers.std.ts';
import { strictAssert } from '../../../util/assert.std.ts';
type PropsType = {
i18n: LocalizerType;
id: string;
muteExpiresAt: undefined | number;
onClose: () => 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<string>();
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 (
<AxoDialog.Root open onOpenChange={onClose}>
<AxoDialog.Content size="sm" escape="cancel-is-noop">
<AxoDialog.Header>
<AxoDialog.Title>
{i18n('icu:muteNotificationsTitle')}
</AxoDialog.Title>
<AxoDialog.Close />
</AxoDialog.Header>
<AxoDialog.Body>
<AxoRadioGroup.Root value={value ?? null} onValueChange={setValue}>
{muteOptions.map(option => {
return (
<AxoRadioGroup.Item
value={`${option.value}`}
disabled={option.disabled}
>
<AxoRadioGroup.Label>{option.name}</AxoRadioGroup.Label>
</AxoRadioGroup.Item>
);
})}
</AxoRadioGroup.Root>
</AxoDialog.Body>
<AxoDialog.Footer>
<AxoDialog.Actions>
<AxoDialog.Action variant="strong-secondary" onClick={onClose}>
{i18n('icu:cancel')}
</AxoDialog.Action>
<AxoDialog.Action
variant="strong-primary"
onClick={onConfirm}
disabled={value == null}
>
{i18n('icu:mute')}
</AxoDialog.Action>
</AxoDialog.Actions>
</AxoDialog.Footer>
</AxoDialog.Content>
</AxoDialog.Root>
);
}
@@ -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]
);
@@ -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: {
</AxoContextMenu.Item>
)}
{!showOnlyUnmuteAll && (
<AxoContextMenu.Sub>
<AxoContextMenu.SubTrigger symbol="bell-slash">
{i18n(
'icu:LeftPaneChatFolders__Item__ContextMenu__MuteNotifications'
)}
</AxoContextMenu.SubTrigger>
<AxoContextMenu.SubContent>
{someChatsMuted && (
<ContextMenuMuteNotificationsItem
value={0}
onSelect={handleChatFolderUpdateMute}
>
{i18n(
'icu:LeftPaneChatFolders__Item__ContextMenu__MuteNotifications__UnmuteAll'
)}
</ContextMenuMuteNotificationsItem>
)}
{muteValuesOptions.map(option => {
return (
<ContextMenuMuteNotificationsItem
key={option.value}
value={option.value}
onSelect={handleChatFolderUpdateMute}
>
{option.name}
</ContextMenuMuteNotificationsItem>
);
})}
</AxoContextMenu.SubContent>
</AxoContextMenu.Sub>
<MuteNotificationsSubMenu
i18n={i18n}
renderer="AxoContextMenu"
title={i18n(
'icu:LeftPaneChatFolders__Item__ContextMenu__MuteNotifications'
)}
options={muteValuesOptions}
onMuteDuration={handleChatFolderUpdateMute}
>
{someChatsMuted && (
<AxoContextMenu.Item onSelect={handleChatFolderUnmuteAll}>
{i18n(
'icu:LeftPaneChatFolders__Item__ContextMenu__MuteNotifications__UnmuteAll'
)}
</AxoContextMenu.Item>
)}
</MuteNotificationsSubMenu>
)}
{showOnlyUnmuteAll && (
<ContextMenuMuteNotificationsItem
<AxoContextMenu.Item
symbol="bell"
value={0}
onSelect={handleChatFolderUpdateMute}
onSelect={handleChatFolderUnmuteAll}
>
{i18n('icu:LeftPaneChatFolders__Item__ContextMenu__UnmuteAll')}
</ContextMenuMuteNotificationsItem>
</AxoContextMenu.Item>
)}
{props.chatFolder.folderType === ChatFolderType.CUSTOM && (
<AxoContextMenu.Item
@@ -375,20 +366,3 @@ function ChatFolderSegmentedControlItemContextMenu(props: {
</AxoContextMenu.Root>
);
}
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 (
<AxoContextMenu.Item symbol={props.symbol} onSelect={handleSelect}>
{props.children}
</AxoContextMenu.Item>
);
}
@@ -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<LeftPaneConversationLis
);
}, [selectedChatFolder]);
const muteOptions = useMemo(() => {
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<LeftPaneConversationLis
{i18n('icu:unpinConversation')}
</AxoContextMenu.Item>
)}
<AxoContextMenu.Sub>
<AxoContextMenu.SubTrigger symbol="bell-slash">
{i18n('icu:muteNotificationsTitle')}
</AxoContextMenu.SubTrigger>
<AxoContextMenu.SubContent>
{muteOptions.map(muteOption => {
return (
<ContextMenuMuteNotificationsItem
key={muteOption.value}
value={muteOption.value}
disabled={muteOption.disabled}
onSelect={handleUpdateMute}
>
{muteOption.name}
</ContextMenuMuteNotificationsItem>
);
})}
</AxoContextMenu.SubContent>
</AxoContextMenu.Sub>
<MuteNotificationsSubMenu
i18n={i18n}
renderer="AxoContextMenu"
title={i18n('icu:muteNotificationsTitle')}
options={muteMenu.options}
label={muteMenu.label}
onMuteDuration={handleUpdateMute}
/>
{!props.isActivelySearching &&
isSelectedChatFolderAllChats &&
props.currentChatFolders.hasAnyCurrentCustomChatFolders && (
@@ -304,23 +294,6 @@ export const LeftPaneConversationListItemContextMenu: FC<LeftPaneConversationLis
);
});
function ContextMenuMuteNotificationsItem(props: {
disabled?: boolean;
value: number;
onSelect: (value: number) => void;
children: ReactNode;
}): JSX.Element {
const { value, onSelect } = props;
const handleSelect = useCallback(() => {
onSelect(value);
}, [onSelect, value]);
return (
<AxoContextMenu.Item disabled={props.disabled} onSelect={handleSelect}>
{props.children}
</AxoContextMenu.Item>
);
}
function ContextMenuCopyTextItem(props: {
value: string;
children: ReactNode;
+22 -17
View File
@@ -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 (
<SmartFunProvider>
<App
state={state}
isMaximized={isMaximized}
isFullScreen={isFullScreen}
osClassName={osClassName}
renderCallManager={renderCallManager}
renderGlobalModalContainer={renderGlobalModalContainer}
renderInstallScreen={renderInstallScreen}
renderLightbox={renderLightbox}
renderStandaloneRegistration={renderStandaloneRegistration}
hasSelectedStoryData={hasSelectedStoryData}
renderStoryViewer={renderStoryViewer}
renderInbox={renderInbox}
theme={theme}
scrollToMessage={scrollToMessage}
viewStory={viewStory}
/>
<MuteUntilDialogProvider i18n={i18n}>
<App
state={state}
isMaximized={isMaximized}
isFullScreen={isFullScreen}
osClassName={osClassName}
renderCallManager={renderCallManager}
renderGlobalModalContainer={renderGlobalModalContainer}
renderInstallScreen={renderInstallScreen}
renderLightbox={renderLightbox}
renderStandaloneRegistration={renderStandaloneRegistration}
hasSelectedStoryData={hasSelectedStoryData}
renderStoryViewer={renderStoryViewer}
renderInbox={renderInbox}
theme={theme}
scrollToMessage={scrollToMessage}
viewStory={viewStory}
/>
</MuteUntilDialogProvider>
</SmartFunProvider>
);
});
+176 -43
View File
@@ -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<MuteOption> => [
{
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' }));
});
});
});
+52 -16
View File
@@ -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<MuteOption> {
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<MuteOption>;
}>;
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,
}),
};
}