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
+38
View File
@@ -0,0 +1,38 @@
CREATE TABLE "notifications" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"type" text NOT NULL,
"actor_id" uuid NOT NULL,
"track_id" uuid,
"listen_id" uuid,
"emoji" varchar(32),
"message" varchar(280),
"read_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "push_subscriptions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"kind" text NOT NULL,
"endpoint" text NOT NULL,
"p256dh" text NOT NULL,
"auth" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "push_subscriptions_endpoint_unique" UNIQUE("endpoint")
);
--> statement-breakpoint
CREATE TABLE "server_keys" (
"name" text PRIMARY KEY NOT NULL,
"value" jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "notify_prefs" jsonb DEFAULT '{}'::jsonb NOT NULL;--> statement-breakpoint
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_actor_id_users_id_fk" FOREIGN KEY ("actor_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_track_id_tracks_id_fk" FOREIGN KEY ("track_id") REFERENCES "public"."tracks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_listen_id_listens_id_fk" FOREIGN KEY ("listen_id") REFERENCES "public"."listens"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "push_subscriptions" ADD CONSTRAINT "push_subscriptions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "notifications_user_created_idx" ON "notifications" USING btree ("user_id","created_at" DESC NULLS LAST);--> statement-breakpoint
CREATE INDEX "push_subscriptions_user_idx" ON "push_subscriptions" USING btree ("user_id");
File diff suppressed because it is too large Load Diff
+7
View File
@@ -113,6 +113,13 @@
"when": 1792025274882, "when": 1792025274882,
"tag": "0015_lyrics", "tag": "0015_lyrics",
"breakpoints": true "breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1792028149643,
"tag": "0016_notifications",
"breakpoints": true
} }
] ]
} }
+2
View File
@@ -36,11 +36,13 @@
"postgres": "^3.4.9", "postgres": "^3.4.9",
"sharp": "^0.35.5", "sharp": "^0.35.5",
"undici": "^6.29.0", "undici": "^6.29.0",
"web-push": "^3.6.7",
"zod": "^4.6.5" "zod": "^4.6.5"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^26.6.4", "@types/node": "^26.6.4",
"@types/nodemailer": "^8.0.2", "@types/nodemailer": "^8.0.2",
"@types/web-push": "^3.6.4",
"@types/ws": "^8.18.2", "@types/ws": "^8.18.2",
"concurrently": "^10.0.5", "concurrently": "^10.0.5",
"drizzle-kit": "^0.31.11", "drizzle-kit": "^0.31.11",
+2
View File
@@ -18,6 +18,7 @@ import { connectionRoutes } from './routes/connections';
import { deviceRoutes } from './routes/devices'; import { deviceRoutes } from './routes/devices';
import { meRoutes } from './routes/me'; import { meRoutes } from './routes/me';
import { reactionRoutes } from './routes/reactions'; import { reactionRoutes } from './routes/reactions';
import { notificationRoutes } from './routes/notifications';
import { realtimeRoutes } from './routes/realtime'; import { realtimeRoutes } from './routes/realtime';
import { safetyRoutes } from './routes/safety'; import { safetyRoutes } from './routes/safety';
import { trackRoutes } from './routes/tracks'; import { trackRoutes } from './routes/tracks';
@@ -76,6 +77,7 @@ export async function buildApp({ logger = true, rateLimits = true }: { logger?:
await app.register(adminRoutes); await app.register(adminRoutes);
await app.register(trackRoutes); await app.register(trackRoutes);
await app.register(reactionRoutes); await app.register(reactionRoutes);
await app.register(notificationRoutes);
await app.register(connectionRoutes); await app.register(connectionRoutes);
await app.register(deviceRoutes); await app.register(deviceRoutes);
await app.register(realtimeRoutes); await app.register(realtimeRoutes);
+51 -1
View File
@@ -13,7 +13,7 @@ import {
uuid, uuid,
varchar, varchar,
} from 'drizzle-orm/pg-core'; } from 'drizzle-orm/pg-core';
import type { NowPlayingProvider, Plan, ReportReason, StreamingService } from '@musicshare/shared'; import type { NotificationPrefs, NotificationType, NowPlayingProvider, Plan, ReportReason, StreamingService } from '@musicshare/shared';
const createdAt = () => timestamp('created_at', { withTimezone: true }).notNull().defaultNow(); const createdAt = () => timestamp('created_at', { withTimezone: true }).notNull().defaultNow();
@@ -33,6 +33,8 @@ export const users = pgTable('users', {
preferredService: text('preferred_service').$type<StreamingService>(), preferredService: text('preferred_service').$type<StreamingService>(),
/** The services they use, so songs only offer those; null until they choose (offer all). */ /** The services they use, so songs only offer those; null until they choose (offer all). */
services: text('services').array().$type<StreamingService[]>(), services: text('services').array().$type<StreamingService[]>(),
/** Which notifications to push; missing keys mean on. Everything still shows in the app's list. */
notifyPrefs: jsonb('notify_prefs').$type<Partial<NotificationPrefs>>().notNull().default({}),
/** Billing plan. Kept in sync by the billing webhook (RevenueCat/Stripe) — see lib/entitlements.ts */ /** Billing plan. Kept in sync by the billing webhook (RevenueCat/Stripe) — see lib/entitlements.ts */
/** The first account on a server becomes admin (moderation). */ /** The first account on a server becomes admin (moderation). */
role: text('role').$type<'user' | 'admin'>().notNull().default('user'), role: text('role').$type<'user' | 'admin'>().notNull().default('user'),
@@ -304,6 +306,54 @@ export const lyrics = pgTable('lyrics', {
fetchedAt: timestamp('fetched_at', { withTimezone: true }).notNull().defaultNow(), fetchedAt: timestamp('fetched_at', { withTimezone: true }).notNull().defaultNow(),
}); });
/** Things that happened to a user: a reaction, a new follower, a song sent to them. */
export const notifications = pgTable(
'notifications',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
type: text('type').$type<NotificationType>().notNull(),
actorId: uuid('actor_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
trackId: uuid('track_id').references(() => tracks.id, { onDelete: 'cascade' }),
/** The play a reaction was on (gone once the play is trimmed from history). */
listenId: uuid('listen_id').references(() => listens.id, { onDelete: 'set null' }),
emoji: varchar('emoji', { length: 32 }),
/** A note sent with a song. */
message: varchar('message', { length: 280 }),
readAt: timestamp('read_at', { withTimezone: true }),
createdAt: createdAt(),
},
(t) => [index('notifications_user_created_idx').on(t.userId, t.createdAt.desc())],
);
/** Where to push notifications: browsers (Web Push) and phones (UnifiedPush). Both use Web Push encryption. */
export const pushSubscriptions = pgTable(
'push_subscriptions',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
kind: text('kind').$type<'web' | 'unifiedpush'>().notNull(),
endpoint: text('endpoint').notNull().unique(),
p256dh: text('p256dh').notNull(),
auth: text('auth').notNull(),
createdAt: createdAt(),
},
(t) => [index('push_subscriptions_user_idx').on(t.userId)],
);
/** Keys the server generates for itself once (e.g. the Web Push VAPID key pair). */
export const serverKeys = pgTable('server_keys', {
name: text('name').primaryKey(),
value: jsonb('value').$type<Record<string, string>>().notNull(),
createdAt: createdAt(),
});
const bytea = customType<{ data: Buffer }>({ dataType: () => 'bytea' }); const bytea = customType<{ data: Buffer }>({ dataType: () => 'bytea' });
/** Profile pictures, already resized and re-encoded (WebP, metadata stripped). */ /** Profile pictures, already resized and re-encoded (WebP, metadata stripped). */
+3
View File
@@ -26,6 +26,9 @@ const schema = z.object({
/** Enables "Sign in with Last.fm" (web auth). Without it, users type their username. */ /** Enables "Sign in with Last.fm" (web auth). Without it, users type their username. */
LASTFM_SHARED_SECRET: optional, LASTFM_SHARED_SECRET: optional,
ODESLI_API_KEY: optional, ODESLI_API_KEY: optional,
/** Web Push keys (`npx web-push generate-vapid-keys`). Optional: without them the server makes a pair and keeps it in the database. */
VAPID_PUBLIC_KEY: optional,
VAPID_PRIVATE_KEY: optional,
/** Lyrics come from LRCLIB (open source, self-hostable). Point this at your own instance, or set it empty to turn lyrics off. */ /** Lyrics come from LRCLIB (open source, self-hostable). Point this at your own instance, or set it empty to turn lyrics off. */
LRCLIB_URL: z LRCLIB_URL: z
.string() .string()
+8 -2
View File
@@ -1,6 +1,6 @@
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import { setFeaturedSchema, setSharingSchema, updateMeSchema, type MeDTO } from '@musicshare/shared'; import { notificationPrefs, setFeaturedSchema, setSharingSchema, updateMeSchema, type MeDTO } from '@musicshare/shared';
import { db, schema } from '../db'; import { db, schema } from '../db';
import type { User } from '../db/schema'; import type { User } from '../db/schema';
import { SESSION_COOKIE, requireUser } from '../lib/auth'; import { SESSION_COOKIE, requireUser } from '../lib/auth';
@@ -25,6 +25,7 @@ export const toMeDTO = (u: User): MeDTO => ({
bio: u.bio, bio: u.bio,
preferredService: u.preferredService, preferredService: u.preferredService,
services: u.services, services: u.services,
notifyPrefs: notificationPrefs(u.notifyPrefs),
role: u.role, role: u.role,
onboardedAt: u.onboardedAt?.toISOString() ?? null, onboardedAt: u.onboardedAt?.toISOString() ?? null,
sharingPausedUntil: isSharingPaused(u) ? u.sharingPausedUntil!.toISOString() : null, sharingPausedUntil: isSharingPaused(u) ? u.sharingPausedUntil!.toISOString() : null,
@@ -40,7 +41,12 @@ export async function meRoutes(app: FastifyInstance) {
const { onboarded, ...input } = updateMeSchema.parse(req.body); const { onboarded, ...input } = updateMeSchema.parse(req.body);
// Clearing the display name falls back to the username. // Clearing the display name falls back to the username.
if ('displayName' in input && !input.displayName) input.displayName = user.username; if ('displayName' in input && !input.displayName) input.displayName = user.username;
const changes = { ...input, ...(onboarded && !user.onboardedAt ? { onboardedAt: new Date() } : {}) }; const { notifyPrefs, ...rest } = input;
const changes = {
...rest,
...(notifyPrefs ? { notifyPrefs: { ...user.notifyPrefs, ...notifyPrefs } } : {}),
...(onboarded && !user.onboardedAt ? { onboardedAt: new Date() } : {}),
};
if (Object.keys(changes).length === 0) return toMeDTO(user); // nothing to update if (Object.keys(changes).length === 0) return toMeDTO(user); // nothing to update
const [updated] = await db.update(schema.users).set(changes).where(eq(schema.users.id, user.id)).returning(); const [updated] = await db.update(schema.users).set(changes).where(eq(schema.users.id, user.id)).returning();
return toMeDTO(updated!); return toMeDTO(updated!);
+88
View File
@@ -0,0 +1,88 @@
import { and, eq, inArray, isNull } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { pushSubscriptionSchema, sendSongSchema } from '@musicshare/shared';
import { db, schema } from '../db';
import { requireUser } from '../lib/auth';
import { HttpError } from '../lib/http';
import { UnsafeUrlError, assertSafeServerUrl } from '../lib/ssrf';
import { hiddenUserIds } from '../services/blocks';
import { listNotifications, notify, vapidKeys } from '../services/notifications';
import { enqueueResolve } from '../services/queues';
/** Most browsers and phones someone might have at once. */
const MAX_PUSH_SUBSCRIPTIONS = 10;
export async function notificationRoutes(app: FastifyInstance) {
app.get<{ Querystring: { cursor?: string } }>('/notifications', async (req) => {
const user = requireUser(req);
return listNotifications(user.id, req.query.cursor ?? null);
});
app.post('/notifications/read', async (req) => {
const user = requireUser(req);
const n = schema.notifications;
await db.update(n).set({ readAt: new Date() }).where(and(eq(n.userId, user.id), isNull(n.readAt)));
return { ok: true };
});
/** Send a song to people who follow you (and haven't blocked you). Others are skipped. */
app.post('/shares', { config: { rateLimit: { max: 30, timeWindow: '1 minute' } } }, async (req) => {
const user = requireUser(req);
const { trackId, to, message } = sendSongSchema.parse(req.body);
const [track] = await db.select({ id: schema.tracks.id }).from(schema.tracks).where(eq(schema.tracks.id, trackId));
if (!track) throw new HttpError(404, 'Track not found');
const recipients = await db
.select({ id: schema.users.id, username: schema.users.username })
.from(schema.users)
.innerJoin(schema.follows, and(eq(schema.follows.followerId, schema.users.id), eq(schema.follows.followeeId, user.id)))
.where(and(inArray(schema.users.username, to), isNull(schema.users.suspendedAt)));
const hidden = await hiddenUserIds(user.id);
const sentTo: string[] = [];
for (const r of recipients) {
if (hidden.has(r.id)) continue;
await notify({ userId: r.id, actorId: user.id, type: 'share', trackId, message: message || null });
sentTo.push(r.username);
}
if (sentTo.length === 0) throw new HttpError(400, 'You can only send songs to people who follow you');
await enqueueResolve(trackId); // so it opens straight in their service
return { sentTo };
});
/** The server's public key, for subscribing browsers and phones to push. */
app.get('/push/key', async () => ({ publicKey: (await vapidKeys()).publicKey }));
app.put('/me/push', async (req) => {
const user = requireUser(req);
const input = pushSubscriptionSchema.parse(req.body);
try {
await assertSafeServerUrl(input.endpoint);
} catch (err) {
if (err instanceof UnsafeUrlError) throw new HttpError(400, `Can't send notifications there: ${err.message}`);
throw err;
}
const values = { userId: user.id, kind: input.kind, endpoint: input.endpoint, ...input.keys };
await db
.insert(schema.pushSubscriptions)
.values(values)
.onConflictDoUpdate({ target: schema.pushSubscriptions.endpoint, set: values });
// Keep the newest few.
const subs = await db
.select({ id: schema.pushSubscriptions.id, createdAt: schema.pushSubscriptions.createdAt })
.from(schema.pushSubscriptions)
.where(eq(schema.pushSubscriptions.userId, user.id));
const extra = subs.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()).slice(MAX_PUSH_SUBSCRIPTIONS);
if (extra.length) await db.delete(schema.pushSubscriptions).where(inArray(schema.pushSubscriptions.id, extra.map((s) => s.id)));
return { ok: true };
});
app.delete('/me/push', async (req) => {
const user = requireUser(req);
const { endpoint } = z.object({ endpoint: z.string().max(2000) }).parse(req.body);
await db
.delete(schema.pushSubscriptions)
.where(and(eq(schema.pushSubscriptions.userId, user.id), eq(schema.pushSubscriptions.endpoint, endpoint)));
return { ok: true };
});
}
+3
View File
@@ -6,6 +6,7 @@ import { db, schema } from '../db';
import { requireUser } from '../lib/auth'; import { requireUser } from '../lib/auth';
import { HttpError } from '../lib/http'; import { HttpError } from '../lib/http';
import { blockStatus } from '../services/blocks'; import { blockStatus } from '../services/blocks';
import { notify, unnotifyReaction } from '../services/notifications';
import { getReactions, publishNowPlaying } from '../services/nowPlaying'; import { getReactions, publishNowPlaying } from '../services/nowPlaying';
const params = z.object({ id: z.uuid() }); const params = z.object({ id: z.uuid() });
@@ -42,6 +43,7 @@ export async function reactionRoutes(app: FastifyInstance) {
.insert(schema.reactions) .insert(schema.reactions)
.values({ listenId: id, userId: user.id, emoji }) .values({ listenId: id, userId: user.id, emoji })
.onConflictDoUpdate({ target: [schema.reactions.listenId, schema.reactions.userId], set: { emoji, createdAt: new Date() } }); .onConflictDoUpdate({ target: [schema.reactions.listenId, schema.reactions.userId], set: { emoji, createdAt: new Date() } });
await notify({ userId: listen.userId, actorId: user.id, type: 'reaction', listenId: id, trackId: listen.trackId, emoji });
return afterChange(listen); return afterChange(listen);
}); });
@@ -50,6 +52,7 @@ export async function reactionRoutes(app: FastifyInstance) {
const { id } = params.parse(req.params); const { id } = params.parse(req.params);
const listen = await reactable(user.id, id); const listen = await reactable(user.id, id);
await db.delete(schema.reactions).where(and(eq(schema.reactions.listenId, id), eq(schema.reactions.userId, user.id))); await db.delete(schema.reactions).where(and(eq(schema.reactions.listenId, id), eq(schema.reactions.userId, user.id)));
await unnotifyReaction(listen.userId, user.id, id);
return afterChange(listen); return afterChange(listen);
}); });
} }
+16 -2
View File
@@ -12,6 +12,7 @@ import type { WebSocket } from 'ws';
import { db, schema } from '../db'; import { db, schema } from '../db';
import { FOLLOWS_CHANGED_CHANNEL, createRedis } from '../lib/redis'; import { FOLLOWS_CHANGED_CHANNEL, createRedis } from '../lib/redis';
import { watchUsers } from '../services/connections'; import { watchUsers } from '../services/connections';
import { unreadCount } from '../services/notifications';
const watchers = new Map<string, Set<WebSocket>>(); // watched userId -> sockets const watchers = new Map<string, Set<WebSocket>>(); // watched userId -> sockets
const socketsByOwner = new Map<string, Set<WebSocket>>(); // viewer userId -> their sockets const socketsByOwner = new Map<string, Set<WebSocket>>(); // viewer userId -> their sockets
@@ -42,10 +43,23 @@ async function subscribeSocket(socket: WebSocket, viewerId: string) {
export async function realtimeRoutes(app: FastifyInstance) { export async function realtimeRoutes(app: FastifyInstance) {
const sub = createRedis(); const sub = createRedis();
await sub.psubscribe('np:*'); await sub.psubscribe('np:*', 'notify:*');
await sub.subscribe(FOLLOWS_CHANGED_CHANNEL); await sub.subscribe(FOLLOWS_CHANGED_CHANNEL);
sub.on('pmessage', (_pattern, channel, message) => { sub.on('pmessage', (pattern, channel, message) => {
if (pattern === 'notify:*') {
// Something happened to this user: tell their open apps the new unread count.
const userId = channel.slice('notify:'.length);
const sockets = socketsByOwner.get(userId);
if (!sockets) return;
unreadCount(userId)
.then((unread) => {
const payload = JSON.stringify({ type: 'notification', data: { unread } });
for (const socket of sockets) socket.send(payload);
})
.catch((e) => app.log.error(e));
return;
}
const userId = channel.slice(3); const userId = channel.slice(3);
const payload = `{"type":"now_playing","data":${message}}`; const payload = `{"type":"now_playing","data":${message}}`;
for (const socket of watchers.get(userId) ?? []) socket.send(payload); for (const socket of watchers.get(userId) ?? []) socket.send(payload);
+7 -1
View File
@@ -2,6 +2,7 @@ import { and, count, desc, eq, ilike, inArray, isNotNull, isNull, or } from 'dri
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import { sortFeed, sourceProfileUrl, type FeedItemDTO, type FollowListDTO, type ProfileDTO } from '@musicshare/shared'; import { sortFeed, sourceProfileUrl, type FeedItemDTO, type FollowListDTO, type ProfileDTO } from '@musicshare/shared';
import { db, schema } from '../db'; import { db, schema } from '../db';
import { notify } from '../services/notifications';
import { requireUser } from '../lib/auth'; import { requireUser } from '../lib/auth';
import { entitlementsFor } from '../lib/entitlements'; import { entitlementsFor } from '../lib/entitlements';
import { HttpError } from '../lib/http'; import { HttpError } from '../lib/http';
@@ -107,8 +108,13 @@ export async function userRoutes(app: FastifyInstance) {
const { user: target, viewerBlocked } = await findVisible(req.params.username, viewer.id); const { user: target, viewerBlocked } = await findVisible(req.params.username, viewer.id);
if (target.id === viewer.id) throw new HttpError(400, "You can't follow yourself"); if (target.id === viewer.id) throw new HttpError(400, "You can't follow yourself");
if (viewerBlocked) throw new HttpError(400, 'Unblock them first'); if (viewerBlocked) throw new HttpError(400, 'Unblock them first');
await db.insert(schema.follows).values({ followerId: viewer.id, followeeId: target.id }).onConflictDoNothing(); const added = await db
.insert(schema.follows)
.values({ followerId: viewer.id, followeeId: target.id })
.onConflictDoNothing()
.returning({ id: schema.follows.followerId });
await redis.publish(FOLLOWS_CHANGED_CHANNEL, viewer.id); await redis.publish(FOLLOWS_CHANGED_CHANNEL, viewer.id);
if (added.length) await notify({ userId: target.id, actorId: viewer.id, type: 'follow' });
return { ok: true }; return { ok: true };
}); });
+194
View File
@@ -0,0 +1,194 @@
import { and, desc, eq, gt, inArray, isNull, lt } from 'drizzle-orm';
import webpush from 'web-push';
import {
notificationPath,
notificationPrefs,
notificationText,
type NotificationDTO,
type NotificationType,
} from '@musicshare/shared';
import { db, schema } from '../db';
import { env } from '../env';
import { redis } from '../lib/redis';
import { assertSafeServerUrl, serverFetchOptions } from '../lib/ssrf';
import { toUserSummary } from './nowPlaying';
import { enqueuePush } from './queues';
import { getTrackDTOs } from './tracks';
export const notifyChannel = (userId: string) => `notify:${userId}`;
/** A second follow from the same person within this long (unfollow, follow again) isn't news. */
const FOLLOW_REPEAT_MS = 24 * 3600_000;
type Row = typeof schema.notifications.$inferSelect;
/**
* Record something that happened to `userId` and push it. Reactions replace that
* person's earlier reaction to the same play rather than piling up.
*/
export async function notify(input: {
userId: string;
actorId: string;
type: NotificationType;
trackId?: string | null;
listenId?: string | null;
emoji?: string | null;
message?: string | null;
}): Promise<string | null> {
if (input.userId === input.actorId) return null;
const n = schema.notifications;
const mine = and(eq(n.userId, input.userId), eq(n.actorId, input.actorId), eq(n.type, input.type));
let row: Row | undefined;
if (input.type === 'follow') {
const [recent] = await db
.select({ id: n.id })
.from(n)
.where(and(mine, gt(n.createdAt, new Date(Date.now() - FOLLOW_REPEAT_MS))));
if (recent) return null;
}
if (input.type === 'reaction' && input.listenId) {
[row] = await db
.update(n)
.set({ emoji: input.emoji, createdAt: new Date(), readAt: null })
.where(and(mine, eq(n.listenId, input.listenId)))
.returning();
}
if (!row) [row] = await db.insert(n).values(input).returning();
await redis.publish(notifyChannel(input.userId), row!.id);
await enqueuePush(row!.id);
return row!.id;
}
/** Take back a reaction's notification when the reaction is removed (if it hasn't been seen). */
export async function unnotifyReaction(userId: string, actorId: string, listenId: string) {
const n = schema.notifications;
await db
.delete(n)
.where(and(eq(n.userId, userId), eq(n.actorId, actorId), eq(n.type, 'reaction'), eq(n.listenId, listenId), isNull(n.readAt)));
}
export async function toNotificationDTOs(rows: Row[]): Promise<NotificationDTO[]> {
const actorIds = [...new Set(rows.map((r) => r.actorId))];
const actors = actorIds.length
? await db.select().from(schema.users).where(inArray(schema.users.id, actorIds))
: [];
const byId = new Map(actors.map((u) => [u.id, toUserSummary(u)]));
const tracks = await getTrackDTOs([...new Set(rows.flatMap((r) => (r.trackId ? [r.trackId] : [])))]);
return rows.flatMap((r) => {
const actor = byId.get(r.actorId);
if (!actor) return [];
return [
{
id: r.id,
type: r.type,
actor,
track: r.trackId ? (tracks.get(r.trackId) ?? null) : null,
emoji: r.emoji,
message: r.message,
read: Boolean(r.readAt),
createdAt: r.createdAt.toISOString(),
},
];
});
}
/** Newest first, 30 at a time; the cursor is the last one's time. */
export async function listNotifications(userId: string, cursor: string | null) {
const n = schema.notifications;
const before = cursor ? new Date(cursor) : null;
const rows = await db
.select()
.from(n)
.where(and(eq(n.userId, userId), before && !isNaN(before.getTime()) ? lt(n.createdAt, before) : undefined))
.orderBy(desc(n.createdAt))
.limit(31);
const page = rows.slice(0, 30);
return {
notifications: await toNotificationDTOs(page),
unread: await unreadCount(userId),
nextCursor: rows.length > 30 ? page.at(-1)!.createdAt.toISOString() : null,
};
}
export async function unreadCount(userId: string): Promise<number> {
const n = schema.notifications;
const rows = await db
.select({ id: n.id })
.from(n)
.where(and(eq(n.userId, userId), isNull(n.readAt)))
.limit(100);
return rows.length;
}
// --- Push -------------------------------------------------------------------------------------
let vapid: { publicKey: string; privateKey: string } | null = null;
/**
* The VAPID key pair push services use to know messages come from us. Set
* VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY, or one is made on first use and kept in
* the database (changing it would break every existing subscription).
*/
export async function vapidKeys() {
if (vapid) return vapid;
if (env.VAPID_PUBLIC_KEY && env.VAPID_PRIVATE_KEY) return (vapid = { publicKey: env.VAPID_PUBLIC_KEY, privateKey: env.VAPID_PRIVATE_KEY });
const generated = webpush.generateVAPIDKeys();
await db.insert(schema.serverKeys).values({ name: 'vapid', value: { ...generated } }).onConflictDoNothing();
const [row] = await db.select().from(schema.serverKeys).where(eq(schema.serverKeys.name, 'vapid'));
return (vapid = row!.value as { publicKey: string; privateKey: string });
}
/** Send one notification to all of its user's browsers and phones, if they want this kind. */
export async function sendPush(notificationId: string) {
const [row] = await db.select().from(schema.notifications).where(eq(schema.notifications.id, notificationId));
if (!row || row.readAt) return;
const [user] = await db.select().from(schema.users).where(eq(schema.users.id, row.userId));
if (!user || !notificationPrefs(user.notifyPrefs)[row.type]) return;
const subs = await db.select().from(schema.pushSubscriptions).where(eq(schema.pushSubscriptions.userId, row.userId));
if (subs.length === 0) return;
const [dto] = await toNotificationDTOs([row]);
if (!dto) return;
const { title, body } = notificationText(dto);
const payload = JSON.stringify({
id: dto.id,
type: dto.type,
title,
body,
path: notificationPath(dto),
icon: dto.actor.avatarUrl,
unread: await unreadCount(row.userId),
});
const keys = await vapidKeys();
await Promise.all(
subs.map(async (sub) => {
try {
// web-push encrypts and signs; we send it ourselves so the address is checked like any user-supplied URL.
const req = webpush.generateRequestDetails({ endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } }, payload, {
vapidDetails: { subject: env.WEB_URL.startsWith('https:') ? env.WEB_URL : 'mailto:app@musicshare.binarygnome.com', ...keys },
TTL: 24 * 3600,
urgency: 'normal',
contentEncoding: 'aes128gcm',
});
await assertSafeServerUrl(req.endpoint);
const res = await fetch(req.endpoint, {
method: req.method,
headers: req.headers as Record<string, string>,
body: req.body as Buffer<ArrayBuffer>,
signal: AbortSignal.timeout(10_000),
...serverFetchOptions(),
});
// Gone: the browser unsubscribed or the app was uninstalled.
if (res.status === 404 || res.status === 410) {
await db.delete(schema.pushSubscriptions).where(eq(schema.pushSubscriptions.id, sub.id));
} else if (!res.ok) {
console.warn(`push to ${new URL(sub.endpoint).host} failed: ${res.status}`);
}
} catch (err) {
console.warn(`push to ${new URL(sub.endpoint).host} failed:`, (err as Error).message);
}
}),
);
}
+8 -1
View File
@@ -5,12 +5,14 @@ const connection = createRedis();
export const POLL_QUEUE = 'poll'; export const POLL_QUEUE = 'poll';
export const RESOLVE_QUEUE = 'resolve-track'; export const RESOLVE_QUEUE = 'resolve-track';
export const PUSH_QUEUE = 'push';
export const pollQueue = new Queue<{ connectionId: string }>(POLL_QUEUE, { connection }); export const pollQueue = new Queue<{ connectionId: string }>(POLL_QUEUE, { connection });
export const resolveQueue = new Queue<{ trackId: string }>(RESOLVE_QUEUE, { connection }); export const resolveQueue = new Queue<{ trackId: string }>(RESOLVE_QUEUE, { connection });
export const pushQueue = new Queue<{ notificationId: string }>(PUSH_QUEUE, { connection });
export async function closeQueues() { export async function closeQueues() {
await Promise.all([pollQueue.close(), resolveQueue.close()]); await Promise.all([pollQueue.close(), resolveQueue.close(), pushQueue.close()]);
await connection.quit(); await connection.quit();
} }
@@ -27,3 +29,8 @@ export function enqueueResolve(trackId: string) {
}, },
); );
} }
/** Push a notification to the user's devices (the worker sends it). */
export function enqueuePush(notificationId: string) {
return pushQueue.add('push', { notificationId }, { attempts: 2, backoff: { type: 'fixed', delay: 30_000 }, removeOnComplete: true, removeOnFail: 1000 });
}
+8 -3
View File
@@ -8,7 +8,8 @@ import { createRedis } from './lib/redis';
import { POLLED_PROVIDERS, pollingProviders } from './providers'; import { POLLED_PROVIDERS, pollingProviders } from './providers';
import { ProviderAuthError, ProviderRateLimitError } from './providers/types'; import { ProviderAuthError, ProviderRateLimitError } from './providers/types';
import { applyPollResult, isSharingPaused } from './services/nowPlaying'; import { applyPollResult, isSharingPaused } from './services/nowPlaying';
import { POLL_QUEUE, RESOLVE_QUEUE, pollQueue } from './services/queues'; import { sendPush } from './services/notifications';
import { POLL_QUEUE, PUSH_QUEUE, RESOLVE_QUEUE, pollQueue } from './services/queues';
import { resolveTrackLinks } from './services/resolver'; import { resolveTrackLinks } from './services/resolver';
import { import {
IDLE_POLL_SECONDS, IDLE_POLL_SECONDS,
@@ -116,7 +117,11 @@ const resolveWorker = new Worker<{ trackId: string }>(RESOLVE_QUEUE, (job) => re
connection: createRedis(), connection: createRedis(),
concurrency: 5, concurrency: 5,
}); });
for (const w of [pollWorker, resolveWorker]) w.on('failed', (job, err) => log(`${w.name} job ${job?.id} failed`, err.message)); const pushWorker = new Worker<{ notificationId: string }>(PUSH_QUEUE, (job) => sendPush(job.data.notificationId), {
connection: createRedis(),
concurrency: 10,
});
for (const w of [pollWorker, resolveWorker, pushWorker]) w.on('failed', (job, err) => log(`${w.name} job ${job?.id} failed`, err.message));
await reconcile(); await reconcile();
const tickTimer = setInterval(tick, 1000); const tickTimer = setInterval(tick, 1000);
@@ -126,7 +131,7 @@ log('worker started');
async function shutdown() { async function shutdown() {
clearInterval(tickTimer); clearInterval(tickTimer);
clearInterval(reconcileTimer); clearInterval(reconcileTimer);
await Promise.all([pollWorker.close(), resolveWorker.close()]); await Promise.all([pollWorker.close(), resolveWorker.close(), pushWorker.close()]);
await closeConnections(); await closeConnections();
process.exit(0); process.exit(0);
} }
+126
View File
@@ -0,0 +1,126 @@
import { createECDH, randomBytes } from 'node:crypto';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { applyPollResult } from '../src/services/nowPlaying';
import { sendPush } from '../src/services/notifications';
import { bearer, createUser, follow, useTestApp } from './helpers';
const app = useTestApp();
afterEach(() => vi.unstubAllGlobals());
const song = (title: string) => ({ track: { title, artist: 'Massive Attack' }, isPlaying: true, progressMs: null });
const inbox = async (token: string) => (await app().inject({ url: '/notifications', headers: bearer(token) })).json();
const react = (token: string, listenId: string, emoji: string) =>
app().inject({ method: 'PUT', url: `/listens/${listenId}/reaction`, headers: bearer(token), payload: { emoji } });
/** Alice plays a song; Bob follows her. */
async function setup() {
const alice = await createUser(app(), 'alice');
const bob = await createUser(app(), 'bob');
await follow(app(), bob.token, 'alice');
await applyPollResult(alice.user.id, 'lastfm', song('Teardrop'));
const feed = (await app().inject({ url: '/feed', headers: bearer(bob.token) })).json();
return { alice, bob, listenId: feed[0].listenId as string, trackId: feed[0].track.id as string };
}
describe('notifications', () => {
it('tells you about a new follower once, even if they unfollow and follow again', async () => {
const { alice, bob } = await setup();
await app().inject({ method: 'DELETE', url: '/users/alice/follow', headers: bearer(bob.token) });
await follow(app(), bob.token, 'alice');
const list = await inbox(alice.token);
expect(list.unread).toBe(1);
expect(list.notifications).toEqual([expect.objectContaining({ type: 'follow', actor: expect.objectContaining({ username: 'bob' }), read: false })]);
});
it('a reaction is one notification that changes with the emoji, and goes if taken back', async () => {
const { alice, bob, listenId } = await setup();
await app().inject({ method: 'POST', url: '/notifications/read', headers: bearer(alice.token), payload: {} });
await react(bob.token, listenId, '🔥');
await react(bob.token, listenId, '😂');
let list = await inbox(alice.token);
const reactions = list.notifications.filter((n: { type: string }) => n.type === 'reaction');
expect(reactions).toEqual([expect.objectContaining({ emoji: '😂', track: expect.objectContaining({ title: 'Teardrop' }) })]);
expect(list.unread).toBe(1);
await app().inject({ method: 'DELETE', url: `/listens/${listenId}/reaction`, headers: bearer(bob.token) });
list = await inbox(alice.token);
expect(list.notifications.filter((n: { type: string }) => n.type === 'reaction')).toEqual([]);
});
it('sends songs only to people who follow you', async () => {
const { alice, bob, trackId } = await setup();
const send = (token: string, to: string[]) =>
app().inject({ method: 'POST', url: '/shares', headers: bearer(token), payload: { trackId, to, message: 'you need this' } });
// Bob follows Alice, so Alice can send to Bob, but Bob can't send to Alice.
expect((await send(alice.token, ['bob', 'nobody'])).json()).toEqual({ sentTo: ['bob'] });
expect((await send(bob.token, ['alice'])).statusCode).toBe(400);
const list = await inbox(bob.token);
expect(list.notifications[0]).toMatchObject({
type: 'share',
message: 'you need this',
actor: { username: 'alice' },
track: { title: 'Teardrop' },
});
await app().inject({ method: 'POST', url: '/notifications/read', headers: bearer(bob.token), payload: {} });
expect((await inbox(bob.token)).unread).toBe(0);
});
it('saves notification settings', async () => {
const { alice } = await setup();
const me = (await app().inject({ method: 'PATCH', url: '/me', headers: bearer(alice.token), payload: { notifyPrefs: { follow: false } } })).json();
expect(me.notifyPrefs).toEqual({ reaction: true, follow: false, share: true });
});
});
describe('push', () => {
// A real P-256 key pair, as a browser or the UnifiedPush connector makes.
const ecdh = createECDH('prime256v1');
ecdh.generateKeys();
const keys = { p256dh: ecdh.getPublicKey('base64url'), auth: randomBytes(16).toString('base64url') };
const subscribe = (token: string, endpoint: string) =>
app().inject({ method: 'PUT', url: '/me/push', headers: bearer(token), payload: { kind: 'unifiedpush', endpoint, keys } });
it('refuses push endpoints on private networks', async () => {
const alice = await createUser(app(), 'alice');
expect((await subscribe(alice.token, 'http://127.0.0.1:8080/up/abc')).statusCode).toBe(400);
expect((await subscribe(alice.token, 'https://93.184.216.34/up/abc')).statusCode).toBe(200);
});
it('sends an encrypted, signed push, respects settings, and forgets endpoints that are gone', async () => {
const { alice } = await setup();
await subscribe(alice.token, 'https://93.184.216.34/up/abc');
const id = (await inbox(alice.token)).notifications[0].id;
const sent: { url: string; headers: Record<string, string>; body: Buffer }[] = [];
let status = 201;
vi.stubGlobal('fetch', async (url: string, init: RequestInit) => {
sent.push({ url, headers: init.headers as Record<string, string>, body: init.body as Buffer });
return new Response(null, { status });
});
await sendPush(id);
expect(sent).toHaveLength(1);
expect(sent[0]!.url).toBe('https://93.184.216.34/up/abc');
expect(sent[0]!.headers['Content-Encoding']).toBe('aes128gcm');
expect(sent[0]!.headers.Authorization).toMatch(/^vapid t=/);
expect(sent[0]!.body.toString()).not.toContain('bob'); // encrypted
await app().inject({ method: 'PATCH', url: '/me', headers: bearer(alice.token), payload: { notifyPrefs: { follow: false } } });
await sendPush(id);
expect(sent).toHaveLength(1);
await app().inject({ method: 'PATCH', url: '/me', headers: bearer(alice.token), payload: { notifyPrefs: { follow: true } } });
status = 410;
await sendPush(id);
expect(sent).toHaveLength(2);
await sendPush(id);
expect(sent).toHaveLength(2); // subscription removed
});
it('serves the public key', async () => {
const res = await app().inject({ url: '/push/key' });
expect(res.json().publicKey).toMatch(/^[A-Za-z0-9_-]{80,}$/);
});
});
@@ -0,0 +1,25 @@
plugins {
id 'com.android.library'
id 'expo-module-gradle-plugin'
}
group = 'tech.chrisedwards.musicshare.push'
version = '0.1.0'
android {
namespace "tech.chrisedwards.musicshare.push"
defaultConfig {
versionCode 1
versionName "0.1.0"
}
lintOptions {
abortOnError false
}
}
dependencies {
// UnifiedPush: notifications through a distributor app the user picks (e.g. ntfy on our own
// server) instead of Google's Firebase. Open source (Apache-2.0), no Play Services.
implementation "org.unifiedpush.android:connector:3.3.5"
implementation "androidx.core:core-ktx:1.16.0"
}
@@ -0,0 +1,14 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application>
<!-- Receives endpoints and messages from the UnifiedPush distributor (via the connector library). -->
<service
android:name=".MusicsharePushService"
android:exported="false">
<intent-filter>
<action android:name="org.unifiedpush.android.connector.PUSH_EVENT" />
</intent-filter>
</service>
</application>
</manifest>
@@ -0,0 +1,88 @@
package tech.chrisedwards.musicshare.push
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import org.json.JSONObject
import org.unifiedpush.android.connector.FailedReason
import org.unifiedpush.android.connector.PushService
import org.unifiedpush.android.connector.data.PushEndpoint
import org.unifiedpush.android.connector.data.PushMessage
/**
* Runs without the app UI: the distributor (e.g. ntfy) wakes it with new endpoints and
* messages. Messages arrive already decrypted by the connector library.
*/
class MusicsharePushService : PushService() {
override fun onNewEndpoint(endpoint: PushEndpoint, instance: String) {
val keys = endpoint.pubKeySet ?: run {
Log.w(TAG, "endpoint without keys; can't receive encrypted pushes")
return
}
val ep = Endpoint(endpoint.url, keys.pubKey, keys.auth)
PushPrefs(this).endpoint = ep
PushBus.emit(ep)
}
override fun onMessage(message: PushMessage, instance: String) {
try {
show(this, JSONObject(String(message.content, Charsets.UTF_8)))
} catch (e: Exception) {
Log.w(TAG, "couldn't show push", e)
}
}
override fun onRegistrationFailed(reason: FailedReason, instance: String) {
Log.w(TAG, "registration failed: $reason")
forget()
}
override fun onUnregistered(instance: String) = forget()
private fun forget() {
PushPrefs(this).endpoint = null
PushBus.emit(null)
}
companion object {
private const val TAG = "MusicsharePush"
private const val CHANNEL = "activity"
/** { id, title, body, path } from the server; tapping opens musicshare://<path>. */
fun show(context: Context, data: JSONObject) {
val manager = NotificationManagerCompat.from(context)
if (!manager.areNotificationsEnabled()) return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(CHANNEL, "Activity", NotificationManager.IMPORTANCE_DEFAULT)
channel.description = "Reactions, new followers and songs sent to you"
context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
}
val id = data.optString("id", System.currentTimeMillis().toString())
val open = Intent(Intent.ACTION_VIEW, Uri.parse("musicshare:/" + data.optString("path", "/")))
.setPackage(context.packageName)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
val tap = PendingIntent.getActivity(context, id.hashCode(), open, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
val notification = NotificationCompat.Builder(context, CHANNEL)
.setSmallIcon(R.drawable.ic_stat_musicshare)
.setColor(0xFF7C4DFF.toInt())
.setContentTitle(data.optString("title"))
.setContentText(data.optString("body"))
.setStyle(NotificationCompat.BigTextStyle().bigText(data.optString("body")))
.setContentIntent(tap)
.setAutoCancel(true)
.build()
try {
manager.notify(id.hashCode(), notification)
} catch (e: SecurityException) {
Log.w(TAG, "notification permission missing", e)
}
}
}
}
@@ -0,0 +1,49 @@
package tech.chrisedwards.musicshare.push
import expo.modules.kotlin.Promise
import expo.modules.kotlin.exception.Exceptions
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import org.unifiedpush.android.connector.UnifiedPush
class PushModule : Module() {
private val context get() = appContext.reactContext ?: throw Exceptions.ReactContextLost()
private val listener: (Endpoint?) -> Unit = { sendEvent("onEndpoint", mapOf("endpoint" to it?.toMap())) }
override fun definition() = ModuleDefinition {
Name("MusicsharePush")
Events("onEndpoint")
OnStartObserving { PushBus.add(listener) }
OnStopObserving { PushBus.remove(listener) }
/** Package names of installed UnifiedPush distributors (ntfy etc.). */
Function("distributors") { UnifiedPush.getDistributors(context) }
/** The distributor in use, if registered. */
Function("currentDistributor") { UnifiedPush.getAckDistributor(context) }
/** The endpoint to give the server: { url, p256dh, auth }, or null. */
Function("getEndpoint") { PushPrefs(context).endpoint?.toMap() }
/**
* Picks a distributor (asks if there are several) and registers. Resolves false when
* there's none installed. The endpoint arrives later through onEndpoint.
*/
AsyncFunction("register") { vapidPublicKey: String, promise: Promise ->
// It may ask the user to pick a distributor, so it needs the app on screen.
val activity = appContext.currentActivity ?: throw Exceptions.MissingActivity()
UnifiedPush.tryUseCurrentOrDefaultDistributor(activity) { ok ->
if (ok) UnifiedPush.register(context, "default", "musicshare notifications", vapidPublicKey)
promise.resolve(ok)
}
}
Function("unregister") {
UnifiedPush.unregister(context, "default")
PushPrefs(context).endpoint = null
}
}
}
@@ -0,0 +1,31 @@
package tech.chrisedwards.musicshare.push
import android.content.Context
/** The current push endpoint, kept so JS can send it to the server whenever the app opens. */
internal class PushPrefs(context: Context) {
private val prefs = context.applicationContext.getSharedPreferences("musicshare.push", Context.MODE_PRIVATE)
var endpoint: Endpoint?
get() {
val url = prefs.getString("url", null) ?: return null
return Endpoint(url, prefs.getString("p256dh", null) ?: return null, prefs.getString("auth", null) ?: return null)
}
set(value) {
val edit = prefs.edit()
if (value == null) edit.clear() else edit.putString("url", value.url).putString("p256dh", value.p256dh).putString("auth", value.auth)
edit.apply()
}
}
internal data class Endpoint(val url: String, val p256dh: String, val auth: String) {
fun toMap() = mapOf("url" to url, "p256dh" to p256dh, "auth" to auth)
}
/** Tells the JS module (when the app is open) that the endpoint changed. */
internal object PushBus {
private val listeners = mutableSetOf<(Endpoint?) -> Unit>()
@Synchronized fun add(listener: (Endpoint?) -> Unit) { listeners.add(listener) }
@Synchronized fun remove(listener: (Endpoint?) -> Unit) { listeners.remove(listener) }
@Synchronized fun emit(endpoint: Endpoint?) { listeners.forEach { it(endpoint) } }
}
@@ -0,0 +1,10 @@
<!-- Status bar icon: a music note (status bar icons must be single-colour). -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M12,3v10.55A4,4 0,1 0,14 17V7h4V3h-6z" />
</vector>
@@ -0,0 +1,6 @@
{
"platforms": ["android"],
"android": {
"modules": ["tech.chrisedwards.musicshare.push.PushModule"]
}
}
+3
View File
@@ -0,0 +1,3 @@
// Android-only native module; the default export is null where it is unavailable.
export { default } from './src/PushModule';
export type { PushEndpoint } from './src/PushModule';
@@ -0,0 +1,20 @@
import { NativeModule, requireOptionalNativeModule } from 'expo';
export interface PushEndpoint {
url: string;
p256dh: string;
auth: string;
}
type Events = { onEndpoint: (event: { endpoint: PushEndpoint | null }) => void };
declare class MusicsharePushModule extends NativeModule<Events> {
distributors(): string[];
currentDistributor(): string | null;
getEndpoint(): PushEndpoint | null;
register(vapidPublicKey: string): Promise<boolean>;
unregister(): void;
}
/** Android only (UnifiedPush); null elsewhere and in Expo Go. */
export default requireOptionalNativeModule<MusicsharePushModule>('MusicsharePush');
+14 -3
View File
@@ -3,9 +3,10 @@ import { useEffect, useState } from 'react';
import { Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native'; import { Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native';
import Animated, { FadeInDown, LinearTransition } from 'react-native-reanimated'; import Animated, { FadeInDown, LinearTransition } from 'react-native-reanimated';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import useSWR from 'swr'; import useSWR, { useSWRConfig } from 'swr';
import { sortFeed, type ConnectionDTO, type FeedItemDTO, type FollowListDTO, type ProfileDTO } from '@musicshare/shared'; import { sortFeed, type ConnectionDTO, type FeedItemDTO, type FollowListDTO, type ProfileDTO } from '@musicshare/shared';
import { AmbientBackground } from '@/components/AmbientBackground'; import { AmbientBackground } from '@/components/AmbientBackground';
import { ActivityBell } from '@/components/ActivityBell';
import { Avatar } from '@/components/Avatar'; import { Avatar } from '@/components/Avatar';
import { NowPlayingCard } from '@/components/NowPlayingCard'; import { NowPlayingCard } from '@/components/NowPlayingCard';
import { RelinkReminder } from '@/components/RelinkReminder'; import { RelinkReminder } from '@/components/RelinkReminder';
@@ -18,10 +19,11 @@ import { useReader } from '@/lib/nowPlayingReader';
import { shouldOfferSetup } from '@/lib/setup'; import { shouldOfferSetup } from '@/lib/setup';
import { useRealtime } from '@/lib/useRealtime'; import { useRealtime } from '@/lib/useRealtime';
import { useSession } from '@/lib/session'; import { useSession } from '@/lib/session';
import { colors, space } from '@/lib/theme'; import { colors, fonts, space } from '@/lib/theme';
export default function FeedScreen() { export default function FeedScreen() {
const { me } = useSession(); const { me } = useSession();
const { mutate: mutateGlobal } = useSWRConfig();
const { data: feed, mutate, isLoading } = useSWR<FeedItemDTO[]>('/feed'); const { data: feed, mutate, isLoading } = useSWR<FeedItemDTO[]>('/feed');
// Your own profile, for the "You" card. Loading it also marks you as watched, so your updates arrive quickly. // 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); const { data: mine, mutate: mutateMine } = useSWR<ProfileDTO>(me ? `/users/${me.username}` : null);
@@ -44,7 +46,10 @@ export default function FeedScreen() {
}, []); }, []);
useRealtime(true, (msg) => { useRealtime(true, (msg) => {
if (msg.type !== 'now_playing') return; if (msg.type === 'notification') {
mutateGlobal('/notifications');
return;
}
if (msg.data.user.id === me?.id) { if (msg.data.user.id === me?.id) {
if (mine) mutateMine({ ...mine, nowPlaying: msg.data }, { revalidate: false }); if (mine) mutateMine({ ...mine, nowPlaying: msg.data }, { revalidate: false });
return; return;
@@ -88,6 +93,10 @@ export default function FeedScreen() {
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={refresh} tintColor={colors.muted} />} refreshControl={<RefreshControl refreshing={refreshing} onRefresh={refresh} tintColor={colors.muted} />}
ListHeaderComponent={ ListHeaderComponent={
<View style={styles.header}> <View style={styles.header}>
<View style={styles.topBar}>
<Text style={styles.brand}>musicshare</Text>
<ActivityBell />
</View>
<SharingControls compact /> <SharingControls compact />
<RelinkReminder /> <RelinkReminder />
{offerAppleMusic && ( {offerAppleMusic && (
@@ -170,6 +179,8 @@ const styles = StyleSheet.create({
safe: { flex: 1, backgroundColor: colors.bg }, safe: { flex: 1, backgroundColor: colors.bg },
list: { padding: space.lg, gap: space.md, paddingBottom: space.xl * 3 }, list: { padding: space.lg, gap: space.md, paddingBottom: space.xl * 3 },
header: { gap: space.md, marginBottom: space.xs }, header: { gap: space.md, marginBottom: space.xs },
topBar: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
brand: { color: colors.text, fontFamily: fonts.display, fontSize: 22, letterSpacing: -0.4 },
sectionLabel: { color: colors.muted, fontSize: 12, fontWeight: '700', letterSpacing: 0.8 }, sectionLabel: { color: colors.muted, fontSize: 12, fontWeight: '700', letterSpacing: 0.8 },
quiet: { gap: space.lg, paddingVertical: space.xs }, quiet: { gap: space.lg, paddingVertical: space.xs },
quietPerson: { width: 64, alignItems: 'center', gap: 4 }, quietPerson: { width: 64, alignItems: 'center', gap: 4 },
+74
View File
@@ -11,6 +11,9 @@ import {
type MeDTO, type MeDTO,
type NowPlayingProvider, type NowPlayingProvider,
type StreamingService, type StreamingService,
type NotificationType,
NOTIFICATION_PREF_LABELS,
NOTIFICATION_TYPES,
} from '@musicshare/shared'; } from '@musicshare/shared';
import { Avatar } from '@/components/Avatar'; import { Avatar } from '@/components/Avatar';
import { RelinkReminder } from '@/components/RelinkReminder'; import { RelinkReminder } from '@/components/RelinkReminder';
@@ -25,6 +28,7 @@ import { showError } from '@/lib/errors';
import { AppleMusicPermissionError, appleMusicAvailable, connectAppleMusic } from '@/lib/appleMusic'; import { AppleMusicPermissionError, appleMusicAvailable, connectAppleMusic } from '@/lib/appleMusic';
import { SignInError, openSpotifyLinkPage, signInWithService } from '@/lib/connect'; import { SignInError, openSpotifyLinkPage, signInWithService } from '@/lib/connect';
import { openReaderSettings, useReader } from '@/lib/nowPlayingReader'; import { openReaderSettings, useReader } from '@/lib/nowPlayingReader';
import { NTFY_URL, disablePush, enablePush, pushAvailable, usePushStatus } from '@/lib/push';
import { useSession } from '@/lib/session'; import { useSession } from '@/lib/session';
import { colors, radius, space } from '@/lib/theme'; import { colors, radius, space } from '@/lib/theme';
@@ -36,6 +40,7 @@ export default function SettingsScreen() {
<SharingControls /> <SharingControls />
<RelinkReminder /> <RelinkReminder />
<MyServices /> <MyServices />
<Notifications />
<ThisPhone /> <ThisPhone />
<Connections /> <Connections />
<FeaturedPicker /> <FeaturedPicker />
@@ -161,6 +166,75 @@ function MyServices() {
); );
} }
/** What to be notified about, and push delivery on this phone. */
function Notifications() {
const { me, setMe } = useSession();
const push = usePushStatus();
const [busy, setBusy] = useState(false);
if (!me) return null;
const toggle = async (type: NotificationType) => {
try {
setMe(await api.updateMe({ notifyPrefs: { [type]: !me.notifyPrefs[type] } }));
} catch (err) {
showError(err);
}
};
const turnOn = async () => {
setBusy(true);
try {
const result = await enablePush();
if (result === 'denied') Alert.alert('Notifications are off', 'Allow notifications for musicshare in Android settings, then try again.');
if (result === 'no-distributor') push.refresh();
} catch (err) {
showError(err);
}
setBusy(false);
};
const turnOff = async () => {
setBusy(true);
await disablePush().catch((err) => showError(err));
push.refresh();
setBusy(false);
};
return (
<Card>
<Heading>Notifications</Heading>
<Body muted style={styles.small}>
Notify me about:
</Body>
<Row>
{NOTIFICATION_TYPES.map((t) => (
<Chip key={t} label={NOTIFICATION_PREF_LABELS[t]} selected={me.notifyPrefs[t]} onPress={() => toggle(t)} />
))}
</Row>
{pushAvailable &&
(push.enabled ? (
<>
<Body muted style={styles.small}>
On for this phone{push.distributor ? `, delivered by ${push.distributor}` : ''}.
</Body>
<Button small title="Turn off on this phone" loading={busy} onPress={turnOff} style={{ alignSelf: 'flex-start' }} />
</>
) : push.hasDistributor ? (
<Button small variant="primary" title="Turn on for this phone" loading={busy} onPress={turnOn} style={{ alignSelf: 'flex-start' }} />
) : (
<>
<Body muted style={styles.small}>
musicshare doesn&apos;t use Google to deliver notifications. Install ntfy (free and open source), then come back and turn
them on. You&apos;ll still see everything under the bell on the feed without it.
</Body>
<Row>
<Button small variant="primary" title="Get ntfy" onPress={() => Linking.openURL(NTFY_URL)} />
<Button small title="I've installed it" loading={busy} onPress={turnOn} />
</Row>
</>
))}
</Card>
);
}
/** Android: share from any music app on this phone (no-op elsewhere). */ /** Android: share from any music app on this phone (no-op elsewhere). */
function ThisPhone() { function ThisPhone() {
const { available, granted, playback } = useReader(); const { available, granted, playback } = useReader();
+5
View File
@@ -10,6 +10,7 @@ import { Body, Button, Title } from '@/components/ui';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { appleMusicAvailable, useAppleMusicLive } from '@/lib/appleMusic'; import { appleMusicAvailable, useAppleMusicLive } from '@/lib/appleMusic';
import { syncDevice } from '@/lib/nowPlayingReader'; import { syncDevice } from '@/lib/nowPlayingReader';
import { syncPush } from '@/lib/push';
import { SessionProvider, useSession } from '@/lib/session'; import { SessionProvider, useSession } from '@/lib/session';
import { colors, space } from '@/lib/theme'; import { colors, space } from '@/lib/theme';
@@ -45,6 +46,7 @@ function AppNavigator() {
// Keep the Android listener pointed at this account (no-op elsewhere). // Keep the Android listener pointed at this account (no-op elsewhere).
useEffect(() => { useEffect(() => {
if (signedIn) syncDevice().catch(() => {}); if (signedIn) syncDevice().catch(() => {});
if (signedIn) syncPush().catch(() => {});
}, [signedIn]); }, [signedIn]);
if (!ready) return null; if (!ready) return null;
@@ -99,6 +101,9 @@ function AppNavigator() {
<Stack.Screen name="change-password" options={{ title: 'Change password', presentation: 'modal' }} /> <Stack.Screen name="change-password" options={{ title: 'Change password', presentation: 'modal' }} />
<Stack.Screen name="change-email" options={{ title: 'Change email', presentation: 'modal' }} /> <Stack.Screen name="change-email" options={{ title: 'Change email', presentation: 'modal' }} />
<Stack.Screen name="scan" options={{ title: 'Scan a code', presentation: 'modal' }} /> <Stack.Screen name="scan" options={{ title: 'Scan a code', presentation: 'modal' }} />
<Stack.Screen name="notifications" options={{ title: 'Notifications' }} />
<Stack.Screen name="t/[id]" options={{ title: '' }} />
<Stack.Screen name="send/[trackId]" options={{ title: 'Send to friends', presentation: 'modal' }} />
</Stack.Protected> </Stack.Protected>
<Stack.Protected guard={!signedIn}> <Stack.Protected guard={!signedIn}>
<Stack.Screen name="login" options={{ headerShown: false }} /> <Stack.Screen name="login" options={{ headerShown: false }} />
+84
View File
@@ -0,0 +1,84 @@
import { Image } from 'expo-image';
import { router, useFocusEffect } from 'expo-router';
import { useCallback } from 'react';
import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native';
import { useSWRConfig } from 'swr';
import useSWRInfinite from 'swr/infinite';
import { notificationPath, notificationText, timeAgo, type NotificationDTO, type NotificationListDTO } from '@musicshare/shared';
import { Avatar } from '@/components/Avatar';
import { Body } from '@/components/ui';
import { api } from '@/lib/api';
import { colors, space } from '@/lib/theme';
/** Reactions, new followers and songs sent to you, newest first. Opening it marks them read. */
export default function NotificationsScreen() {
const { mutate: mutateGlobal } = useSWRConfig();
const { data, size, setSize, isLoading, mutate } = useSWRInfinite<NotificationListDTO>(
(i, prev: NotificationListDTO | null) => (i > 0 && !prev?.nextCursor ? null : ['notifications', prev?.nextCursor ?? null]),
([, cursor]: [string, string | null]) => api.notifications(cursor),
);
const items = data?.flatMap((p) => p.notifications) ?? [];
const more = Boolean(data?.at(-1)?.nextCursor);
// Seen once you've looked; the bell clears (the list keeps showing which were new until you leave).
const unread = data?.[0]?.unread ?? 0;
useFocusEffect(
useCallback(() => {
if (!unread) return;
api
.markNotificationsRead()
.then(() => mutateGlobal('/notifications'))
.catch(() => {});
return () => {
mutate();
};
}, [unread, mutate, mutateGlobal]),
);
return (
<FlatList
data={items}
keyExtractor={(n) => n.id}
contentContainerStyle={styles.list}
style={{ backgroundColor: colors.bg }}
onEndReached={() => more && setSize(size + 1)}
ListEmptyComponent={
isLoading ? null : <Body muted>Nothing yet. Reactions, new followers and songs sent to you show up here.</Body>
}
renderItem={({ item }) => <Row n={item} />}
/>
);
}
function Row({ n }: { n: NotificationDTO }) {
const { title, body } = notificationText(n);
return (
<Pressable
onPress={() => router.push(notificationPath(n) as never)}
style={({ pressed }) => [styles.row, !n.read && styles.unread, pressed && { opacity: 0.7 }]}
accessibilityRole="link">
<Avatar user={n.actor} size={40} />
<View style={styles.text}>
<Text style={styles.title} numberOfLines={2}>
{title}
</Text>
<Text style={styles.body} numberOfLines={3}>
{body}
</Text>
<Text style={styles.time}>{timeAgo(n.createdAt)}</Text>
</View>
{n.track?.artworkUrl && <Image source={n.track.artworkUrl} style={styles.art} contentFit="cover" />}
</Pressable>
);
}
const styles = StyleSheet.create({
list: { padding: space.lg, gap: space.sm },
row: { flexDirection: 'row', gap: space.md, alignItems: 'center', padding: space.md, borderRadius: 14 },
unread: { backgroundColor: colors.surface2 },
text: { flex: 1, minWidth: 0, gap: 2 },
title: { color: colors.text, fontWeight: '700', fontSize: 15 },
body: { color: colors.text, opacity: 0.85, fontSize: 14 },
time: { color: colors.muted, fontSize: 12 },
art: { width: 44, height: 44, borderRadius: 8 },
});
+107
View File
@@ -0,0 +1,107 @@
import { router, useLocalSearchParams } from 'expo-router';
import { useState } from 'react';
import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native';
import useSWR from 'swr';
import type { TrackDTO } from '@musicshare/shared';
import { Avatar } from '@/components/Avatar';
import { emptyFollowText, useFollowList } from '@/components/FollowList';
import { Body, Button, Field } from '@/components/ui';
import { api } from '@/lib/api';
import { showError } from '@/lib/errors';
import { successHaptic, tapHaptic } from '@/lib/haptics';
import { useSession } from '@/lib/session';
import { colors, radius, space } from '@/lib/theme';
/** Send a song to some of your followers, with an optional note. */
export default function SendSongScreen() {
const { trackId } = useLocalSearchParams<{ trackId: string }>();
const { me } = useSession();
const { data: track } = useSWR<TrackDTO>(trackId ? `/tracks/${trackId}` : null);
const followers = useFollowList(me?.username, 'followers');
const [picked, setPicked] = useState<Set<string>>(new Set());
const [message, setMessage] = useState('');
const [busy, setBusy] = useState(false);
const toggle = (username: string) => {
tapHaptic();
setPicked((p) => {
const next = new Set(p);
if (next.has(username)) next.delete(username);
else next.add(username);
return next;
});
};
const send = async () => {
setBusy(true);
try {
await api.sendSong({ trackId, to: [...picked], message: message.trim() || null });
successHaptic();
router.back();
} catch (err) {
showError(err, "Couldn't send");
}
setBusy(false);
};
return (
<FlatList
style={{ backgroundColor: colors.bg }}
contentContainerStyle={styles.list}
data={followers.users}
keyExtractor={(u) => u.id}
onEndReached={followers.loadMore}
keyboardShouldPersistTaps="handled"
ListHeaderComponent={
<View style={{ gap: space.md }}>
{track && (
<Text style={styles.song} numberOfLines={2}>
{track.title} <Text style={styles.muted}>· {track.artist}</Text>
</Text>
)}
<Field placeholder="Add a note (optional)" value={message} onChangeText={setMessage} maxLength={280} />
<Body muted style={{ fontSize: 13 }}>
You can send songs to people who follow you.
</Body>
</View>
}
ListEmptyComponent={followers.isLoading ? null : <Body muted>{emptyFollowText('followers')}</Body>}
renderItem={({ item: u }) => {
const on = picked.has(u.username);
return (
<Pressable
onPress={() => toggle(u.username)}
style={[styles.person, on && styles.on]}
accessibilityRole="checkbox"
accessibilityState={{ checked: on }}>
<Avatar user={u} size={36} />
<Text style={styles.name} numberOfLines={1}>
{u.displayName ?? u.username}
</Text>
<Text style={[styles.check, on && { color: colors.text }]}>{on ? '✓' : '○'}</Text>
</Pressable>
);
}}
ListFooterComponent={
<Button
variant="primary"
title={picked.size ? `Send to ${picked.size}` : 'Pick someone'}
disabled={!picked.size}
loading={busy}
onPress={send}
style={{ marginTop: space.md }}
/>
}
/>
);
}
const styles = StyleSheet.create({
list: { padding: space.lg, gap: space.sm, paddingBottom: space.xl * 2 },
song: { color: colors.text, fontSize: 17, fontWeight: '700' },
muted: { color: colors.muted, fontWeight: '400' },
person: { flexDirection: 'row', alignItems: 'center', gap: space.md, padding: space.sm, borderRadius: radius.md },
on: { backgroundColor: colors.surface2 },
name: { flex: 1, color: colors.text, fontSize: 15, fontWeight: '600' },
check: { color: colors.muted, fontSize: 18 },
});
+29
View File
@@ -0,0 +1,29 @@
import { Stack, useLocalSearchParams } from 'expo-router';
import useSWR from 'swr';
import type { TrackDTO } from '@musicshare/shared';
import { Lyrics } from '@/components/Lyrics';
import { Screen } from '@/components/Screen';
import { TrackCard } from '@/components/TrackCard';
import { Body, Card } from '@/components/ui';
import { useSession } from '@/lib/session';
/** One song: where notifications and shared links lead. */
export default function TrackScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const { me } = useSession();
const { data: track, error } = useSWR<TrackDTO>(id ? `/tracks/${id}` : null);
return (
<Screen edges={[]}>
<Stack.Screen options={{ title: track?.title ?? '' }} />
{error && <Body muted>That song couldn&apos;t be found.</Body>}
{track && (
<>
<TrackCard track={track} preferred={me?.preferredService} />
<Card>
<Lyrics track={track} />
</Card>
</>
)}
</Screen>
);
}
@@ -0,0 +1,44 @@
import { router } from 'expo-router';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import useSWR from 'swr';
import type { NotificationListDTO } from '@musicshare/shared';
import { colors } from '@/lib/theme';
/** Bell with the unread count; opens your notifications. */
export function ActivityBell() {
const { data } = useSWR<NotificationListDTO>('/notifications');
const unread = data?.unread ?? 0;
return (
<Pressable
onPress={() => router.push('/notifications')}
hitSlop={10}
accessibilityRole="button"
accessibilityLabel={unread ? `Notifications, ${unread} new` : 'Notifications'}
style={styles.bell}>
<Text style={styles.icon}>🔔</Text>
{unread > 0 && (
<View style={styles.badge}>
<Text style={styles.badgeText}>{unread > 99 ? '99+' : unread}</Text>
</View>
)}
</Pressable>
);
}
const styles = StyleSheet.create({
bell: { width: 38, height: 38, borderRadius: 19, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.surface2 },
icon: { fontSize: 18 },
badge: {
position: 'absolute',
top: -2,
right: -4,
minWidth: 18,
height: 18,
paddingHorizontal: 4,
borderRadius: 9,
backgroundColor: colors.danger,
alignItems: 'center',
justifyContent: 'center',
},
badgeText: { color: '#fff', fontSize: 11, fontWeight: '700' },
});
+6
View File
@@ -1,4 +1,5 @@
import { Image } from 'expo-image'; import { Image } from 'expo-image';
import { router } from 'expo-router';
import { Linking, Pressable, Share, StyleSheet, Text, View } from 'react-native'; import { Linking, Pressable, Share, StyleSheet, Text, View } from 'react-native';
import { SERVICE_LABELS, openUrl, shareText, trackUrl, viewerServices, type StreamingService, type TrackDTO } from '@musicshare/shared'; import { SERVICE_LABELS, openUrl, shareText, trackUrl, viewerServices, type StreamingService, type TrackDTO } from '@musicshare/shared';
import { WEB_URL } from '@/lib/config'; import { WEB_URL } from '@/lib/config';
@@ -60,6 +61,11 @@ export function ServiceLinks({ track, preferred }: { track: TrackDTO; preferred:
</Pressable> </Pressable>
); );
})} })}
{me && (
<Pressable onPress={() => router.push(`/send/${track.id}`)} hitSlop={6} accessibilityRole="button" accessibilityLabel="Send to friends">
<Text style={[styles.service, styles.share]}>Send ✉</Text>
</Pressable>
)}
<Pressable onPress={() => shareTrack(track, me?.username)} hitSlop={6} accessibilityRole="button" accessibilityLabel="Share this song"> <Pressable onPress={() => shareTrack(track, me?.username)} hitSlop={6} accessibilityRole="button" accessibilityLabel="Share this song">
<Text style={[styles.service, styles.share]}>Share ↗</Text> <Text style={[styles.service, styles.share]}>Share ↗</Text>
</Pressable> </Pressable>
+67
View File
@@ -0,0 +1,67 @@
import { useEffect, useState } from 'react';
import { PermissionsAndroid, Platform } from 'react-native';
import Push, { type PushEndpoint } from '@/modules/push';
import { api } from './api';
/**
* Push notifications through UnifiedPush: a distributor app on the phone (ntfy is the
* usual one, pointed at your own server) holds the connection, so there's no Google
* Firebase. Android only; elsewhere the app's notification list still works.
*/
export const pushAvailable = Push !== null;
/** ntfy, the distributor we suggest, on F-Droid. */
export const NTFY_URL = 'https://f-droid.org/packages/io.heckel.ntfy/';
const send = (ep: PushEndpoint) =>
api.addPushSubscription({ kind: 'unifiedpush', endpoint: ep.url, keys: { p256dh: ep.p256dh, auth: ep.auth } });
/** Make sure the server has this phone's current endpoint (call when signed in). */
export async function syncPush() {
const ep = Push?.getEndpoint();
if (ep) await send(ep);
}
/**
* Ask for permission and register with the distributor. Returns 'no-distributor' when
* none is installed; otherwise the endpoint arrives shortly (see usePushStatus).
*/
export async function enablePush(): Promise<'ok' | 'no-distributor' | 'denied'> {
if (!Push) return 'no-distributor';
if (Platform.OS === 'android' && Number(Platform.Version) >= 33) {
const result = await PermissionsAndroid.request('android.permission.POST_NOTIFICATIONS');
if (result !== PermissionsAndroid.RESULTS.GRANTED) return 'denied';
}
const { publicKey } = await api.pushKey();
return (await Push.register(publicKey)) ? 'ok' : 'no-distributor';
}
export async function disablePush() {
const ep = Push?.getEndpoint();
Push?.unregister();
if (ep) await api.removePushSubscription(ep.url);
}
/** Whether push is set up, which distributor delivers it, and whether any is installed. Keeps the server told of changes. */
export function usePushStatus() {
const [endpoint, setEndpoint] = useState(() => Push?.getEndpoint() ?? null);
const [distributors, setDistributors] = useState(() => Push?.distributors() ?? []);
useEffect(() => {
if (!Push) return;
const sub = Push.addListener('onEndpoint', ({ endpoint: ep }) => {
setEndpoint(ep);
setDistributors(Push!.distributors());
if (ep) send(ep).catch(() => {});
});
return () => sub.remove();
}, []);
return {
enabled: Boolean(endpoint),
distributor: endpoint ? (Push?.currentDistributor() ?? null) : null,
hasDistributor: distributors.length > 0,
refresh: () => {
setEndpoint(Push?.getEndpoint() ?? null);
setDistributors(Push?.distributors() ?? []);
},
};
}
+34 -2
View File
@@ -1,8 +1,8 @@
// musicshare service worker. // musicshare service worker.
// Deliberately small: live data must always come from the network, so we only // Deliberately small: live data must always come from the network, so we only
// cache Next.js's immutable build assets, and show an offline page when a // cache Next.js's immutable build assets, and show an offline page when a
// navigation fails. (Web push notifications will hook in here later.) // navigation fails. It also shows push notifications (reactions, follows, songs sent to you).
const VERSION = 'v1'; const VERSION = 'v2';
const STATIC_CACHE = `static-${VERSION}`; const STATIC_CACHE = `static-${VERSION}`;
const OFFLINE_URL = '/offline.html'; const OFFLINE_URL = '/offline.html';
@@ -47,3 +47,35 @@ self.addEventListener('fetch', (event) => {
event.respondWith(fetch(request).catch(() => caches.match(OFFLINE_URL))); event.respondWith(fetch(request).catch(() => caches.match(OFFLINE_URL)));
} }
}); });
// Push: { id, title, body, path, icon } from the server, already decrypted by the browser.
self.addEventListener('push', (event) => {
let data = {};
try {
data = event.data ? event.data.json() : {};
} catch {
return;
}
event.waitUntil(
self.registration.showNotification(data.title || 'musicshare', {
body: data.body || '',
icon: data.icon || '/icon-192.png',
badge: '/icon-192.png',
tag: data.id,
data: { path: data.path || '/' },
}),
);
});
// Tap: focus an open tab and go there, or open a new one.
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const url = new URL(event.notification.data?.path || '/', self.location.origin).href;
event.waitUntil(
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((tabs) => {
const tab = tabs.find((t) => new URL(t.url).origin === self.location.origin);
if (tab) return tab.focus().then(() => tab.navigate(url));
return self.clients.openWindow(url);
}),
);
});
+19
View File
@@ -79,6 +79,24 @@ input:focus, textarea:focus, select:focus {
.modal { max-width: 26rem; width: calc(100% - 2rem); color: var(--text); background: #15112c; } .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::backdrop { background: rgba(5, 3, 15, 0.7); backdrop-filter: blur(4px); }
.modal p { margin-bottom: 1rem; } .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; } .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 { 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; } .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) { @media (max-width: 640px) {
/* Signed in, the tab bar has the links; the top bar is just the logo. */ /* Signed in, the tab bar has the links; the top bar is just the logo. */
.nav:has(+ .tabbar) { justify-content: center; } .nav:has(+ .tabbar) { justify-content: center; }
.nav:has(+ .tabbar) .bell { position: absolute; right: 1rem; margin: 0; }
.nav:has(+ .tabbar) .nav-links { display: none; } .nav:has(+ .tabbar) .nav-links { display: none; }
.tabbar { .tabbar {
display: flex; position: fixed; left: 0; right: 0; bottom: 0; z-index: 20; display: flex; position: fixed; left: 0; right: 0; bottom: 0; z-index: 20;
+60
View File
@@ -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>
);
}
+6 -2
View File
@@ -2,7 +2,7 @@
import { AnimatePresence, motion } from 'motion/react'; import { AnimatePresence, motion } from 'motion/react';
import Link from 'next/link'; 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 { sortFeed, type FeedItemDTO, type FollowListDTO, type ProfileDTO } from '@musicshare/shared';
import { Avatar } from '@/components/Avatar'; import { Avatar } from '@/components/Avatar';
import { Equalizer } from '@/components/Equalizer'; import { Equalizer } from '@/components/Equalizer';
@@ -20,6 +20,7 @@ export default function Home() {
function Feed() { function Feed() {
const { me } = useMe(); const { me } = useMe();
const { mutate: mutateGlobal } = useSWRConfig();
const { data: feed, mutate } = useSWR<FeedItemDTO[]>('/feed', fetcher); 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. // 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); 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)) ?? []; const quiet = following?.users.filter((u) => !inFeed.has(u.id)) ?? [];
useRealtime(true, (msg) => { useRealtime(true, (msg) => {
if (msg.type !== 'now_playing') return; if (msg.type === 'notification') {
mutateGlobal('/notifications');
return;
}
if (msg.data.user.id === me?.id) { if (msg.data.user.id === me?.id) {
if (mine) mutateMine({ ...mine, nowPlaying: msg.data }, { revalidate: false }); if (mine) mutateMine({ ...mine, nowPlaying: msg.data }, { revalidate: false });
return; return;
+14 -2
View File
@@ -3,7 +3,7 @@ import Link from 'next/link';
export const metadata: Metadata = { title: 'Privacy policy · musicshare' }; export const metadata: Metadata = { title: 'Privacy policy · musicshare' };
const UPDATED = 'October 6, 2026'; const UPDATED = 'October 14, 2026';
const CONTACT = 'app@musicshare.binarygnome.com'; const CONTACT = 'app@musicshare.binarygnome.com';
export default function PrivacyPage() { export default function PrivacyPage() {
@@ -43,6 +43,11 @@ export default function PrivacyPage() {
<li> <li>
<strong>People:</strong> who you follow, who you&apos;ve pinned, who you&apos;ve blocked, and reports you send. <strong>People:</strong> who you follow, who you&apos;ve pinned, who you&apos;ve blocked, and reports you send.
</li> </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> <li>
<strong>Technical data:</strong> sign-in sessions (stored as hashes), and your IP address, which is used briefly <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. 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> <li>
<strong>Song matching:</strong> to build &ldquo;open in your app&rdquo; links we look up song titles and artists <strong>Song matching:</strong> to build &ldquo;open in your app&rdquo; links we look up song titles and artists
with Deezer, Apple&apos;s iTunes search, Spotify&apos;s catalog and Odesli (song.link). These lookups contain only with Deezer, Apple&apos;s iTunes search, Spotify&apos;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&apos;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&apos;t read
the contents.
</li> </li>
<li> <li>
<strong>Our own servers:</strong> the app, database and email run on servers we manage ourselves. <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> <h2>How long we keep it</h2>
<ul> <ul>
<li>Recent plays: the latest 50. Older ones are deleted as new ones come in.</li> <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> <li>
Everything else: until you delete it or your account. Deleting your account (Settings &rarr; Delete account) Everything else: until you delete it or your account. Deleting your account (Settings &rarr; Delete account)
removes your profile, picture, plays, follows and connected services right away. Database backups are kept for removes your profile, picture, plays, follows and connected services right away. Database backups are kept for
+71 -1
View File
@@ -2,7 +2,7 @@
import Link from 'next/link'; import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { Suspense, useRef, useState } from 'react'; import { Suspense, useEffect, useRef, useState } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import { import {
SERVICE_LABELS, SERVICE_LABELS,
@@ -14,6 +14,9 @@ import {
type NowPlayingProvider, type NowPlayingProvider,
type ProfileDTO, type ProfileDTO,
type StreamingService, type StreamingService,
type NotificationType,
NOTIFICATION_PREF_LABELS,
NOTIFICATION_TYPES,
type TrackSearchResultDTO, type TrackSearchResultDTO,
type UserSummaryDTO, type UserSummaryDTO,
} from '@musicshare/shared'; } from '@musicshare/shared';
@@ -22,6 +25,7 @@ import { ServiceName } from '@/components/ServiceName';
import { SharingControls } from '@/components/SharingControls'; import { SharingControls } from '@/components/SharingControls';
import { DONATION_URL } from '@/components/SupportLink'; import { DONATION_URL } from '@/components/SupportLink';
import { api, client, fetcher, uploadFile, useMe } from '@/lib/api'; import { api, client, fetcher, uploadFile, useMe } from '@/lib/api';
import { browserPushSupported, currentSubscription, disableBrowserPush, enableBrowserPush } from '@/lib/push';
import { timeAgo } from '@musicshare/shared'; import { timeAgo } from '@musicshare/shared';
import { useDebounced } from '@/lib/useDebounced'; import { useDebounced } from '@/lib/useDebounced';
@@ -43,6 +47,7 @@ function Settings() {
<h1>Settings</h1> <h1>Settings</h1>
<ProfileForm me={me} /> <ProfileForm me={me} />
<SharingControls /> <SharingControls />
<NotificationSettings me={me} />
<Connections /> <Connections />
<FeaturedPicker /> <FeaturedPicker />
<AccountCard me={me} /> <AccountCard me={me} />
@@ -503,6 +508,71 @@ function FeaturedPicker() {
type Panel = 'email' | 'password' | 'blocked' | 'delete' | null; type Panel = 'email' | 'password' | 'blocked' | 'delete' | null;
/** Everything about the account itself, as one list of rows; each opens in place. */ /** 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&apos;t show notifications. Everything still appears under the bell.</p>}
{error && <p className="error">{error}</p>}
</section>
);
}
function AccountCard({ me }: { me: MeDTO }) { function AccountCard({ me }: { me: MeDTO }) {
const { mutate } = useMe(); const { mutate } = useMe();
const router = useRouter(); const router = useRouter();
+21
View File
@@ -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>
);
}
+2
View File
@@ -4,6 +4,7 @@ import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { useState } from 'react'; import { useState } from 'react';
import { client, useMe } from '@/lib/api'; 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. */ /** Top bar: logo and the four main places. On phones those move to a bottom tab bar. */
export function Nav() { export function Nav() {
@@ -25,6 +26,7 @@ export function Nav() {
<img className="brand-logo" src="/logo.png" alt="" width={30} height={30} /> <img className="brand-logo" src="/logo.png" alt="" width={30} height={30} />
musicshare musicshare
</Link> </Link>
{me && <ActivityBell />}
<div className="nav-links"> <div className="nav-links">
{me ? ( {me ? (
tabs.map((t) => ( tabs.map((t) => (
+104
View File
@@ -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
View File
@@ -2,6 +2,7 @@
import { SERVICE_LABELS, openUrl, viewerServices, type StreamingService, type TrackDTO } from '@musicshare/shared'; import { SERVICE_LABELS, openUrl, viewerServices, type StreamingService, type TrackDTO } from '@musicshare/shared';
import { useMe } from '@/lib/api'; import { useMe } from '@/lib/api';
import { SendSongButton } from './SendSong';
import { ShareButton } from './ShareButton'; import { ShareButton } from './ShareButton';
function Artwork({ track, small }: { track: TrackDTO; small?: boolean }) { function Artwork({ track, small }: { track: TrackDTO; small?: boolean }) {
@@ -41,6 +42,7 @@ export function ServiceLinks({ track, preferred }: { track: TrackDTO; preferred:
</a> </a>
); );
})} })}
<SendSongButton track={track} />
<ShareButton track={track} /> <ShareButton track={track} />
</> </>
); );
+41
View File
@@ -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();
}
+9
View File
@@ -34,3 +34,12 @@ MAIL_FROM=musicshare <app@musicshare.binarygnome.com>
PROXY_BIND=127.0.0.1 PROXY_BIND=127.0.0.1
WEB_PORT=3737 WEB_PORT=3737
API_PORT=3738 API_PORT=3738
# Phone notifications through your own ntfy server (docker-compose.ntfy.yml, docs/PUSH.md).
# A subdomain pointed at it, e.g. push.example.com.
NTFY_DOMAIN=
# Web Push keys (optional; made automatically and kept in the database if empty).
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
# Lyrics: LRCLIB (or your own copy of it). Empty turns lyrics off.
LRCLIB_URL=https://lrclib.net
+41
View File
@@ -0,0 +1,41 @@
# Your own push server for phone notifications (UnifiedPush via ntfy), so nothing goes
# through Google. Add this file to the usual production command, e.g.:
#
# docker compose -f docker-compose.yml -f docker-compose.prod.yml -f docker-compose.behind-proxy.yml \
# -f docker-compose.ntfy.yml --env-file deploy/production.env up -d
#
# Then point a subdomain (NTFY_DOMAIN, e.g. push.example.com) at 127.0.0.1:${NTFY_PORT:-3739} in your
# reverse proxy, with websockets on. ntfy can't live under a path like /push. See docs/PUSH.md.
services:
ntfy:
image: binwiederhier/ntfy:v2.30.0
command: serve
restart: unless-stopped
environment:
NTFY_BASE_URL: https://${NTFY_DOMAIN:?set NTFY_DOMAIN in production.env}
NTFY_BEHIND_PROXY: 'true'
# Keep messages for phones that are offline for a while.
NTFY_CACHE_FILE: /var/cache/ntfy/cache.db
NTFY_CACHE_DURATION: 24h
# Private server: nobody can use it for anything except UnifiedPush topics ("up" + a long
# random name only the phone and our server know).
NTFY_AUTH_FILE: /var/lib/ntfy/auth.db
NTFY_AUTH_DEFAULT_ACCESS: deny-all
NTFY_AUTH_ACCESS: '*:up*:rw'
NTFY_ENABLE_SIGNUP: 'false'
NTFY_ENABLE_LOGIN: 'false'
NTFY_WEB_ROOT: disable
volumes:
- ntfy_cache:/var/cache/ntfy
- ntfy_lib:/var/lib/ntfy
ports:
- "${PROXY_BIND:-127.0.0.1}:${NTFY_PORT:-3739}:80"
healthcheck:
test: ["CMD-SHELL", "wget -q --tries=1 http://localhost:80/v1/health -O - | grep -q '\"healthy\":true'"]
interval: 60s
timeout: 10s
retries: 3
volumes:
ntfy_cache:
ntfy_lib:
+45
View File
@@ -0,0 +1,45 @@
# Notifications
musicshare tells people about reactions to their songs, new followers, and songs sent to them. Each user picks
which kinds in Settings. Everything always shows under the bell on the feed; push notifications are extra.
Nothing goes through Google's Firebase or another push company's account:
- **Phones (Android):** [UnifiedPush](https://unifiedpush.org). A "distributor" app on the phone keeps one
connection open to a push server, and apps receive their notifications through it. We run the push server
ourselves ([ntfy](https://ntfy.sh), `docker-compose.ntfy.yml`). People install the ntfy app (free, open source,
on F-Droid and Play) and point it at our server.
- **Browsers:** standard Web Push. The server signs messages with its own key pair (VAPID), so there's no account
with anyone, but each browser maker runs its own delivery service (Chrome uses Google's, Firefox uses Mozilla's).
That's built into browsers and can't be swapped.
- **iPhone:** Apple only allows its own push service. Not set up.
Messages are end-to-end encrypted (RFC 8291) in every case: the push server and browser relays only see ciphertext.
## Server setup
1. Pick a subdomain for the push server, e.g. `push.binarygnome.com`, and point DNS at the server.
2. Set `NTFY_DOMAIN` in `deploy/production.env`.
3. Start it with the other services. Add `-f docker-compose.ntfy.yml` to the usual command:
```bash
docker compose -f docker-compose.yml -f docker-compose.prod.yml -f docker-compose.behind-proxy.yml \
-f docker-compose.ntfy.yml --env-file deploy/production.env up -d
```
4. In the reverse proxy (NPMplus), add a proxy host for that subdomain:
- Forward to `http://127.0.0.1:3739`.
- Turn on websocket support and HTTPS.
5. Check it: `curl https://push.binarygnome.com/v1/health` should say `{"healthy":true}`.
The server's Web Push keys are generated the first time they're needed and kept in the `server_keys` table. Don't
delete that row: every phone and browser would have to turn notifications on again. To manage the keys yourself,
set `VAPID_PUBLIC_KEY` and `VAPID_PRIVATE_KEY` (make a pair with `npx web-push generate-vapid-keys`).
## On a phone
1. Install **ntfy** ([F-Droid](https://f-droid.org/packages/io.heckel.ntfy/) or Play).
2. In ntfy, open **Settings > Default server** and enter `https://push.binarygnome.com`. Without this step it
uses the public ntfy.sh server, which also works, but then the push server isn't ours.
3. In musicshare, go to **Settings > Notifications > Turn on for this phone**.
ntfy shows one quiet, permanent notification so Android keeps its connection alive. People can hide it by
long-pressing it and turning off that notification category.
+111 -1
View File
@@ -5,6 +5,7 @@
"packages": { "packages": {
"": { "": {
"name": "musicshare", "name": "musicshare",
"license": "AGPL-3.0-or-later",
"workspaces": [ "workspaces": [
"apps/*", "apps/*",
"packages/*" "packages/*"
@@ -20,6 +21,7 @@
"apps/api": { "apps/api": {
"name": "@musicshare/api", "name": "@musicshare/api",
"version": "0.0.0", "version": "0.0.0",
"license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
"@fastify/cookie": "^11.1.2", "@fastify/cookie": "^11.1.2",
"@fastify/cors": "^11.3.0", "@fastify/cors": "^11.3.0",
@@ -37,11 +39,13 @@
"postgres": "^3.4.9", "postgres": "^3.4.9",
"sharp": "^0.35.5", "sharp": "^0.35.5",
"undici": "^6.29.0", "undici": "^6.29.0",
"web-push": "^3.6.7",
"zod": "^4.6.5" "zod": "^4.6.5"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^26.6.4", "@types/node": "^26.6.4",
"@types/nodemailer": "^8.0.2", "@types/nodemailer": "^8.0.2",
"@types/web-push": "^3.6.4",
"@types/ws": "^8.18.2", "@types/ws": "^8.18.2",
"concurrently": "^10.0.5", "concurrently": "^10.0.5",
"drizzle-kit": "^0.31.11", "drizzle-kit": "^0.31.11",
@@ -806,6 +810,7 @@
"apps/mobile": { "apps/mobile": {
"name": "@musicshare/mobile", "name": "@musicshare/mobile",
"version": "1.0.0", "version": "1.0.0",
"license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
"@expo-google-fonts/space-grotesk": "^0.4.1", "@expo-google-fonts/space-grotesk": "^0.4.1",
"@expo/ui": "~57.0.21", "@expo/ui": "~57.0.21",
@@ -855,6 +860,7 @@
"apps/web": { "apps/web": {
"name": "@musicshare/web", "name": "@musicshare/web",
"version": "0.0.0", "version": "0.0.0",
"license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
"@musicshare/shared": "*", "@musicshare/shared": "*",
"motion": "^14.0.0", "motion": "^14.0.0",
@@ -7537,6 +7543,16 @@
"@types/react": "*" "@types/react": "*"
} }
}, },
"node_modules/@types/web-push": {
"version": "3.6.4",
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",
"integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/ws": { "node_modules/@types/ws": {
"version": "8.18.2", "version": "8.18.2",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz",
@@ -8445,6 +8461,18 @@
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/asn1.js": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
"license": "MIT",
"dependencies": {
"bn.js": "^4.0.0",
"inherits": "^2.0.1",
"minimalistic-assert": "^1.0.0",
"safer-buffer": "^2.1.0"
}
},
"node_modules/assertion-error": { "node_modules/assertion-error": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -8717,6 +8745,12 @@
"node": ">=0.6" "node": ">=0.6"
} }
}, },
"node_modules/bn.js": {
"version": "4.12.5",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz",
"integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==",
"license": "MIT"
},
"node_modules/boolbase": { "node_modules/boolbase": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
@@ -8810,6 +8844,12 @@
"node-int64": "^0.4.0" "node-int64": "^0.4.0"
} }
}, },
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/buffer-from": { "node_modules/buffer-from": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@@ -9912,6 +9952,15 @@
"stream-shift": "^1.0.2" "stream-shift": "^1.0.2"
} }
}, },
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/ee-first": { "node_modules/ee-first": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -12386,6 +12435,15 @@
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/http_ece": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/http-errors": { "node_modules/http-errors": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -13359,6 +13417,27 @@
"node": ">=4.0" "node": ">=4.0"
} }
}, },
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/keyv": { "node_modules/keyv": {
"version": "4.5.4", "version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -15040,6 +15119,12 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "10.2.6", "version": "10.2.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
@@ -15059,7 +15144,6 @@
"version": "1.2.8", "version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
@@ -17269,6 +17353,12 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/sandbox-cli-detector": { "node_modules/sandbox-cli-detector": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/sandbox-cli-detector/-/sandbox-cli-detector-0.2.0.tgz", "resolved": "https://registry.npmjs.org/sandbox-cli-detector/-/sandbox-cli-detector-0.2.0.tgz",
@@ -19948,6 +20038,25 @@
"defaults": "^1.0.3" "defaults": "^1.0.3"
} }
}, },
"node_modules/web-push": {
"version": "3.6.7",
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
"license": "MPL-2.0",
"dependencies": {
"asn1.js": "^5.3.0",
"http_ece": "1.2.0",
"https-proxy-agent": "^7.0.0",
"jws": "^4.0.0",
"minimist": "^1.2.5"
},
"bin": {
"web-push": "src/cli.js"
},
"engines": {
"node": ">= 16"
}
},
"node_modules/webidl-conversions": { "node_modules/webidl-conversions": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
@@ -20316,6 +20425,7 @@
"packages/shared": { "packages/shared": {
"name": "@musicshare/shared", "name": "@musicshare/shared",
"version": "0.0.0", "version": "0.0.0",
"license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
"qrcode-generator": "^2.0.4", "qrcode-generator": "^2.0.4",
"zod": "^4.6.5" "zod": "^4.6.5"
+12
View File
@@ -12,6 +12,7 @@ import type {
} from './dto'; } from './dto';
import type { DeviceDTO } from './devices'; import type { DeviceDTO } from './devices';
import type { LyricsDTO } from './lyrics'; import type { LyricsDTO } from './lyrics';
import type { NotificationListDTO } from './notifications';
import type { import type {
ChangeEmailInput, ChangeEmailInput,
ChangePasswordInput, ChangePasswordInput,
@@ -21,6 +22,8 @@ import type {
ResetPasswordInput, ResetPasswordInput,
SetFeaturedInput, SetFeaturedInput,
UpdateMeInput, UpdateMeInput,
PushSubscriptionInput,
SendSongInput,
} from './schemas'; } from './schemas';
export class ApiError extends Error { 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)}`), searchTracks: (query: string) => request<TrackSearchResultDTO[]>(`/tracks/search?q=${q(query)}`),
/** Null when no lyrics were found for the song. */ /** Null when no lyrics were found for the song. */
lyrics: (trackId: string) => request<LyricsDTO | null>(`/tracks/${q(trackId)}/lyrics`), 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'), connections: () => request<ConnectionDTO[]>('/connections'),
authorizeUrl: (provider: 'plex' | 'lastfm', body: { client: 'web' | 'mobile'; returnTo?: string }) => 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 { Entitlements, Plan } from './plans';
import type { NotificationPrefs } from './notifications';
import type { NowPlayingProvider, StreamingService } from './services'; import type { NowPlayingProvider, StreamingService } from './services';
export interface TrackDTO { export interface TrackDTO {
@@ -28,6 +29,7 @@ export interface MeDTO extends UserSummaryDTO {
preferredService: StreamingService | null; preferredService: StreamingService | null;
/** The services they use; null until chosen, meaning all of them. */ /** The services they use; null until chosen, meaning all of them. */
services: StreamingService[] | null; services: StreamingService[] | null;
notifyPrefs: NotificationPrefs;
role: 'user' | 'admin'; role: 'user' | 'admin';
/** When onboarding was finished; null means show it. */ /** When onboarding was finished; null means show it. */
onboardedAt: string | null; onboardedAt: string | null;
@@ -161,4 +163,7 @@ export interface TrackSearchResultDTO {
} }
/** Messages pushed over the realtime websocket. */ /** 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 './progress';
export * from './reactions'; export * from './reactions';
export * from './lyrics'; 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)]) .transform((s) => [...new Set(s)])
.nullable() .nullable()
.optional(), .optional(),
notifyPrefs: z
.object({ reaction: z.boolean(), follow: z.boolean(), share: z.boolean() })
.partial()
.optional(),
/** Mark onboarding as finished. */ /** Mark onboarding as finished. */
onboarded: z.literal(true).optional(), 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([ export const setFeaturedSchema = z.union([
z.object({ deezerId: z.string().regex(/^\d+$/), comment: z.string().trim().max(280).nullable().optional() }), 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() }), 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 ChangeEmailInput = z.infer<typeof changeEmailSchema>;
export type UpdateMeInput = z.infer<typeof updateMeSchema>; export type UpdateMeInput = z.infer<typeof updateMeSchema>;
export type SetFeaturedInput = z.infer<typeof setFeaturedSchema>; 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({ export const jellyfinConnectSchema = z.object({
serverUrl: z.string().trim().min(1).max(500), serverUrl: z.string().trim().min(1).max(500),