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);
+ },
+ }}
+ >
+
+
+
+
+ );
+}
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 = (
-