Карточка появляется в установленном приложении (и сразу по событию appinstalled), объясняет пользу — новый штраф, дошедшее пополнение — и только по кнопке вызывает системное окно. Порядок именно такой, потому что системное окно показывается ОДИН раз за установку: отказ браузер запоминает навсегда, и переспросить нечем. Своё предложение отказа не стоит — «не сейчас» откладывает разговор на неделю. Во вкладке браузера карточку не показываем: на iPhone уведомления существуют только в установленном приложении, а человеку, ещё не решившему, нужен ли ему сайт, вопрос про уведомления мешает. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
93 lines
4.4 KiB
TypeScript
93 lines
4.4 KiB
TypeScript
import { getPushKey, subscribePush, unsubscribePush } from "@/api/driver";
|
||
|
||
/**
|
||
* Включение уведомлений в приложении.
|
||
*
|
||
* Главное правило экрана вокруг этого кода: разрешение спрашивается ОДИН раз за
|
||
* всю жизнь установки. Отказ браузер запоминает, переспросить нельзя — только
|
||
* руками в настройках сайта, куда водитель не пойдёт. Поэтому запрос идёт
|
||
* строго по нажатию и только когда сервер подтвердил, что уведомления настроены.
|
||
*/
|
||
export type PushState =
|
||
| "unsupported" // браузер не умеет (или iOS без установки на домашний экран)
|
||
| "needs-install" // iOS: умеет, но только когда приложение добавлено на экран
|
||
| "denied" // разрешение отклонено — переспросить нечем
|
||
| "off" // можно включить
|
||
| "on"; // подписка есть
|
||
|
||
/** Приложение открыто как установленное (с домашнего экрана), а не вкладкой. */
|
||
export function installed(): boolean {
|
||
return isStandalone();
|
||
}
|
||
|
||
function isStandalone(): boolean {
|
||
return window.matchMedia("(display-mode: standalone)").matches ||
|
||
// iOS до 17 не поддерживает display-mode: standalone в matchMedia
|
||
(navigator as { standalone?: boolean }).standalone === true;
|
||
}
|
||
|
||
function isIos(): boolean {
|
||
return /iPad|iPhone|iPod/.test(navigator.userAgent);
|
||
}
|
||
|
||
export function supported(): boolean {
|
||
return "serviceWorker" in navigator && "PushManager" in window && "Notification" in window;
|
||
}
|
||
|
||
export async function currentState(): Promise<PushState> {
|
||
if (!supported()) return isIos() && !isStandalone() ? "needs-install" : "unsupported";
|
||
if (Notification.permission === "denied") return "denied";
|
||
const reg = await navigator.serviceWorker.getRegistration();
|
||
const sub = await reg?.pushManager.getSubscription();
|
||
return sub ? "on" : "off";
|
||
}
|
||
|
||
/** base64url → байты: ключ VAPID браузер принимает только так.
|
||
* Возвращаем ArrayBuffer, а не Uint8Array: типы DOM ждут BufferSource. */
|
||
function keyToBytes(base64: string): ArrayBuffer {
|
||
const padded = base64.replace(/-/g, "+").replace(/_/g, "/") +
|
||
"=".repeat((4 - (base64.length % 4)) % 4);
|
||
const raw = atob(padded);
|
||
const bytes = new Uint8Array(raw.length);
|
||
for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
|
||
return bytes.buffer;
|
||
}
|
||
|
||
/** Включить уведомления. Возвращает новое состояние. */
|
||
export async function enable(): Promise<PushState> {
|
||
if (!supported()) return currentState();
|
||
const { key, enabled } = await getPushKey();
|
||
// Ключа нет — сервер уведомления не отправляет. Разрешение не спрашиваем:
|
||
// потратить единственную попытку впустую нельзя.
|
||
if (!enabled || !key) return "off";
|
||
|
||
const permission = await Notification.requestPermission();
|
||
if (permission !== "granted") return permission === "denied" ? "denied" : "off";
|
||
|
||
const reg = await navigator.serviceWorker.ready;
|
||
const sub = await reg.pushManager.subscribe({
|
||
userVisibleOnly: true,
|
||
applicationServerKey: keyToBytes(key),
|
||
});
|
||
const json = sub.toJSON() as { endpoint?: string; keys?: { p256dh?: string; auth?: string } };
|
||
await subscribePush({
|
||
endpoint: json.endpoint ?? sub.endpoint,
|
||
p256dh: json.keys?.p256dh ?? "",
|
||
auth: json.keys?.auth ?? "",
|
||
});
|
||
return "on";
|
||
}
|
||
|
||
/** Выключить: снимаем подписку и в браузере, и на сервере. */
|
||
export async function disable(): Promise<PushState> {
|
||
const reg = await navigator.serviceWorker.getRegistration();
|
||
const sub = await reg?.pushManager.getSubscription();
|
||
if (sub) {
|
||
// Сначала сервер: не сумеем достучаться — подписка останется живой, и
|
||
// уведомления продолжат приходить, а это лучше «выключил, а они идут».
|
||
await unsubscribePush(sub.endpoint);
|
||
await sub.unsubscribe();
|
||
}
|
||
return "off";
|
||
}
|