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:
2026-10-14 22:14:34 -04:00
co-authored by Claude Opus 5.5
parent 3b39910f13
commit f112a3515c
55 changed files with 3783 additions and 22 deletions
+12
View File
@@ -12,6 +12,7 @@ import type {
} from './dto';
import type { DeviceDTO } from './devices';
import type { LyricsDTO } from './lyrics';
import type { NotificationListDTO } from './notifications';
import type {
ChangeEmailInput,
ChangePasswordInput,
@@ -21,6 +22,8 @@ import type {
ResetPasswordInput,
SetFeaturedInput,
UpdateMeInput,
PushSubscriptionInput,
SendSongInput,
} from './schemas';
export class ApiError extends Error {
@@ -135,6 +138,15 @@ export function createApiClient({ baseUrl, getToken, credentials }: ApiClientOpt
searchTracks: (query: string) => request<TrackSearchResultDTO[]>(`/tracks/search?q=${q(query)}`),
/** Null when no lyrics were found for the song. */
lyrics: (trackId: string) => request<LyricsDTO | null>(`/tracks/${q(trackId)}/lyrics`),
/** Send a song to people who follow you; returns who it went to. */
sendSong: (input: SendSongInput) => request<{ sentTo: string[] }>('/shares', { method: 'POST', body: input }),
notifications: (cursor?: string | null) =>
request<NotificationListDTO>(`/notifications${cursor ? `?cursor=${q(cursor)}` : ''}`),
markNotificationsRead: () => request<{ ok: true }>('/notifications/read', { method: 'POST', body: {} }),
/** The server's public key for push subscriptions (VAPID). */
pushKey: () => request<{ publicKey: string }>('/push/key'),
addPushSubscription: (input: PushSubscriptionInput) => request<{ ok: true }>('/me/push', { method: 'PUT', body: input }),
removePushSubscription: (endpoint: string) => request<{ ok: true }>('/me/push', { method: 'DELETE', body: { endpoint } }),
connections: () => request<ConnectionDTO[]>('/connections'),
authorizeUrl: (provider: 'plex' | 'lastfm', body: { client: 'web' | 'mobile'; returnTo?: string }) =>
+6 -1
View File
@@ -1,4 +1,5 @@
import type { Entitlements, Plan } from './plans';
import type { NotificationPrefs } from './notifications';
import type { NowPlayingProvider, StreamingService } from './services';
export interface TrackDTO {
@@ -28,6 +29,7 @@ export interface MeDTO extends UserSummaryDTO {
preferredService: StreamingService | null;
/** The services they use; null until chosen, meaning all of them. */
services: StreamingService[] | null;
notifyPrefs: NotificationPrefs;
role: 'user' | 'admin';
/** When onboarding was finished; null means show it. */
onboardedAt: string | null;
@@ -161,4 +163,7 @@ export interface TrackSearchResultDTO {
}
/** Messages pushed over the realtime websocket. */
export type RealtimeMessage = { type: 'now_playing'; data: NowPlayingDTO };
export type RealtimeMessage =
| { type: 'now_playing'; data: NowPlayingDTO }
/** Something new in the viewer's notifications. */
| { type: 'notification'; data: { unread: number } };
+1
View File
@@ -10,3 +10,4 @@ export * from './qr';
export * from './progress';
export * from './reactions';
export * from './lyrics';
export * from './notifications';
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { notificationPath, notificationPrefs, notificationText } from './notifications';
const actor = { id: 'u1', username: 'bob', displayName: 'Bob', avatarUrl: null };
const track = { id: 't1', title: 'Teardrop', artist: 'Massive Attack', album: null, artworkUrl: null, durationMs: null, isrc: null, links: {} };
describe('notifications', () => {
it('words each kind', () => {
expect(notificationText({ type: 'reaction', actor, track, emoji: '🔥', message: null })).toEqual({
title: 'Bob reacted 🔥',
body: 'to Teardrop by Massive Attack',
});
expect(notificationText({ type: 'follow', actor: { ...actor, displayName: null }, track: null, emoji: null, message: null }).title).toBe(
'bob followed you',
);
expect(notificationText({ type: 'share', actor, track, emoji: null, message: 'so good' }).body).toBe('Teardrop by Massive Attack: “so good”');
});
it('links songs to the song and follows to the person', () => {
expect(notificationPath({ type: 'share', actor, track })).toBe('/t/t1');
expect(notificationPath({ type: 'follow', actor, track: null })).toBe('/u/bob');
});
it('treats missing settings as on', () => {
expect(notificationPrefs({ follow: false })).toEqual({ reaction: true, follow: false, share: true });
});
});
+61
View File
@@ -0,0 +1,61 @@
import type { TrackDTO, UserSummaryDTO } from './dto';
export const NOTIFICATION_TYPES = ['reaction', 'follow', 'share'] as const;
export type NotificationType = (typeof NOTIFICATION_TYPES)[number];
/** Which notifications get pushed to your phone/browser. */
export type NotificationPrefs = Record<NotificationType, boolean>;
export const NOTIFICATION_PREF_LABELS: Record<NotificationType, string> = {
reaction: 'Reactions to your songs',
follow: 'New followers',
share: 'Songs sent to you',
};
/** Missing settings mean on. */
export const notificationPrefs = (stored: Partial<NotificationPrefs> | null | undefined): NotificationPrefs => ({
reaction: stored?.reaction ?? true,
follow: stored?.follow ?? true,
share: stored?.share ?? true,
});
export interface NotificationDTO {
id: string;
type: NotificationType;
actor: UserSummaryDTO;
track: TrackDTO | null;
emoji: string | null;
message: string | null;
read: boolean;
createdAt: string;
}
export interface NotificationListDTO {
notifications: NotificationDTO[];
unread: number;
/** Pass back as ?cursor= for older ones; null at the end. */
nextCursor: string | null;
}
/** What a push or a row in the list says. */
export function notificationText(n: Pick<NotificationDTO, 'type' | 'actor' | 'track' | 'emoji' | 'message'>): {
title: string;
body: string;
} {
const who = n.actor.displayName ?? n.actor.username;
const song = n.track ? `${n.track.title} by ${n.track.artist}` : 'a song';
switch (n.type) {
case 'reaction':
return { title: `${who} reacted ${n.emoji ?? ''}`.trim(), body: `to ${song}` };
case 'follow':
return { title: `${who} followed you`, body: 'Follow back to see what they play.' };
case 'share':
return { title: `${who} sent you a song`, body: n.message ? `${song}: “${n.message}”` : song };
}
}
/** Where tapping it goes, as an app/web path. */
export function notificationPath(n: Pick<NotificationDTO, 'type' | 'actor' | 'track'>): string {
if ((n.type === 'share' || n.type === 'reaction') && n.track) return `/t/${n.track.id}`;
return `/u/${n.actor.username}`;
}
+20
View File
@@ -52,10 +52,28 @@ export const updateMeSchema = z.object({
.transform((s) => [...new Set(s)])
.nullable()
.optional(),
notifyPrefs: z
.object({ reaction: z.boolean(), follow: z.boolean(), share: z.boolean() })
.partial()
.optional(),
/** Mark onboarding as finished. */
onboarded: z.literal(true).optional(),
});
/** Send a song to people who follow you. */
export const sendSongSchema = z.object({
trackId: z.uuid(),
to: z.array(z.string().min(1).max(32)).min(1).max(20),
message: z.string().trim().max(280).nullable().optional(),
});
/** A browser's or phone's push subscription (both use Web Push encryption keys). */
export const pushSubscriptionSchema = z.object({
kind: z.enum(['web', 'unifiedpush']),
endpoint: z.url({ protocol: /^https?$/ }).max(2000),
keys: z.object({ p256dh: z.string().min(20).max(200), auth: z.string().min(8).max(100) }),
});
export const setFeaturedSchema = z.union([
z.object({ deezerId: z.string().regex(/^\d+$/), comment: z.string().trim().max(280).nullable().optional() }),
z.object({ trackId: z.uuid(), comment: z.string().trim().max(280).nullable().optional() }),
@@ -72,6 +90,8 @@ export type ChangePasswordInput = z.infer<typeof changePasswordSchema>;
export type ChangeEmailInput = z.infer<typeof changeEmailSchema>;
export type UpdateMeInput = z.infer<typeof updateMeSchema>;
export type SetFeaturedInput = z.infer<typeof setFeaturedSchema>;
export type SendSongInput = z.infer<typeof sendSongSchema>;
export type PushSubscriptionInput = z.infer<typeof pushSubscriptionSchema>;
export const jellyfinConnectSchema = z.object({
serverUrl: z.string().trim().min(1).max(500),