mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2025-12-20 02:08:57 +00:00
2138 lines
62 KiB
TypeScript
2138 lines
62 KiB
TypeScript
// Copyright 2025 Signal Messenger, LLC
|
||
// SPDX-License-Identifier: AGPL-3.0-only
|
||
|
||
import React from 'react';
|
||
import type { MutableRefObject } from 'react';
|
||
import { DateInput, DateSegment, TimeField } from 'react-aria-components';
|
||
import { Time } from '@internationalized/date';
|
||
import { sample, isEqual, noop, range } from 'lodash';
|
||
import classNames from 'classnames';
|
||
import { Popper } from 'react-popper';
|
||
|
||
import {
|
||
isEmojiVariantValue,
|
||
getEmojiVariantByKey,
|
||
getEmojiVariantKeyByValue,
|
||
} from './fun/data/emojis.std.js';
|
||
import { FunStaticEmoji } from './fun/FunEmoji.dom.js';
|
||
import { FunEmojiPicker } from './fun/FunEmojiPicker.dom.js';
|
||
import { useFunEmojiLocalizer } from './fun/useFunEmojiLocalizer.dom.js';
|
||
import { FunEmojiPickerButton } from './fun/FunButton.dom.js';
|
||
import { tw } from '../axo/tw.dom.js';
|
||
import { AxoButton } from '../axo/AxoButton.dom.js';
|
||
import { AxoSelect } from '../axo/AxoSelect.dom.js';
|
||
import { AxoSwitch } from '../axo/AxoSwitch.dom.js';
|
||
import { AxoSymbol } from '../axo/AxoSymbol.dom.js';
|
||
import { Input } from './Input.dom.js';
|
||
import { Checkbox } from './Checkbox.dom.js';
|
||
import { AvatarColorMap, AvatarColors } from '../types/Colors.std.js';
|
||
import { PreferencesSelectChatsDialog } from './preferences/PreferencesSelectChatsDialog.dom.js';
|
||
import {
|
||
DayOfWeek,
|
||
getMidnight,
|
||
scheduleToTime,
|
||
} from '../types/NotificationProfile.std.js';
|
||
import { Avatar } from './Avatar.dom.js';
|
||
import { missingCaseError } from '../util/missingCaseError.std.js';
|
||
import { formatTimestamp } from '../util/formatTimestamp.dom.js';
|
||
import { strictAssert } from '../util/assert.std.js';
|
||
import { ConfirmationDialog } from './ConfirmationDialog.dom.js';
|
||
import { SettingsPage } from '../types/Nav.std.js';
|
||
import { useConfirmDiscard } from '../hooks/useConfirmDiscard.dom.js';
|
||
import { AriaClickable } from '../axo/AriaClickable.dom.js';
|
||
import { offsetDistanceModifier } from '../util/popperUtil.std.js';
|
||
import { themeClassName2 } from '../util/theme.std.js';
|
||
import { useRefMerger } from '../hooks/useRefMerger.std.js';
|
||
import { handleOutsideClick } from '../util/handleOutsideClick.dom.js';
|
||
import { useEscapeHandling } from '../hooks/useEscapeHandling.dom.js';
|
||
import { Modal } from './Modal.dom.js';
|
||
|
||
import type { EmojiVariantKey } from './fun/data/emojis.std.js';
|
||
import type { LocalizerType } from '../types/I18N.std.js';
|
||
import type { ThemeType } from '../types/Util.std.js';
|
||
import type { ConversationType } from '../state/ducks/conversations.preload.js';
|
||
import type { GetConversationByIdType } from '../state/selectors/conversations.dom.js';
|
||
import type { PreferredBadgeSelectorType } from '../state/selectors/badges.preload.js';
|
||
import type {
|
||
NotificationProfileIdString,
|
||
NotificationProfileType,
|
||
ScheduleDays,
|
||
} from '../types/NotificationProfile.std.js';
|
||
import type { SettingsLocation } from '../types/Nav.std.js';
|
||
import { addLeadingZero } from '../util/timestamp.std.js';
|
||
|
||
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;
|
||
};
|
||
|
||
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;
|
||
|
||
function getRandomColor(): number {
|
||
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;
|
||
}
|
||
|
||
export function getColorFromProfile(argb: number): string {
|
||
const rgb = argb - ARGB_BITS;
|
||
return `#${rgb.toString(16)}`;
|
||
}
|
||
|
||
function getEmojiVariantKey(value: string): EmojiVariantKey | undefined {
|
||
if (isEmojiVariantValue(value)) {
|
||
return getEmojiVariantKeyByValue(value);
|
||
}
|
||
|
||
return undefined;
|
||
}
|
||
|
||
type ProfileToSave = Omit<NotificationProfileType, 'id'>;
|
||
|
||
export function NotificationProfilesCreateFlow({
|
||
contentsRef,
|
||
conversations,
|
||
conversationSelector,
|
||
createProfile,
|
||
i18n,
|
||
preferredBadgeSelector,
|
||
setSettingsLocation,
|
||
theme,
|
||
}: CreateFlowProps): JSX.Element {
|
||
const [page, setPage] = React.useState(CreateFlowPage.Name);
|
||
|
||
const [name, setName] = React.useState<string | undefined>();
|
||
const [emoji, setEmoji] = React.useState<string | undefined>();
|
||
const [allowedMembers, setAllowedMembers] = React.useState<
|
||
ReadonlySet<string>
|
||
>(new Set<string>());
|
||
const [allowAllCalls, setAllowAllCalls] = React.useState(DEFAULT_ALLOW_CALLS);
|
||
const [allowAllMentions, setAllowAllMentions] = React.useState(
|
||
DEFAULT_ALLOW_MENTIONS
|
||
);
|
||
const [isEnabled, setIsEnabled] = React.useState<boolean>(DEFAULT_ENABLED);
|
||
const [scheduleDays, setScheduledDays] =
|
||
React.useState<ScheduleDays>(DEFAULT_SCHEDULE);
|
||
const [startTime, setStartTime] = React.useState<number>(DEFAULT_START);
|
||
const [endTime, setEndTime] = React.useState<number>(DEFAULT_END);
|
||
const [color] = React.useState<number>(getRandomColor());
|
||
|
||
const tryClose = React.useRef<() => void | undefined>();
|
||
const [confirmDiscardModal, confirmDiscardIf] = useConfirmDiscard({
|
||
i18n,
|
||
name: 'NotificationProfilesCreateFlow',
|
||
tryClose,
|
||
});
|
||
|
||
const onTryClose = React.useCallback(() => {
|
||
const isDirty =
|
||
page !== CreateFlowPage.Done && (Boolean(name) || Boolean(emoji));
|
||
const discardChanges = noop;
|
||
|
||
confirmDiscardIf(isDirty, discardChanges);
|
||
}, [confirmDiscardIf, emoji, name, page]);
|
||
tryClose.current = onTryClose;
|
||
|
||
function makeNotificationProfile(): ProfileToSave {
|
||
return {
|
||
name: name || '',
|
||
emoji,
|
||
|
||
color,
|
||
|
||
createdAtMs: Date.now(),
|
||
|
||
allowAllCalls,
|
||
allowAllMentions,
|
||
|
||
allowedMembers,
|
||
scheduleEnabled: isEnabled,
|
||
|
||
scheduleStartTime: startTime,
|
||
scheduleEndTime: endTime,
|
||
scheduleDaysEnabled: scheduleDays,
|
||
|
||
deletedAtTimestampMs: undefined,
|
||
storageNeedsSync: true,
|
||
};
|
||
}
|
||
|
||
const goToNotificationsProfilesHome = React.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] = React.useState(HomePage.List);
|
||
const [profile, setProfile] = React.useState<
|
||
NotificationProfileType | undefined
|
||
>();
|
||
const [isShowingOnboardModal, setIsShowingOnboardModal] =
|
||
React.useState(false);
|
||
|
||
const goBackToNotifications = React.useCallback(() => {
|
||
setSettingsLocation({ page: SettingsPage.Notifications });
|
||
}, [setSettingsLocation]);
|
||
const goToNotificationsProfilesCreateFlow = React.useCallback(() => {
|
||
setSettingsLocation({ page: SettingsPage.NotificationProfilesCreateFlow });
|
||
}, [setSettingsLocation]);
|
||
|
||
React.useEffect(() => {
|
||
if (page === HomePage.List && !hasOnboardingBeenSeen) {
|
||
if (allProfiles.length === 0) {
|
||
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)} // TODO: probably don't show Next button?
|
||
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 NotificationProfilesOnboardingDialog({
|
||
i18n,
|
||
onDismiss,
|
||
}: {
|
||
i18n: LocalizerType;
|
||
onDismiss: VoidFunction;
|
||
}) {
|
||
return (
|
||
<Modal
|
||
modalName="NotificationProfilesOnboarding"
|
||
onClose={onDismiss}
|
||
i18n={i18n}
|
||
>
|
||
<div className={tw('flex flex-col items-center')}>
|
||
<div className={tw('mt-4 mb-3')}>
|
||
<ProfileAvatar i18n={i18n} size="large" />
|
||
</div>
|
||
<Title title={i18n('icu:NotificationProfiles--title')} />
|
||
<p className={tw('mt-4 mb-12 max-w-[340px] text-center leading-5')}>
|
||
{i18n('icu:NotificationProfiles--setup-description')}
|
||
</p>
|
||
<AxoButton.Root variant="primary" onClick={onDismiss} size="lg">
|
||
{i18n('icu:NotificationProfiles--setup-continue')}
|
||
</AxoButton.Root>
|
||
</div>
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
function NotificationProfilesNamePage({
|
||
contentsRef,
|
||
i18n,
|
||
initialEmoji,
|
||
initialName,
|
||
isEditing,
|
||
onBack,
|
||
onNext,
|
||
onUpdate,
|
||
theme,
|
||
}: {
|
||
contentsRef: MutableRefObject<HTMLDivElement | null>;
|
||
i18n: LocalizerType;
|
||
initialEmoji: string | undefined;
|
||
initialName?: string;
|
||
isEditing: boolean;
|
||
onBack: VoidFunction;
|
||
onNext: () => void;
|
||
onUpdate: (data: { emoji: string | undefined; name: string }) => void;
|
||
theme: ThemeType;
|
||
}) {
|
||
const [emojiPickerOpen, setEmojiPickerOpen] = React.useState(false);
|
||
const [name, setName] = React.useState(initialName);
|
||
const [emoji, setEmoji] = React.useState<string | undefined>(initialEmoji);
|
||
const emojiLocalizer = useFunEmojiLocalizer();
|
||
|
||
const isValid = Boolean(name);
|
||
const sampleProfileNames = React.useMemo(() => {
|
||
return [
|
||
{
|
||
emoji: '💪',
|
||
text: i18n('icu:NotificationProfiles--sample-name__work'),
|
||
},
|
||
{
|
||
emoji: '😴',
|
||
text: i18n('icu:NotificationProfiles--sample-name__sleep'),
|
||
},
|
||
{
|
||
emoji: '🚗',
|
||
text: i18n('icu:NotificationProfiles--sample-name__driving'),
|
||
},
|
||
{
|
||
emoji: '😊',
|
||
text: i18n('icu:NotificationProfiles--sample-name__downtime'),
|
||
},
|
||
{
|
||
emoji: '💡',
|
||
text: i18n('icu:NotificationProfiles--sample-name__focus'),
|
||
},
|
||
] as const;
|
||
}, [i18n]);
|
||
|
||
const handleFunEmojiPickerOpenChange = React.useCallback((open: boolean) => {
|
||
setEmojiPickerOpen(open);
|
||
}, []);
|
||
|
||
const handleInputChange = React.useCallback(
|
||
(newName: string) => {
|
||
setName(newName);
|
||
|
||
if (newName === '') {
|
||
setEmoji(undefined);
|
||
} else {
|
||
onUpdate({ name: newName, emoji });
|
||
}
|
||
},
|
||
[emoji, setEmoji, setName, onUpdate]
|
||
);
|
||
|
||
const emojiKey = emoji ? getEmojiVariantKey(emoji) : null;
|
||
|
||
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 = getEmojiVariantByKey(data.variantKey)?.value;
|
||
|
||
setEmoji(newEmoji);
|
||
if (name) {
|
||
onUpdate({ name, emoji: newEmoji });
|
||
}
|
||
}}
|
||
closeOnSelect
|
||
theme={theme}
|
||
>
|
||
<FunEmojiPickerButton i18n={i18n} selectedEmoji={emojiKey} />
|
||
</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 => {
|
||
const itemEmojiKey = getEmojiVariantKey(item.emoji);
|
||
strictAssert(
|
||
itemEmojiKey,
|
||
'Emoji for name defaults should exist'
|
||
);
|
||
const itemEmojiData = getEmojiVariantByKey(itemEmojiKey);
|
||
|
||
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={emojiLocalizer.getLocaleShortName(
|
||
itemEmojiData.key
|
||
)}
|
||
size={24}
|
||
emoji={itemEmojiData}
|
||
/>
|
||
{item.text}
|
||
</FullWidthButton>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</Container>
|
||
<ButtonContainer>
|
||
<AxoButton.Root
|
||
variant="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="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 daysInUIOrder = React.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="start-label" className={tw('grow')}>
|
||
{i18n('icu:NotificationProfiles--schedule-from')}
|
||
</span>
|
||
<span className={tw('shrink-0')}>
|
||
<TimePicker
|
||
i18n={i18n}
|
||
isDisabled={!isEnabled}
|
||
labelId="start-label"
|
||
onUpdateTime={onSetStartTime}
|
||
theme={theme}
|
||
time={startTime}
|
||
/>
|
||
</span>
|
||
</FullWidthRow>
|
||
<FullWidthRow className={tw('flex min-h-[40px] items-center')}>
|
||
<span id="end-label" className={tw('grow')}>
|
||
{i18n('icu:NotificationProfiles--schedule-until')}
|
||
</span>
|
||
<span className={tw('shrink-0')}>
|
||
<TimePicker
|
||
i18n={i18n}
|
||
isDisabled={!isEnabled}
|
||
labelId="end-label"
|
||
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="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="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] = React.useState<
|
||
ReadonlyArray<NotificationProfileType>
|
||
>([]);
|
||
React.useEffect(() => {
|
||
if (!loading) {
|
||
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-label-primary')}>
|
||
{i18n('icu:NotificationProfiles--list--sync')}
|
||
</div>
|
||
<div className={tw('mt-1 type-body-small text-label-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] = React.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 = React.useMemo(() => {
|
||
return Array.from(profile.allowedMembers);
|
||
}, [profile.allowedMembers]);
|
||
|
||
return (
|
||
<>
|
||
{isConfirmingDelete ? (
|
||
<ConfirmationDialog
|
||
dialogName="NotificationProfileDelete"
|
||
actions={[
|
||
{
|
||
action: onDeleteProfile,
|
||
text: i18n('icu:NotificationProfiles--delete-button'),
|
||
style: 'affirmative',
|
||
},
|
||
]}
|
||
i18n={i18n}
|
||
onClose={() => setIsConfirmingDelete(false)}
|
||
>
|
||
{i18n('icu:NotificationProfiles--delete-confirmation')}
|
||
</ConfirmationDialog>
|
||
) : null}
|
||
<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 data-[focused]:border-color-label-light'
|
||
)}
|
||
>
|
||
<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}
|
||
aria-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={currentActiveString}>
|
||
{currentActiveString}
|
||
</AxoSelect.Trigger>
|
||
<AxoSelect.Content>
|
||
<AxoSelect.Item
|
||
key="isActive"
|
||
value={activeString}
|
||
textValue={activeString}
|
||
>
|
||
<AxoSelect.ItemText>{activeString}</AxoSelect.ItemText>
|
||
</AxoSelect.Item>
|
||
<AxoSelect.Item
|
||
key="isNotActive"
|
||
value={notActiveString}
|
||
textValue={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-label-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-color-label-destructive')}>
|
||
<AxoSymbol.Icon size={24} symbol="trash" label={null} />
|
||
</div>
|
||
<span className={tw('grow text-start text-color-label-destructive')}>
|
||
{i18n('icu:NotificationProfiles--delete')}
|
||
</span>
|
||
</FullWidthButton>
|
||
</Container>
|
||
<ButtonContainer>
|
||
<AxoButton.Root variant="primary" size="lg" onClick={onBack}>
|
||
{i18n('icu:done')}
|
||
</AxoButton.Root>
|
||
</ButtonContainer>
|
||
</>
|
||
);
|
||
}
|
||
|
||
// Utility components
|
||
|
||
export function FullWidthButton({
|
||
children,
|
||
className,
|
||
onClick,
|
||
testId,
|
||
}: {
|
||
children: React.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 focus-visible:border-color-label-light'
|
||
),
|
||
className
|
||
)}
|
||
data-testid={testId}
|
||
onClick={onClick}
|
||
type="button"
|
||
>
|
||
{children}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function FullWidthRow({
|
||
children,
|
||
className,
|
||
}: {
|
||
children: React.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-label-secondary')}
|
||
/>
|
||
</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: React.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: React.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: React.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?: EmojiVariantKey | undefined;
|
||
forceLightTheme?: boolean;
|
||
i18n: LocalizerType;
|
||
size: IconSize;
|
||
}) {
|
||
const emojiLocalizer = useFunEmojiLocalizer();
|
||
const sizeMap = React.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 start-1/2 top-1/2 -translate-1/2 text-color-label-primary'
|
||
)}
|
||
style={
|
||
forceLightTheme
|
||
? {
|
||
colorScheme: 'light',
|
||
}
|
||
: {}
|
||
}
|
||
>
|
||
<AxoSymbol.Icon
|
||
label={i18n('icu:NotificationProfile--moon-icon')}
|
||
size={sizeMap[size]}
|
||
symbol="moon-fill"
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const emojiData = getEmojiVariantByKey(emoji);
|
||
|
||
return (
|
||
<span className={tw('absolute start-1/2 top-1/2 -translate-1/2 leading-0')}>
|
||
<FunStaticEmoji
|
||
role="img"
|
||
aria-label={emojiLocalizer.getLocaleShortName(emojiData.key)}
|
||
size={sizeMap[size]}
|
||
emoji={emojiData}
|
||
/>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function PlusIconInCircle() {
|
||
return (
|
||
<div
|
||
className={tw(
|
||
'me-3 flex size-[36px] items-center justify-center rounded-full bg-background-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] = React.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-background-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;
|
||
}): React.ReactNode {
|
||
const emoji = profile?.emoji ? getEmojiVariantKey(profile.emoji) : undefined;
|
||
const backgroundColor = profile?.color
|
||
? getColorFromProfile(profile.color)
|
||
: undefined;
|
||
const forceLightTheme = profile && !profile.emoji;
|
||
|
||
const sizeMap = React.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-border-selected') : undefined,
|
||
!backgroundColor ? tw('bg-color-label-light-disabled') : undefined
|
||
)}
|
||
style={{ backgroundColor }}
|
||
>
|
||
<EmojiOrMoon
|
||
emoji={emoji}
|
||
forceLightTheme={forceLightTheme}
|
||
i18n={i18n}
|
||
size={size}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ScheduleSummary({
|
||
i18n,
|
||
scheduleDays,
|
||
}: {
|
||
i18n: LocalizerType;
|
||
scheduleDays: ScheduleDays;
|
||
}): string {
|
||
const daysInUIOrder = React.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;
|
||
}
|
||
|
||
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] = React.useState(false);
|
||
const use24HourTime = need24HourTime();
|
||
const AM_PM: Array<PERIOD> = ['AM', 'PM'];
|
||
const periodLookup = React.useMemo(() => {
|
||
return {
|
||
AM: i18n('icu:NotificationProfile--am'),
|
||
PM: i18n('icu:NotificationProfile--pm'),
|
||
};
|
||
}, [i18n]);
|
||
const [timeFieldElement, setTimeFieldElement] = React.useState<
|
||
HTMLDivElement | undefined
|
||
>();
|
||
const [popupElement, setPopupElement] = React.useState<
|
||
HTMLDivElement | undefined
|
||
>();
|
||
const { minutes, hours, period } = getTimeDetails(time, use24HourTime);
|
||
const refMerger = useRefMerger();
|
||
const selectedHour = React.useRef<HTMLButtonElement | null>(null);
|
||
const selectedMinute = React.useRef<HTMLButtonElement | null>(null);
|
||
|
||
React.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]);
|
||
|
||
React.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-background-secondary p-1 shadow-elevation-1'
|
||
),
|
||
use24HourTime ? tw('w-[102px]') : tw('w-[150px]'),
|
||
theme ? themeClassName2(theme) : undefined
|
||
)}
|
||
>
|
||
<div
|
||
className={tw(
|
||
'w-[46px] overflow-y-scroll scrollbar-width-none'
|
||
)}
|
||
>
|
||
{(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 focus:border-border-focused'
|
||
),
|
||
isSelected ? tw('bg-fill-secondary') : null
|
||
)}
|
||
type="button"
|
||
onClick={() => {
|
||
const newTime = makeTime(hour, minutes, period);
|
||
onUpdateTime(newTime);
|
||
}}
|
||
>
|
||
{hour}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
<div
|
||
className={tw(
|
||
'ms-0.5 w-[46px] overflow-y-scroll scrollbar-width-none'
|
||
)}
|
||
>
|
||
{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 focus:border-border-focused'
|
||
),
|
||
isSelected ? tw('bg-fill-secondary') : 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] overflow-y-scroll scrollbar-width-none'
|
||
)}
|
||
>
|
||
{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 focus:border-border-focused'
|
||
),
|
||
isSelected ? tw('bg-fill-secondary') : 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-fill-secondary px-2 py-0.5 focus-within:border-border-focused'
|
||
)}
|
||
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
|
||
if (segment.type === 'literal' && segment.text === ' ') {
|
||
return <span />;
|
||
}
|
||
if (segment.type === 'literal') {
|
||
// eslint-disable-next-line no-param-reassign
|
||
segment.text = i18n('icu:NotificationProfile--time-separator');
|
||
}
|
||
return (
|
||
<DateSegment
|
||
className={classNames(
|
||
tw(
|
||
'inline-block px-[1px] type-body-medium outline-none focus:bg-fill-selected'
|
||
),
|
||
segment.type === 'literal' ? tw('px-[3px]') : null,
|
||
segment.type === 'dayPeriod' ? tw('ps-[2px]') : null,
|
||
segment.type === 'hour' ? tw('flex-grow text-end') : null,
|
||
isDisabled ? tw('text-label-placeholder') : null
|
||
)}
|
||
segment={segment}
|
||
/>
|
||
);
|
||
}}
|
||
</DateInput>
|
||
<button
|
||
className={classNames(
|
||
tw('ms-3 p-0.5 outline-0 focus-visible:bg-fill-selected'),
|
||
isDisabled ? tw('text-label-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>
|
||
</>
|
||
);
|
||
}
|