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:
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 (
|
||||
<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 (
|
||||
<div
|
||||
role="dialog"
|
||||
@@ -136,3 +199,14 @@ export function PushEnablePrompt() {
|
||||
</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
|
||||
* "تعذّر تفعيل الإشعارات" 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" &&
|
||||
|
||||
@@ -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 من أيقونة الشاشة الرئيسية أولاً (مشاركة → إضافة إلى الشاشة الرئيسية)، ثم فعّل الإشعارات من داخل التطبيق.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user