push notifications permission on start app
This commit is contained in:
parent
52de41cf04
commit
43cd7c6a61
8 changed files with 233 additions and 7 deletions
|
|
@ -171,6 +171,9 @@
|
|||
"push_description": "Receive notifications even when Vojo is closed or minimized.",
|
||||
"push_permission_blocked": "Push notification permission was denied. Please enable it in your device settings.",
|
||||
"push_error": "Failed to enable background notifications.",
|
||||
"push_prompt_title": "Enable notifications",
|
||||
"push_prompt_body": "Get new messages even when Vojo is closed. You can change this anytime in Settings.",
|
||||
"push_prompt_later": "Not now",
|
||||
"email_notification": "Email Notification",
|
||||
"email_no_email": "Your account does not have any email attached.",
|
||||
"email_send_notif": "Send notification to your email.",
|
||||
|
|
|
|||
|
|
@ -171,6 +171,9 @@
|
|||
"push_description": "Получать уведомления даже когда Vojo свёрнут или закрыт.",
|
||||
"push_permission_blocked": "Разрешение на push-уведомления отклонено. Включите его в настройках устройства.",
|
||||
"push_error": "Не удалось включить фоновые уведомления.",
|
||||
"push_prompt_title": "Включить уведомления",
|
||||
"push_prompt_body": "Получайте новые сообщения, даже когда Vojo закрыт. Вы можете изменить это в настройках.",
|
||||
"push_prompt_later": "Позже",
|
||||
"email_notification": "Уведомления по почте",
|
||||
"email_no_email": "К вашему аккаунту не привязана электронная почта.",
|
||||
"email_send_notif": "Отправлять уведомления на вашу почту.",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,178 @@
|
|||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import {
|
||||
Dialog,
|
||||
Overlay,
|
||||
OverlayCenter,
|
||||
OverlayBackdrop,
|
||||
Header,
|
||||
config,
|
||||
Box,
|
||||
Text,
|
||||
IconButton,
|
||||
Icon,
|
||||
Icons,
|
||||
color,
|
||||
Button,
|
||||
Spinner,
|
||||
} from 'folds';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { isNativePlatform } from '../../utils/capacitor';
|
||||
import {
|
||||
usePushNotificationStatus,
|
||||
useRegisterPushNotifications,
|
||||
usePushEnabled,
|
||||
} from '../../hooks/usePushNotifications';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
|
||||
// 7-day cooldown between dismissals. Short enough that users who change their mind
|
||||
// don't have to dig into settings to find the toggle; long enough that saying "not now"
|
||||
// doesn't feel ignored.
|
||||
const DISMISS_KEY = 'vojo_push_prompt_dismissed_at';
|
||||
const DISMISS_COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
// Slight delay so the prompt doesn't collide with login/first-render UI.
|
||||
const INITIAL_DELAY_MS = 1500;
|
||||
|
||||
const wasRecentlyDismissed = (): boolean => {
|
||||
const raw = localStorage.getItem(DISMISS_KEY);
|
||||
if (!raw) return false;
|
||||
const ts = Number(raw);
|
||||
if (!Number.isFinite(ts)) return false;
|
||||
return Date.now() - ts < DISMISS_COOLDOWN_MS;
|
||||
};
|
||||
|
||||
const markDismissed = (): void => {
|
||||
localStorage.setItem(DISMISS_KEY, String(Date.now()));
|
||||
};
|
||||
|
||||
const noop = (): void => undefined;
|
||||
|
||||
export function PushPermissionPrompt() {
|
||||
const { t } = useTranslation();
|
||||
const status = usePushNotificationStatus();
|
||||
const register = useRegisterPushNotifications();
|
||||
const enabled = usePushEnabled();
|
||||
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
const [registerState, doRegister] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
await register();
|
||||
}, [register])
|
||||
);
|
||||
const busy = registerState.status === AsyncStatus.Loading;
|
||||
|
||||
useEffect(() => {
|
||||
// Only nag on native — browsers have their own permission UX and stacking
|
||||
// our soft prompt on top just spams the user.
|
||||
if (!isNativePlatform()) return undefined;
|
||||
if (status !== 'prompt') return undefined;
|
||||
// `enabled` is reactive via usePushEnabled — if push gets turned on from
|
||||
// anywhere else (settings, another tab) during the delay, the effect reruns,
|
||||
// cleanup cancels the timer, and the prompt stays hidden.
|
||||
if (enabled) {
|
||||
setVisible(false);
|
||||
return undefined;
|
||||
}
|
||||
if (wasRecentlyDismissed()) return undefined;
|
||||
|
||||
const timer = setTimeout(() => setVisible(true), INITIAL_DELAY_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [status, enabled]);
|
||||
|
||||
// Close on a successful enable — the status change would hide it too, but
|
||||
// closing eagerly avoids a visible flicker while Capacitor round-trips permissions.
|
||||
useEffect(() => {
|
||||
if (registerState.status === AsyncStatus.Success) setVisible(false);
|
||||
}, [registerState.status]);
|
||||
|
||||
// Dismiss paths are guarded against firing mid-register: closing during the
|
||||
// native permission round-trip would stamp the 7-day cooldown AND hide any
|
||||
// transient error, leaving the user stuck with push off and no nag for a week.
|
||||
const handleLater = () => {
|
||||
if (busy) return;
|
||||
markDismissed();
|
||||
setVisible(false);
|
||||
};
|
||||
|
||||
const handleEnable = () => {
|
||||
if (busy) return;
|
||||
// useAsyncCallback re-throws so the state can be read via registerState;
|
||||
// we don't need the rejection at the call site and without .catch it
|
||||
// surfaces as an unhandled rejection for expected permission_denied flows.
|
||||
doRegister().catch(noop);
|
||||
};
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const denied =
|
||||
registerState.status === AsyncStatus.Error &&
|
||||
(registerState.error as Error | undefined)?.message === 'permission_denied';
|
||||
|
||||
return (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: handleLater,
|
||||
// Lock out click-outside / Esc while register() is in-flight — same
|
||||
// reason as handleLater's busy guard: we don't want to cooldown the
|
||||
// prompt on an accidental dismiss during the native permission dialog.
|
||||
clickOutsideDeactivates: () => !busy,
|
||||
escapeDeactivates: (ev) => {
|
||||
if (busy) return false;
|
||||
return stopPropagation(ev);
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Dialog variant="Surface">
|
||||
<Header
|
||||
style={{
|
||||
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
||||
borderBottomWidth: config.borderWidth.B300,
|
||||
}}
|
||||
variant="Surface"
|
||||
size="500"
|
||||
>
|
||||
<Box grow="Yes">
|
||||
<Text size="H4">{t('Settings.push_prompt_title')}</Text>
|
||||
</Box>
|
||||
<IconButton size="300" onClick={handleLater} radii="300" disabled={busy}>
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
||||
<Text priority="400">{t('Settings.push_prompt_body')}</Text>
|
||||
{denied && (
|
||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||
{t('Settings.push_permission_blocked')}
|
||||
</Text>
|
||||
)}
|
||||
<Box direction="Row" gap="200" justifyContent="End">
|
||||
<Button
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
onClick={handleLater}
|
||||
disabled={busy}
|
||||
>
|
||||
<Text size="B400">{t('Settings.push_prompt_later')}</Text>
|
||||
</Button>
|
||||
<Button
|
||||
variant="Primary"
|
||||
onClick={handleEnable}
|
||||
before={busy ? <Spinner fill="Solid" variant="Primary" size="200" /> : undefined}
|
||||
disabled={busy}
|
||||
>
|
||||
<Text size="B400">{t('Settings.enable')}</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Dialog>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
);
|
||||
}
|
||||
1
src/app/components/push-permission-prompt/index.ts
Normal file
1
src/app/components/push-permission-prompt/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './PushPermissionPrompt';
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useCallback, useState } from 'react';
|
||||
import React, { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Box, Text, Switch, Button, color, Spinner } from 'folds';
|
||||
import { IPusherRequest } from 'matrix-js-sdk';
|
||||
|
|
@ -15,9 +15,11 @@ import {
|
|||
useRegisterPushNotifications,
|
||||
useDisablePushNotifications,
|
||||
usePushNotificationStatus,
|
||||
isPushEnabled,
|
||||
usePushEnabled,
|
||||
} from '../../../hooks/usePushNotifications';
|
||||
|
||||
const noop = (): void => undefined;
|
||||
|
||||
function EmailNotification() {
|
||||
const { t } = useTranslation();
|
||||
const mx = useMatrixClient();
|
||||
|
|
@ -98,19 +100,20 @@ function PushNotification() {
|
|||
const register = useRegisterPushNotifications();
|
||||
const disable = useDisablePushNotifications();
|
||||
|
||||
const [enabled, setEnabled] = useState<boolean>(() => isPushEnabled());
|
||||
// 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();
|
||||
setEnabled(true);
|
||||
}, [register])
|
||||
);
|
||||
|
||||
const [disableState, doDisable] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
await disable();
|
||||
setEnabled(false);
|
||||
}, [disable])
|
||||
);
|
||||
|
||||
|
|
@ -142,18 +145,21 @@ function PushNotification() {
|
|||
);
|
||||
}
|
||||
|
||||
// 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() : doDisable())}
|
||||
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()}>
|
||||
<Button size="300" radii="300" onClick={() => enable().catch(noop)}>
|
||||
<Text size="B300">{t('Settings.enable')}</Text>
|
||||
</Button>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import { useClientConfig } from './useClientConfig';
|
|||
import { isNativePlatform } from '../utils/capacitor';
|
||||
import {
|
||||
PUSH_APP_IDS,
|
||||
PUSH_ENABLED_KEY,
|
||||
PUSH_STATE_CHANGE_EVENT,
|
||||
clearPusherIds,
|
||||
isPushEnabled,
|
||||
loadPusherIds,
|
||||
|
|
@ -21,6 +23,30 @@ const noop = (): void => undefined;
|
|||
|
||||
export type PushStatus = 'unavailable' | 'prompt' | 'granted' | 'denied';
|
||||
|
||||
/**
|
||||
* Reactive mirror of isPushEnabled(). Source of truth lives in localStorage
|
||||
* (see PUSH_ENABLED_KEY); this hook just subscribes to same-tab changes via
|
||||
* our custom event and cross-tab changes via the `storage` event.
|
||||
*/
|
||||
export function usePushEnabled(): boolean {
|
||||
const [enabled, setEnabled] = useState<boolean>(() => isPushEnabled());
|
||||
|
||||
useEffect(() => {
|
||||
const sync = () => setEnabled(isPushEnabled());
|
||||
const onStorage = (ev: StorageEvent) => {
|
||||
if (ev.key === PUSH_ENABLED_KEY) sync();
|
||||
};
|
||||
window.addEventListener(PUSH_STATE_CHANGE_EVENT, sync);
|
||||
window.addEventListener('storage', onStorage);
|
||||
return () => {
|
||||
window.removeEventListener(PUSH_STATE_CHANGE_EVENT, sync);
|
||||
window.removeEventListener('storage', onStorage);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return enabled;
|
||||
}
|
||||
|
||||
const webStatus = (): PushStatus => {
|
||||
if (typeof window === 'undefined') return 'unavailable';
|
||||
if (!('PushManager' in window) || !('serviceWorker' in navigator)) return 'unavailable';
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
|
|||
import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { usePushNotificationsLifecycle } from '../../hooks/usePushNotifications';
|
||||
import { PushPermissionPrompt } from '../../components/push-permission-prompt';
|
||||
|
||||
function SystemEmojiFeature() {
|
||||
const [twitterEmoji] = useSetting(settingsAtom, 'twitterEmoji');
|
||||
|
|
@ -272,6 +273,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
|
|||
<InviteNotifications />
|
||||
<MessageNotifications />
|
||||
<PushNotificationsFeature />
|
||||
<PushPermissionPrompt />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -11,12 +11,19 @@ export const PUSH_APP_IDS = {
|
|||
} as const;
|
||||
|
||||
export const PUSH_ENABLED_KEY = 'vojo_push_enabled';
|
||||
export const PUSH_STATE_CHANGE_EVENT = 'vojo:pushStateChange';
|
||||
|
||||
export const isPushEnabled = (): boolean => localStorage.getItem(PUSH_ENABLED_KEY) === 'true';
|
||||
|
||||
export const setPushEnabled = (enabled: boolean): void => {
|
||||
const prev = isPushEnabled();
|
||||
if (enabled) localStorage.setItem(PUSH_ENABLED_KEY, 'true');
|
||||
else localStorage.removeItem(PUSH_ENABLED_KEY);
|
||||
// Fan out to any UI that mirrors the flag (settings toggle, soft prompt, etc.)
|
||||
// The `storage` event only fires cross-tab, so in-tab observers need this.
|
||||
if (prev !== enabled && typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent(PUSH_STATE_CHANGE_EVENT));
|
||||
}
|
||||
};
|
||||
|
||||
export function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue