Files
Desktop/ts/components/PreferencesNotificationProfiles.dom.tsx
T

1723 lines
49 KiB
TypeScript

// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import {
useState,
useRef,
useCallback,
useEffect,
useId,
useMemo,
} from 'react';
import type { MutableRefObject, JSX, ReactNode } from 'react';
import { sample, isEqual, noop } from 'lodash';
import classNames from 'classnames';
import { FunStaticEmoji } from './fun/FunEmoji.dom.tsx';
import { FunEmojiPicker } from './fun/FunEmojiPicker.dom.tsx';
import { FunEmojiPickerButton } from './fun/FunButton.dom.tsx';
import { tw } from '../axo/tw.dom.tsx';
import { AxoButton } from '../axo/AxoButton.dom.tsx';
import { AxoSelect } from '../axo/AxoSelect.dom.tsx';
import { AxoSwitch } from '../axo/AxoSwitch.dom.tsx';
import { AxoSymbol } from '../axo/AxoSymbol.dom.tsx';
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 } from '../types/NotificationProfile.std.ts';
import { Avatar } from './Avatar.dom.tsx';
import { missingCaseError } from '../util/missingCaseError.std.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 type { LocalizerType } from '../types/I18N.std.ts';
import type { ThemeType } from '../types/Util.std.ts';
import type { ConversationType } from '../state/ducks/conversations.preload.ts';
import type { GetConversationByIdType } from '../state/selectors/conversations.dom.ts';
import type { PreferredBadgeSelectorType } from '../state/selectors/badges.preload.ts';
import type {
NotificationProfileIdString,
NotificationProfileType,
ScheduleDays,
} from '../types/NotificationProfile.std.ts';
import type { SettingsLocation } from '../types/Nav.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',
Allowed = 'Allowed',
Schedule = 'Schedule',
Done = 'Done',
}
enum HomePage {
List = 'List',
Edit = 'Edit',
Name = 'Name',
Schedule = 'Schedule',
}
const DEFAULT_ALLOW_CALLS = true;
const DEFAULT_ALLOW_MENTIONS = false;
const NINE_AM = 900;
const FIVE_PM = 1700;
const DEFAULT_ENABLED = false;
const DEFAULT_START = NINE_AM;
const DEFAULT_END = FIVE_PM;
const WEEKDAY_SCHEDULE: ScheduleDays = {
[DayOfWeek.MONDAY]: true,
[DayOfWeek.TUESDAY]: true,
[DayOfWeek.WEDNESDAY]: true,
[DayOfWeek.THURSDAY]: true,
[DayOfWeek.FRIDAY]: true,
[DayOfWeek.SATURDAY]: false,
[DayOfWeek.SUNDAY]: false,
};
const WEEKEND_SCHEDULE: ScheduleDays = {
[DayOfWeek.MONDAY]: false,
[DayOfWeek.TUESDAY]: false,
[DayOfWeek.WEDNESDAY]: false,
[DayOfWeek.THURSDAY]: false,
[DayOfWeek.FRIDAY]: false,
[DayOfWeek.SATURDAY]: true,
[DayOfWeek.SUNDAY]: true,
};
const DAILY_SCHEDULE: ScheduleDays = {
[DayOfWeek.MONDAY]: true,
[DayOfWeek.TUESDAY]: true,
[DayOfWeek.WEDNESDAY]: true,
[DayOfWeek.THURSDAY]: true,
[DayOfWeek.FRIDAY]: true,
[DayOfWeek.SATURDAY]: true,
[DayOfWeek.SUNDAY]: true,
};
const DEFAULT_SCHEDULE = WEEKDAY_SCHEDULE;
type CreateFlowProps = {
contentsRef: MutableRefObject<HTMLDivElement | null>;
conversations: ReadonlyArray<ConversationType>;
conversationSelector: GetConversationByIdType;
createProfile: (profile: Omit<NotificationProfileType, 'id'>) => void;
i18n: LocalizerType;
setSettingsLocation: (location: SettingsLocation) => void;
preferredBadgeSelector: PreferredBadgeSelectorType;
theme: ThemeType;
};
type HomeProps = {
activeProfileId: NotificationProfileIdString | undefined;
allProfiles: ReadonlyArray<NotificationProfileType>;
contentsRef: MutableRefObject<HTMLDivElement | null>;
conversations: ReadonlyArray<ConversationType>;
conversationSelector: GetConversationByIdType;
hasOnboardingBeenSeen: boolean;
i18n: LocalizerType;
isSyncEnabled: boolean;
loading: boolean;
markProfileDeleted: (id: string) => void;
preferredBadgeSelector: PreferredBadgeSelectorType;
setHasOnboardingBeenSeen: (value: boolean) => void;
setIsSyncEnabled: (value: boolean) => void;
setSettingsLocation: (location: SettingsLocation) => void;
setProfileOverride: (
id: NotificationProfileIdString,
enabled: boolean
) => void;
theme: ThemeType;
updateProfile: (profile: NotificationProfileType) => void;
};
const ARGB_BITS = 0xff000000;
const A100_BACKGROUND_ARGB = 0xffe3e3fe;
function getRandomColor(): number {
// oxlint-disable-next-line typescript/no-non-null-assertion
const colorName = sample(AvatarColors) || AvatarColors[0]!;
const color = AvatarColorMap.get(colorName);
if (!color) {
return A100_BACKGROUND_ARGB; // A100, background, with bits for ARGB
}
const rgb = parseInt(color.bg.slice(1), 16);
const argb = rgb + ARGB_BITS;
return argb;
}
function getColorFromProfile(argb: number): string {
const rgb = argb - ARGB_BITS;
return `#${rgb.toString(16)}`;
}
type ProfileToSave = Omit<NotificationProfileType, 'id'>;
export function NotificationProfilesCreateFlow({
contentsRef,
conversations,
conversationSelector,
createProfile,
i18n,
preferredBadgeSelector,
setSettingsLocation,
theme,
}: CreateFlowProps): JSX.Element {
const [page, setPage] = useState(CreateFlowPage.Name);
const [name, setName] = useState<string | undefined>();
const [emoji, setEmoji] = useState<Emoji.Variant | undefined>();
const [allowedMembers, setAllowedMembers] = useState<ReadonlySet<string>>(
new Set<string>()
);
const [allowAllCalls, setAllowAllCalls] = useState(DEFAULT_ALLOW_CALLS);
const [allowAllMentions, setAllowAllMentions] = useState(
DEFAULT_ALLOW_MENTIONS
);
const [isEnabled, setIsEnabled] = useState<boolean>(DEFAULT_ENABLED);
const [scheduleDays, setScheduledDays] =
useState<ScheduleDays>(DEFAULT_SCHEDULE);
const [startTime, setStartTime] = useState<number>(DEFAULT_START);
const [endTime, setEndTime] = useState<number>(DEFAULT_END);
const [color] = useState<number>(getRandomColor());
const tryClose = useRef<(() => void) | null>(null);
const [confirmDiscardModal, confirmDiscardIf] = useConfirmDiscard({
i18n,
name: 'NotificationProfilesCreateFlow',
tryClose,
// @ts-expect-error ConfirmationDialog migration: Needs title
title: null,
// @ts-expect-error ConfirmationDialog migration: Needs description
description: null,
});
const onTryClose = useCallback(() => {
const isDirty =
page !== CreateFlowPage.Done && (Boolean(name) || Boolean(emoji));
const discardChanges = noop;
confirmDiscardIf(isDirty, discardChanges);
}, [confirmDiscardIf, emoji, name, page]);
// oxlint-disable-next-line react/refs
tryClose.current = onTryClose;
function makeNotificationProfile(): ProfileToSave {
return {
name: name || '',
emoji,
color,
// oxlint-disable-next-line react/purity
createdAtMs: Date.now(),
allowAllCalls,
allowAllMentions,
allowedMembers,
scheduleEnabled: isEnabled,
scheduleStartTime: startTime,
scheduleEndTime: endTime,
scheduleDaysEnabled: scheduleDays,
deletedAtTimestampMs: undefined,
storageNeedsSync: true,
};
}
const goToNotificationsProfilesHome = useCallback(() => {
setSettingsLocation({ page: SettingsPage.NotificationProfilesHome });
}, [setSettingsLocation]);
function getPageContents() {
switch (page) {
case CreateFlowPage.Name:
return (
<NotificationProfilesNamePage
contentsRef={contentsRef}
i18n={i18n}
initialName={name}
initialEmoji={emoji}
isEditing={false}
onBack={goToNotificationsProfilesHome}
onNext={() => {
setPage(CreateFlowPage.Allowed);
}}
onUpdate={({ name: newName, emoji: newEmoji }) => {
setEmoji(newEmoji);
setName(newName);
}}
theme={theme}
/>
);
case CreateFlowPage.Allowed:
return (
<NotificationProfilesAllowedPage
allowedMembers={Array.from(allowedMembers)}
allowAllCalls={allowAllCalls}
contentsRef={contentsRef}
conversations={conversations}
conversationSelector={conversationSelector}
i18n={i18n}
allowAllMentions={allowAllMentions}
onBack={() => setPage(CreateFlowPage.Name)}
onNext={() => setPage(CreateFlowPage.Schedule)}
onSetAllowedMembers={(members: ReadonlyArray<string>) =>
setAllowedMembers(new Set(members))
}
onSetAllowAllCalls={(value: boolean) => setAllowAllCalls(value)}
onSetAllowAllMentions={() =>
setAllowAllMentions(
existingallowAllMentions => !existingallowAllMentions
)
}
preferredBadgeSelector={preferredBadgeSelector}
theme={theme}
/>
);
case CreateFlowPage.Schedule:
return (
<NotificationProfilesSchedulePage
isEnabled={isEnabled}
scheduleDays={scheduleDays}
startTime={startTime}
endTime={endTime}
contentsRef={contentsRef}
i18n={i18n}
isEditing={false}
onBack={() => setPage(CreateFlowPage.Allowed)}
onNext={() => {
const profile = makeNotificationProfile();
createProfile(profile);
setPage(CreateFlowPage.Done);
}}
onSetIsEnabled={(value: boolean) => setIsEnabled(value)}
onSetScheduleDays={(schedule: ScheduleDays) =>
setScheduledDays(schedule)
}
onSetStartTime={(value: number) => setStartTime(value)}
onSetEndTime={(value: number) => setEndTime(value)}
theme={theme}
/>
);
case CreateFlowPage.Done:
return (
<NotificationProfilesDonePage
profile={makeNotificationProfile()}
contentsRef={contentsRef}
i18n={i18n}
onNext={goToNotificationsProfilesHome}
/>
);
default:
throw missingCaseError(page);
}
}
return (
<div className={tw('relative flex grow flex-col')}>
{confirmDiscardModal}
{getPageContents()}
</div>
);
}
export function NotificationProfilesHome({
activeProfileId,
allProfiles,
contentsRef,
conversations,
conversationSelector,
hasOnboardingBeenSeen,
i18n,
isSyncEnabled,
loading,
markProfileDeleted,
preferredBadgeSelector,
setHasOnboardingBeenSeen,
setIsSyncEnabled,
setSettingsLocation,
setProfileOverride,
theme,
updateProfile,
}: HomeProps): JSX.Element {
const [page, setPage] = useState(HomePage.List);
const [profile, setProfile] = useState<NotificationProfileType | undefined>();
const [isShowingOnboardModal, setIsShowingOnboardModal] = useState(false);
const goBackToNotifications = useCallback(() => {
setSettingsLocation({ page: SettingsPage.Notifications });
}, [setSettingsLocation]);
const goToNotificationsProfilesCreateFlow = useCallback(() => {
setSettingsLocation({ page: SettingsPage.NotificationProfilesCreateFlow });
}, [setSettingsLocation]);
useEffect(() => {
if (page === HomePage.List && !hasOnboardingBeenSeen) {
if (allProfiles.length === 0) {
// oxlint-disable-next-line react/set-state-in-effect
setIsShowingOnboardModal(true);
} else {
setHasOnboardingBeenSeen(true);
}
}
if (
profile &&
(page === HomePage.Name ||
page === HomePage.Schedule ||
page === HomePage.Edit)
) {
const newProfile = allProfiles.find(item => item.id === profile.id);
if (newProfile) {
setProfile(newProfile);
} else {
setProfile(undefined);
setPage(HomePage.List);
}
}
}, [
allProfiles,
hasOnboardingBeenSeen,
page,
profile,
setHasOnboardingBeenSeen,
setPage,
setProfile,
]);
function getPageContents() {
switch (page) {
case HomePage.List:
return (
<NotificationProfilesListPage
allProfiles={allProfiles}
contentsRef={contentsRef}
i18n={i18n}
isSyncEnabled={isSyncEnabled}
loading={loading}
onCreateProfile={goToNotificationsProfilesCreateFlow}
onEditProfile={(profileToEdit: NotificationProfileType) => {
setProfile(profileToEdit);
setPage(HomePage.Edit);
}}
onBack={goBackToNotifications}
setIsSyncEnabled={setIsSyncEnabled}
/>
);
case HomePage.Name:
strictAssert(profile, 'HomePage.Name: Need a profile to edit!');
return (
<NotificationProfilesNamePage
contentsRef={contentsRef}
i18n={i18n}
initialName={profile.name}
initialEmoji={profile?.emoji}
isEditing
onBack={() => setPage(HomePage.Edit)}
onNext={() => {
setPage(HomePage.Edit);
}}
onUpdate={({ emoji, name }) => {
const newProfile = {
...profile,
emoji,
name,
};
updateProfile(newProfile);
setProfile(newProfile);
}}
theme={theme}
/>
);
case HomePage.Schedule:
strictAssert(profile, 'HomePage.Schedule: Need a profile to edit!');
return (
<NotificationProfilesSchedulePage
isEnabled={profile.scheduleEnabled}
scheduleDays={profile.scheduleDaysEnabled ?? DEFAULT_SCHEDULE}
startTime={profile.scheduleStartTime ?? DEFAULT_START}
endTime={profile.scheduleEndTime ?? DEFAULT_END}
contentsRef={contentsRef}
i18n={i18n}
isEditing
onBack={() => setPage(HomePage.Edit)}
onNext={() => setPage(HomePage.Edit)}
onSetIsEnabled={(scheduleEnabled: boolean) => {
const newProfile = {
...profile,
scheduleEnabled,
};
updateProfile(newProfile);
setProfile(newProfile);
}}
onSetScheduleDays={(scheduleDaysEnabled: ScheduleDays) => {
const newProfile = {
...profile,
scheduleDaysEnabled,
};
updateProfile(newProfile);
setProfile(newProfile);
}}
onSetStartTime={(scheduleStartTime: number) => {
const newProfile = {
...profile,
scheduleStartTime,
};
updateProfile(newProfile);
setProfile(newProfile);
}}
onSetEndTime={(scheduleEndTime: number) => {
const newProfile = {
...profile,
scheduleEndTime,
};
updateProfile(newProfile);
setProfile(newProfile);
}}
theme={theme}
/>
);
case HomePage.Edit:
strictAssert(profile, 'HomePage.Edit: Need a profile to edit!');
return (
<NotificationProfilesEditPage
activeProfileId={activeProfileId}
profile={profile}
contentsRef={contentsRef}
conversations={conversations}
conversationSelector={conversationSelector}
i18n={i18n}
onBack={() => setPage(HomePage.List)}
onDeleteProfile={() => {
markProfileDeleted(profile.id);
setPage(HomePage.List);
}}
onEditName={() => setPage(HomePage.Name)}
onEditProfile={newProfile => {
setProfile(newProfile);
updateProfile(newProfile);
}}
onEditSchedule={() => setPage(HomePage.Schedule)}
onUpdateOverrideState={value =>
setProfileOverride(profile.id, value)
}
preferredBadgeSelector={preferredBadgeSelector}
theme={theme}
/>
);
default:
throw missingCaseError(page);
}
}
return (
<div className={tw('relative flex grow flex-col')}>
{isShowingOnboardModal ? (
<NotificationProfilesOnboardingDialog
i18n={i18n}
onDismiss={() => {
setHasOnboardingBeenSeen(true);
setIsShowingOnboardModal(false);
}}
/>
) : null}
{getPageContents()}
</div>
);
}
function NotificationProfilesNamePage({
contentsRef,
i18n,
initialEmoji,
initialName,
isEditing,
onBack,
onNext,
onUpdate,
theme,
}: {
contentsRef: MutableRefObject<HTMLDivElement | null>;
i18n: LocalizerType;
initialEmoji: Emoji.Variant | undefined;
initialName?: string;
isEditing: boolean;
onBack: VoidFunction;
onNext: () => void;
onUpdate: (data: { emoji: Emoji.Variant | undefined; name: string }) => void;
theme: ThemeType;
}) {
const [emojiPickerOpen, setEmojiPickerOpen] = useState(false);
const [name, setName] = useState(initialName);
const [emoji, setEmoji] = useState<Emoji.Variant | undefined>(initialEmoji);
const isValid = Boolean(name);
const sampleProfileNames = useMemo(() => {
return [
{
emoji: Emoji.getDefaultVariant(Emoji.MUSCLE),
text: i18n('icu:NotificationProfiles--sample-name__work'),
},
{
emoji: Emoji.SLEEPING,
text: i18n('icu:NotificationProfiles--sample-name__sleep'),
},
{
emoji: Emoji.CAR,
text: i18n('icu:NotificationProfiles--sample-name__driving'),
},
{
emoji: Emoji.BLUSH,
text: i18n('icu:NotificationProfiles--sample-name__downtime'),
},
{
emoji: Emoji.BULB,
text: i18n('icu:NotificationProfiles--sample-name__focus'),
},
] as const;
}, [i18n]);
const handleFunEmojiPickerOpenChange = useCallback((open: boolean) => {
setEmojiPickerOpen(open);
}, []);
const handleInputChange = useCallback(
(newName: string) => {
setName(newName);
if (newName === '') {
setEmoji(undefined);
} else {
onUpdate({ name: newName, emoji });
}
},
[emoji, setEmoji, setName, onUpdate]
);
return (
<>
<Header
onBack={onBack}
title={
isEditing
? i18n('icu:NotificationProfiles--name-title--editing')
: undefined
}
i18n={i18n}
/>
<Container contentsRef={contentsRef}>
{!isEditing ? (
<Title title={i18n('icu:NotificationProfiles--name-title')} />
) : undefined}
<div className={tw('mt-9 w-full grow')}>
<Input
expandable
hasClearButton
i18n={i18n}
icon={
<FunEmojiPicker
open={emojiPickerOpen}
onOpenChange={handleFunEmojiPickerOpenChange}
placement="bottom"
onSelectEmoji={data => {
const newEmoji = data.emoji;
setEmoji(newEmoji);
if (name) {
onUpdate({ name, emoji: newEmoji });
}
}}
closeOnSelect
theme={theme}
>
<FunEmojiPickerButton i18n={i18n} selectedEmoji={emoji} />
</FunEmojiPicker>
}
maxLengthCount={140}
maxByteCount={512}
moduleClassName="NotificationProfiles__NamePage"
onChange={handleInputChange}
ref={undefined}
placeholder={i18n('icu:NotificationProfiles--name-placeholder')}
value={name}
whenToShowRemainingCount={40}
/>
<div className={tw('mx-auto w-full max-w-[320px]')}>
{sampleProfileNames.map(item => {
return (
<FullWidthButton
key={item.text}
className={tw('ms-[-4px] min-h-[52px] gap-4 ps-4 pe-3')}
onClick={() => {
const newName = item.text;
const newEmoji = item.emoji;
setName(newName);
setEmoji(newEmoji);
onUpdate({ emoji: newEmoji, name: newName });
}}
>
<FunStaticEmoji
role="img"
aria-label={Emoji.getDisplayLabel(item.emoji)}
size={24}
emoji={item.emoji}
/>
{item.text}
</FullWidthButton>
);
})}
</div>
</div>
</Container>
<ButtonContainer>
<AxoButton.Root
variant="strong-primary"
size="lg"
disabled={!isValid}
onClick={onNext}
>
{isEditing ? i18n('icu:done') : i18n('icu:next2')}
</AxoButton.Root>
</ButtonContainer>
</>
);
}
function NotificationProfilesAllowedPage({
allowAllCalls,
allowedMembers,
contentsRef,
conversations,
conversationSelector,
i18n,
allowAllMentions,
onBack,
onNext,
onSetAllowedMembers,
onSetAllowAllCalls,
onSetAllowAllMentions,
preferredBadgeSelector,
theme,
}: {
allowAllCalls: boolean;
allowedMembers: ReadonlyArray<string>;
contentsRef: MutableRefObject<HTMLDivElement | null>;
conversations: ReadonlyArray<ConversationType>;
conversationSelector: GetConversationByIdType;
i18n: LocalizerType;
allowAllMentions: boolean;
onBack: VoidFunction;
onNext: VoidFunction;
onSetAllowedMembers: (members: ReadonlyArray<string>) => void;
onSetAllowAllCalls: (value: boolean) => void;
onSetAllowAllMentions: (value: boolean) => void;
preferredBadgeSelector: PreferredBadgeSelectorType;
theme: ThemeType;
}) {
return (
<>
<Header onBack={onBack} i18n={i18n} />
<Container contentsRef={contentsRef}>
<Title title={i18n('icu:NotificationProfiles--allowed-title')} />
<p className={tw('mt-4 mb-13 max-w-[335px] text-center leading-5')}>
{i18n('icu:NotificationProfiles--allowed-description')}
</p>
<AllowedMembersSection
allowedMembers={allowedMembers}
conversations={conversations}
conversationSelector={conversationSelector}
i18n={i18n}
onSetAllowedMembers={onSetAllowedMembers}
preferredBadgeSelector={preferredBadgeSelector}
theme={theme}
title={i18n('icu:NotificationProfiles--allowed-title')}
/>
<ExceptionsSection
allowAllCalls={allowAllCalls}
allowAllMentions={allowAllMentions}
i18n={i18n}
onSetAllowAllCalls={onSetAllowAllCalls}
onSetAllowAllMentions={onSetAllowAllMentions}
/>
</Container>
<ButtonContainer>
<AxoButton.Root variant="strong-primary" size="lg" onClick={onNext}>
{i18n('icu:next2')}
</AxoButton.Root>
</ButtonContainer>
</>
);
}
function NotificationProfilesSchedulePage({
isEnabled,
scheduleDays,
startTime,
endTime,
contentsRef,
i18n,
isEditing,
onBack,
onNext,
onSetIsEnabled,
onSetScheduleDays,
onSetStartTime,
onSetEndTime,
theme,
}: {
isEnabled: boolean;
scheduleDays: ScheduleDays;
startTime: number;
endTime: number;
contentsRef: MutableRefObject<HTMLDivElement | null>;
i18n: LocalizerType;
isEditing: boolean;
onBack: () => void;
onNext: () => void;
onSetIsEnabled: (value: boolean) => void;
onSetScheduleDays: (value: ScheduleDays) => void;
onSetStartTime: (value: number) => void;
onSetEndTime: (value: number) => void;
theme: ThemeType;
}) {
const startLabelId = useId();
const endLabelId = useId();
const daysInUIOrder = useMemo(() => {
return [
{
dayOfWeek: DayOfWeek.SUNDAY,
label: i18n('icu:NotificationProfiles--schedule-sunday'),
},
{
dayOfWeek: DayOfWeek.MONDAY,
label: i18n('icu:NotificationProfiles--schedule-monday'),
},
{
dayOfWeek: DayOfWeek.TUESDAY,
label: i18n('icu:NotificationProfiles--schedule-tuesday'),
},
{
dayOfWeek: DayOfWeek.WEDNESDAY,
label: i18n('icu:NotificationProfiles--schedule-wednesday'),
},
{
dayOfWeek: DayOfWeek.THURSDAY,
label: i18n('icu:NotificationProfiles--schedule-thursday'),
},
{
dayOfWeek: DayOfWeek.FRIDAY,
label: i18n('icu:NotificationProfiles--schedule-friday'),
},
{
dayOfWeek: DayOfWeek.SATURDAY,
label: i18n('icu:NotificationProfiles--schedule-saturday'),
},
];
}, [i18n]);
return (
<>
<Header
onBack={onBack}
title={
isEditing
? i18n('icu:NotificationProfiles--schedule-title--editing')
: undefined
}
i18n={i18n}
/>
<Container contentsRef={contentsRef}>
{!isEditing && (
<>
<Title title={i18n('icu:NotificationProfiles--schedule-title')} />
<FullWidthRow>
<p
className={tw(
'mx-auto mt-2 mb-4 max-w-[335px] text-center leading-5'
)}
>
{i18n('icu:NotificationProfiles--schedule-description')}
</p>
</FullWidthRow>
</>
)}
<FullWidthRow
className={tw('mt-4 flex min-h-[40px] w-full items-center py-2')}
>
<div className={tw('grow type-body-large')}>
{i18n('icu:NotificationProfiles--schedule-enable')}
</div>
<div className={tw('ms-4')}>
<AxoSwitch.Root
checked={isEnabled}
onCheckedChange={onSetIsEnabled}
/>
</div>
</FullWidthRow>
<FullWidthRow className={tw('mt-3 min-h-[40px] w-full pt-3 pb-2')}>
<h2 className={tw('type-title-small')}>
{i18n('icu:NotificationProfiles--schedule')}
</h2>
</FullWidthRow>
<FullWidthRow className={tw('flex min-h-[40px] items-center')}>
<span id={startLabelId} className={tw('grow')}>
{i18n('icu:NotificationProfiles--schedule-from')}
</span>
<span className={tw('shrink-0')}>
<TimePicker
i18n={i18n}
isDisabled={!isEnabled}
aria-labelledby={startLabelId}
onUpdateTime={onSetStartTime}
theme={theme}
time={startTime}
/>
</span>
</FullWidthRow>
<FullWidthRow className={tw('flex min-h-[40px] items-center')}>
<span id={endLabelId} className={tw('grow')}>
{i18n('icu:NotificationProfiles--schedule-until')}
</span>
<span className={tw('shrink-0')}>
<TimePicker
i18n={i18n}
isDisabled={!isEnabled}
aria-labelledby={endLabelId}
onUpdateTime={onSetEndTime}
theme={theme}
time={endTime}
/>
</span>
</FullWidthRow>
<FullWidthRow className={tw('mt-3')}>
{daysInUIOrder.map(day => {
return (
<DayCheckbox
key={day.label}
label={day.label}
dayOfWeek={day.dayOfWeek}
isEnabled={isEnabled}
scheduleDays={scheduleDays}
onSetScheduleDays={onSetScheduleDays}
/>
);
})}
</FullWidthRow>
</Container>
<ButtonContainer>
<AxoButton.Root variant="strong-primary" size="lg" onClick={onNext}>
{isEditing ? i18n('icu:done') : i18n('icu:next2')}
</AxoButton.Root>
</ButtonContainer>
</>
);
}
function NotificationProfilesDonePage({
contentsRef,
i18n,
onNext,
profile,
}: {
contentsRef: MutableRefObject<HTMLDivElement | null>;
i18n: LocalizerType;
onNext: () => void;
profile: ProfileToSave;
}): JSX.Element {
return (
<>
<Header i18n={i18n} />
<MidFloatingContainer contentsRef={contentsRef}>
<div className={tw('mb-4')}>
<ProfileAvatar i18n={i18n} profile={profile} size="large" />
</div>
<Title title={i18n('icu:NotificationProfiles--done-title')} />
<p className={tw('mt-4 mb-6 max-w-[350px] text-center leading-5')}>
{i18n('icu:NotificationProfiles--done-description')}
</p>
<AxoButton.Root variant="strong-primary" size="lg" onClick={onNext}>
{i18n('icu:done')}
</AxoButton.Root>
</MidFloatingContainer>
</>
);
}
function NotificationProfilesListPage({
allProfiles,
contentsRef,
i18n,
isSyncEnabled,
loading,
onBack,
onCreateProfile,
onEditProfile,
setIsSyncEnabled,
}: {
allProfiles: ReadonlyArray<NotificationProfileType>;
contentsRef: MutableRefObject<HTMLDivElement | null>;
i18n: LocalizerType;
isSyncEnabled: boolean;
loading: boolean;
onBack: () => void;
onCreateProfile: () => void;
onEditProfile: (profileToEdit: NotificationProfileType) => void;
setIsSyncEnabled: (value: boolean) => void;
}) {
const [cachedProfiles, setCachedProfiles] = useState<
ReadonlyArray<NotificationProfileType>
>([]);
useEffect(() => {
if (!loading) {
// oxlint-disable-next-line react/set-state-in-effect
setCachedProfiles(allProfiles);
}
}, [loading, allProfiles]);
const profilesToRender = loading ? cachedProfiles : allProfiles;
return (
<>
<Header
onBack={onBack}
title={i18n('icu:NotificationProfiles--title')}
i18n={i18n}
/>
<Container contentsRef={contentsRef}>
<FullWidthRow className={tw('mt-3 min-h-[40px] pt-3')}>
<h2 className={tw('type-title-small')}>
{i18n('icu:NotificationProfiles--list--header')}
</h2>
</FullWidthRow>
<FullWidthButton
className={tw('min-h-[52px]')}
onClick={() => onCreateProfile()}
>
<PlusIconInCircle />
<span>{i18n('icu:NotificationProfiles--create')}</span>
</FullWidthButton>
{profilesToRender.map(profile => {
return (
<FullWidthButton
key={profile.id}
className={tw('min-h-[52px]')}
onClick={() => onEditProfile(profile)}
testId={`EditProfile--${profile.name}`}
>
<ProfileAvatar i18n={i18n} profile={profile} size="medium" />
<span className={tw('ms-4')}>{profile.name}</span>
</FullWidthButton>
);
})}
<FullWidthDivider />
<FullWidthRow className={tw('flex min-h-[40px] items-start pt-1')}>
<div className={tw('grow')}>
<div className={tw('type-body-large text-primary')}>
{i18n('icu:NotificationProfiles--list--sync')}
</div>
<div className={tw('mt-1 type-body-small text-secondary')}>
{i18n('icu:NotificationProfiles--list--sync--description')}
</div>
</div>
<div className={tw('ms-4')}>
<AxoSwitch.Root
checked={isSyncEnabled}
onCheckedChange={value => {
setIsSyncEnabled(value);
}}
/>
</div>
</FullWidthRow>
</Container>
</>
);
}
function NotificationProfilesEditPage({
activeProfileId,
contentsRef,
conversations,
conversationSelector,
i18n,
onBack,
onDeleteProfile,
onEditName,
onEditSchedule,
onEditProfile,
onUpdateOverrideState,
preferredBadgeSelector,
profile,
theme,
}: {
activeProfileId: NotificationProfileIdString | undefined;
contentsRef: MutableRefObject<HTMLDivElement | null>;
conversations: ReadonlyArray<ConversationType>;
conversationSelector: GetConversationByIdType;
i18n: LocalizerType;
onBack: () => void;
onDeleteProfile: () => void;
onEditName: () => void;
onEditSchedule: () => void;
onEditProfile: (profile: NotificationProfileType) => void;
onUpdateOverrideState: (value: boolean) => void;
preferredBadgeSelector: PreferredBadgeSelectorType;
profile: NotificationProfileType;
theme: ThemeType;
}) {
const [isConfirmingDelete, setIsConfirmingDelete] = useState(false);
const activeString = i18n('icu:NotificationProfiles--edit--is-active');
const notActiveString = i18n('icu:NotificationProfiles--edit--is-not-active');
const isProfileActive = activeProfileId === profile.id;
const currentActiveString = isProfileActive ? activeString : notActiveString;
const allowedMembersArray = useMemo(() => {
return Array.from(profile.allowedMembers);
}, [profile.allowedMembers]);
return (
<>
<AxoConfirmDialog.Root
open={isConfirmingDelete}
onOpenChange={setIsConfirmingDelete}
// @ts-expect-error ConfirmationDialog migration: Needs title
title={null}
description={i18n('icu:NotificationProfiles--delete-confirmation')}
>
<AxoConfirmDialog.Cancel />
<AxoConfirmDialog.Action
variant="strong-destructive"
onClick={onDeleteProfile}
>
{i18n('icu:NotificationProfiles--delete-button')}
</AxoConfirmDialog.Action>
</AxoConfirmDialog.Root>
<Header onBack={onBack} title={profile.name} i18n={i18n} />
<Container contentsRef={contentsRef}>
<AriaClickable.Root
className={tw(
'group mb-3 flex min-h-[80px] w-full items-center rounded-md border-[2.5px] border-transparent px-[11.5px] outline-none keyboard-mode:data-focused:axo-focus-ring'
)}
>
<ProfileAvatar i18n={i18n} profile={profile} size="medium" />
<span className={tw('ms-3 text-start')}>{profile.name}</span>
<span
id="edit-icon"
className={tw(
'ms-2 opacity-0 group-hover:opacity-100 group-data-focused:opacity-100'
)}
>
<AxoSymbol.Icon
size={20}
symbol="pencil"
label={i18n('icu:NotificationProfiles--edit--edit-name-label')}
/>
<AriaClickable.HiddenTrigger
onClick={onEditName}
labelledby="edit-icon"
/>
</span>
<span className={tw('grow')} />
<span>
<AriaClickable.SubWidget>
<AxoSelect.Root
value={currentActiveString}
onValueChange={stringValue => {
const value = stringValue === activeString;
onUpdateOverrideState(value);
}}
>
<AxoSelect.Trigger placeholder="" />
<AxoSelect.Content>
<AxoSelect.Item value={activeString}>
<AxoSelect.ItemText>{activeString}</AxoSelect.ItemText>
</AxoSelect.Item>
<AxoSelect.Item value={notActiveString}>
<AxoSelect.ItemText>{notActiveString}</AxoSelect.ItemText>
</AxoSelect.Item>
</AxoSelect.Content>
</AxoSelect.Root>
</AriaClickable.SubWidget>
</span>
</AriaClickable.Root>
<AllowedMembersSection
allowedMembers={allowedMembersArray}
conversations={conversations}
conversationSelector={conversationSelector}
i18n={i18n}
onSetAllowedMembers={allowedMembers => {
onEditProfile({
...profile,
allowedMembers: new Set(allowedMembers),
});
}}
preferredBadgeSelector={preferredBadgeSelector}
theme={theme}
title={i18n('icu:NotificationProfiles--edit--allowed')}
/>
<FullWidthRow className={tw('mt-8 mb-2')}>
<h2 className={tw('type-title-small')}>
{i18n('icu:NotificationProfiles--schedule')}
</h2>
</FullWidthRow>
<FullWidthButton
testId="EditSchedule"
onClick={onEditSchedule}
className={tw('min-h-[55px] py-2')}
>
<div className={tw('grow text-start')}>
<div>
{i18n('icu:NotificationProfiles--edit--schedule-timing', {
startTime: formatTimeForDisplay(
profile.scheduleStartTime ?? DEFAULT_START
),
endTime: formatTimeForDisplay(
profile.scheduleEndTime ?? DEFAULT_END
),
})}
</div>
<div className={tw('mt-0.5 type-body-small text-secondary')}>
<ScheduleSummary
i18n={i18n}
scheduleDays={profile.scheduleDaysEnabled ?? DEFAULT_SCHEDULE}
/>
</div>
</div>
<div className={tw('ms-5')}>
{profile.scheduleEnabled
? i18n('icu:NotificationProfiles--edit--schedule-enabled')
: i18n('icu:NotificationProfiles--edit--schedule-disabled')}
</div>
</FullWidthButton>
<ExceptionsSection
allowAllCalls={profile.allowAllCalls}
allowAllMentions={profile.allowAllMentions}
i18n={i18n}
onSetAllowAllCalls={allowAllCalls => {
onEditProfile({ ...profile, allowAllCalls });
}}
onSetAllowAllMentions={allowAllMentions => {
onEditProfile({ ...profile, allowAllMentions });
}}
/>
<FullWidthButton
className={tw('mt-6 min-h-[52px]')}
onClick={() => setIsConfirmingDelete(true)}
>
<div className={tw('me-4 text-destructive')}>
<AxoSymbol.Icon size={24} symbol="trash" label={null} />
</div>
<span className={tw('grow text-start text-destructive')}>
{i18n('icu:NotificationProfiles--delete')}
</span>
</FullWidthButton>
</Container>
<ButtonContainer>
<AxoButton.Root variant="strong-primary" size="lg" onClick={onBack}>
{i18n('icu:done')}
</AxoButton.Root>
</ButtonContainer>
</>
);
}
// Utility components
function FullWidthButton({
children,
className,
onClick,
testId,
}: {
children: ReactNode;
className?: string;
onClick: () => void;
testId?: string;
}): JSX.Element {
return (
<button
className={classNames(
tw(
'flex w-full items-center rounded-md border-[2.5px] border-transparent px-[11.5px] outline-none keyboard-mode:focus:axo-focus-ring'
),
className
)}
data-testid={testId}
onClick={onClick}
type="button"
>
{children}
</button>
);
}
function FullWidthRow({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<div className={classNames(tw('w-full px-[14px]'), className)}>
{children}
</div>
);
}
function FullWidthDivider() {
return (
<div className={tw('my-3 w-full px-[14px]')}>
<hr
aria-orientation="horizontal"
className={tw('border-t-[0.5px] border-primary')}
/>
</div>
);
}
function Header({
onBack,
title,
i18n,
}: {
onBack?: VoidFunction;
title?: string;
i18n: LocalizerType;
}) {
return (
<div className="Preferences__title">
{onBack ? (
<button
aria-label={i18n('icu:goBack')}
className="Preferences__back-icon"
onClick={onBack}
type="button"
/>
) : null}
{title ? <div className="Preferences__title--header">{title}</div> : null}
</div>
);
}
function Container({
children,
contentsRef,
}: {
children: ReactNode;
contentsRef: MutableRefObject<HTMLDivElement | null>;
}) {
return (
<div className={tw('relative flex grow overflow-y-scroll')}>
<div className={tw('grow')} />
<div
ref={contentsRef}
className={tw(
'flex w-full max-w-[798px] grow flex-col items-center px-[10px]'
)}
>
{children}
</div>
<div className={tw('grow')} />
</div>
);
}
function Title({ title }: { title: string }) {
return <h1 className={tw('type-title-medium')}>{title}</h1>;
}
function ButtonContainer({ children }: { children: ReactNode }) {
return (
<div
className={tw(
'mx-auto flex w-full max-w-[798px] justify-end p-6 pe-[33px]'
)}
>
{children}
</div>
);
}
function MidFloatingContainer({
children,
contentsRef,
}: {
children: ReactNode;
contentsRef: MutableRefObject<HTMLDivElement | null>;
}) {
return (
<div className={tw('relative h-full grow')}>
<div
className={tw(
'absolute top-4/10 flex w-full transform-[translateY(-40%)] flex-col items-center px-4'
)}
ref={contentsRef}
>
{children}
</div>
</div>
);
}
function DayCheckbox({
label,
dayOfWeek,
isEnabled,
scheduleDays,
onSetScheduleDays,
}: {
label: string;
dayOfWeek: DayOfWeek;
isEnabled: boolean;
scheduleDays: ScheduleDays;
onSetScheduleDays: (value: ScheduleDays) => void;
}) {
return (
<div className={tw('py-[3px]')}>
<Checkbox
label={label}
disabled={!isEnabled}
checked={scheduleDays[dayOfWeek]}
name={`dayEnabled-${dayOfWeek}`}
onChange={value => {
onSetScheduleDays({
...scheduleDays,
[dayOfWeek]: value,
});
}}
/>
</div>
);
}
type IconSize = 'large' | 'medium' | 'medium-small' | 'small';
function EmojiOrMoon({
emoji,
forceLightTheme,
i18n,
size,
}: {
emoji?: Emoji.Variant | undefined;
forceLightTheme?: boolean;
i18n: LocalizerType;
size: IconSize;
}) {
const sizeMap = useMemo(
() => ({
large: 48 as const,
medium: 20 as const,
'medium-small': 16 as const,
small: 12 as const,
}),
[]
);
if (!emoji) {
return (
<div
className={tw(
'absolute inset-s-1/2 top-1/2 -translate-1/2 text-accent'
)}
style={
forceLightTheme
? {
colorScheme: 'light',
}
: {}
}
>
<AxoSymbol.Icon
label={i18n('icu:NotificationProfile--moon-icon')}
size={sizeMap[size]}
symbol="moon-fill"
/>
</div>
);
}
return (
<span
className={tw('absolute inset-s-1/2 top-1/2 -translate-1/2 leading-0')}
>
<FunStaticEmoji
role="img"
aria-label={Emoji.getDisplayLabel(emoji)}
size={sizeMap[size]}
emoji={emoji}
/>
</span>
);
}
function PlusIconInCircle() {
return (
<div
className={tw(
'me-3 flex size-[36px] items-center justify-center rounded-full bg-surface-secondary'
)}
>
<AxoSymbol.Icon size={20} symbol="plus" label={null} />
</div>
);
}
function AllowedMembersSection({
allowedMembers,
conversations,
conversationSelector,
i18n,
onSetAllowedMembers,
preferredBadgeSelector,
theme,
title,
}: {
allowedMembers: ReadonlyArray<string>;
conversations: ReadonlyArray<ConversationType>;
conversationSelector: GetConversationByIdType;
i18n: LocalizerType;
onSetAllowedMembers: (members: ReadonlyArray<string>) => void;
preferredBadgeSelector: PreferredBadgeSelectorType;
theme: ThemeType;
title: string;
}) {
const [showingMemberChooser, setShowingMemberChooser] = useState(false);
return (
<>
{showingMemberChooser ? (
<PreferencesSelectChatsDialog
i18n={i18n}
title={i18n('icu:NotificationProfiles--allowed-title')}
conversations={conversations}
conversationSelector={conversationSelector}
onClose={({ selectedRecipientIds }) => {
onSetAllowedMembers(selectedRecipientIds);
setShowingMemberChooser(false);
}}
preferredBadgeSelector={preferredBadgeSelector}
theme={theme}
initialSelection={{
selectedRecipientIds: Array.from(allowedMembers),
selectAllIndividualChats: false,
selectAllGroupChats: false,
}}
showChatTypes={false}
/>
) : null}
<FullWidthRow>
<h2 className={tw('mb-1 type-title-small')}>{title}</h2>
</FullWidthRow>
<FullWidthButton
onClick={() => setShowingMemberChooser(true)}
className={tw('min-h-[52px]')}
>
<PlusIconInCircle />
<span>{i18n('icu:NotificationProfiles--allowed-add-label')}</span>
</FullWidthButton>
{allowedMembers.map(member => {
const conversation = conversationSelector(member);
const badge = preferredBadgeSelector(conversation.badges);
return (
<FullWidthButton
key={conversation.id}
onClick={() => setShowingMemberChooser(true)}
className={tw('min-h-[52px]')}
>
<div
className={tw(
'me-3 flex size-[36px] items-center justify-center rounded-full bg-surface-secondary'
)}
>
<Avatar
{...conversation}
badge={badge}
conversationType={conversation.type}
i18n={i18n}
size={36}
theme={theme}
/>
</div>
<span>{conversation.title}</span>
</FullWidthButton>
);
})}
</>
);
}
function ExceptionsSection({
allowAllCalls,
allowAllMentions,
i18n,
onSetAllowAllMentions,
onSetAllowAllCalls,
}: {
allowAllCalls: boolean;
allowAllMentions: boolean;
i18n: LocalizerType;
onSetAllowAllMentions: (value: boolean) => void;
onSetAllowAllCalls: (value: boolean) => void;
}) {
return (
<>
<FullWidthRow className={tw('mt-8 mb-1')}>
<h2 className={tw('type-title-small')}>
{i18n('icu:NotificationProfiles--exceptions')}
</h2>
</FullWidthRow>
<FullWidthRow className={tw('flex min-h-[40px] items-center')}>
<div className={tw('grow type-body-large')}>
{i18n('icu:NotificationProfiles--exceptions--allow-all-calls')}
</div>
<div className={tw('ms-4')}>
<AxoSwitch.Root
checked={allowAllCalls}
onCheckedChange={onSetAllowAllCalls}
/>
</div>
</FullWidthRow>
<FullWidthRow className={tw('flex min-h-[40px] items-center')}>
<div className={tw('grow type-body-large')}>
{i18n('icu:NotificationProfiles--exceptions--notify-for-mentions')}
</div>
<div className={tw('ms-4')}>
<AxoSwitch.Root
checked={allowAllMentions}
onCheckedChange={onSetAllowAllMentions}
/>
</div>
</FullWidthRow>
</>
);
}
export function ProfileAvatar({
i18n,
isActive,
profile,
size,
}: {
i18n: LocalizerType;
isActive?: boolean;
profile?: ProfileToSave;
size: IconSize;
}): ReactNode {
const backgroundColor = profile?.color
? getColorFromProfile(profile.color)
: undefined;
const forceLightTheme = profile && !profile.emoji;
const sizeMap = useMemo(
() => ({
large: tw('size-[80px]'),
medium: tw('size-[36px]'),
'medium-small': tw('size-[28px]'),
small: tw('size-[20px]'),
}),
[]
);
const sizeClass = sizeMap[size];
return (
<div
className={classNames(
tw('relative rounded-full'),
sizeClass,
isActive ? tw('border-2 border-selected') : undefined,
!backgroundColor ? tw('bg-accent-tint') : undefined
)}
style={{ backgroundColor }}
>
<EmojiOrMoon
emoji={profile?.emoji}
forceLightTheme={forceLightTheme}
i18n={i18n}
size={size}
/>
</div>
);
}
function ScheduleSummary({
i18n,
scheduleDays,
}: {
i18n: LocalizerType;
scheduleDays: ScheduleDays;
}): string {
const daysInUIOrder = useMemo(() => {
return [
{
dayOfWeek: DayOfWeek.SUNDAY,
label: i18n('icu:NotificationProfiles--schedule-sunday-short'),
},
{
dayOfWeek: DayOfWeek.MONDAY,
label: i18n('icu:NotificationProfiles--schedule-monday-short'),
},
{
dayOfWeek: DayOfWeek.TUESDAY,
label: i18n('icu:NotificationProfiles--schedule-tuesday-short'),
},
{
dayOfWeek: DayOfWeek.WEDNESDAY,
label: i18n('icu:NotificationProfiles--schedule-wednesday-short'),
},
{
dayOfWeek: DayOfWeek.THURSDAY,
label: i18n('icu:NotificationProfiles--schedule-thursday-short'),
},
{
dayOfWeek: DayOfWeek.FRIDAY,
label: i18n('icu:NotificationProfiles--schedule-friday-short'),
},
{
dayOfWeek: DayOfWeek.SATURDAY,
label: i18n('icu:NotificationProfiles--schedule-saturday-short'),
},
];
}, [i18n]);
if (isEqual(scheduleDays, DEFAULT_SCHEDULE)) {
return i18n('icu:NotificationProfiles--schedule-weekdays');
}
if (isEqual(scheduleDays, WEEKEND_SCHEDULE)) {
return i18n('icu:NotificationProfiles--schedule-weekends');
}
if (isEqual(scheduleDays, DAILY_SCHEDULE)) {
return i18n('icu:NotificationProfiles--schedule-daily');
}
let result = '';
daysInUIOrder.forEach(day => {
if (!scheduleDays[day.dayOfWeek]) {
return;
}
if (result) {
result += i18n('icu:NotificationProfiles--schedule-separator');
}
result += day.label;
});
return result;
}