vojo/src/app/features/settings/notifications/SystemNotification.tsx

113 lines
4.1 KiB
TypeScript

import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Text, Switch, Button, color, Spinner } from 'folds';
import { SettingTile } from '../../../components/setting-tile';
import { SettingsSection } from '../SettingsSection';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import {
useRegisterPushNotifications,
useDisablePushNotifications,
usePushNotificationStatus,
usePushEnabled,
} from '../../../hooks/usePushNotifications';
const noop = (): void => undefined;
function PushNotification() {
const { t } = useTranslation();
const status = usePushNotificationStatus();
const register = useRegisterPushNotifications();
const disable = useDisablePushNotifications();
// Source of truth: setPushEnabled() in utils/push.ts fans out a custom event
// that this hook listens for, so the toggle reflects changes made elsewhere
// (e.g. the global PushPermissionPrompt on first login).
const enabled = usePushEnabled();
const [enableState, enable] = useAsyncCallback(
useCallback(async () => {
await register();
}, [register])
);
const [disableState, doDisable] = useAsyncCallback(
useCallback(async () => {
await disable();
}, [disable])
);
if (status === 'unavailable') return null;
const busy =
enableState.status === AsyncStatus.Loading || disableState.status === AsyncStatus.Loading;
let description: React.ReactNode = <span>{t('Settings.push_description')}</span>;
// A permission denial surfaces as both status='denied' AND enableState.error, but
// the status change is async on native (checkPermissions round-trip) while the
// error lands synchronously. Check the error reason first to avoid a flash of
// the generic push_error before the permission-specific message.
const permissionDenied =
status === 'denied' ||
(enableState.status === AsyncStatus.Error &&
(enableState.error as Error | undefined)?.message === 'permission_denied');
if (permissionDenied) {
description = (
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
{t('Settings.push_permission_blocked')}
</Text>
);
} else if (enableState.status === AsyncStatus.Error) {
description = (
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
{t('Settings.push_error')}
</Text>
);
}
// useAsyncCallback re-throws after recording the error state, so every call
// site needs .catch — otherwise expected flows like permission_denied turn
// into unhandled promise rejections.
let after: React.ReactNode = (
<Switch
disabled={status === 'denied'}
value={enabled}
onChange={(val) => (val ? enable().catch(noop) : doDisable().catch(noop))}
/>
);
if (busy) {
after = <Spinner variant="Secondary" />;
} else if (status === 'prompt' && !enabled) {
after = (
<Button size="300" radii="300" onClick={() => enable().catch(noop)}>
<Text size="B300">{t('Settings.enable')}</Text>
</Button>
);
}
return (
<SettingTile title={t('Settings.push_notifications')} description={description} after={after} />
);
}
export function SystemNotification() {
const { t } = useTranslation();
const [inviteSpamFilter, setInviteSpamFilter] = useSetting(settingsAtom, 'inviteSpamFilter');
// Gate the push row at the call site: PushNotification renders null when push
// is unavailable, and SettingsSection's `filter(Boolean)` can only drop
// JSX-level falsy children — a returned-null component would otherwise leave
// an empty bordered first row in the grouped panel.
const pushStatus = usePushNotificationStatus();
return (
<SettingsSection label={t('Settings.system')}>
{pushStatus !== 'unavailable' && <PushNotification />}
<SettingTile
title={t('Settings.invite_spam_filter')}
description={t('Settings.invite_spam_filter_desc')}
after={<Switch value={inviteSpamFilter} onChange={setInviteSpamFilter} />}
/>
</SettingsSection>
);
}