mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 13:35:42 +09:00
311 lines
11 KiB
TypeScript
311 lines
11 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import {CustomStatusDisplay} from '@app/features/app/components/shared/custom_status_display/CustomStatusDisplay';
|
|
import {
|
|
getTimeWindowPresets,
|
|
minutesToMs,
|
|
TIME_WINDOW_FOR_LABEL_MESSAGES,
|
|
type TimeWindowKey,
|
|
type TimeWindowPreset,
|
|
} from '@app/features/app/config/TimeWindowPresets';
|
|
import {getStatusTypeLabel, STATUS_UNTIL_I_CHANGE_IT_DESCRIPTOR} from '@app/features/app/constants/AppConstants';
|
|
import DeveloperMode from '@app/features/devtools/state/DeveloperMode';
|
|
import Presence from '@app/features/presence/state/Presence';
|
|
import {BottomSheet} from '@app/features/ui/bottom_sheet/BottomSheet';
|
|
import {StatusIndicator} from '@app/features/ui/components/StatusIndicator';
|
|
import * as UserSettingsCommands from '@app/features/user/commands/UserSettingsCommands';
|
|
import {CustomStatusBottomSheet} from '@app/features/user/components/modals/CustomStatusBottomSheet';
|
|
import styles from '@app/features/user/components/modals/StatusChangeBottomSheet.module.css';
|
|
import {normalizeCustomStatus} from '@app/features/user/state/CustomStatus';
|
|
import StatusExpiry from '@app/features/user/state/StatusExpiry';
|
|
import Users from '@app/features/user/state/Users';
|
|
import type {StatusType} from '@fluxer/constants/src/StatusConstants';
|
|
import {StatusTypes} from '@fluxer/constants/src/StatusConstants';
|
|
import type {MessageDescriptor} from '@lingui/core';
|
|
import {msg} from '@lingui/core/macro';
|
|
import {Trans, useLingui} from '@lingui/react/macro';
|
|
import {CaretDownIcon, CheckIcon} from '@phosphor-icons/react';
|
|
import clsx from 'clsx';
|
|
import {observer} from 'mobx-react-lite';
|
|
import React, {useCallback, useMemo, useState} from 'react';
|
|
|
|
const SET_STATUS_DESCRIPTOR = msg({
|
|
message: 'Set status',
|
|
comment: 'Short label in the status change bottom sheet. Keep it concise.',
|
|
});
|
|
const STATUS_ORDER = [StatusTypes.ONLINE, StatusTypes.IDLE, StatusTypes.DND, StatusTypes.INVISIBLE] as const;
|
|
const STATUS_DESCRIPTIONS: Record<(typeof STATUS_ORDER)[number], React.ReactNode | null> = {
|
|
[StatusTypes.ONLINE]: null,
|
|
[StatusTypes.IDLE]: null,
|
|
[StatusTypes.DND]: <Trans>You won't receive notifications on desktop</Trans>,
|
|
[StatusTypes.INVISIBLE]: <Trans>You'll appear offline</Trans>,
|
|
};
|
|
const STATUS_EXPIRY_LABEL_MESSAGES: Record<TimeWindowKey, MessageDescriptor> = {
|
|
...TIME_WINDOW_FOR_LABEL_MESSAGES,
|
|
never: STATUS_UNTIL_I_CHANGE_IT_DESCRIPTOR,
|
|
};
|
|
|
|
interface StatusExpiryOption {
|
|
id: TimeWindowKey;
|
|
key: TimeWindowKey;
|
|
label: MessageDescriptor;
|
|
durationMs: number | null;
|
|
}
|
|
|
|
const getStatusExpiryOptions = (includeDeveloperOptions: boolean): ReadonlyArray<StatusExpiryOption> =>
|
|
getTimeWindowPresets({includeDeveloperOptions}).map((preset: TimeWindowPreset) => ({
|
|
id: preset.key,
|
|
key: preset.key,
|
|
label: STATUS_EXPIRY_LABEL_MESSAGES[preset.key],
|
|
durationMs: minutesToMs(preset.minutes),
|
|
}));
|
|
const STATUS_SHEET_SNAP_POINTS: Array<number> = [0, 0.75, 1];
|
|
|
|
interface StatusChangeBottomSheetProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
}
|
|
|
|
interface StatusItemProps {
|
|
status: StatusType;
|
|
currentStatus: StatusType;
|
|
expiryOptions: ReadonlyArray<StatusExpiryOption>;
|
|
onSelect: (status: StatusType, durationMs: number | null) => void;
|
|
}
|
|
|
|
const StatusItem = observer(({status, currentStatus, expiryOptions, onSelect}: StatusItemProps) => {
|
|
const {i18n} = useLingui();
|
|
const isSelected = currentStatus === status;
|
|
const description = STATUS_DESCRIPTIONS[status as keyof typeof STATUS_DESCRIPTIONS];
|
|
const hasExpiryOptions = status !== StatusTypes.ONLINE;
|
|
const [showExpiry, setShowExpiry] = useState(false);
|
|
const handleSelect = () => {
|
|
if (hasExpiryOptions) {
|
|
setShowExpiry(!showExpiry);
|
|
} else {
|
|
onSelect(status, null);
|
|
}
|
|
};
|
|
const handleExpirySelect = (durationMs: number | null) => {
|
|
onSelect(status, durationMs);
|
|
setShowExpiry(false);
|
|
};
|
|
return (
|
|
<div
|
|
className={styles.statusItemWrapper}
|
|
data-flx="user.status-change-bottom-sheet.status-item.status-item-wrapper"
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={handleSelect}
|
|
className={styles.statusItemButton}
|
|
aria-pressed={isSelected}
|
|
aria-expanded={hasExpiryOptions ? showExpiry : undefined}
|
|
data-flx="user.status-change-bottom-sheet.status-item.status-item-button.select"
|
|
>
|
|
<div
|
|
className={styles.statusItemContent}
|
|
data-flx="user.status-change-bottom-sheet.status-item.status-item-content"
|
|
>
|
|
<StatusIndicator
|
|
status={status}
|
|
size={14}
|
|
monochromeColor="var(--brand-primary-fill)"
|
|
data-flx="user.status-change-bottom-sheet.status-item.status-indicator"
|
|
/>
|
|
<div
|
|
className={styles.statusItemInfo}
|
|
data-flx="user.status-change-bottom-sheet.status-item.status-item-info"
|
|
>
|
|
<span className={styles.statusLabel} data-flx="user.status-change-bottom-sheet.status-item.status-label">
|
|
{getStatusTypeLabel(i18n, status)}
|
|
</span>
|
|
{description && (
|
|
<span
|
|
className={styles.statusDescription}
|
|
data-flx="user.status-change-bottom-sheet.status-item.status-description"
|
|
>
|
|
{description}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div
|
|
className={styles.statusItemRight}
|
|
data-flx="user.status-change-bottom-sheet.status-item.status-item-right"
|
|
>
|
|
{isSelected && (
|
|
<div
|
|
className={styles.selectedIndicator}
|
|
data-flx="user.status-change-bottom-sheet.status-item.selected-indicator"
|
|
>
|
|
<CheckIcon
|
|
weight="bold"
|
|
className={styles.checkIcon}
|
|
data-flx="user.status-change-bottom-sheet.status-item.check-icon"
|
|
/>
|
|
</div>
|
|
)}
|
|
{hasExpiryOptions && (
|
|
<CaretDownIcon
|
|
weight="bold"
|
|
className={clsx(styles.chevronIcon, showExpiry && styles.chevronIconExpanded)}
|
|
data-flx="user.status-change-bottom-sheet.status-item.chevron-icon"
|
|
/>
|
|
)}
|
|
</div>
|
|
</button>
|
|
{showExpiry && (
|
|
<div className={styles.expiryList} data-flx="user.status-change-bottom-sheet.status-item.expiry-list">
|
|
{expiryOptions.map((option: StatusExpiryOption) => (
|
|
<button
|
|
key={option.id}
|
|
type="button"
|
|
className={styles.expiryItem}
|
|
onClick={() => handleExpirySelect(option.durationMs)}
|
|
data-flx="user.status-change-bottom-sheet.status-item.expiry-item.expiry-select.button"
|
|
>
|
|
{i18n._(option.label)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
});
|
|
|
|
interface CustomStatusSectionProps {
|
|
onOpenEditor: () => void;
|
|
}
|
|
|
|
const CustomStatusSection = observer(({onOpenEditor}: CustomStatusSectionProps) => {
|
|
const currentUser = Users.getCurrentUser();
|
|
const currentUserId = currentUser?.id ?? null;
|
|
const existingCustomStatus = currentUserId ? Presence.getCustomStatus(currentUserId) : null;
|
|
const normalizedExisting = normalizeCustomStatus(existingCustomStatus);
|
|
const hasExistingStatus = Boolean(normalizedExisting);
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
if (!hasExistingStatus || !normalizedExisting) {
|
|
return null;
|
|
}
|
|
const handleClear = async () => {
|
|
if (isSaving) return;
|
|
setIsSaving(true);
|
|
try {
|
|
await UserSettingsCommands.update({customStatus: null});
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
return (
|
|
<div
|
|
className={styles.customStatusSection}
|
|
data-flx="user.status-change-bottom-sheet.custom-status-section.custom-status-section"
|
|
>
|
|
<div
|
|
className={styles.customStatusHeader}
|
|
data-flx="user.status-change-bottom-sheet.custom-status-section.custom-status-header"
|
|
>
|
|
<span
|
|
className={styles.customStatusTitle}
|
|
data-flx="user.status-change-bottom-sheet.custom-status-section.custom-status-title"
|
|
>
|
|
<Trans>Custom status</Trans>
|
|
</span>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className={styles.customStatusButton}
|
|
onClick={onOpenEditor}
|
|
data-flx="user.status-change-bottom-sheet.custom-status-section.custom-status-button.open-editor"
|
|
>
|
|
<CustomStatusDisplay
|
|
customStatus={normalizedExisting}
|
|
animateOnParentHover
|
|
data-flx="user.status-change-bottom-sheet.custom-status-section.custom-status-display"
|
|
/>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={styles.clearCustomStatusButton}
|
|
onClick={handleClear}
|
|
disabled={isSaving}
|
|
data-flx="user.status-change-bottom-sheet.custom-status-section.clear-custom-status-button"
|
|
>
|
|
<Trans>Clear custom status</Trans>
|
|
</button>
|
|
</div>
|
|
);
|
|
});
|
|
export const StatusChangeBottomSheet = observer(({isOpen, onClose}: StatusChangeBottomSheetProps) => {
|
|
const {i18n} = useLingui();
|
|
const currentUser = Users.getCurrentUser();
|
|
const currentUserId = currentUser?.id ?? null;
|
|
const status = currentUserId ? Presence.getStatus(currentUserId) : StatusTypes.ONLINE;
|
|
const [customStatusSheetOpen, setCustomStatusSheetOpen] = useState(false);
|
|
const isDeveloper = DeveloperMode.isDeveloper;
|
|
const statusExpiryOptions = useMemo(() => getStatusExpiryOptions(isDeveloper), [isDeveloper]);
|
|
const handleStatusChange = useCallback(
|
|
(statusType: StatusType, durationMs: number | null) => {
|
|
StatusExpiry.setActiveStatusExpiry({
|
|
status: statusType,
|
|
durationMs,
|
|
});
|
|
onClose();
|
|
},
|
|
[onClose],
|
|
);
|
|
const handleOpenCustomStatusEditor = useCallback(() => {
|
|
setCustomStatusSheetOpen(true);
|
|
}, []);
|
|
const handleCloseCustomStatusEditor = useCallback(() => {
|
|
setCustomStatusSheetOpen(false);
|
|
}, []);
|
|
return (
|
|
<>
|
|
<BottomSheet
|
|
isOpen={isOpen}
|
|
onClose={onClose}
|
|
snapPoints={STATUS_SHEET_SNAP_POINTS}
|
|
initialSnap={STATUS_SHEET_SNAP_POINTS.length - 1}
|
|
title={i18n._(SET_STATUS_DESCRIPTOR)}
|
|
data-flx="user.status-change-bottom-sheet.bottom-sheet"
|
|
>
|
|
<div className={styles.content} data-flx="user.status-change-bottom-sheet.content">
|
|
<div className={styles.topSpacer} data-flx="user.status-change-bottom-sheet.top-spacer" />
|
|
<CustomStatusSection
|
|
onOpenEditor={handleOpenCustomStatusEditor}
|
|
data-flx="user.status-change-bottom-sheet.custom-status-section"
|
|
/>
|
|
<div className={styles.statusSection} data-flx="user.status-change-bottom-sheet.status-section">
|
|
<div className={styles.sectionHeader} data-flx="user.status-change-bottom-sheet.section-header">
|
|
<Trans>Online status</Trans>
|
|
</div>
|
|
<div className={styles.statusContainer} data-flx="user.status-change-bottom-sheet.status-container">
|
|
{STATUS_ORDER.map((statusType, index, arr) => (
|
|
<React.Fragment key={statusType}>
|
|
<StatusItem
|
|
status={statusType}
|
|
currentStatus={status}
|
|
expiryOptions={statusExpiryOptions}
|
|
onSelect={handleStatusChange}
|
|
data-flx="user.status-change-bottom-sheet.status-item.status-change"
|
|
/>
|
|
{index < arr.length - 1 && (
|
|
<div className={styles.divider} data-flx="user.status-change-bottom-sheet.divider" />
|
|
)}
|
|
</React.Fragment>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</BottomSheet>
|
|
<CustomStatusBottomSheet
|
|
isOpen={customStatusSheetOpen}
|
|
onClose={handleCloseCustomStatusEditor}
|
|
data-flx="user.status-change-bottom-sheet.custom-status-bottom-sheet"
|
|
/>
|
|
</>
|
|
);
|
|
});
|