Notifications and sending songs to friends
Reactions to your songs, new followers, and songs sent to you are now notifications: a bell on the feed (app and website) with an unread count that updates live, and a list that marks them read. "Send ✉" on a song sends it to people who follow you, with an optional note. Settings picks which kinds to be notified about. Push delivery uses no third-party accounts: - Android: UnifiedPush through a distributor app (ntfy), pointed at our own ntfy server (docker-compose.ntfy.yml). Works the same in the F-Droid build; no Firebase. - Browsers: standard Web Push from the existing service worker. Both use Web Push encryption with a VAPID key pair the server makes and keeps in the database. Push addresses are checked like any other user-supplied URL. Setup is in docs/PUSH.md; the privacy page covers the new data. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -79,6 +79,24 @@ input:focus, textarea:focus, select:focus {
|
||||
.modal { max-width: 26rem; width: calc(100% - 2rem); color: var(--text); background: #15112c; }
|
||||
.modal::backdrop { background: rgba(5, 3, 15, 0.7); backdrop-filter: blur(4px); }
|
||||
.modal p { margin-bottom: 1rem; }
|
||||
.modal .form p { margin: 0; }
|
||||
.pick-list { max-height: 45vh; overflow-y: auto; }
|
||||
.pick-list .checkbox { display: flex; align-items: center; gap: 0.6rem; cursor: pointer; font-weight: 500; }
|
||||
.bell {
|
||||
position: relative; display: grid; place-items: center; width: 38px; height: 38px; border-radius: 999px; margin-left: auto; margin-right: 1rem;
|
||||
color: var(--muted); background: rgba(255, 255, 255, 0.06); transition: color 0.2s, background 0.2s;
|
||||
}
|
||||
.bell:hover { color: var(--text); background: rgba(255, 255, 255, 0.12); }
|
||||
.bell-badge {
|
||||
position: absolute; top: -3px; right: -5px; min-width: 18px; height: 18px; padding: 0 4px; border-radius: 9px;
|
||||
background: var(--danger); color: #fff; font-size: 0.68rem; font-weight: 700; display: grid; place-items: center;
|
||||
}
|
||||
.notifications { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.35rem; }
|
||||
.notification { display: flex; align-items: center; gap: 0.8rem; padding: 0.7rem 0.8rem; border-radius: 14px; text-decoration: none; color: var(--text); }
|
||||
.notification:hover { background: rgba(255, 255, 255, 0.05); }
|
||||
.notifications li.unread .notification { background: var(--surface-2); }
|
||||
.notification-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 0.1rem; font-size: 0.92rem; }
|
||||
.notification-text .muted { font-size: 0.78rem; }
|
||||
.choices { border: 0; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 0.45rem; }
|
||||
.people { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 0.6rem; }
|
||||
.people li { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; }
|
||||
@@ -174,6 +192,7 @@ input:focus, textarea:focus, select:focus {
|
||||
@media (max-width: 640px) {
|
||||
/* Signed in, the tab bar has the links; the top bar is just the logo. */
|
||||
.nav:has(+ .tabbar) { justify-content: center; }
|
||||
.nav:has(+ .tabbar) .bell { position: absolute; right: 1rem; margin: 0; }
|
||||
.nav:has(+ .tabbar) .nav-links { display: none; }
|
||||
.tabbar {
|
||||
display: flex; position: fixed; left: 0; right: 0; bottom: 0; z-index: 20;
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useEffect } from 'react';
|
||||
import { useSWRConfig } from 'swr';
|
||||
import useSWRInfinite from 'swr/infinite';
|
||||
import { notificationPath, notificationText, timeAgo, type NotificationListDTO } from '@musicshare/shared';
|
||||
import { Avatar } from '@/components/Avatar';
|
||||
import { client, useMe } from '@/lib/api';
|
||||
|
||||
/** Reactions, new followers and songs sent to you, newest first. Opening it marks them read. */
|
||||
export default function NotificationsPage() {
|
||||
const { me, loggedOut } = useMe();
|
||||
const { mutate } = useSWRConfig();
|
||||
const { data, size, setSize, isLoading } = useSWRInfinite<NotificationListDTO>(
|
||||
(i, prev: NotificationListDTO | null) => (!me || (i > 0 && !prev?.nextCursor) ? null : ['notifications', prev?.nextCursor ?? null]),
|
||||
([, cursor]: [string, string | null]) => client.notifications(cursor),
|
||||
{ revalidateOnFocus: false },
|
||||
);
|
||||
const items = data?.flatMap((p) => p.notifications) ?? [];
|
||||
const unread = data?.[0]?.unread ?? 0;
|
||||
|
||||
// Seen once you've looked: clear the bell (rows stay highlighted until you come back).
|
||||
useEffect(() => {
|
||||
if (unread) client.markNotificationsRead().then(() => mutate('/notifications'), () => {});
|
||||
}, [unread, mutate]);
|
||||
|
||||
if (loggedOut) return <p className="muted"><Link href="/login">Log in</Link> to see your notifications.</p>;
|
||||
return (
|
||||
<div className="stack">
|
||||
<h1>Notifications</h1>
|
||||
{items.length === 0 && !isLoading && (
|
||||
<p className="muted">Nothing yet. Reactions, new followers and songs sent to you show up here.</p>
|
||||
)}
|
||||
<ul className="notifications">
|
||||
{items.map((n) => {
|
||||
const { title, body } = notificationText(n);
|
||||
return (
|
||||
<li key={n.id} className={n.read ? '' : 'unread'}>
|
||||
<Link href={notificationPath(n)} className="notification">
|
||||
<Avatar user={n.actor} size={40} />
|
||||
<span className="notification-text">
|
||||
<strong>{title}</strong>
|
||||
<span>{body}</span>
|
||||
<span className="muted">{timeAgo(n.createdAt)}</span>
|
||||
</span>
|
||||
{n.track?.artworkUrl && <img className="artwork-sm" src={n.track.artworkUrl} alt="" />}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{data?.at(-1)?.nextCursor && (
|
||||
<button className="btn" onClick={() => setSize(size + 1)}>
|
||||
Show more
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import Link from 'next/link';
|
||||
import useSWR from 'swr';
|
||||
import useSWR, { useSWRConfig } from 'swr';
|
||||
import { sortFeed, type FeedItemDTO, type FollowListDTO, type ProfileDTO } from '@musicshare/shared';
|
||||
import { Avatar } from '@/components/Avatar';
|
||||
import { Equalizer } from '@/components/Equalizer';
|
||||
@@ -20,6 +20,7 @@ export default function Home() {
|
||||
|
||||
function Feed() {
|
||||
const { me } = useMe();
|
||||
const { mutate: mutateGlobal } = useSWRConfig();
|
||||
const { data: feed, mutate } = useSWR<FeedItemDTO[]>('/feed', fetcher);
|
||||
// Your own profile, for the "You" card. Loading it also marks you as watched, so your updates arrive quickly.
|
||||
const { data: mine, mutate: mutateMine } = useSWR<ProfileDTO>(me ? `/users/${me.username}` : null, fetcher);
|
||||
@@ -29,7 +30,10 @@ function Feed() {
|
||||
const quiet = following?.users.filter((u) => !inFeed.has(u.id)) ?? [];
|
||||
|
||||
useRealtime(true, (msg) => {
|
||||
if (msg.type !== 'now_playing') return;
|
||||
if (msg.type === 'notification') {
|
||||
mutateGlobal('/notifications');
|
||||
return;
|
||||
}
|
||||
if (msg.data.user.id === me?.id) {
|
||||
if (mine) mutateMine({ ...mine, nowPlaying: msg.data }, { revalidate: false });
|
||||
return;
|
||||
|
||||
@@ -3,7 +3,7 @@ import Link from 'next/link';
|
||||
|
||||
export const metadata: Metadata = { title: 'Privacy policy · musicshare' };
|
||||
|
||||
const UPDATED = 'October 6, 2026';
|
||||
const UPDATED = 'October 14, 2026';
|
||||
const CONTACT = 'app@musicshare.binarygnome.com';
|
||||
|
||||
export default function PrivacyPage() {
|
||||
@@ -43,6 +43,11 @@ export default function PrivacyPage() {
|
||||
<li>
|
||||
<strong>People:</strong> who you follow, who you've pinned, who you've blocked, and reports you send.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Notifications:</strong> reactions, new followers and songs (with any note) sent to you, and your
|
||||
notification settings. If you turn on push notifications, we store the push address your browser or
|
||||
phone gives us and its encryption keys.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Technical data:</strong> sign-in sessions (stored as hashes), and your IP address, which is used briefly
|
||||
to prevent abuse (like limiting sign-in attempts) and appears in server logs that are kept for a short time.
|
||||
@@ -82,7 +87,13 @@ export default function PrivacyPage() {
|
||||
<li>
|
||||
<strong>Song matching:</strong> to build “open in your app” links we look up song titles and artists
|
||||
with Deezer, Apple's iTunes search, Spotify's catalog and Odesli (song.link). These lookups contain only
|
||||
the song, never who listened to it.
|
||||
the song, never who listened to it. Lyrics come from LRCLIB, looked up the same way: just the song.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Push notifications:</strong> if you turn them on, notifications are encrypted on our server and
|
||||
delivered through your browser maker's push service (for example Google for Chrome, Mozilla for Firefox) or,
|
||||
on Android, through the push server your ntfy app uses (ours, unless you chose another). They can't read
|
||||
the contents.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Our own servers:</strong> the app, database and email run on servers we manage ourselves.
|
||||
@@ -97,6 +108,7 @@ export default function PrivacyPage() {
|
||||
<h2>How long we keep it</h2>
|
||||
<ul>
|
||||
<li>Recent plays: the latest 50. Older ones are deleted as new ones come in.</li>
|
||||
<li>Notifications: until you delete your account. Push addresses are deleted when you turn push off or the device stops accepting them.</li>
|
||||
<li>
|
||||
Everything else: until you delete it or your account. Deleting your account (Settings → Delete account)
|
||||
removes your profile, picture, plays, follows and connected services right away. Database backups are kept for
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Suspense, useRef, useState } from 'react';
|
||||
import { Suspense, useEffect, useRef, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import {
|
||||
SERVICE_LABELS,
|
||||
@@ -14,6 +14,9 @@ import {
|
||||
type NowPlayingProvider,
|
||||
type ProfileDTO,
|
||||
type StreamingService,
|
||||
type NotificationType,
|
||||
NOTIFICATION_PREF_LABELS,
|
||||
NOTIFICATION_TYPES,
|
||||
type TrackSearchResultDTO,
|
||||
type UserSummaryDTO,
|
||||
} from '@musicshare/shared';
|
||||
@@ -22,6 +25,7 @@ import { ServiceName } from '@/components/ServiceName';
|
||||
import { SharingControls } from '@/components/SharingControls';
|
||||
import { DONATION_URL } from '@/components/SupportLink';
|
||||
import { api, client, fetcher, uploadFile, useMe } from '@/lib/api';
|
||||
import { browserPushSupported, currentSubscription, disableBrowserPush, enableBrowserPush } from '@/lib/push';
|
||||
import { timeAgo } from '@musicshare/shared';
|
||||
import { useDebounced } from '@/lib/useDebounced';
|
||||
|
||||
@@ -43,6 +47,7 @@ function Settings() {
|
||||
<h1>Settings</h1>
|
||||
<ProfileForm me={me} />
|
||||
<SharingControls />
|
||||
<NotificationSettings me={me} />
|
||||
<Connections />
|
||||
<FeaturedPicker />
|
||||
<AccountCard me={me} />
|
||||
@@ -503,6 +508,71 @@ function FeaturedPicker() {
|
||||
type Panel = 'email' | 'password' | 'blocked' | 'delete' | null;
|
||||
|
||||
/** Everything about the account itself, as one list of rows; each opens in place. */
|
||||
/** What to be notified about, and push in this browser. */
|
||||
function NotificationSettings({ me }: { me: MeDTO }) {
|
||||
const { mutate } = useMe();
|
||||
const [push, setPush] = useState<'unsupported' | 'off' | 'on' | 'blocked' | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!browserPushSupported()) return setPush('unsupported');
|
||||
if (Notification.permission === 'denied') return setPush('blocked');
|
||||
currentSubscription().then((s) => setPush(s ? 'on' : 'off'), () => setPush('off'));
|
||||
}, []);
|
||||
|
||||
const toggle = async (type: NotificationType) => {
|
||||
try {
|
||||
await mutate(await client.updateMe({ notifyPrefs: { [type]: !me.notifyPrefs[type] } }), { revalidate: false });
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
};
|
||||
const setBrowser = async (on: boolean) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (on) setPush((await enableBrowserPush()) ? 'on' : 'blocked');
|
||||
else {
|
||||
await disableBrowserPush();
|
||||
setPush('off');
|
||||
}
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="card form">
|
||||
<h2>Notifications</h2>
|
||||
<span className="muted">Notify me about:</span>
|
||||
{NOTIFICATION_TYPES.map((t) => (
|
||||
<label key={t} className="checkbox">
|
||||
<input type="checkbox" checked={me.notifyPrefs[t]} onChange={() => toggle(t)} />
|
||||
{NOTIFICATION_PREF_LABELS[t]}
|
||||
</label>
|
||||
))}
|
||||
{push === 'on' && (
|
||||
<p className="muted">
|
||||
On in this browser.{' '}
|
||||
<button type="button" className="link-button" onClick={() => setBrowser(false)} disabled={busy}>
|
||||
Turn off
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
{push === 'off' && (
|
||||
<button type="button" className="btn btn-primary" onClick={() => setBrowser(true)} disabled={busy}>
|
||||
Turn on in this browser
|
||||
</button>
|
||||
)}
|
||||
{push === 'blocked' && <p className="muted">This browser is blocking notifications from musicshare; allow them in its site settings.</p>}
|
||||
{push === 'unsupported' && <p className="muted">This browser can't show notifications. Everything still appears under the bell.</p>}
|
||||
{error && <p className="error">{error}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountCard({ me }: { me: MeDTO }) {
|
||||
const { mutate } = useMe();
|
||||
const router = useRouter();
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import useSWR from 'swr';
|
||||
import type { NotificationListDTO } from '@musicshare/shared';
|
||||
import { fetcher } from '@/lib/api';
|
||||
|
||||
/** Bell with the unread count; opens your notifications. */
|
||||
export function ActivityBell() {
|
||||
const { data } = useSWR<NotificationListDTO>('/notifications', fetcher, { refreshInterval: 60_000 });
|
||||
const unread = data?.unread ?? 0;
|
||||
return (
|
||||
<Link href="/notifications" className="bell" aria-label={unread ? `Notifications, ${unread} new` : 'Notifications'}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
|
||||
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
|
||||
</svg>
|
||||
{unread > 0 && <span className="bell-badge">{unread > 99 ? '99+' : unread}</span>}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { client, useMe } from '@/lib/api';
|
||||
import { ActivityBell } from './ActivityBell';
|
||||
|
||||
/** Top bar: logo and the four main places. On phones those move to a bottom tab bar. */
|
||||
export function Nav() {
|
||||
@@ -25,6 +26,7 @@ export function Nav() {
|
||||
<img className="brand-logo" src="/logo.png" alt="" width={30} height={30} />
|
||||
musicshare
|
||||
</Link>
|
||||
{me && <ActivityBell />}
|
||||
<div className="nav-links">
|
||||
{me ? (
|
||||
tabs.map((t) => (
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
import useSWRInfinite from 'swr/infinite';
|
||||
import type { FollowListDTO, TrackDTO } from '@musicshare/shared';
|
||||
import { client, useMe } from '@/lib/api';
|
||||
import { Avatar } from './Avatar';
|
||||
|
||||
/** "Send ✉": pick followers and send them the song, with an optional note. */
|
||||
export function SendSongButton({ track }: { track: TrackDTO }) {
|
||||
const { me } = useMe();
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
if (!me) return null;
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="service-link share-link"
|
||||
onClick={() => {
|
||||
setOpen(true);
|
||||
dialog.current?.showModal();
|
||||
}}
|
||||
title="Send to friends"
|
||||
>
|
||||
Send ✉
|
||||
</button>
|
||||
<dialog ref={dialog} className="modal card" onClose={() => setOpen(false)} onClick={(e) => e.target === dialog.current && dialog.current?.close()}>
|
||||
{open && <SendForm track={track} username={me.username} close={() => dialog.current?.close()} />}
|
||||
</dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SendForm({ track, username, close }: { track: TrackDTO; username: string; close: () => void }) {
|
||||
const { data, size, setSize, isLoading } = useSWRInfinite<FollowListDTO>(
|
||||
(i, prev: FollowListDTO | null) => (i > 0 && !prev?.nextCursor ? null : ['followers', username, prev?.nextCursor ?? null]),
|
||||
([, u, cursor]: [string, string, string | null]) => client.followers(u, cursor),
|
||||
);
|
||||
const followers = data?.flatMap((p) => p.users) ?? [];
|
||||
const [picked, setPicked] = useState<Set<string>>(new Set());
|
||||
const [message, setMessage] = useState('');
|
||||
const [state, setState] = useState<'idle' | 'sending' | 'sent'>('idle');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const toggle = (u: string) =>
|
||||
setPicked((p) => {
|
||||
const next = new Set(p);
|
||||
if (next.has(u)) next.delete(u);
|
||||
else next.add(u);
|
||||
return next;
|
||||
});
|
||||
|
||||
const send = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setState('sending');
|
||||
setError(null);
|
||||
try {
|
||||
await client.sendSong({ trackId: track.id, to: [...picked], message: message.trim() || null });
|
||||
setState('sent');
|
||||
setTimeout(close, 900);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
setState('idle');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="form" onSubmit={send}>
|
||||
<h2>Send to friends</h2>
|
||||
<p className="muted">
|
||||
{track.title} · {track.artist}
|
||||
</p>
|
||||
<input placeholder="Add a note (optional)" value={message} onChange={(e) => setMessage(e.target.value)} maxLength={280} />
|
||||
<p className="muted small">You can send songs to people who follow you.</p>
|
||||
{followers.length === 0 && !isLoading && <p className="muted">No followers yet.</p>}
|
||||
<ul className="people pick-list">
|
||||
{followers.map((u) => (
|
||||
<li key={u.id}>
|
||||
<label className="checkbox">
|
||||
<input type="checkbox" checked={picked.has(u.username)} onChange={() => toggle(u.username)} />
|
||||
<Avatar user={u} size={32} />
|
||||
<span className="user-name">{u.displayName ?? u.username}</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{data?.at(-1)?.nextCursor && (
|
||||
<button type="button" className="btn btn-small" onClick={() => setSize(size + 1)}>
|
||||
Show more
|
||||
</button>
|
||||
)}
|
||||
{error && <p className="error">{error}</p>}
|
||||
<div className="row">
|
||||
<button className="btn btn-primary" disabled={!picked.size || state !== 'idle'}>
|
||||
{state === 'sent' ? 'Sent ✓' : picked.size ? `Send to ${picked.size}` : 'Pick someone'}
|
||||
</button>
|
||||
<button type="button" className="btn" onClick={close}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { SERVICE_LABELS, openUrl, viewerServices, type StreamingService, type TrackDTO } from '@musicshare/shared';
|
||||
import { useMe } from '@/lib/api';
|
||||
import { SendSongButton } from './SendSong';
|
||||
import { ShareButton } from './ShareButton';
|
||||
|
||||
function Artwork({ track, small }: { track: TrackDTO; small?: boolean }) {
|
||||
@@ -41,6 +42,7 @@ export function ServiceLinks({ track, preferred }: { track: TrackDTO; preferred:
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
<SendSongButton track={track} />
|
||||
<ShareButton track={track} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
'use client';
|
||||
|
||||
import { client } from './api';
|
||||
|
||||
/** Browser push needs a service worker (production builds register /sw.js) and the Push API. */
|
||||
export const browserPushSupported = () =>
|
||||
typeof window !== 'undefined' && 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window;
|
||||
|
||||
async function registration() {
|
||||
return (await navigator.serviceWorker.getRegistration('/')) ?? navigator.serviceWorker.register('/sw.js', { scope: '/', updateViaCache: 'none' });
|
||||
}
|
||||
|
||||
/** This browser's push subscription, if any. */
|
||||
export async function currentSubscription(): Promise<PushSubscription | null> {
|
||||
if (!browserPushSupported()) return null;
|
||||
return (await registration()).pushManager.getSubscription();
|
||||
}
|
||||
|
||||
const toBytes = (base64url: string) => {
|
||||
const raw = atob(base64url.replace(/-/g, '+').replace(/_/g, '/'));
|
||||
return Uint8Array.from(raw, (c) => c.charCodeAt(0));
|
||||
};
|
||||
|
||||
/** Ask for permission, subscribe, and tell the server. Returns false if the user said no. */
|
||||
export async function enableBrowserPush(): Promise<boolean> {
|
||||
if ((await Notification.requestPermission()) !== 'granted') return false;
|
||||
const { publicKey } = await client.pushKey();
|
||||
const reg = await registration();
|
||||
await navigator.serviceWorker.ready;
|
||||
const sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: toBytes(publicKey) });
|
||||
const json = sub.toJSON() as { endpoint: string; keys: { p256dh: string; auth: string } };
|
||||
await client.addPushSubscription({ kind: 'web', endpoint: json.endpoint, keys: json.keys });
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function disableBrowserPush() {
|
||||
const sub = await currentSubscription();
|
||||
if (!sub) return;
|
||||
await client.removePushSubscription(sub.endpoint).catch(() => {});
|
||||
await sub.unsubscribe();
|
||||
}
|
||||
Reference in New Issue
Block a user