diff --git a/artifacts/api-server/src/lib/push.ts b/artifacts/api-server/src/lib/push.ts index 8f703296..b0e21350 100644 --- a/artifacts/api-server/src/lib/push.ts +++ b/artifacts/api-server/src/lib/push.ts @@ -50,6 +50,10 @@ async function loadVapid(): Promise<{ if (envPub && envPriv) { try { webpush.setVapidDetails(subject, envPub, envPriv); + logger.info( + { source: "env" }, + "VAPID keys loaded from environment — push subscriptions persist across container rebuilds.", + ); return { publicKey: envPub, privateKey: envPriv, subject }; } catch (err) { logger.error({ err }, "Invalid VAPID env keys — falling back to disk"); @@ -65,6 +69,10 @@ async function loadVapid(): Promise<{ }; if (parsed.publicKey && parsed.privateKey) { webpush.setVapidDetails(subject, parsed.publicKey, parsed.privateKey); + logger.info( + { source: "disk", file }, + "VAPID keys loaded from disk. To share across hosts, copy them into the VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY env vars.", + ); return { publicKey: parsed.publicKey, privateKey: parsed.privateKey, diff --git a/artifacts/tx-os/src/components/notification-settings.tsx b/artifacts/tx-os/src/components/notification-settings.tsx index a54442e2..fcd9c499 100644 --- a/artifacts/tx-os/src/components/notification-settings.tsx +++ b/artifacts/tx-os/src/components/notification-settings.tsx @@ -8,7 +8,10 @@ import { type NotificationSoundId, } from "@workspace/api-client-react"; import { Bell, BellOff, Check, Volume2, VolumeX, Play } from "lucide-react"; -import { usePushSubscription } from "@/hooks/use-push-subscription"; +import { + isIosSafariNonStandalone, + usePushSubscription, +} from "@/hooks/use-push-subscription"; import { useToast } from "@/hooks/use-toast"; import { Popover, @@ -335,9 +338,19 @@ function PushToggleRow() { const { toast } = useToast(); const { status, busy, enable, disable } = usePushSubscription(); const subscribed = status === "subscribed"; - const disabled = busy || status === "unsupported" || status === "denied"; - const subtitle = - status === "unsupported" + // iOS Safari outside Home Screen exposes the Push APIs but + // `subscribe()` always fails — treat it as a first-class "needs PWA + // install" state so the toggle doesn't tease the user with an + // Enable affordance that can't succeed. + const needsIosInstall = isIosSafariNonStandalone() && !subscribed; + const disabled = + busy || + status === "unsupported" || + status === "denied" || + needsIosInstall; + const subtitle = needsIosInstall + ? t("notifSettings.push.errors.unsupported_ios_safari") + : status === "unsupported" ? t("notifSettings.push.unsupported") : status === "denied" ? t("notifSettings.push.denied") diff --git a/artifacts/tx-os/src/components/push-enable-prompt.tsx b/artifacts/tx-os/src/components/push-enable-prompt.tsx index 87a4ea8e..cc6d2f03 100644 --- a/artifacts/tx-os/src/components/push-enable-prompt.tsx +++ b/artifacts/tx-os/src/components/push-enable-prompt.tsx @@ -1,73 +1,136 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Bell, X } from "lucide-react"; +import { Bell, Share, X } from "lucide-react"; import { useAuth } from "@/contexts/AuthContext"; -import { usePushSubscription } from "@/hooks/use-push-subscription"; +import { + isIosSafariNonStandalone, + usePushSubscription, +} from "@/hooks/use-push-subscription"; import { useToast } from "@/hooks/use-toast"; const DISMISS_KEY = "tx.pushPrompt.dismissedAt"; +const IOS_INSTALL_DISMISS_KEY = "tx.pushPrompt.iosInstallDismissedAt"; // Re-prompt after 14 days if the user dismissed but never opted in. const RE_PROMPT_AFTER_MS = 14 * 24 * 60 * 60 * 1000; /** * One-tap "Enable lock-screen alerts" card surfaced after the user logs - * in for the first time on a device. Renders only when: - * - the user is authenticated - * - Web Push is supported (Notifications API + SW + PushManager) - * - permission is still "default" (we haven't asked yet) - * - the user has no active subscription on this device - * - they haven't dismissed the prompt recently + * in for the first time on a device. Renders one of three variants: * - * On unsupported devices (notably iOS Safari before "Add to Home - * Screen") we stay silent — the toggle inside Notification Settings - * exposes the relevant copy for power users. We don't want a - * permanent "not supported" banner cluttering the home screen. + * 1. iOS Safari NOT installed as PWA → install-steps card explaining + * Share → Add to Home Screen. (Tapping "Enable" in this state + * would fail with `unsupported_ios_safari`, which is opaque.) + * 2. Supported + permission still "default" → regular Enable card. + * 3. Otherwise (already subscribed, denied, or genuinely unsupported) + * → render nothing. + * + * Both variants have independent 14-day dismiss memory so a user who + * dismisses one doesn't suppress the other. */ export function PushEnablePrompt() { const { t } = useTranslation(); const { user } = useAuth(); const { toast } = useToast(); const { status, busy, enable } = usePushSubscription(); - const [hidden, setHidden] = useState(true); + const [variant, setVariant] = useState<"hidden" | "ios-install" | "enable">( + "hidden", + ); useEffect(() => { if (!user) { - setHidden(true); + setVariant("hidden"); return; } - if (status !== "default") { - setHidden(true); - return; - } - try { - const raw = localStorage.getItem(DISMISS_KEY); - if (raw) { - const ts = Number(raw); - if ( - Number.isFinite(ts) && - Date.now() - ts < RE_PROMPT_AFTER_MS - ) { - setHidden(true); - return; - } + + // iOS Safari outside Home Screen: show install steps regardless of + // `status`, because `enable()` will fail there with + // `unsupported_ios_safari`. The hook keeps `status === "default"` + // since the APIs *exist*; we override here on the install path. + if (isIosSafariNonStandalone()) { + if (isDismissed(IOS_INSTALL_DISMISS_KEY)) { + setVariant("hidden"); + return; } - } catch { - /* localStorage unavailable — just show the prompt */ + setVariant("ios-install"); + return; } - setHidden(false); + + if (status !== "default") { + setVariant("hidden"); + return; + } + if (isDismissed(DISMISS_KEY)) { + setVariant("hidden"); + return; + } + setVariant("enable"); }, [user, status]); - if (hidden) return null; + if (variant === "hidden") return null; const dismiss = () => { + const key = + variant === "ios-install" ? IOS_INSTALL_DISMISS_KEY : DISMISS_KEY; try { - localStorage.setItem(DISMISS_KEY, String(Date.now())); + localStorage.setItem(key, String(Date.now())); } catch { /* ignore */ } - setHidden(true); + setVariant("hidden"); }; + if (variant === "ios-install") { + return ( +
+
+
+ +
+
+
+ {t("notifSettings.push.iosInstall.title")} +
+
+ {t("notifSettings.push.iosInstall.desc")} +
+
    +
  1. + + {t("notifSettings.push.iosInstall.step1")} + +
  2. +
  3. {t("notifSettings.push.iosInstall.step2")}
  4. +
  5. {t("notifSettings.push.iosInstall.step3")}
  6. +
+
+ +
+
+ +
+
+ ); + } + return (
); } + +function isDismissed(key: string): boolean { + try { + const raw = localStorage.getItem(key); + if (!raw) return false; + const ts = Number(raw); + return Number.isFinite(ts) && Date.now() - ts < RE_PROMPT_AFTER_MS; + } catch { + return false; + } +} diff --git a/artifacts/tx-os/src/hooks/use-push-subscription.ts b/artifacts/tx-os/src/hooks/use-push-subscription.ts index cd3bd3a4..dd0be230 100644 --- a/artifacts/tx-os/src/hooks/use-push-subscription.ts +++ b/artifacts/tx-os/src/hooks/use-push-subscription.ts @@ -51,7 +51,7 @@ function urlBase64ToUint8Array(base64: string): Uint8Array { * iOS builds, which is what was producing the generic * "تعذّر تفعيل الإشعارات" toast. */ -function isIosSafariNonStandalone(): boolean { +export function isIosSafariNonStandalone(): boolean { if (typeof window === "undefined" || typeof navigator === "undefined") { return false; } @@ -66,6 +66,16 @@ function isIosSafariNonStandalone(): boolean { (navigator as Navigator & { maxTouchPoints: number }).maxTouchPoints > 1); if (!isIos) return false; + // On iOS, Chrome (CriOS), Firefox (FxiOS), Edge (EdgiOS), Opera + // (OPiOS), DuckDuckGo (DuckDuckGo), etc. all wrap WebKit but cannot + // register a push subscription even after Add-to-Home-Screen — only + // mobile Safari can. So only show the install-steps prompt when the + // user is actually in Safari; otherwise there's nothing actionable + // they can do regardless of installing to the Home Screen. + const isSafari = + /Safari\//.test(ua) && + !/CriOS|FxiOS|EdgiOS|OPiOS|DuckDuckGo|YaBrowser|GSA\//.test(ua); + if (!isSafari) return false; const standalone = (navigator as Navigator & { standalone?: boolean }).standalone === true || (typeof window.matchMedia === "function" && diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index 69c4b2d8..40f7a24c 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -236,6 +236,13 @@ "unsupported": "غير مدعوم على هذا الجهاز. على الآيباد، أضف Tx إلى الشاشة الرئيسية أولاً.", "enableFailed": "تعذّر تفعيل الإشعارات. حاول مرة أخرى.", "enableSuccess": "تم تفعيل الإشعارات", + "iosInstall": { + "title": "ثبّت Tx على الشاشة الرئيسية لاستقبال الإشعارات", + "desc": "الآيفون والآيباد لا يوصّلان الإشعارات إلا لما يكون Tx مفتوحاً من أيقونة الشاشة الرئيسية، مو من تبويب Safari.", + "step1": "اضغط زر المشاركة في أسفل Safari.", + "step2": "اسحب لأسفل واضغط \"إضافة إلى الشاشة الرئيسية\".", + "step3": "افتح Tx من الأيقونة الجديدة، ثم فعّل الإشعارات من هنا." + }, "errors": { "unsupported": "هذا المتصفح لا يدعم إشعارات شاشة القفل.", "unsupported_ios_safari": "على الآيفون/الآيباد، افتح Tx من أيقونة الشاشة الرئيسية أولاً (مشاركة → إضافة إلى الشاشة الرئيسية)، ثم فعّل الإشعارات من داخل التطبيق.", diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index a8bcfac7..55d706af 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -242,6 +242,13 @@ "unsupported": "Not supported on this device. On iPad, add Tx OS to the Home Screen first.", "enableFailed": "Could not enable notifications. Try again.", "enableSuccess": "Notifications enabled", + "iosInstall": { + "title": "Install Tx OS to get lock-screen alerts", + "desc": "iPhone & iPad only deliver notifications when Tx OS is opened from a Home Screen icon, not from a Safari tab.", + "step1": "Tap the Share button at the bottom of Safari.", + "step2": "Scroll and tap \"Add to Home Screen\".", + "step3": "Open Tx OS from its new icon, then enable notifications here." + }, "errors": { "unsupported": "This browser does not support lock-screen notifications.", "unsupported_ios_safari": "On iPhone/iPad, open Tx OS from its Home Screen icon first (Share → Add to Home Screen), then enable notifications from inside the app.",