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:
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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). */
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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!);
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
+88
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+49
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+31
@@ -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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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't use Google to deliver notifications. Install ntfy (free and open source), then come back and turn
|
||||||
|
them on. You'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();
|
||||||
|
|||||||
@@ -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 }} />
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
});
|
||||||
@@ -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 },
|
||||||
|
});
|
||||||
@@ -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'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' },
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useSWRConfig } from 'swr';
|
||||||
|
import useSWRInfinite from 'swr/infinite';
|
||||||
|
import { notificationPath, notificationText, timeAgo, type NotificationListDTO } from '@musicshare/shared';
|
||||||
|
import { Avatar } from '@/components/Avatar';
|
||||||
|
import { client, useMe } from '@/lib/api';
|
||||||
|
|
||||||
|
/** Reactions, new followers and songs sent to you, newest first. Opening it marks them read. */
|
||||||
|
export default function NotificationsPage() {
|
||||||
|
const { me, loggedOut } = useMe();
|
||||||
|
const { mutate } = useSWRConfig();
|
||||||
|
const { data, size, setSize, isLoading } = useSWRInfinite<NotificationListDTO>(
|
||||||
|
(i, prev: NotificationListDTO | null) => (!me || (i > 0 && !prev?.nextCursor) ? null : ['notifications', prev?.nextCursor ?? null]),
|
||||||
|
([, cursor]: [string, string | null]) => client.notifications(cursor),
|
||||||
|
{ revalidateOnFocus: false },
|
||||||
|
);
|
||||||
|
const items = data?.flatMap((p) => p.notifications) ?? [];
|
||||||
|
const unread = data?.[0]?.unread ?? 0;
|
||||||
|
|
||||||
|
// Seen once you've looked: clear the bell (rows stay highlighted until you come back).
|
||||||
|
useEffect(() => {
|
||||||
|
if (unread) client.markNotificationsRead().then(() => mutate('/notifications'), () => {});
|
||||||
|
}, [unread, mutate]);
|
||||||
|
|
||||||
|
if (loggedOut) return <p className="muted"><Link href="/login">Log in</Link> to see your notifications.</p>;
|
||||||
|
return (
|
||||||
|
<div className="stack">
|
||||||
|
<h1>Notifications</h1>
|
||||||
|
{items.length === 0 && !isLoading && (
|
||||||
|
<p className="muted">Nothing yet. Reactions, new followers and songs sent to you show up here.</p>
|
||||||
|
)}
|
||||||
|
<ul className="notifications">
|
||||||
|
{items.map((n) => {
|
||||||
|
const { title, body } = notificationText(n);
|
||||||
|
return (
|
||||||
|
<li key={n.id} className={n.read ? '' : 'unread'}>
|
||||||
|
<Link href={notificationPath(n)} className="notification">
|
||||||
|
<Avatar user={n.actor} size={40} />
|
||||||
|
<span className="notification-text">
|
||||||
|
<strong>{title}</strong>
|
||||||
|
<span>{body}</span>
|
||||||
|
<span className="muted">{timeAgo(n.createdAt)}</span>
|
||||||
|
</span>
|
||||||
|
{n.track?.artworkUrl && <img className="artwork-sm" src={n.track.artworkUrl} alt="" />}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
{data?.at(-1)?.nextCursor && (
|
||||||
|
<button className="btn" onClick={() => setSize(size + 1)}>
|
||||||
|
Show more
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { AnimatePresence, motion } from 'motion/react';
|
import { 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;
|
||||||
|
|||||||
@@ -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've pinned, who you've blocked, and reports you send.
|
<strong>People:</strong> who you follow, who you've pinned, who you'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 “open in your app” links we look up song titles and artists
|
<strong>Song matching:</strong> to build “open in your app” links we look up song titles and artists
|
||||||
with Deezer, Apple's iTunes search, Spotify's catalog and Odesli (song.link). These lookups contain only
|
with Deezer, Apple's iTunes search, Spotify's catalog and Odesli (song.link). These lookups contain only
|
||||||
the song, never who listened to it.
|
the song, never who listened to it. Lyrics come from LRCLIB, looked up the same way: just the song.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Push notifications:</strong> if you turn them on, notifications are encrypted on our server and
|
||||||
|
delivered through your browser maker's push service (for example Google for Chrome, Mozilla for Firefox) or,
|
||||||
|
on Android, through the push server your ntfy app uses (ours, unless you chose another). They can't read
|
||||||
|
the contents.
|
||||||
</li>
|
</li>
|
||||||
<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 → Delete account)
|
Everything else: until you delete it or your account. Deleting your account (Settings → Delete account)
|
||||||
removes your profile, picture, plays, follows and connected services right away. Database backups are kept for
|
removes your profile, picture, plays, follows and connected services right away. Database backups are kept for
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import 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'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();
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
import type { NotificationListDTO } from '@musicshare/shared';
|
||||||
|
import { fetcher } from '@/lib/api';
|
||||||
|
|
||||||
|
/** Bell with the unread count; opens your notifications. */
|
||||||
|
export function ActivityBell() {
|
||||||
|
const { data } = useSWR<NotificationListDTO>('/notifications', fetcher, { refreshInterval: 60_000 });
|
||||||
|
const unread = data?.unread ?? 0;
|
||||||
|
return (
|
||||||
|
<Link href="/notifications" className="bell" aria-label={unread ? `Notifications, ${unread} new` : 'Notifications'}>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||||
|
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
|
||||||
|
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
|
||||||
|
</svg>
|
||||||
|
{unread > 0 && <span className="bell-badge">{unread > 99 ? '99+' : unread}</span>}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import Link from 'next/link';
|
|||||||
import { usePathname } from 'next/navigation';
|
import { 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) => (
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import useSWRInfinite from 'swr/infinite';
|
||||||
|
import type { FollowListDTO, TrackDTO } from '@musicshare/shared';
|
||||||
|
import { client, useMe } from '@/lib/api';
|
||||||
|
import { Avatar } from './Avatar';
|
||||||
|
|
||||||
|
/** "Send ✉": pick followers and send them the song, with an optional note. */
|
||||||
|
export function SendSongButton({ track }: { track: TrackDTO }) {
|
||||||
|
const { me } = useMe();
|
||||||
|
const dialog = useRef<HTMLDialogElement>(null);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
if (!me) return null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="service-link share-link"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(true);
|
||||||
|
dialog.current?.showModal();
|
||||||
|
}}
|
||||||
|
title="Send to friends"
|
||||||
|
>
|
||||||
|
Send ✉
|
||||||
|
</button>
|
||||||
|
<dialog ref={dialog} className="modal card" onClose={() => setOpen(false)} onClick={(e) => e.target === dialog.current && dialog.current?.close()}>
|
||||||
|
{open && <SendForm track={track} username={me.username} close={() => dialog.current?.close()} />}
|
||||||
|
</dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SendForm({ track, username, close }: { track: TrackDTO; username: string; close: () => void }) {
|
||||||
|
const { data, size, setSize, isLoading } = useSWRInfinite<FollowListDTO>(
|
||||||
|
(i, prev: FollowListDTO | null) => (i > 0 && !prev?.nextCursor ? null : ['followers', username, prev?.nextCursor ?? null]),
|
||||||
|
([, u, cursor]: [string, string, string | null]) => client.followers(u, cursor),
|
||||||
|
);
|
||||||
|
const followers = data?.flatMap((p) => p.users) ?? [];
|
||||||
|
const [picked, setPicked] = useState<Set<string>>(new Set());
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
const [state, setState] = useState<'idle' | 'sending' | 'sent'>('idle');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const toggle = (u: string) =>
|
||||||
|
setPicked((p) => {
|
||||||
|
const next = new Set(p);
|
||||||
|
if (next.has(u)) next.delete(u);
|
||||||
|
else next.add(u);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
const send = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setState('sending');
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await client.sendSong({ trackId: track.id, to: [...picked], message: message.trim() || null });
|
||||||
|
setState('sent');
|
||||||
|
setTimeout(close, 900);
|
||||||
|
} catch (err) {
|
||||||
|
setError((err as Error).message);
|
||||||
|
setState('idle');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="form" onSubmit={send}>
|
||||||
|
<h2>Send to friends</h2>
|
||||||
|
<p className="muted">
|
||||||
|
{track.title} · {track.artist}
|
||||||
|
</p>
|
||||||
|
<input placeholder="Add a note (optional)" value={message} onChange={(e) => setMessage(e.target.value)} maxLength={280} />
|
||||||
|
<p className="muted small">You can send songs to people who follow you.</p>
|
||||||
|
{followers.length === 0 && !isLoading && <p className="muted">No followers yet.</p>}
|
||||||
|
<ul className="people pick-list">
|
||||||
|
{followers.map((u) => (
|
||||||
|
<li key={u.id}>
|
||||||
|
<label className="checkbox">
|
||||||
|
<input type="checkbox" checked={picked.has(u.username)} onChange={() => toggle(u.username)} />
|
||||||
|
<Avatar user={u} size={32} />
|
||||||
|
<span className="user-name">{u.displayName ?? u.username}</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{data?.at(-1)?.nextCursor && (
|
||||||
|
<button type="button" className="btn btn-small" onClick={() => setSize(size + 1)}>
|
||||||
|
Show more
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
<div className="row">
|
||||||
|
<button className="btn btn-primary" disabled={!picked.size || state !== 'idle'}>
|
||||||
|
{state === 'sent' ? 'Sent ✓' : picked.size ? `Send to ${picked.size}` : 'Pick someone'}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn" onClick={close}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { SERVICE_LABELS, openUrl, viewerServices, type StreamingService, type TrackDTO } from '@musicshare/shared';
|
import { 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} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -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.
|
||||||
Generated
+111
-1
@@ -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,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 }) =>
|
||||||
|
|||||||
@@ -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 } };
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
|||||||
Reference in New Issue
Block a user