mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2026-05-19 22:29:04 +01:00
2148 lines
62 KiB
TypeScript
2148 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.ts';
|
|
import { FunStaticEmoji } from './fun/FunEmoji.dom.tsx';
|
|
import { FunEmojiPicker } from './fun/FunEmojiPicker.dom.tsx';
|
|
import { useFunEmojiLocalizer } from './fun/useFunEmojiLocalizer.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,
|
|
getMidnight,
|
|
scheduleToTime,
|
|
} from '../types/NotificationProfile.std.ts';
|
|
import { Avatar } from './Avatar.dom.tsx';
|
|
import { missingCaseError } from '../util/missingCaseError.std.ts';
|
|
import { formatTimestamp } from '../util/formatTimestamp.dom.ts';
|
|
import { strictAssert } from '../util/assert.std.ts';
|
|
import { ConfirmationDialog } from './ConfirmationDialog.dom.tsx';
|
|
import { SettingsPage } from '../types/Nav.std.ts';
|
|
import { useConfirmDiscard } from '../hooks/useConfirmDiscard.dom.tsx';
|
|
import { AriaClickable } from '../axo/AriaClickable.dom.tsx';
|
|
import { offsetDistanceModifier } from '../util/popperUtil.std.ts';
|
|
import { themeClassName2 } from '../util/theme.std.ts';
|
|
import { useRefMerger } from '../hooks/useRefMerger.std.ts';
|
|
import { handleOutsideClick } from '../util/handleOutsideClick.dom.ts';
|
|
import { useEscapeHandling } from '../hooks/useEscapeHandling.dom.ts';
|
|
import { Modal } from './Modal.dom.tsx';
|
|
|
|
import type { EmojiVariantKey } from './fun/data/emojis.std.ts';
|
|
import type { LocalizerType } from '../types/I18N.std.ts';
|
|
import type { ThemeType } from '../types/Util.std.ts';
|
|
import type { ConversationType } from '../state/ducks/conversations.preload.ts';
|
|
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 { addLeadingZero } from '../util/timestamp.std.ts';
|
|
|
|
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 {
|
|
// 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)}`;
|
|
}
|
|
|
|
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): React.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) | null>(null);
|
|
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): React.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)}
|
|
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;
|
|
}): React.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;
|
|
}): React.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 inset-s-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 inset-s-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
|
|
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/formatToParts#using_formattoparts
|
|
// https://github.com/adobe/react-spectrum/blob/36fdd8bca2df281fa955117d946e6dd9718241e4/packages/react-stately/src/datepicker/useDateFieldState.ts#L443-L470
|
|
if (
|
|
segment.type === 'literal' &&
|
|
(segment.text === ' ' ||
|
|
segment.text === '\u2066' ||
|
|
segment.text === '\u2069')
|
|
) {
|
|
return <span />;
|
|
}
|
|
if (segment.type === 'literal') {
|
|
// oxlint-disable-next-line no-param-reassign
|
|
segment.text = i18n('icu:NotificationProfile--time-separator');
|
|
}
|
|
return (
|
|
<DateSegment
|
|
className={classNames(
|
|
tw(
|
|
'inline-block px-px type-body-medium outline-none focus:bg-fill-selected'
|
|
),
|
|
segment.type === 'literal' ? tw('px-[3px]') : null,
|
|
segment.type === 'dayPeriod' ? tw('ps-[2px]') : null,
|
|
segment.type === 'hour' ? tw('grow text-end') : null,
|
|
isDisabled ? tw('text-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>
|
|
</>
|
|
);
|
|
}
|