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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user