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) {
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
setVariant("ios-install");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (status !== "default") {
|
if (status !== "default") {
|
||||||
setHidden(true);
|
setVariant("hidden");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
if (isDismissed(DISMISS_KEY)) {
|
||||||
const raw = localStorage.getItem(DISMISS_KEY);
|
setVariant("hidden");
|
||||||
if (raw) {
|
|
||||||
const ts = Number(raw);
|
|
||||||
if (
|
|
||||||
Number.isFinite(ts) &&
|
|
||||||
Date.now() - ts < RE_PROMPT_AFTER_MS
|
|
||||||
) {
|
|
||||||
setHidden(true);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
setVariant("enable");
|
||||||
} catch {
|
|
||||||
/* localStorage unavailable — just show the prompt */
|
|
||||||
}
|
|
||||||
setHidden(false);
|
|
||||||
}, [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" &&
|
||||||
|
|||||||
@@ -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 من أيقونة الشاشة الرئيسية أولاً (مشاركة → إضافة إلى الشاشة الرئيسية)، ثم فعّل الإشعارات من داخل التطبيق.",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
Reference in New Issue
Block a user