fix(push): tell iPhone Safari users to open the Home Screen app

iOS offers Web Push only to a web app opened from the Home Screen. In a
Safari tab the push card said the browser does not support push
notifications, and the step that fixes it was only in the docs. On an
iPhone or iPad outside the installed app the card now says to add
HealthLog to the Home Screen, open it from there and turn push on.

WebPushCard grows by two lines for the second sentence; its baseline
goes from 201 to 203, still below the 215 it had before the previous
change.
This commit is contained in:
Marc-André Bombeck committed 2026-11-11 20:46:58 +01:00
1 parent 32911fea37
commit 7f8ba0ae98
13 files changed
+97 -8

No files matched your search

@@ -0,0 +1,62 @@
/**
* Which "push is unavailable" sentence the Web Push card shows.
*
* An iPhone in a Safari tab has no PushManager, the same as a browser that
* cannot do push at all, and the card used to tell both that push is not
* supported. On iOS the fix is to open HealthLog from the Home Screen, so
* that case gets its own sentence.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import { needsHomeScreen } from "../web-push-card";
const IPHONE =
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Mobile/15E148 Safari/604.1";
const IPAD_AS_MAC =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Safari/605.1.15";
const ANDROID_FIREFOX =
"Mozilla/5.0 (Android 14; Mobile; rv:132.0) Gecko/132.0 Firefox/132.0";
function device(
userAgent: string,
maxTouchPoints: number,
standalone: boolean,
) {
vi.stubGlobal("navigator", { userAgent, maxTouchPoints });
vi.stubGlobal("window", {
matchMedia: (query: string) => ({
matches: standalone && query === "(display-mode: standalone)",
}),
});
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe("needsHomeScreen", () => {
it("asks an iPhone in a Safari tab to open the Home Screen app", () => {
device(IPHONE, 5, false);
expect(needsHomeScreen()).toBe(true);
});
it("asks an iPad, which reports itself as a Mac, the same", () => {
device(IPAD_AS_MAC, 5, false);
expect(needsHomeScreen()).toBe(true);
});
it("does not ask the installed app, where push is missing for real", () => {
device(IPHONE, 5, true);
expect(needsHomeScreen()).toBe(false);
});
it("does not ask a Mac without a touch screen", () => {
device(IPAD_AS_MAC, 0, false);
expect(needsHomeScreen()).toBe(false);
});
it("does not ask a browser that has no push at all", () => {
device(ANDROID_FIREFOX, 5, false);
expect(needsHomeScreen()).toBe(false);
});
});
+23 -6
View File
@@ -25,10 +25,25 @@ function urlBase64ToArrayBuffer(base64String: string): ArrayBuffer {
return buffer;
}
/**
* iPhone and iPad offer Web Push only to HealthLog opened from the Home
* Screen; in a Safari tab the API is missing. iPadOS Safari reports itself
* as a Mac, so a Mac with a touch screen counts too.
*/
export function needsHomeScreen(): boolean {
const ua = navigator.userAgent;
const appleTouch =
/iPhone|iPad|iPod/.test(ua) ||
(/Macintosh/.test(ua) && navigator.maxTouchPoints > 1);
return appleTouch && !window.matchMedia("(display-mode: standalone)").matches;
}
type Unsupported = "browser" | "install" | null;
export function WebPushCard() {
const { t } = useTranslations();
const [isSubscribed, setIsSubscribed] = useState(false);
const [isSupported, setIsSupported] = useState(true);
const [unsupported, setUnsupported] = useState<Unsupported>(null);
const [isDenied, setIsDenied] = useState(false);
const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState(false);
@@ -41,7 +56,7 @@ export function WebPushCard() {
// the effect body is rejected.
async function checkSubscription() {
if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
setIsSupported(false);
setUnsupported(needsHomeScreen() ? "install" : "browser");
setLoading(false);
return;
}
@@ -156,7 +171,7 @@ export function WebPushCard() {
// carries a status badge.
const cardAction = loading ? (
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
) : !isSupported || isDenied ? null : (
) : unsupported || isDenied ? null : (
<SettingsCardActions>
{isSubscribed && (
<TestConnectionButton endpoint="/api/notifications/web-push/test" />
@@ -199,11 +214,13 @@ export function WebPushCard() {
description={t("settings.webPushDescription")}
/>
{(!loading && !isSupported) || (!loading && isDenied) || msg ? (
{(!loading && unsupported) || (!loading && isDenied) || msg ? (
<div className="space-y-4">
{!loading && !isSupported ? (
{!loading && unsupported ? (
<p className="text-muted-foreground text-sm">
{t("settings.webPushNotSupported")}
{unsupported === "install"
? t("settings.webPushInstallFirst")
: t("settings.webPushNotSupported")}
</p>
) : !loading && isDenied ? (
<p className="text-destructive text-sm">