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 ( +