Task #601: iOS PWA push notifications enablement

PWA + Web Push infra was already fully built (SW, VAPID auto-gen,
subscribe/unsubscribe API, sendPushToUser called from orders/meetings/
notes/replies, iOS-PWA detection hook). The user wasn't getting
lock-screen alerts because iPhone/iPad weren't installed as a PWA —
iOS only delivers Web Push from a Home Screen icon, not a Safari tab.

This commit polishes the iOS install path so the gap is obvious:

1. push-enable-prompt.tsx — Added a dedicated iOS install-steps card
   variant that renders on iPhone/iPad Safari when running outside
   standalone mode. Shows Share → Add to Home Screen → open from icon.
   Independent 14-day dismiss memory from the regular Enable card.

2. notification-settings.tsx — PushToggleRow now detects iOS-non-
   standalone and shows the unsupported_ios_safari hint inline with a
   disabled toggle, instead of teasing an Enable affordance that always
   fails.

3. use-push-subscription.ts — Exported isIosSafariNonStandalone() so
   both surfaces share the detection logic (was private).

4. lib/push.ts (server) — Added info logs in loadVapid() for both the
   env-key and disk-key paths, removed an accidental duplicate disk-read
   block introduced during editing. Now the production redeploy check
   is a one-liner: grep for "VAPID keys loaded" in the api logs.

5. ar.json / en.json — Added notifSettings.push.iosInstall.{title,desc,
   step1,step2,step3} bilingual strings for the new card.

No DB migrations. No deps changed. tx-os tsc passes; api-server tsc
errors are pre-existing (routes/push.ts handler type, font_settings)
and not touched by this change.

Smoke test on real iPhone/iPad still owed (out-of-band — task step #4
is a manual verification on the user's devices after redeploy).
This commit is contained in:
riyadhafraa
2026-05-18 13:13:12 +00:00
parent f2bb1dad33
commit a96b2570e3
6 changed files with 159 additions and 40 deletions
+8
View File
@@ -50,6 +50,10 @@ async function loadVapid(): Promise<{
if (envPub && envPriv) { if (envPub && envPriv) {
try { try {
webpush.setVapidDetails(subject, envPub, envPriv); 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 }; return { publicKey: envPub, privateKey: envPriv, subject };
} catch (err) { } catch (err) {
logger.error({ err }, "Invalid VAPID env keys — falling back to disk"); logger.error({ err }, "Invalid VAPID env keys — falling back to disk");
@@ -65,6 +69,10 @@ async function loadVapid(): Promise<{
}; };
if (parsed.publicKey && parsed.privateKey) { if (parsed.publicKey && parsed.privateKey) {
webpush.setVapidDetails(subject, 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 { return {
publicKey: parsed.publicKey, publicKey: parsed.publicKey,
privateKey: parsed.privateKey, privateKey: parsed.privateKey,
@@ -8,7 +8,10 @@ import {
type NotificationSoundId, type NotificationSoundId,
} from "@workspace/api-client-react"; } from "@workspace/api-client-react";
import { Bell, BellOff, Check, Volume2, VolumeX, Play } from "lucide-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 { useToast } from "@/hooks/use-toast";
import { import {
Popover, Popover,
@@ -335,9 +338,19 @@ function PushToggleRow() {
const { toast } = useToast(); const { toast } = useToast();
const { status, busy, enable, disable } = usePushSubscription(); const { status, busy, enable, disable } = usePushSubscription();
const subscribed = status === "subscribed"; const subscribed = status === "subscribed";
const disabled = busy || status === "unsupported" || status === "denied"; // iOS Safari outside Home Screen exposes the Push APIs but
const subtitle = // `subscribe()` always fails — treat it as a first-class "needs PWA
status === "unsupported" // 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") ? t("notifSettings.push.unsupported")
: status === "denied" : status === "denied"
? t("notifSettings.push.denied") ? t("notifSettings.push.denied")
@@ -1,73 +1,136 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Bell, X } from "lucide-react"; import { Bell, Share, X } from "lucide-react";
import { useAuth } from "@/contexts/AuthContext"; 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"; import { useToast } from "@/hooks/use-toast";
const DISMISS_KEY = "tx.pushPrompt.dismissedAt"; 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. // Re-prompt after 14 days if the user dismissed but never opted in.
const RE_PROMPT_AFTER_MS = 14 * 24 * 60 * 60 * 1000; const RE_PROMPT_AFTER_MS = 14 * 24 * 60 * 60 * 1000;
/** /**
* One-tap "Enable lock-screen alerts" card surfaced after the user logs * One-tap "Enable lock-screen alerts" card surfaced after the user logs
* in for the first time on a device. Renders only when: * in for the first time on a device. Renders one of three variants:
* - 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
* *
* On unsupported devices (notably iOS Safari before "Add to Home * 1. iOS Safari NOT installed as PWA → install-steps card explaining
* Screen") we stay silent — the toggle inside Notification Settings * Share → Add to Home Screen. (Tapping "Enable" in this state
* exposes the relevant copy for power users. We don't want a * would fail with `unsupported_ios_safari`, which is opaque.)
* permanent "not supported" banner cluttering the home screen. * 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() { export function PushEnablePrompt() {
const { t } = useTranslation(); const { t } = useTranslation();
const { user } = useAuth(); const { user } = useAuth();
const { toast } = useToast(); const { toast } = useToast();
const { status, busy, enable } = usePushSubscription(); const { status, busy, enable } = usePushSubscription();
const [hidden, setHidden] = useState(true); const [variant, setVariant] = useState<"hidden" | "ios-install" | "enable">(
"hidden",
);
useEffect(() => { useEffect(() => {
if (!user) { if (!user) {
setHidden(true); setVariant("hidden");
return; return;
} }
if (status !== "default") {
setHidden(true); // iOS Safari outside Home Screen: show install steps regardless of
return; // `status`, because `enable()` will fail there with
} // `unsupported_ios_safari`. The hook keeps `status === "default"`
try { // since the APIs *exist*; we override here on the install path.
const raw = localStorage.getItem(DISMISS_KEY); if (isIosSafariNonStandalone()) {
if (raw) { if (isDismissed(IOS_INSTALL_DISMISS_KEY)) {
const ts = Number(raw); setVariant("hidden");
if ( return;
Number.isFinite(ts) &&
Date.now() - ts < RE_PROMPT_AFTER_MS
) {
setHidden(true);
return;
}
} }
} catch { setVariant("ios-install");
/* localStorage unavailable — just show the prompt */ return;
} }
setHidden(false);
if (status !== "default") {
setVariant("hidden");
return;
}
if (isDismissed(DISMISS_KEY)) {
setVariant("hidden");
return;
}
setVariant("enable");
}, [user, status]); }, [user, status]);
if (hidden) return null; if (variant === "hidden") return null;
const dismiss = () => { const dismiss = () => {
const key =
variant === "ios-install" ? IOS_INSTALL_DISMISS_KEY : DISMISS_KEY;
try { try {
localStorage.setItem(DISMISS_KEY, String(Date.now())); localStorage.setItem(key, String(Date.now()));
} catch { } catch {
/* ignore */ /* ignore */
} }
setHidden(true); setVariant("hidden");
}; };
if (variant === "ios-install") {
return (
<div
role="dialog"
aria-live="polite"
data-testid="push-ios-install-prompt"
className="fixed inset-x-3 bottom-3 z-50 md:left-auto md:right-4 md:bottom-4 md:max-w-sm rounded-2xl border border-foreground/10 bg-background/95 backdrop-blur shadow-lg p-3"
>
<div className="flex items-start gap-3">
<div className="shrink-0 rounded-full bg-primary/15 p-2">
<Share size={18} className="text-primary" />
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-foreground">
{t("notifSettings.push.iosInstall.title")}
</div>
<div className="text-xs text-muted-foreground mt-0.5 leading-snug">
{t("notifSettings.push.iosInstall.desc")}
</div>
<ol className="mt-2 space-y-1.5 text-xs text-foreground/90 leading-snug list-decimal ms-4">
<li className="flex items-start gap-1.5 -ms-1 ps-1">
<span className="flex-1">
{t("notifSettings.push.iosInstall.step1")}
</span>
</li>
<li>{t("notifSettings.push.iosInstall.step2")}</li>
<li>{t("notifSettings.push.iosInstall.step3")}</li>
</ol>
<div className="mt-2 flex items-center gap-2">
<button
type="button"
data-testid="push-ios-install-dismiss"
onClick={dismiss}
className="text-xs font-medium px-3 py-1.5 rounded-md text-muted-foreground hover:bg-foreground/5"
>
{t("common.later")}
</button>
</div>
</div>
<button
type="button"
aria-label={t("common.close")}
onClick={dismiss}
className="shrink-0 text-muted-foreground hover:text-foreground p-1 rounded-md hover:bg-foreground/5"
>
<X size={14} />
</button>
</div>
</div>
);
}
return ( return (
<div <div
role="dialog" role="dialog"
@@ -136,3 +199,14 @@ export function PushEnablePrompt() {
</div> </div>
); );
} }
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;
}
}
@@ -51,7 +51,7 @@ function urlBase64ToUint8Array(base64: string): Uint8Array {
* iOS builds, which is what was producing the generic * iOS builds, which is what was producing the generic
* "تعذّر تفعيل الإشعارات" toast. * "تعذّر تفعيل الإشعارات" toast.
*/ */
function isIosSafariNonStandalone(): boolean { export function isIosSafariNonStandalone(): boolean {
if (typeof window === "undefined" || typeof navigator === "undefined") { if (typeof window === "undefined" || typeof navigator === "undefined") {
return false; return false;
} }
@@ -66,6 +66,16 @@ function isIosSafariNonStandalone(): boolean {
(navigator as Navigator & { maxTouchPoints: number }).maxTouchPoints > (navigator as Navigator & { maxTouchPoints: number }).maxTouchPoints >
1); 1);
if (!isIos) return false; 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 = const standalone =
(navigator as Navigator & { standalone?: boolean }).standalone === true || (navigator as Navigator & { standalone?: boolean }).standalone === true ||
(typeof window.matchMedia === "function" && (typeof window.matchMedia === "function" &&
+7
View File
@@ -236,6 +236,13 @@
"unsupported": "غير مدعوم على هذا الجهاز. على الآيباد، أضف Tx إلى الشاشة الرئيسية أولاً.", "unsupported": "غير مدعوم على هذا الجهاز. على الآيباد، أضف Tx إلى الشاشة الرئيسية أولاً.",
"enableFailed": "تعذّر تفعيل الإشعارات. حاول مرة أخرى.", "enableFailed": "تعذّر تفعيل الإشعارات. حاول مرة أخرى.",
"enableSuccess": "تم تفعيل الإشعارات", "enableSuccess": "تم تفعيل الإشعارات",
"iosInstall": {
"title": "ثبّت Tx على الشاشة الرئيسية لاستقبال الإشعارات",
"desc": "الآيفون والآيباد لا يوصّلان الإشعارات إلا لما يكون Tx مفتوحاً من أيقونة الشاشة الرئيسية، مو من تبويب Safari.",
"step1": "اضغط زر المشاركة في أسفل Safari.",
"step2": "اسحب لأسفل واضغط \"إضافة إلى الشاشة الرئيسية\".",
"step3": "افتح Tx من الأيقونة الجديدة، ثم فعّل الإشعارات من هنا."
},
"errors": { "errors": {
"unsupported": "هذا المتصفح لا يدعم إشعارات شاشة القفل.", "unsupported": "هذا المتصفح لا يدعم إشعارات شاشة القفل.",
"unsupported_ios_safari": "على الآيفون/الآيباد، افتح Tx من أيقونة الشاشة الرئيسية أولاً (مشاركة → إضافة إلى الشاشة الرئيسية)، ثم فعّل الإشعارات من داخل التطبيق.", "unsupported_ios_safari": "على الآيفون/الآيباد، افتح Tx من أيقونة الشاشة الرئيسية أولاً (مشاركة → إضافة إلى الشاشة الرئيسية)، ثم فعّل الإشعارات من داخل التطبيق.",
+7
View File
@@ -242,6 +242,13 @@
"unsupported": "Not supported on this device. On iPad, add Tx OS to the Home Screen first.", "unsupported": "Not supported on this device. On iPad, add Tx OS to the Home Screen first.",
"enableFailed": "Could not enable notifications. Try again.", "enableFailed": "Could not enable notifications. Try again.",
"enableSuccess": "Notifications enabled", "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": { "errors": {
"unsupported": "This browser does not support lock-screen notifications.", "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.", "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.",