// SPDX-License-Identifier: AGPL-3.0-or-later import * as Modal from '@app/features/app/components/dialogs/Modal'; import { DEFAULT_TIME_WINDOW_KEY, getTimeWindowKeyForExpiresAt, getTimeWindowPresets, TIME_WINDOW_LABEL_MESSAGES, type TimeWindowKey, type TimeWindowPreset, } from '@app/features/app/config/TimeWindowPresets'; import {useShouldAnimate} from '@app/features/app/hooks/useShouldAnimate'; import DeveloperMode from '@app/features/devtools/state/DeveloperMode'; import Emoji from '@app/features/emoji/state/Emoji'; import type {FlatEmoji} from '@app/features/emoji/types/EmojiTypes'; import {ExpressionPickerPopout} from '@app/features/expressions/components/popouts/ExpressionPickerPopout'; import {buildCustomEmojiURL} from '@app/features/expressions/utils/CustomEmojiImageUrl'; import {getEmojiURL} from '@app/features/expressions/utils/EmojiUtils'; import {getSkinTonedSurrogate} from '@app/features/expressions/utils/SkinToneUtils'; import {Button} from '@app/features/ui/button/Button'; import {CharacterCountAnnouncer} from '@app/features/ui/character_counter/CharacterCountAnnouncer'; import * as ModalCommands from '@app/features/ui/commands/ModalCommands'; import {Combobox, type ComboboxOption} from '@app/features/ui/components/form/FormCombobox'; import {Input} from '@app/features/ui/components/form/FormInput'; import FocusRing from '@app/features/ui/focus_ring/FocusRing'; import {Popout} from '@app/features/ui/popover/PopoverPopout'; import {getRelativeDayLabelLower} from '@app/features/ui/utils/RelativeDayLabels'; import * as UserSettingsCommands from '@app/features/user/commands/UserSettingsCommands'; import styles from '@app/features/user/components/modals/CustomStatusModal.module.css'; import {ProfilePreview} from '@app/features/user/components/profile/ProfilePreview'; import {type CustomStatus, normalizeCustomStatus} from '@app/features/user/state/CustomStatus'; import UserSettings from '@app/features/user/state/UserSettings'; import Users from '@app/features/user/state/Users'; import {handleAccountLimitedError} from '@app/features/user/utils/AccountLimitUtils'; import {getCurrentLocale} from '@app/features/user/utils/LocaleUtils'; import {getDaysBetween} from '@fluxer/date_utils/src/DateComparison'; import {getFormattedShortDate, getFormattedTime} from '@fluxer/date_utils/src/DateFormatting'; import type {I18n} from '@lingui/core'; import {msg} from '@lingui/core/macro'; import {Trans, useLingui} from '@lingui/react/macro'; import {SmileyIcon, XIcon} from '@phosphor-icons/react'; import clsx from 'clsx'; import {observer} from 'mobx-react-lite'; import type React from 'react'; import {useCallback, useMemo, useRef, useState} from 'react'; const SET_YOUR_STATUS_DESCRIPTOR = msg({ message: 'Set your status', comment: 'Short label in the custom status modal. Keep it concise.', }); const WHAT_S_HAPPENING_DESCRIPTOR = msg({ message: "What's happening?", comment: 'Placeholder question in the custom status text field.', }); const CHANGE_EMOJI_DESCRIPTOR = msg({ message: 'Change emoji', comment: 'Short label in the custom status modal. Keep it concise.', }); const CHOOSE_AN_EMOJI_DESCRIPTOR = msg({ message: 'Choose an emoji', comment: 'Button or menu action label in the custom status modal. Keep it concise.', }); const CLEAR_CUSTOM_STATUS_DESCRIPTOR = msg({ message: 'Clear custom status', comment: 'Button or menu action label in the custom status modal. Keep it concise.', }); const MS_PER_MINUTE = 60 * 1000; const EXPIRATION_MENU_MAX_HEIGHT = 600; const EMPTY_PREVIEW_CUSTOM_STATUS: CustomStatus = { text: '\u200B', expiresAt: null, emojiId: null, emojiName: null, }; interface TimeLabel { dayLabel: string; timeString: string; } interface ExpirationPreset { key: TimeWindowKey; label: string; minutes: number | null; } interface ExpirationOption { key: TimeWindowKey; minutes: number | null; expiresAt: string | null; relativeLabel: TimeLabel | null; label: string; } const DEFAULT_EXPIRATION_KEY: TimeWindowKey = DEFAULT_TIME_WINDOW_KEY; const RELATIVE_DAY_TIME_DESCRIPTOR = msg({ message: '{dayLabel} at {timeString}', comment: 'Custom status clear-time label. dayLabel is a relative day such as "today" or a formatted date; timeString is the localized time.', }); const getPopoutClose = (renderProps: unknown): (() => void) => { const props = renderProps as { close?: unknown; requestClose?: unknown; onClose?: unknown; }; if (typeof props.close === 'function') return props.close as () => void; if (typeof props.requestClose === 'function') return props.requestClose as () => void; if (typeof props.onClose === 'function') return props.onClose as () => void; return () => {}; }; const formatLabelWithRelative = (i18n: I18n, label: string, relative: TimeLabel | null): React.ReactNode => { if (!relative) return label; return ( <> {label} ({i18n._(RELATIVE_DAY_TIME_DESCRIPTOR, {dayLabel: relative.dayLabel, timeString: relative.timeString})}) ); }; const getDayDifference = (reference: Date, target: Date): number => { return getDaysBetween(target, reference); }; const formatTimeString = (date: Date): string => getFormattedTime(date, getCurrentLocale()); const formatRelativeDayTimeLabel = (reference: Date, target: Date): TimeLabel => { const dayOffset = getDayDifference(reference, target); const timeString = formatTimeString(target); if (dayOffset === 0) return {dayLabel: getRelativeDayLabelLower(getCurrentLocale(), 0), timeString}; if (dayOffset === 1) return {dayLabel: getRelativeDayLabelLower(getCurrentLocale(), 1), timeString}; const dayLabel = getFormattedShortDate(target, getCurrentLocale()); return {dayLabel, timeString}; }; const buildDraftStatus = (params: { text: string; emojiId: string | null; emojiName: string | null; expiresAt: string | null; }): CustomStatus | null => { return normalizeCustomStatus({ text: params.text || null, emojiId: params.emojiId, emojiName: params.emojiName, expiresAt: params.expiresAt, }); }; export const CustomStatusModal = observer(() => { const {i18n} = useLingui(); const initialStatus = normalizeCustomStatus(UserSettings.customStatus); const currentUser = Users.getCurrentUser(); const isDeveloper = DeveloperMode.isDeveloper; const shouldAnimateEmojiPreview = useShouldAnimate({kind: 'emoji'}); const [statusText, setStatusText] = useState(initialStatus?.text ?? ''); const [emojiId, setEmojiId] = useState(initialStatus?.emojiId ?? null); const [emojiName, setEmojiName] = useState(initialStatus?.emojiName ?? null); const mountedAt = useMemo(() => new Date(), []); const [emojiPickerOpen, setEmojiPickerOpen] = useState(false); const emojiButtonRef = useRef(null); const expirationPresets = useMemo( () => getTimeWindowPresets({includeDeveloperOptions: isDeveloper}).map((preset: TimeWindowPreset) => ({ key: preset.key, label: i18n._(TIME_WINDOW_LABEL_MESSAGES[preset.key]), minutes: preset.minutes, })), [i18n.locale, isDeveloper], ); const expirationOptions = useMemo>( () => expirationPresets.map((preset: ExpirationPreset) => { if (preset.minutes == null) { return {...preset, expiresAt: null, relativeLabel: null}; } const target = new Date(mountedAt.getTime() + preset.minutes * MS_PER_MINUTE); return { ...preset, expiresAt: target.toISOString(), relativeLabel: formatRelativeDayTimeLabel(mountedAt, target), }; }), [mountedAt, expirationPresets], ); const expirationLabelMap = useMemo>(() => { return expirationOptions.reduce>( (acc, option) => { acc[option.key] = option.relativeLabel; return acc; }, {} as Record, ); }, [expirationOptions]); const selectOptions = useMemo>>(() => { return expirationOptions.map((option) => ({value: option.key, label: option.label})); }, [expirationOptions]); const [selectedExpiration, setSelectedExpiration] = useState(() => initialStatus ? getTimeWindowKeyForExpiresAt(initialStatus.expiresAt, { includeDeveloperOptions: isDeveloper, referenceTime: mountedAt, fallbackKey: DEFAULT_EXPIRATION_KEY, }) : DEFAULT_EXPIRATION_KEY, ); const [isSaving, setIsSaving] = useState(false); const draftStatus = useMemo( () => buildDraftStatus({text: statusText.trim(), emojiId, emojiName, expiresAt: null}), [statusText, emojiId, emojiName], ); const previewCustomStatus = draftStatus ?? EMPTY_PREVIEW_CUSTOM_STATUS; const getExpiresAtForSave = useCallback((): string | null => { const option = expirationOptions.find((entry) => entry.key === selectedExpiration); if (!option?.minutes) return null; return new Date(Date.now() + option.minutes * MS_PER_MINUTE).toISOString(); }, [expirationOptions, selectedExpiration]); const handleExpirationChange = (value: TimeWindowKey) => { setSelectedExpiration(value); }; const handleEmojiSelect = useCallback((emoji: FlatEmoji) => { if (emoji.id) { setEmojiId(emoji.id); setEmojiName(emoji.name); } else { setEmojiId(null); setEmojiName(getSkinTonedSurrogate(emoji)); } }, []); const handleSave = async () => { if (isSaving) return; setIsSaving(true); try { const statusToSave = buildDraftStatus({ text: statusText.trim(), emojiId, emojiName, expiresAt: getExpiresAtForSave(), }); await UserSettingsCommands.update({customStatus: statusToSave}); ModalCommands.pop(); } catch (error) { if (!handleAccountLimitedError(error)) throw error; } finally { setIsSaving(false); } }; const handleClearDraft = () => { setStatusText(''); setEmojiId(null); setEmojiName(null); }; const renderEmojiPreview = (): React.ReactNode => { if (!draftStatus) return null; if (draftStatus.emojiId) { const emoji = Emoji.getEmojiById(draftStatus.emojiId); if (emoji) { return ( {emoji.name} ); } } if (draftStatus.emojiName) { const twemojiUrl = getEmojiURL(draftStatus.emojiName); if (!twemojiUrl) return null; return ( {draftStatus.emojiName} ); } return null; }; const emojiPreview = renderEmojiPreview(); return ( ModalCommands.pop()} size="medium" className={styles.modalRoot} data-flx="user.custom-status-modal.modal-root" >
{currentUser && ( )}
setStatusText(event.target.value.slice(0, 128))} maxLength={128} placeholder={i18n._(WHAT_S_HAPPENING_DESCRIPTOR)} leftElement={ setEmojiPickerOpen(true)} onClose={() => setEmojiPickerOpen(false)} returnFocusRef={emojiButtonRef} render={(renderProps) => { const closePopout = getPopoutClose(renderProps); return ( { handleEmojiSelect(emoji); setEmojiPickerOpen(false); closePopout(); }} onClose={() => { setEmojiPickerOpen(false); closePopout(); }} visibleTabs={['emojis']} data-flx="user.custom-status-modal.expression-picker-popout" /> ); }} data-flx="user.custom-status-modal.popout" > } rightElement={ draftStatus ? ( ) : null } data-flx="user.custom-status-modal.custom-status-text.set-status-text" />
formatLabelWithRelative(i18n, option.label, expirationLabelMap[option.value])} renderValue={(option) => option ? formatLabelWithRelative(i18n, option.label, expirationLabelMap[option.value]) : null } data-flx="user.custom-status-modal.custom-status-expiration.expiration-change" />
); }); CustomStatusModal.displayName = 'CustomStatusModal';