Make the app an installable PWA with phone layout and Web Push
Add a generated manifest, icon set, offline page and service worker that never caches authenticated HTML, plus install and update UI. Turn the rail into a drawer below md with container-query internals, dvh and safe-area handling, and reconnect the socket after visibility/network changes. Add Web Push subscriptions, presence-aware delivery, notification settings and tests.
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* Changes after a reason the socket may have died: the browser went back
|
||||
* online, the page was restored, or a hidden tab became visible again.
|
||||
* twui's `useWebSocket` gives up after three attempts, so the socket is
|
||||
* remounted instead of waiting for a reload.
|
||||
*/
|
||||
export default function useConnectionEpoch() {
|
||||
const [epoch, setEpoch] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const bump = () => setEpoch((value) => value + 1);
|
||||
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState == "visible") bump();
|
||||
};
|
||||
|
||||
window.addEventListener("online", bump);
|
||||
window.addEventListener("pageshow", bump);
|
||||
document.addEventListener("visibilitychange", onVisible);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("online", bump);
|
||||
window.removeEventListener("pageshow", bump);
|
||||
document.removeEventListener("visibilitychange", onVisible);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return epoch;
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export type InstallPromptEvent = Event & {
|
||||
prompt: () => Promise<void>;
|
||||
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
/**
|
||||
* Captured by `PwaInitScript` before React mounts.
|
||||
*/
|
||||
__cobbleInstallPrompt?: InstallPromptEvent | null;
|
||||
}
|
||||
}
|
||||
|
||||
const StandaloneQuery = "(display-mode: standalone)";
|
||||
|
||||
function isIosDevice() {
|
||||
if (typeof navigator == "undefined") return false;
|
||||
|
||||
const ua = navigator.userAgent;
|
||||
|
||||
return (
|
||||
/iphone|ipad|ipod/i.test(ua) ||
|
||||
(navigator.platform == "MacIntel" && navigator.maxTouchPoints > 1)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this browser can install the app now, whether it is already
|
||||
* installed, and the iOS steps when Safari has no install event.
|
||||
*/
|
||||
export default function useInstallPrompt() {
|
||||
const [prompt_event, setPromptEvent] = useState<InstallPromptEvent | null>(
|
||||
null,
|
||||
);
|
||||
const [is_standalone, setIsStandalone] = useState(false);
|
||||
const [is_ios, setIsIos] = useState(false);
|
||||
const [installing, setInstalling] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window == "undefined") return;
|
||||
|
||||
const standalone_query = window.matchMedia(StandaloneQuery);
|
||||
const sync_standalone = () =>
|
||||
setIsStandalone(
|
||||
standalone_query.matches ||
|
||||
Boolean((navigator as Navigator & { standalone?: boolean }).standalone),
|
||||
);
|
||||
|
||||
setPromptEvent(window.__cobbleInstallPrompt || null);
|
||||
setIsIos(isIosDevice());
|
||||
sync_standalone();
|
||||
|
||||
const sync_prompt = () =>
|
||||
setPromptEvent(window.__cobbleInstallPrompt || null);
|
||||
const on_installed = () => {
|
||||
window.__cobbleInstallPrompt = null;
|
||||
setPromptEvent(null);
|
||||
sync_standalone();
|
||||
};
|
||||
|
||||
window.addEventListener("cobble:install-available", sync_prompt);
|
||||
window.addEventListener("cobble:installed", on_installed);
|
||||
standalone_query.addEventListener("change", sync_standalone);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("cobble:install-available", sync_prompt);
|
||||
window.removeEventListener("cobble:installed", on_installed);
|
||||
standalone_query.removeEventListener("change", sync_standalone);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const install = async () => {
|
||||
if (!prompt_event) return false;
|
||||
|
||||
setInstalling(true);
|
||||
|
||||
try {
|
||||
await prompt_event.prompt();
|
||||
const choice = await prompt_event.userChoice;
|
||||
window.__cobbleInstallPrompt = null;
|
||||
setPromptEvent(null);
|
||||
|
||||
return choice.outcome == "accepted";
|
||||
} catch (error) {
|
||||
return false;
|
||||
} finally {
|
||||
setInstalling(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
can_install: Boolean(prompt_event) && !is_standalone,
|
||||
is_standalone,
|
||||
is_ios,
|
||||
/**
|
||||
* Safari has no install event, so it needs the Share menu steps.
|
||||
*/
|
||||
needs_ios_steps: is_ios && !is_standalone,
|
||||
installing,
|
||||
install,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* `navigator.onLine` as state. Starts online so server and first client
|
||||
* render match; the real value arrives on mount.
|
||||
*/
|
||||
export default function useOnlineStatus() {
|
||||
const [online, setOnline] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const sync = () => setOnline(navigator.onLine);
|
||||
|
||||
sync();
|
||||
window.addEventListener("online", sync);
|
||||
window.addEventListener("offline", sync);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("online", sync);
|
||||
window.removeEventListener("offline", sync);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return online;
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
import useInstallPrompt from "@/src/hooks/use-install-prompt";
|
||||
import urlBase64ToUint8Array from "@/src/utils/url-base64-to-uint8-array";
|
||||
import { PwaServiceWorkerPath } from "@/src/data/pwa-data";
|
||||
|
||||
export type PushDeviceState =
|
||||
| "loading"
|
||||
| "dev"
|
||||
| "unsupported"
|
||||
| "needs-install"
|
||||
| "denied"
|
||||
| "off"
|
||||
| "on";
|
||||
|
||||
/**
|
||||
* The registrant may still be registering when Settings opens, so give it
|
||||
* a moment before deciding the browser has no worker.
|
||||
*/
|
||||
async function grabRegistration() {
|
||||
const existing = await navigator.serviceWorker.getRegistration();
|
||||
|
||||
if (existing) return existing;
|
||||
|
||||
return await Promise.race([
|
||||
navigator.serviceWorker.ready,
|
||||
new Promise<undefined>((resolve) => setTimeout(() => resolve(undefined), 4000)),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* One device's Web Push subscription. Permission is only ever asked for
|
||||
* from an explicit tap: a denied permission is permanent in most browsers
|
||||
* and iOS requires the gesture. In development the service worker is not
|
||||
* registered, so this reports "dev" instead of hanging.
|
||||
*/
|
||||
export default function usePushNotifications({
|
||||
public_key,
|
||||
}: {
|
||||
public_key?: string | null;
|
||||
}) {
|
||||
const { is_ios, is_standalone } = useInstallPrompt();
|
||||
const [state, setState] = useState<PushDeviceState>("loading");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
const read_subscription = useCallback(async () => {
|
||||
if (process.env.NODE_ENV != "production") {
|
||||
setState("dev");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof navigator == "undefined" ||
|
||||
!("serviceWorker" in navigator) ||
|
||||
!("PushManager" in window) ||
|
||||
!("Notification" in window)
|
||||
) {
|
||||
setState("unsupported");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_ios && !is_standalone) {
|
||||
setState("needs-install");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Notification.permission == "denied") {
|
||||
setState("denied");
|
||||
return null;
|
||||
}
|
||||
|
||||
const registration = await grabRegistration();
|
||||
|
||||
if (!registration) {
|
||||
setState("unsupported");
|
||||
return null;
|
||||
}
|
||||
|
||||
const subscription =
|
||||
await registration.pushManager.getSubscription();
|
||||
|
||||
setState(subscription ? "on" : "off");
|
||||
|
||||
return subscription;
|
||||
}, [is_ios, is_standalone]);
|
||||
|
||||
useEffect(() => {
|
||||
read_subscription().catch(() => setState("unsupported"));
|
||||
}, [read_subscription]);
|
||||
|
||||
const enable = async () => {
|
||||
if (!public_key) {
|
||||
setError("Notifications aren't set up on this server yet.");
|
||||
return false;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
setError(undefined);
|
||||
|
||||
try {
|
||||
const permission = await Notification.requestPermission();
|
||||
|
||||
if (permission != "granted") {
|
||||
setState(permission == "denied" ? "denied" : "off");
|
||||
setError(
|
||||
permission == "denied"
|
||||
? "Notifications are blocked for this site. Allow them in the browser's site settings, then try again."
|
||||
: "Notifications were not allowed.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The registrant normally registers, but a settings page can
|
||||
* be the first thing opened.
|
||||
*/
|
||||
const registration = await navigator.serviceWorker.register(
|
||||
PwaServiceWorkerPath,
|
||||
{ scope: "/", updateViaCache: "none" },
|
||||
);
|
||||
|
||||
const subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(public_key),
|
||||
});
|
||||
|
||||
const res = await fetchApi("/api/admin/push/subscribe", {
|
||||
method: "POST",
|
||||
body: {
|
||||
endpoint: subscription.endpoint,
|
||||
keys: subscription.toJSON().keys,
|
||||
user_agent: navigator.userAgent,
|
||||
platform: navigator.platform,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.success) {
|
||||
await subscription.unsubscribe().catch(() => {});
|
||||
setError(res.msg || "Notifications could not be turned on.");
|
||||
setState("off");
|
||||
return false;
|
||||
}
|
||||
|
||||
setState("on");
|
||||
return true;
|
||||
} catch (caught: any) {
|
||||
setError(
|
||||
caught?.message || "Notifications could not be turned on.",
|
||||
);
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const disable = async () => {
|
||||
setBusy(true);
|
||||
setError(undefined);
|
||||
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.getRegistration();
|
||||
const subscription = await registration?.pushManager.getSubscription();
|
||||
|
||||
if (subscription) {
|
||||
await fetchApi("/api/admin/push/unsubscribe", {
|
||||
method: "POST",
|
||||
body: { endpoint: subscription.endpoint },
|
||||
});
|
||||
await subscription.unsubscribe();
|
||||
}
|
||||
|
||||
setState("off");
|
||||
return true;
|
||||
} catch (caught: any) {
|
||||
setError(
|
||||
caught?.message || "Notifications could not be turned off.",
|
||||
);
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const send_test = async () => {
|
||||
setBusy(true);
|
||||
setError(undefined);
|
||||
|
||||
try {
|
||||
const res = await fetchApi<{}, APIResponseObject>(
|
||||
"/api/admin/push/test",
|
||||
{ method: "POST", body: {} },
|
||||
);
|
||||
|
||||
if (!res.success) {
|
||||
setError(res.msg || "The test could not be sent.");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (caught: any) {
|
||||
setError(caught?.message || "The test could not be sent.");
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* The browser rotated the subscription. Re-subscribing needs the key
|
||||
* and the session, so the page does it.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (process.env.NODE_ENV != "production") return;
|
||||
if (!("serviceWorker" in navigator)) return;
|
||||
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
if (event.data?.type != "PUSH_RESUBSCRIBE") return;
|
||||
event.data.oldEndpoint &&
|
||||
fetchApi("/api/admin/push/unsubscribe", {
|
||||
method: "POST",
|
||||
body: { endpoint: event.data.oldEndpoint },
|
||||
}).catch(() => {});
|
||||
enable().catch(() => {});
|
||||
};
|
||||
|
||||
navigator.serviceWorker.addEventListener("message", onMessage);
|
||||
return () =>
|
||||
navigator.serviceWorker.removeEventListener("message", onMessage);
|
||||
}, [public_key]);
|
||||
|
||||
return {
|
||||
state,
|
||||
enabled: state == "on",
|
||||
busy,
|
||||
error,
|
||||
clearError: () => setError(undefined),
|
||||
enable,
|
||||
disable,
|
||||
send_test,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user