mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2026-08-28 00:46:33 +01:00
Updated conversation mute options
Co-authored-by: jamiebuilds-signal <jamie@signal.org>
This commit is contained in:
co-authored by
jamiebuilds-signal
parent
b8c94cbe37
commit
94fa10ed52
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
-88
@@ -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>
|
||||
);
|
||||
}
|
||||
+17
-14
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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' }));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user