diff --git a/public/locales/en.json b/public/locales/en.json index b715ec79..6a10eaa2 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -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.", diff --git a/public/locales/ru.json b/public/locales/ru.json index 5eece7bc..f278571c 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -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": "Отправлять уведомления на вашу почту.", diff --git a/src/app/components/push-permission-prompt/PushPermissionPrompt.tsx b/src/app/components/push-permission-prompt/PushPermissionPrompt.tsx new file mode 100644 index 00000000..0ecd4e76 --- /dev/null +++ b/src/app/components/push-permission-prompt/PushPermissionPrompt.tsx @@ -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 ( + }> + + !busy, + escapeDeactivates: (ev) => { + if (busy) return false; + return stopPropagation(ev); + }, + }} + > + +
+ + {t('Settings.push_prompt_title')} + + + + +
+ + {t('Settings.push_prompt_body')} + {denied && ( + + {t('Settings.push_permission_blocked')} + + )} + + + + + +
+
+
+
+ ); +} diff --git a/src/app/components/push-permission-prompt/index.ts b/src/app/components/push-permission-prompt/index.ts new file mode 100644 index 00000000..e6440738 --- /dev/null +++ b/src/app/components/push-permission-prompt/index.ts @@ -0,0 +1 @@ +export * from './PushPermissionPrompt'; diff --git a/src/app/features/settings/notifications/SystemNotification.tsx b/src/app/features/settings/notifications/SystemNotification.tsx index c1433403..d28611dd 100644 --- a/src/app/features/settings/notifications/SystemNotification.tsx +++ b/src/app/features/settings/notifications/SystemNotification.tsx @@ -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(() => 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 = ( (val ? enable() : doDisable())} + onChange={(val) => (val ? enable().catch(noop) : doDisable().catch(noop))} /> ); if (busy) { after = ; } else if (status === 'prompt' && !enabled) { after = ( - ); diff --git a/src/app/hooks/usePushNotifications.ts b/src/app/hooks/usePushNotifications.ts index af1593f2..3625e4c3 100644 --- a/src/app/hooks/usePushNotifications.ts +++ b/src/app/hooks/usePushNotifications.ts @@ -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(() => 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'; diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index ddb01504..28f73101 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -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) { + {children} ); diff --git a/src/app/utils/push.ts b/src/app/utils/push.ts index f5d68e37..f66bfbab 100644 --- a/src/app/utils/push.ts +++ b/src/app/utils/push.ts @@ -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 {