diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx
index 52b2c8d6d7..e00de3ff08 100644
--- a/.storybook/preview.tsx
+++ b/.storybook/preview.tsx
@@ -28,6 +28,7 @@ import { Environment, setEnvironment } from '../ts/environment.std.ts';
import { parseUnknown } from '../ts/util/schemas.std.ts';
import { LocaleEmojiListSchema } from '../ts/types/emoji.std.ts';
import { FunProvider } from '../ts/components/fun/FunProvider.dom.tsx';
+import { MuteUntilDialogProvider } from '../ts/components/MuteNotificationsMenu.dom.tsx';
import { MOCK_GIFS_PAGINATED_ONE_PAGE } from '../ts/test-helpers/funPickerMocks.dom.tsx';
import { NavTab } from '../ts/types/Nav.std.ts';
@@ -309,7 +310,16 @@ function withAppProvider(Story, context) {
);
}
+function withMutedUntilDialogProvider(Story, context) {
+ return (
+
+
+
+ );
+}
+
export const decorators = [
+ withMutedUntilDialogProvider,
withAppProvider,
withGlobalTypesProvider,
withMockStoreProvider,
diff --git a/_locales/en/messages.json b/_locales/en/messages.json
index 9d8573545f..4f608b78eb 100644
--- a/_locales/en/messages.json
+++ b/_locales/en/messages.json
@@ -5298,6 +5298,62 @@
"messageformat": "Unmute",
"description": "Label for unmuting the conversation"
},
+ "icu:MuteMenu__label": {
+ "messageformat": "Mute this chat for…",
+ "description": "Header shown at the top of the mute notifications menu when the chat is not currently muted"
+ },
+ "icu:MuteMenu__labelChatFolder": {
+ "messageformat": "Mute these chats for…",
+ "description": "Header shown at the top of the mute notifications menu for a chat folder, which mutes every chat in the folder"
+ },
+ "icu:MuteMenu__hour": {
+ "messageformat": "1 hour",
+ "description": "Label for muting the conversation for one hour"
+ },
+ "icu:MuteMenu__eightHours": {
+ "messageformat": "8 hours",
+ "description": "Label for muting the conversation for eight hours"
+ },
+ "icu:MuteMenu__day": {
+ "messageformat": "1 day",
+ "description": "Label for muting the conversation for one day"
+ },
+ "icu:MuteMenu__week": {
+ "messageformat": "1 week",
+ "description": "Label for muting the conversation for one week"
+ },
+ "icu:MuteMenu__until": {
+ "messageformat": "Until…",
+ "description": "Label for muting the conversation until a date and time the user picks. Opens a dialog."
+ },
+ "icu:MuteMenu__always": {
+ "messageformat": "Always",
+ "description": "Label for muting the conversation forever"
+ },
+ "icu:DatePicker__popupTitle": {
+ "messageformat": "Choose a date",
+ "description": "Title of the popup for choosing a date, only read out by screen readers"
+ },
+ "icu:TimePicker__popupTitle": {
+ "messageformat": "Choose a time",
+ "description": "Title of the popup for choosing a time, only read out by screen readers"
+ },
+ "icu:MuteUntilDialog__title": {
+ "messageformat": "Mute this chat until…",
+ "description": "Title of the dialog where a date and time to mute the conversation until is picked"
+ },
+ "icu:MuteUntilDialog__dateLabel": {
+ "messageformat": "Date",
+ "description": "Accessible label for the date field in the dialog where a date and time to mute the conversation until is picked"
+ },
+ "icu:MuteUntilDialog__timeLabel": {
+ "messageformat": "Time",
+ "description": "Accessible label for the time field in the dialog where a date and time to mute the conversation until is picked"
+ },
+ "icu:MuteUntilDialog__timeZoneNote": {
+ "messageformat": "All times in {timeZone}",
+ "description": "Note below the date and time fields telling the user which time zone the times are in. {timeZone} is a time zone name, like 'Eastern Time'."
+ },
"icu:muteExpirationLabelAlways": {
"messageformat": "Muted always",
"description": "Shown in the mute notifications submenu whenever a conversation has been muted"
diff --git a/ts/components/DatePicker.dom.tsx b/ts/components/DatePicker.dom.tsx
new file mode 100644
index 0000000000..035567ad5b
--- /dev/null
+++ b/ts/components/DatePicker.dom.tsx
@@ -0,0 +1,205 @@
+// Copyright 2026 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import { useState, type JSX } from 'react';
+import {
+ Button,
+ Calendar,
+ CalendarCell,
+ CalendarGrid,
+ DateInput,
+ DatePicker as AriaDatePicker,
+ DateSegment,
+ Group,
+ Heading,
+ Popover,
+ CalendarGridBody,
+ CalendarGridHeader,
+ CalendarHeaderCell,
+} from 'react-aria-components';
+import { Dialog as RadixDialog } from 'radix-ui';
+import type { CalendarDate } from '@internationalized/date';
+import classNames from 'classnames';
+
+import type { LocalizerType } from '../types/Util.std.ts';
+import { AxoSymbol } from '../axo/AxoSymbol.dom.tsx';
+import { tw } from '../axo/tw.dom.tsx';
+
+type AriaLabelPropsType =
+ | { 'aria-label': string; 'aria-labelledby'?: never }
+ | { 'aria-label'?: never; 'aria-labelledby': string };
+
+export type PropsType = Readonly<{
+ i18n: LocalizerType;
+ isDisabled?: boolean;
+ minValue?: CalendarDate;
+ value: CalendarDate | null;
+ onUpdateDate: (value: CalendarDate | null) => void;
+}> &
+ AriaLabelPropsType;
+
+export function DatePicker(props: PropsType): JSX.Element {
+ const { i18n, isDisabled = false, minValue, value, onUpdateDate } = props;
+ const [open, setOpen] = useState(false);
+ return (
+
+
+
+ {segment => (
+
+ )}
+
+
+
+
+ {/**
+ * Radix UI and React Aria both have their own focus scope logic and
+ * scroll locking behavior. We're sorta using Radix UI as a "decorator"
+ * here to inform it of this other portaled element.
+ *
+ * React Aria does not appear to pass through all props the way that
+ * Radix UI wants it to, but it does give Radix UI a ref and merges event
+ * handlers well enough that this just works
+ */}
+
+ modal
+ >
+ {/**
+ * We need to render even though asChild+null prevents it
+ * from creating any element because it contains the logic to break
+ * out of scroll locking.
+ */}
+ {null}
+ with react-aria's
+ asChild
+ // Remove extra radix attributes that don't do anything
+ aria-labelledby={undefined}
+ data-state={undefined}
+ >
+
+
+ {i18n('icu:DatePicker__popupTitle')}
+
+
+
+
+
+
+
+