feat: web push notifications, mobile-ready (PRD §5.6)
ADR 0003: VAPID self-hosted today, DeviceToken model agnostic to
platform so iOS/Android (Expo/APNs/FCM) plug in as new senders later.
Backend (apps/api/src/modules/notifications)
- sender.ts: NotificationSender interface, WebPushSender via VAPID
- notifications.routes.ts: GET /me/notifications/config, POST/DELETE
/me/device-tokens, PATCH /me/notifications/settings, POST /me/notifications/test
- scheduler.ts: node-cron Mon 09:00 Europe/Rome for weekly weigh-in
reminder; auto-cleanup of expired tokens on 404/410
- env: VAPID_PUBLIC_KEY/PRIVATE_KEY/SUBJECT (all optional → push gracefully off)
Frontend
- public/sw.js minimal (push + notificationclick)
- lib/notifications/push-client.ts: subscribe / unsubscribe / getCurrentSubscription
- profile/notifications-{actions,panel}.tsx: editorial panel with toggles,
device list, "send test", per-device removal
- pushReady requires both permission AND active subscription (covers the
case where the user revoked the SW but kept the browser permission)
Schema
- DeviceToken { userId, platform, endpoint, p256dh, auth, token, userAgent,
createdAt, lastSeenAt } with unique(userId, endpoint)
- ExtendedPrismaClient type exported from @ketopath/db
- NotificationSettings zod schema in @ketopath/shared
Tooling
- lint-staged: split .js out of eslint glob so service worker is only
formatted (it lives outside the TS project)
i18n
- Notifications namespace (it) with typed error keys
Smoke tested: POST /me/device-tokens 201, POST /me/notifications/test 200,
real push delivered to a macOS Chrome device.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
cdfc685855
commit
bb48357179
26 files changed
+1144
-2
No files matched your search
@@ -0,0 +1,100 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
const API_URL = process.env.API_URL ?? 'http://localhost:4000';
|
||||
|
||||
function cookieHeader(): string {
|
||||
return headers().get('cookie') ?? '';
|
||||
}
|
||||
|
||||
export interface NotificationDevice {
|
||||
id: string;
|
||||
platform: string;
|
||||
endpoint: string | null;
|
||||
userAgent: string | null;
|
||||
createdAt: string;
|
||||
lastSeenAt: string;
|
||||
}
|
||||
|
||||
export interface NotificationConfig {
|
||||
pushEnabled: boolean;
|
||||
settings: { weeklyWeighIn: boolean; fastingMilestones: boolean };
|
||||
devices: NotificationDevice[];
|
||||
}
|
||||
|
||||
export async function fetchNotificationConfig(): Promise<NotificationConfig | null> {
|
||||
const res = await fetch(`${API_URL}/me/notifications/config`, {
|
||||
headers: { cookie: cookieHeader() },
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as NotificationConfig;
|
||||
}
|
||||
|
||||
export interface SubscribePayload {
|
||||
endpoint: string;
|
||||
keys: { p256dh: string; auth: string };
|
||||
userAgent?: string;
|
||||
}
|
||||
|
||||
export type ActionResult = { ok: true } | { ok: false; error: string };
|
||||
|
||||
export async function registerDevice(payload: SubscribePayload): Promise<ActionResult> {
|
||||
const res = await fetch(`${API_URL}/me/device-tokens`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', cookie: cookieHeader() },
|
||||
body: JSON.stringify(payload),
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
return { ok: false, error: body.error ?? `api_error_${res.status}` };
|
||||
}
|
||||
revalidatePath('/profile');
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function unregisterDevice(deviceId: string): Promise<ActionResult> {
|
||||
const res = await fetch(`${API_URL}/me/device-tokens/${deviceId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { cookie: cookieHeader() },
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!res.ok && res.status !== 204) {
|
||||
return { ok: false, error: `api_error_${res.status}` };
|
||||
}
|
||||
revalidatePath('/profile');
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function updateNotificationSettings(
|
||||
patch: Partial<{ weeklyWeighIn: boolean; fastingMilestones: boolean }>,
|
||||
): Promise<ActionResult> {
|
||||
const res = await fetch(`${API_URL}/me/notifications/settings`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', cookie: cookieHeader() },
|
||||
body: JSON.stringify(patch),
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
return { ok: false, error: body.error ?? `api_error_${res.status}` };
|
||||
}
|
||||
revalidatePath('/profile');
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function sendTestNotification(): Promise<ActionResult> {
|
||||
const res = await fetch(`${API_URL}/me/notifications/test`, {
|
||||
method: 'POST',
|
||||
headers: { cookie: cookieHeader() },
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
return { ok: false, error: body.error ?? `api_error_${res.status}` };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useEffect, useState, useTransition } from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
pushSupported,
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
getCurrentSubscription,
|
||||
} from '@/lib/notifications/push-client';
|
||||
|
||||
import {
|
||||
sendTestNotification,
|
||||
unregisterDevice,
|
||||
updateNotificationSettings,
|
||||
type NotificationConfig,
|
||||
type NotificationDevice,
|
||||
} from './notifications-actions';
|
||||
|
||||
const FORMATTER = new Intl.DateTimeFormat('it-IT', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
export function NotificationsPanel({ initial }: { initial: NotificationConfig | null }) {
|
||||
const t = useTranslations('Notifications');
|
||||
const [config, setConfig] = useState<NotificationConfig | null>(initial);
|
||||
const [supported, setSupported] = useState<boolean>(true);
|
||||
const [permission, setPermission] = useState<NotificationPermission | 'unknown'>('unknown');
|
||||
const [hasSubscription, setHasSubscription] = useState<boolean>(false);
|
||||
const [feedback, setFeedback] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
useEffect(() => {
|
||||
setSupported(pushSupported());
|
||||
if (typeof Notification !== 'undefined') setPermission(Notification.permission);
|
||||
void getCurrentSubscription().then((sub) => setHasSubscription(!!sub));
|
||||
}, []);
|
||||
|
||||
if (!config) {
|
||||
return <p className="font-display text-ink-soft text-base italic">{t('configUnavailable')}</p>;
|
||||
}
|
||||
|
||||
// Capture narrowed config in a const so handler closures don't lose the
|
||||
// narrowing (TS treats `config` as nullable inside async closures because
|
||||
// `setConfig` is in scope).
|
||||
const cfg: NotificationConfig = config;
|
||||
// Una subscription web richiede tutti e tre: server abilitato, browser
|
||||
// capace, permesso concesso, e una subscription effettivamente registrata
|
||||
// sul pushManager. Se manca l'ultima, mostriamo di nuovo "Attiva".
|
||||
const pushReady = cfg.pushEnabled && supported && permission === 'granted' && hasSubscription;
|
||||
const blocked = permission === 'denied';
|
||||
|
||||
function refreshConfig(next: NotificationConfig): void {
|
||||
setConfig(next);
|
||||
}
|
||||
|
||||
function flash(msg: string): void {
|
||||
setError(null);
|
||||
setFeedback(msg);
|
||||
window.setTimeout(() => setFeedback(null), 3000);
|
||||
}
|
||||
function flashError(msg: string): void {
|
||||
setFeedback(null);
|
||||
setError(msg);
|
||||
}
|
||||
|
||||
function handleSubscribe(): void {
|
||||
startTransition(async () => {
|
||||
const result = await subscribe();
|
||||
if (!result.ok) {
|
||||
const reasonKey = `errors.${result.reason ?? 'unknown'}` as const;
|
||||
flashError(t.has(reasonKey) ? t(reasonKey) : t('errors.unknown'));
|
||||
return;
|
||||
}
|
||||
setPermission('granted');
|
||||
flash(t('subscribed'));
|
||||
// revalidatePath è già stato chiamato lato server action; ricarico la
|
||||
// pagina così l'elenco device si rinfresca senza altre fetch a mano.
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
|
||||
function handleUnsubscribe(deviceId: string): void {
|
||||
startTransition(async () => {
|
||||
const sub = await getCurrentSubscription();
|
||||
const subEndpoint = sub?.endpoint;
|
||||
const device = cfg.devices.find((d) => d.id === deviceId);
|
||||
if (subEndpoint && device?.endpoint === subEndpoint) {
|
||||
await unsubscribe(deviceId);
|
||||
} else {
|
||||
const r = await unregisterDevice(deviceId);
|
||||
if (!r.ok) {
|
||||
flashError(t('errors.api_error'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
flash(t('unsubscribed'));
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
|
||||
function handleToggle(key: 'weeklyWeighIn' | 'fastingMilestones', value: boolean): void {
|
||||
startTransition(async () => {
|
||||
const result = await updateNotificationSettings({ [key]: value });
|
||||
if (!result.ok) {
|
||||
flashError(t('errors.api_error'));
|
||||
return;
|
||||
}
|
||||
refreshConfig({ ...cfg, settings: { ...cfg.settings, [key]: value } });
|
||||
});
|
||||
}
|
||||
|
||||
function handleTest(): void {
|
||||
startTransition(async () => {
|
||||
const result = await sendTestNotification();
|
||||
if (!result.ok) {
|
||||
const key = `errors.${result.error}` as const;
|
||||
flashError(t.has(key) ? t(key) : t('errors.api_error'));
|
||||
return;
|
||||
}
|
||||
flash(t('testSent'));
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-10">
|
||||
<header className="space-y-3">
|
||||
<p className="editorial-eyebrow">{t('eyebrow')}</p>
|
||||
<h2 className="font-display text-ink text-3xl font-medium leading-tight tracking-tight">
|
||||
{t('title')}
|
||||
</h2>
|
||||
<p className="font-display text-ink-soft max-w-xl text-base italic leading-snug">
|
||||
{t('subtitle')}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="border-ink/15 border-t" />
|
||||
|
||||
{!supported ? (
|
||||
<p className="font-display text-ink-soft text-base italic">{t('unsupported')}</p>
|
||||
) : !cfg.pushEnabled ? (
|
||||
<p className="font-display text-ink-soft text-base italic">{t('serverDisabled')}</p>
|
||||
) : (
|
||||
<div className="grid gap-10 md:grid-cols-12">
|
||||
<div className="space-y-5 md:col-span-7">
|
||||
<ToggleRow
|
||||
label={t('weeklyWeighInLabel')}
|
||||
description={t('weeklyWeighInHint')}
|
||||
checked={cfg.settings.weeklyWeighIn}
|
||||
onChange={(v) => handleToggle('weeklyWeighIn', v)}
|
||||
disabled={pending || !pushReady}
|
||||
/>
|
||||
<ToggleRow
|
||||
label={t('fastingMilestonesLabel')}
|
||||
description={t('fastingMilestonesHint')}
|
||||
checked={cfg.settings.fastingMilestones}
|
||||
onChange={(v) => handleToggle('fastingMilestones', v)}
|
||||
disabled={pending || !pushReady}
|
||||
comingSoon
|
||||
/>
|
||||
</div>
|
||||
|
||||
<aside className="md:border-ink/15 space-y-5 md:col-span-5 md:border-l md:pl-8">
|
||||
<p className="editorial-eyebrow">{t('thisDevice')}</p>
|
||||
{pushReady ? (
|
||||
<>
|
||||
<p className="font-display text-ink text-base italic leading-snug">
|
||||
{t('thisDeviceActive')}
|
||||
</p>
|
||||
<Button type="button" variant="outline" onClick={handleTest} disabled={pending}>
|
||||
{pending ? t('working') : t('sendTest')}
|
||||
</Button>
|
||||
</>
|
||||
) : blocked ? (
|
||||
<p className="font-display text-pomodoro text-base italic leading-snug">
|
||||
{t('blocked')}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="font-display text-ink-soft text-base italic leading-snug">
|
||||
{t('thisDeviceInactive')}
|
||||
</p>
|
||||
<Button type="button" onClick={handleSubscribe} disabled={pending}>
|
||||
{pending ? t('working') : t('enableHere')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cfg.devices.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
<p className="editorial-eyebrow">{t('registeredDevices')}</p>
|
||||
<ul className="divide-ink/10 divide-y">
|
||||
{cfg.devices.map((d) => (
|
||||
<DeviceRow
|
||||
key={d.id}
|
||||
device={d}
|
||||
onRemove={(id) => handleUnsubscribe(id)}
|
||||
disabled={pending}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{feedback ? (
|
||||
<p className="font-display text-oliva text-base italic" role="status">
|
||||
{feedback}
|
||||
</p>
|
||||
) : null}
|
||||
{error ? (
|
||||
<p className="font-display text-pomodoro text-base italic" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleRow({
|
||||
label,
|
||||
description,
|
||||
checked,
|
||||
onChange,
|
||||
disabled,
|
||||
comingSoon,
|
||||
}: {
|
||||
label: string;
|
||||
description: string;
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
disabled?: boolean;
|
||||
comingSoon?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<label className="border-ink/10 flex cursor-pointer items-baseline gap-4 border-b py-4 last:border-b-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
disabled={disabled || comingSoon}
|
||||
className="border-ink mt-1 h-4 w-4 cursor-pointer accent-current disabled:cursor-not-allowed disabled:opacity-40"
|
||||
/>
|
||||
<span className="flex-1">
|
||||
<span className="font-display text-ink flex items-baseline gap-2 text-lg leading-tight">
|
||||
{label}
|
||||
{comingSoon ? (
|
||||
<span className="text-ink-dim font-mono text-[10px] uppercase tracking-widest">
|
||||
· in arrivo
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="font-display text-ink-soft mt-1 block text-sm italic leading-snug">
|
||||
{description}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function DeviceRow({
|
||||
device,
|
||||
onRemove,
|
||||
disabled,
|
||||
}: {
|
||||
device: NotificationDevice;
|
||||
onRemove: (id: string) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const ua = device.userAgent ?? '';
|
||||
const label = describeUserAgent(ua) || device.platform;
|
||||
return (
|
||||
<li className="grid grid-cols-[1fr_auto] items-baseline gap-4 py-3">
|
||||
<div>
|
||||
<p className="font-display text-ink text-base leading-tight">{label}</p>
|
||||
<p className="text-ink-soft mt-1 font-mono text-[10px] uppercase tracking-widest">
|
||||
{device.platform} · {FORMATTER.format(new Date(device.createdAt))}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(device.id)}
|
||||
disabled={disabled}
|
||||
className="text-ink-soft decoration-pomodoro hover:text-ink font-mono text-[11px] uppercase tracking-widest underline decoration-[1.5px] underline-offset-[5px] transition-colors disabled:opacity-40"
|
||||
>
|
||||
Rimuovi
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function describeUserAgent(ua: string): string | null {
|
||||
if (!ua) return null;
|
||||
const matches = [
|
||||
[/iPhone|iPad/i, 'iOS'],
|
||||
[/Android/i, 'Android'],
|
||||
[/Mac OS X/i, 'macOS'],
|
||||
[/Windows/i, 'Windows'],
|
||||
[/Linux/i, 'Linux'],
|
||||
] as const;
|
||||
const platform = matches.find(([re]) => re.test(ua))?.[1] ?? 'Browser';
|
||||
const browserMatches = [
|
||||
[/Edg\//i, 'Edge'],
|
||||
[/Chrome\//i, 'Chrome'],
|
||||
[/Safari\//i, 'Safari'],
|
||||
[/Firefox\//i, 'Firefox'],
|
||||
] as const;
|
||||
const browser = browserMatches.find(([re]) => re.test(ua))?.[1];
|
||||
return browser ? `${platform} · ${browser}` : platform;
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import { Masthead } from '@/components/masthead';
|
||||
import { getServerSession } from '@/lib/auth';
|
||||
|
||||
import { fetchProfile } from './actions';
|
||||
import { fetchNotificationConfig } from './notifications-actions';
|
||||
import { NotificationsPanel } from './notifications-panel';
|
||||
import { ProfileForm } from './profile-form';
|
||||
|
||||
export default async function ProfilePage({ params: { locale } }: { params: { locale: string } }) {
|
||||
@@ -21,14 +23,17 @@ export default async function ProfilePage({ params: { locale } }: { params: { lo
|
||||
if (!user?.disclaimerAcceptedAt) redirect('/welcome');
|
||||
|
||||
const profile = (await fetchProfile()) as Parameters<typeof ProfileForm>[0]['initial'];
|
||||
const notifications = await fetchNotificationConfig();
|
||||
|
||||
return <ProfilePageContent initial={profile} />;
|
||||
return <ProfilePageContent initial={profile} notifications={notifications} />;
|
||||
}
|
||||
|
||||
function ProfilePageContent({
|
||||
initial,
|
||||
notifications,
|
||||
}: {
|
||||
initial: Parameters<typeof ProfileForm>[0]['initial'];
|
||||
notifications: Awaited<ReturnType<typeof fetchNotificationConfig>>;
|
||||
}) {
|
||||
const t = useTranslations('Profile');
|
||||
|
||||
@@ -50,6 +55,12 @@ function ProfilePageContent({
|
||||
<div className="animate-fade-up [animation-delay:300ms]">
|
||||
<ProfileForm initial={initial} />
|
||||
</div>
|
||||
|
||||
<div className="rule animate-rule-in my-16 [animation-delay:480ms]" />
|
||||
|
||||
<div className="animate-fade-up [animation-delay:540ms]">
|
||||
<NotificationsPanel initial={notifications} />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// Helper client-side per il flusso Web Push (PRD §5.6).
|
||||
// Registra il SW, chiede il permesso, ottiene la PushSubscription,
|
||||
// la passa a una server action che la inoltra all'API. Idempotente:
|
||||
// se l'utente è già iscritto, riusiamo la subscription corrente.
|
||||
|
||||
import {
|
||||
registerDevice,
|
||||
unregisterDevice,
|
||||
type SubscribePayload,
|
||||
} from '@/app/[locale]/profile/notifications-actions';
|
||||
|
||||
const VAPID_PUBLIC_KEY = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY ?? '';
|
||||
|
||||
export interface SubscribeResult {
|
||||
ok: boolean;
|
||||
reason?:
|
||||
| 'unsupported'
|
||||
| 'permission_denied'
|
||||
| 'permission_default'
|
||||
| 'no_vapid_key'
|
||||
| 'api_error'
|
||||
| 'sw_error';
|
||||
}
|
||||
|
||||
export function pushSupported(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
return 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window;
|
||||
}
|
||||
|
||||
export async function ensureRegistration(): Promise<ServiceWorkerRegistration | null> {
|
||||
if (!pushSupported()) return null;
|
||||
try {
|
||||
const existing = await navigator.serviceWorker.getRegistration();
|
||||
if (existing) return existing;
|
||||
return await navigator.serviceWorker.register('/sw.js');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCurrentSubscription(): Promise<PushSubscription | null> {
|
||||
const reg = await ensureRegistration();
|
||||
if (!reg) return null;
|
||||
return reg.pushManager.getSubscription();
|
||||
}
|
||||
|
||||
export async function subscribe(): Promise<SubscribeResult> {
|
||||
if (!pushSupported()) return { ok: false, reason: 'unsupported' };
|
||||
if (!VAPID_PUBLIC_KEY) return { ok: false, reason: 'no_vapid_key' };
|
||||
|
||||
const reg = await ensureRegistration();
|
||||
if (!reg) return { ok: false, reason: 'sw_error' };
|
||||
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission === 'denied') return { ok: false, reason: 'permission_denied' };
|
||||
if (permission !== 'granted') return { ok: false, reason: 'permission_default' };
|
||||
|
||||
let sub = await reg.pushManager.getSubscription();
|
||||
if (!sub) {
|
||||
sub = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
|
||||
});
|
||||
}
|
||||
|
||||
const json = sub.toJSON();
|
||||
if (!json.endpoint || !json.keys?.p256dh || !json.keys?.auth) {
|
||||
return { ok: false, reason: 'sw_error' };
|
||||
}
|
||||
const payload: SubscribePayload = {
|
||||
endpoint: json.endpoint,
|
||||
keys: { p256dh: json.keys.p256dh, auth: json.keys.auth },
|
||||
userAgent: navigator.userAgent,
|
||||
};
|
||||
const result = await registerDevice(payload);
|
||||
return result.ok ? { ok: true } : { ok: false, reason: 'api_error' };
|
||||
}
|
||||
|
||||
export async function unsubscribe(deviceId?: string): Promise<boolean> {
|
||||
const sub = await getCurrentSubscription();
|
||||
if (sub) await sub.unsubscribe();
|
||||
if (deviceId) {
|
||||
await unregisterDevice(deviceId).catch(() => {
|
||||
/* il record verrà ripulito al prossimo invio */
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function urlBase64ToUint8Array(base64String: string): Uint8Array {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||
const raw = window.atob(base64);
|
||||
const out = new Uint8Array(raw.length);
|
||||
for (let i = 0; i < raw.length; ++i) out[i] = raw.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
Reference in new issue
Block a user