From 4402171f46bdb79e028662b66c92b2d89b3dc7f4 Mon Sep 17 00:00:00 2001
From: chris
Date: Thu, 15 Oct 2026 13:32:14 -0400
Subject: [PATCH] Comments on plays
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Anyone who can see a play can read its comments; the player and the people they follow can write.
The author, the player and admins can delete; comments can be reported (admins see the text).
- Notifications: "commented on" for the player, "replied on" for earlier commenters, each with its
own setting. Reaction and comment notifications now open the play.
- A 💬 chip with the count next to reactions on the feed and recent plays, opening the thread on
the app (/play/[id]) and the website. Threads refresh every few seconds while open.
- Blocking removes comments between the two people. Plays with comments survive history trimming.
- Privacy page updated.
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_011Y2iUsKEWDuRGjK5FPmcUP
---
apps/api/drizzle/0017_comments.sql | 16 +
apps/api/drizzle/meta/0017_snapshot.json | 1977 +++++++++++++++++
apps/api/drizzle/meta/_journal.json | 7 +
apps/api/src/app.ts | 2 +
apps/api/src/db/schema.ts | 28 +-
apps/api/src/routes/admin.ts | 1 +
apps/api/src/routes/comments.ts | 163 ++
apps/api/src/routes/safety.ts | 2 +
apps/api/src/services/notifications.ts | 2 +
apps/api/src/services/nowPlaying.ts | 32 +-
apps/api/test/comments.test.ts | 116 +
apps/api/test/notifications.test.ts | 4 +-
apps/mobile/src/app/_layout.tsx | 1 +
apps/mobile/src/app/play/[id].tsx | 195 ++
apps/mobile/src/app/report/[username].tsx | 8 +-
apps/mobile/src/components/NowPlayingCard.tsx | 2 +-
apps/mobile/src/components/Reactions.tsx | 22 +-
apps/mobile/src/components/RecentPlays.tsx | 2 +-
apps/web/src/app/admin/page.tsx | 5 +
apps/web/src/app/globals.css | 18 +
apps/web/src/app/notifications/page.tsx | 2 +-
apps/web/src/app/play/[id]/page.tsx | 145 ++
apps/web/src/app/privacy/page.tsx | 17 +-
apps/web/src/components/NowPlayingCard.tsx | 2 +-
apps/web/src/components/ProfileMenu.tsx | 8 +-
apps/web/src/components/Reactions.tsx | 27 +-
apps/web/src/components/RecentPlays.tsx | 2 +-
packages/shared/src/client.ts | 11 +
packages/shared/src/dto.test.ts | 1 +
packages/shared/src/dto.ts | 28 +-
packages/shared/src/notifications.test.ts | 17 +-
packages/shared/src/notifications.ts | 15 +-
packages/shared/src/schemas.ts | 8 +-
33 files changed, 2850 insertions(+), 36 deletions(-)
create mode 100644 apps/api/drizzle/0017_comments.sql
create mode 100644 apps/api/drizzle/meta/0017_snapshot.json
create mode 100644 apps/api/src/routes/comments.ts
create mode 100644 apps/api/test/comments.test.ts
create mode 100644 apps/mobile/src/app/play/[id].tsx
create mode 100644 apps/web/src/app/play/[id]/page.tsx
diff --git a/apps/api/drizzle/0017_comments.sql b/apps/api/drizzle/0017_comments.sql
new file mode 100644
index 0000000..186703c
--- /dev/null
+++ b/apps/api/drizzle/0017_comments.sql
@@ -0,0 +1,16 @@
+CREATE TABLE "comments" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "listen_id" uuid NOT NULL,
+ "user_id" uuid NOT NULL,
+ "body" varchar(500) NOT NULL,
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL
+);
+--> statement-breakpoint
+ALTER TABLE "notifications" ADD COLUMN "comment_id" uuid;--> statement-breakpoint
+ALTER TABLE "reports" ADD COLUMN "comment_id" uuid;--> statement-breakpoint
+ALTER TABLE "reports" ADD COLUMN "comment_body" varchar(500);--> statement-breakpoint
+ALTER TABLE "comments" ADD CONSTRAINT "comments_listen_id_listens_id_fk" FOREIGN KEY ("listen_id") REFERENCES "public"."listens"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "comments" ADD CONSTRAINT "comments_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+CREATE INDEX "comments_listen_created_idx" ON "comments" USING btree ("listen_id","created_at");--> statement-breakpoint
+ALTER TABLE "notifications" ADD CONSTRAINT "notifications_comment_id_comments_id_fk" FOREIGN KEY ("comment_id") REFERENCES "public"."comments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "reports" ADD CONSTRAINT "reports_comment_id_comments_id_fk" FOREIGN KEY ("comment_id") REFERENCES "public"."comments"("id") ON DELETE set null ON UPDATE no action;
\ No newline at end of file
diff --git a/apps/api/drizzle/meta/0017_snapshot.json b/apps/api/drizzle/meta/0017_snapshot.json
new file mode 100644
index 0000000..474acb0
--- /dev/null
+++ b/apps/api/drizzle/meta/0017_snapshot.json
@@ -0,0 +1,1977 @@
+{
+ "id": "ec354816-b5e6-4f80-ad3c-1db8a40b23cd",
+ "prevId": "08aa0049-d19c-4d78-be82-c577de3d211c",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.avatars": {
+ "name": "avatars",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "image": {
+ "name": "image",
+ "type": "bytea",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "content_type": {
+ "name": "content_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "avatars_user_id_users_id_fk": {
+ "name": "avatars_user_id_users_id_fk",
+ "tableFrom": "avatars",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.blocks": {
+ "name": "blocks",
+ "schema": "",
+ "columns": {
+ "blocker_id": {
+ "name": "blocker_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "blocked_id": {
+ "name": "blocked_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "blocks_blocked_idx": {
+ "name": "blocks_blocked_idx",
+ "columns": [
+ {
+ "expression": "blocked_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "blocks_blocker_id_users_id_fk": {
+ "name": "blocks_blocker_id_users_id_fk",
+ "tableFrom": "blocks",
+ "tableTo": "users",
+ "columnsFrom": [
+ "blocker_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "blocks_blocked_id_users_id_fk": {
+ "name": "blocks_blocked_id_users_id_fk",
+ "tableFrom": "blocks",
+ "tableTo": "users",
+ "columnsFrom": [
+ "blocked_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "blocks_blocker_id_blocked_id_pk": {
+ "name": "blocks_blocker_id_blocked_id_pk",
+ "columns": [
+ "blocker_id",
+ "blocked_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.comments": {
+ "name": "comments",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "listen_id": {
+ "name": "listen_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "body": {
+ "name": "body",
+ "type": "varchar(500)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "comments_listen_created_idx": {
+ "name": "comments_listen_created_idx",
+ "columns": [
+ {
+ "expression": "listen_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "comments_listen_id_listens_id_fk": {
+ "name": "comments_listen_id_listens_id_fk",
+ "tableFrom": "comments",
+ "tableTo": "listens",
+ "columnsFrom": [
+ "listen_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "comments_user_id_users_id_fk": {
+ "name": "comments_user_id_users_id_fk",
+ "tableFrom": "comments",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.connections": {
+ "name": "connections",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "external_user_id": {
+ "name": "external_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "display_name": {
+ "name": "display_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token_enc": {
+ "name": "access_token_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token_enc": {
+ "name": "refresh_token_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "token_expires_at": {
+ "name": "token_expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'active'"
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_polled_at": {
+ "name": "last_polled_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_activity_at": {
+ "name": "last_activity_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "meta": {
+ "name": "meta",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "webhook_secret": {
+ "name": "webhook_secret",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "connections_user_provider_idx": {
+ "name": "connections_user_provider_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "provider",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "connections_user_id_users_id_fk": {
+ "name": "connections_user_id_users_id_fk",
+ "tableFrom": "connections",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "connections_webhook_secret_unique": {
+ "name": "connections_webhook_secret_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "webhook_secret"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.devices": {
+ "name": "devices",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "platform": {
+ "name": "platform",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(100)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_seen_at": {
+ "name": "last_seen_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "devices_user_idx": {
+ "name": "devices_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "devices_user_id_users_id_fk": {
+ "name": "devices_user_id_users_id_fk",
+ "tableFrom": "devices",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "devices_token_hash_unique": {
+ "name": "devices_token_hash_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token_hash"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.email_verifications": {
+ "name": "email_verifications",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "varchar(320)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "email_verifications_user_idx": {
+ "name": "email_verifications_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "email_verifications_user_id_users_id_fk": {
+ "name": "email_verifications_user_id_users_id_fk",
+ "tableFrom": "email_verifications",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.featured_songs": {
+ "name": "featured_songs",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "track_id": {
+ "name": "track_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "comment": {
+ "name": "comment",
+ "type": "varchar(280)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "featured_songs_user_id_users_id_fk": {
+ "name": "featured_songs_user_id_users_id_fk",
+ "tableFrom": "featured_songs",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "featured_songs_track_id_tracks_id_fk": {
+ "name": "featured_songs_track_id_tracks_id_fk",
+ "tableFrom": "featured_songs",
+ "tableTo": "tracks",
+ "columnsFrom": [
+ "track_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.follows": {
+ "name": "follows",
+ "schema": "",
+ "columns": {
+ "follower_id": {
+ "name": "follower_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "followee_id": {
+ "name": "followee_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "pinned_at": {
+ "name": "pinned_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "follows_followee_idx": {
+ "name": "follows_followee_idx",
+ "columns": [
+ {
+ "expression": "followee_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "follows_follower_id_users_id_fk": {
+ "name": "follows_follower_id_users_id_fk",
+ "tableFrom": "follows",
+ "tableTo": "users",
+ "columnsFrom": [
+ "follower_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "follows_followee_id_users_id_fk": {
+ "name": "follows_followee_id_users_id_fk",
+ "tableFrom": "follows",
+ "tableTo": "users",
+ "columnsFrom": [
+ "followee_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "follows_follower_id_followee_id_pk": {
+ "name": "follows_follower_id_followee_id_pk",
+ "columns": [
+ "follower_id",
+ "followee_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.listens": {
+ "name": "listens",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "track_id": {
+ "name": "track_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "listens_user_started_idx": {
+ "name": "listens_user_started_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "started_at",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "listens_user_id_users_id_fk": {
+ "name": "listens_user_id_users_id_fk",
+ "tableFrom": "listens",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "listens_track_id_tracks_id_fk": {
+ "name": "listens_track_id_tracks_id_fk",
+ "tableFrom": "listens",
+ "tableTo": "tracks",
+ "columnsFrom": [
+ "track_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.lyrics": {
+ "name": "lyrics",
+ "schema": "",
+ "columns": {
+ "track_id": {
+ "name": "track_id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "synced": {
+ "name": "synced",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "plain": {
+ "name": "plain",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "instrumental": {
+ "name": "instrumental",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "source_url": {
+ "name": "source_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "fetched_at": {
+ "name": "fetched_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "lyrics_track_id_tracks_id_fk": {
+ "name": "lyrics_track_id_tracks_id_fk",
+ "tableFrom": "lyrics",
+ "tableTo": "tracks",
+ "columnsFrom": [
+ "track_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.notifications": {
+ "name": "notifications",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "actor_id": {
+ "name": "actor_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "track_id": {
+ "name": "track_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "listen_id": {
+ "name": "listen_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "comment_id": {
+ "name": "comment_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "emoji": {
+ "name": "emoji",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "message": {
+ "name": "message",
+ "type": "varchar(280)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "read_at": {
+ "name": "read_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "notifications_user_created_idx": {
+ "name": "notifications_user_created_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "notifications_user_id_users_id_fk": {
+ "name": "notifications_user_id_users_id_fk",
+ "tableFrom": "notifications",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "notifications_actor_id_users_id_fk": {
+ "name": "notifications_actor_id_users_id_fk",
+ "tableFrom": "notifications",
+ "tableTo": "users",
+ "columnsFrom": [
+ "actor_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "notifications_track_id_tracks_id_fk": {
+ "name": "notifications_track_id_tracks_id_fk",
+ "tableFrom": "notifications",
+ "tableTo": "tracks",
+ "columnsFrom": [
+ "track_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "notifications_listen_id_listens_id_fk": {
+ "name": "notifications_listen_id_listens_id_fk",
+ "tableFrom": "notifications",
+ "tableTo": "listens",
+ "columnsFrom": [
+ "listen_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "notifications_comment_id_comments_id_fk": {
+ "name": "notifications_comment_id_comments_id_fk",
+ "tableFrom": "notifications",
+ "tableTo": "comments",
+ "columnsFrom": [
+ "comment_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.now_playing": {
+ "name": "now_playing",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "track_id": {
+ "name": "track_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_app": {
+ "name": "source_app",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source_url": {
+ "name": "source_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_playing": {
+ "name": "is_playing",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "progress_ms": {
+ "name": "progress_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "now_playing_track_idx": {
+ "name": "now_playing_track_idx",
+ "columns": [
+ {
+ "expression": "track_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "now_playing_user_id_users_id_fk": {
+ "name": "now_playing_user_id_users_id_fk",
+ "tableFrom": "now_playing",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "now_playing_track_id_tracks_id_fk": {
+ "name": "now_playing_track_id_tracks_id_fk",
+ "tableFrom": "now_playing",
+ "tableTo": "tracks",
+ "columnsFrom": [
+ "track_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.password_resets": {
+ "name": "password_resets",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "used_at": {
+ "name": "used_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "password_resets_user_idx": {
+ "name": "password_resets_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "password_resets_user_id_users_id_fk": {
+ "name": "password_resets_user_id_users_id_fk",
+ "tableFrom": "password_resets",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.push_subscriptions": {
+ "name": "push_subscriptions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "endpoint": {
+ "name": "endpoint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "p256dh": {
+ "name": "p256dh",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "auth": {
+ "name": "auth",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "push_subscriptions_user_idx": {
+ "name": "push_subscriptions_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "push_subscriptions_user_id_users_id_fk": {
+ "name": "push_subscriptions_user_id_users_id_fk",
+ "tableFrom": "push_subscriptions",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "push_subscriptions_endpoint_unique": {
+ "name": "push_subscriptions_endpoint_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "endpoint"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.reactions": {
+ "name": "reactions",
+ "schema": "",
+ "columns": {
+ "listen_id": {
+ "name": "listen_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "emoji": {
+ "name": "emoji",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "reactions_listen_id_listens_id_fk": {
+ "name": "reactions_listen_id_listens_id_fk",
+ "tableFrom": "reactions",
+ "tableTo": "listens",
+ "columnsFrom": [
+ "listen_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "reactions_user_id_users_id_fk": {
+ "name": "reactions_user_id_users_id_fk",
+ "tableFrom": "reactions",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "reactions_listen_id_user_id_pk": {
+ "name": "reactions_listen_id_user_id_pk",
+ "columns": [
+ "listen_id",
+ "user_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.reports": {
+ "name": "reports",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "reporter_id": {
+ "name": "reporter_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "reported_id": {
+ "name": "reported_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "note": {
+ "name": "note",
+ "type": "varchar(500)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "comment_id": {
+ "name": "comment_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "comment_body": {
+ "name": "comment_body",
+ "type": "varchar(500)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'open'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "reports_status_idx": {
+ "name": "reports_status_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "reports_reporter_id_users_id_fk": {
+ "name": "reports_reporter_id_users_id_fk",
+ "tableFrom": "reports",
+ "tableTo": "users",
+ "columnsFrom": [
+ "reporter_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "reports_reported_id_users_id_fk": {
+ "name": "reports_reported_id_users_id_fk",
+ "tableFrom": "reports",
+ "tableTo": "users",
+ "columnsFrom": [
+ "reported_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "reports_comment_id_comments_id_fk": {
+ "name": "reports_comment_id_comments_id_fk",
+ "tableFrom": "reports",
+ "tableTo": "comments",
+ "columnsFrom": [
+ "comment_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.server_keys": {
+ "name": "server_keys",
+ "schema": "",
+ "columns": {
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.sessions": {
+ "name": "sessions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "sessions_user_idx": {
+ "name": "sessions_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "sessions_user_id_users_id_fk": {
+ "name": "sessions_user_id_users_id_fk",
+ "tableFrom": "sessions",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.track_links": {
+ "name": "track_links",
+ "schema": "",
+ "columns": {
+ "track_id": {
+ "name": "track_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "service": {
+ "name": "service",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "external_id": {
+ "name": "external_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "match_type": {
+ "name": "match_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "track_links_external_idx": {
+ "name": "track_links_external_idx",
+ "columns": [
+ {
+ "expression": "service",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "external_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "track_links_track_id_tracks_id_fk": {
+ "name": "track_links_track_id_tracks_id_fk",
+ "tableFrom": "track_links",
+ "tableTo": "tracks",
+ "columnsFrom": [
+ "track_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "track_links_track_id_service_pk": {
+ "name": "track_links_track_id_service_pk",
+ "columns": [
+ "track_id",
+ "service"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.tracks": {
+ "name": "tracks",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "isrc": {
+ "name": "isrc",
+ "type": "varchar(12)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "match_key": {
+ "name": "match_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "artist": {
+ "name": "artist",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "album": {
+ "name": "album",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "duration_ms": {
+ "name": "duration_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "artwork_url": {
+ "name": "artwork_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "links_resolved_at": {
+ "name": "links_resolved_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "tracks_isrc_idx": {
+ "name": "tracks_isrc_idx",
+ "columns": [
+ {
+ "expression": "isrc",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tracks_match_key_idx": {
+ "name": "tracks_match_key_idx",
+ "columns": [
+ {
+ "expression": "match_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.users": {
+ "name": "users",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "username": {
+ "name": "username",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "varchar(320)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "password_hash": {
+ "name": "password_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email_verified_at": {
+ "name": "email_verified_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "pending_email": {
+ "name": "pending_email",
+ "type": "varchar(320)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "display_name": {
+ "name": "display_name",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "bio": {
+ "name": "bio",
+ "type": "varchar(280)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "avatar_url": {
+ "name": "avatar_url",
+ "type": "varchar(500)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "preferred_service": {
+ "name": "preferred_service",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "services": {
+ "name": "services",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "notify_prefs": {
+ "name": "notify_prefs",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'user'"
+ },
+ "suspended_at": {
+ "name": "suspended_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "onboarded_at": {
+ "name": "onboarded_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sharing_paused_until": {
+ "name": "sharing_paused_until",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "plan": {
+ "name": "plan",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'free'"
+ },
+ "plan_expires_at": {
+ "name": "plan_expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "users_username_unique": {
+ "name": "users_username_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "username"
+ ]
+ },
+ "users_email_unique": {
+ "name": "users_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json
index da45b76..3e744e1 100644
--- a/apps/api/drizzle/meta/_journal.json
+++ b/apps/api/drizzle/meta/_journal.json
@@ -120,6 +120,13 @@
"when": 1792028149643,
"tag": "0016_notifications",
"breakpoints": true
+ },
+ {
+ "idx": 17,
+ "version": "7",
+ "when": 1792084480262,
+ "tag": "0017_comments",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts
index ec1b0cb..c2d16bf 100644
--- a/apps/api/src/app.ts
+++ b/apps/api/src/app.ts
@@ -12,6 +12,7 @@ import { HttpError } from './lib/http';
import { redis } from './lib/redis';
import { adminRoutes } from './routes/admin';
import { appUpdateRoutes } from './routes/appUpdates';
+import { commentRoutes } from './routes/comments';
import { accountRoutes } from './routes/account';
import { authRoutes } from './routes/auth';
import { avatarRoutes } from './routes/avatars';
@@ -78,6 +79,7 @@ export async function buildApp({ logger = true, rateLimits = true }: { logger?:
await app.register(adminRoutes);
await app.register(trackRoutes);
await app.register(reactionRoutes);
+ await app.register(commentRoutes);
await app.register(notificationRoutes);
await app.register(connectionRoutes);
await app.register(deviceRoutes);
diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts
index 4ca7e97..bf1f626 100644
--- a/apps/api/src/db/schema.ts
+++ b/apps/api/src/db/schema.ts
@@ -105,6 +105,9 @@ export const reports = pgTable(
.references(() => users.id, { onDelete: 'cascade' }),
reason: text('reason').$type().notNull(),
note: varchar('note', { length: 500 }),
+ /** When a comment was reported: which, and what it said then (kept if the comment is deleted). */
+ commentId: uuid('comment_id').references(() => comments.id, { onDelete: 'set null' }),
+ commentBody: varchar('comment_body', { length: 500 }),
status: text('status').$type<'open' | 'resolved'>().notNull().default('open'),
createdAt: createdAt(),
},
@@ -262,6 +265,23 @@ export const reactions = pgTable(
(t) => [primaryKey({ columns: [t.listenId, t.userId] })],
);
+/** Comments on a play. They go with the play (plays with comments are kept when history is trimmed). */
+export const comments = pgTable(
+ 'comments',
+ {
+ id: uuid('id').primaryKey().defaultRandom(),
+ listenId: uuid('listen_id')
+ .notNull()
+ .references(() => listens.id, { onDelete: 'cascade' }),
+ userId: uuid('user_id')
+ .notNull()
+ .references(() => users.id, { onDelete: 'cascade' }),
+ body: varchar('body', { length: 500 }).notNull(),
+ createdAt: createdAt(),
+ },
+ (t) => [index('comments_listen_created_idx').on(t.listenId, t.createdAt)],
+);
+
/** Email confirmation links (sign-up and email changes). Only a hash of the token is stored. */
export const emailVerifications = pgTable(
'email_verifications',
@@ -306,7 +326,7 @@ export const lyrics = pgTable('lyrics', {
fetchedAt: timestamp('fetched_at', { withTimezone: true }).notNull().defaultNow(),
});
-/** Things that happened to a user: a reaction, a new follower, a song sent to them. */
+/** Things that happened to a user: a reaction or comment, a new follower, a song sent to them. */
export const notifications = pgTable(
'notifications',
{
@@ -319,10 +339,12 @@ export const notifications = pgTable(
.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). */
+ /** The play a reaction or comment was on (gone once the play is trimmed from history). */
listenId: uuid('listen_id').references(() => listens.id, { onDelete: 'set null' }),
+ /** The comment (or reply); deleting the comment takes its notifications with it. */
+ commentId: uuid('comment_id').references(() => comments.id, { onDelete: 'cascade' }),
emoji: varchar('emoji', { length: 32 }),
- /** A note sent with a song. */
+ /** A note sent with a song, or the start of a comment. */
message: varchar('message', { length: 280 }),
readAt: timestamp('read_at', { withTimezone: true }),
createdAt: createdAt(),
diff --git a/apps/api/src/routes/admin.ts b/apps/api/src/routes/admin.ts
index 16652f0..915ae1e 100644
--- a/apps/api/src/routes/admin.ts
+++ b/apps/api/src/routes/admin.ts
@@ -40,6 +40,7 @@ export async function adminRoutes(app: FastifyInstance) {
createdAt: report.createdAt.toISOString(),
reporter: by ? toUserSummary(by) : null,
reported: { ...toUserSummary(who), suspended: Boolean(who.suspendedAt) },
+ comment: report.commentBody ? { id: report.commentId, body: report.commentBody } : null,
}));
});
diff --git a/apps/api/src/routes/comments.ts b/apps/api/src/routes/comments.ts
new file mode 100644
index 0000000..b0cc2cf
--- /dev/null
+++ b/apps/api/src/routes/comments.ts
@@ -0,0 +1,163 @@
+import { and, asc, eq, inArray, isNull, ne, or } from 'drizzle-orm';
+import type { FastifyInstance } from 'fastify';
+import { z } from 'zod';
+import { commentSchema, reportSchema, type CommentDTO, type CommentThreadDTO } from '@musicshare/shared';
+import { db, schema } from '../db';
+import type { User } from '../db/schema';
+import { requireUser } from '../lib/auth';
+import { HttpError } from '../lib/http';
+import { blockStatus, hiddenUserIds } from '../services/blocks';
+import { notify } from '../services/notifications';
+import { getReactions, publishNowPlaying, toUserSummary } from '../services/nowPlaying';
+import { getTrackDTOs } from '../services/tracks';
+
+const params = z.object({ id: z.uuid() });
+
+/** Who may write on a play: the person who played it, and the people they follow. */
+async function canCommentOn(viewerId: string, ownerId: string) {
+ if (viewerId === ownerId) return true;
+ const [row] = await db
+ .select({ one: schema.follows.followerId })
+ .from(schema.follows)
+ .where(and(eq(schema.follows.followerId, ownerId), eq(schema.follows.followeeId, viewerId)));
+ return Boolean(row);
+}
+
+/** The play and its owner, if the viewer may see it: the owner isn't suspended, and no block either way. */
+async function visiblePlay(viewerId: string, listenId: string) {
+ const [row] = await db
+ .select({ listen: schema.listens, owner: schema.users })
+ .from(schema.listens)
+ .innerJoin(schema.users, eq(schema.users.id, schema.listens.userId))
+ .where(eq(schema.listens.id, listenId));
+ if (!row || row.owner.suspendedAt) throw new HttpError(404, 'That play is gone');
+ const { viewerBlocked, blockedByThem } = await blockStatus(viewerId, row.owner.id);
+ if (viewerBlocked || blockedByThem) throw new HttpError(404, 'That play is gone');
+ return row;
+}
+
+const toCommentDTO = (c: typeof schema.comments.$inferSelect, author: User, viewer: User, ownerId: string): CommentDTO => ({
+ id: c.id,
+ user: toUserSummary(author),
+ body: c.body,
+ createdAt: c.createdAt.toISOString(),
+ canDelete: c.userId === viewer.id || ownerId === viewer.id || viewer.role === 'admin',
+});
+
+export async function commentRoutes(app: FastifyInstance) {
+ app.get('/listens/:id/comments', async (req): Promise => {
+ const viewer = requireUser(req);
+ const { id } = params.parse(req.params);
+ const { listen, owner } = await visiblePlay(viewer.id, id);
+ const hidden = await hiddenUserIds(viewer.id);
+ const rows = await db
+ .select({ comment: schema.comments, author: schema.users })
+ .from(schema.comments)
+ .innerJoin(schema.users, eq(schema.users.id, schema.comments.userId))
+ .where(and(eq(schema.comments.listenId, id), isNull(schema.users.suspendedAt)))
+ .orderBy(asc(schema.comments.createdAt));
+ const [tracks, reactions, canComment] = await Promise.all([
+ getTrackDTOs([listen.trackId]),
+ getReactions([id]),
+ canCommentOn(viewer.id, owner.id),
+ ]);
+ return {
+ listenId: id,
+ owner: toUserSummary(owner),
+ track: tracks.get(listen.trackId)!,
+ playedAt: listen.startedAt.toISOString(),
+ reactions: reactions.get(id) ?? [],
+ comments: rows.filter((r) => !hidden.has(r.author.id)).map((r) => toCommentDTO(r.comment, r.author, viewer, owner.id)),
+ canComment,
+ };
+ });
+
+ app.post(
+ '/listens/:id/comments',
+ { config: { rateLimit: { max: 20, timeWindow: '1 minute' } } },
+ async (req): Promise => {
+ const viewer = requireUser(req);
+ const { id } = params.parse(req.params);
+ const { body } = commentSchema.parse(req.body);
+ const { listen, owner } = await visiblePlay(viewer.id, id);
+ if (!(await canCommentOn(viewer.id, owner.id))) {
+ throw new HttpError(403, `Only people @${owner.username} follows can comment on their songs`);
+ }
+ const [comment] = await db.insert(schema.comments).values({ listenId: id, userId: viewer.id, body }).returning();
+
+ const excerpt = body.length > 280 ? `${body.slice(0, 279)}…` : body;
+ const about = { actorId: viewer.id, listenId: id, trackId: listen.trackId, commentId: comment!.id, message: excerpt };
+ await notify({ ...about, userId: owner.id, type: 'comment' });
+ // Everyone else who has commented here hears about replies (unless either side has blocked the other).
+ const others = await db
+ .selectDistinct({ userId: schema.comments.userId })
+ .from(schema.comments)
+ .where(and(eq(schema.comments.listenId, id), ne(schema.comments.userId, viewer.id), ne(schema.comments.userId, owner.id)));
+ const hidden = await hiddenUserIds(viewer.id);
+ for (const { userId } of others) {
+ if (!hidden.has(userId)) await notify({ ...about, userId, type: 'reply' });
+ }
+
+ await publishNowPlaying(owner.id); // the comment count on the feed
+ return toCommentDTO(comment!, viewer, viewer, owner.id);
+ },
+ );
+
+ async function findComment(commentId: string) {
+ const [row] = await db
+ .select({ comment: schema.comments, ownerId: schema.listens.userId })
+ .from(schema.comments)
+ .innerJoin(schema.listens, eq(schema.listens.id, schema.comments.listenId))
+ .where(eq(schema.comments.id, commentId));
+ if (!row) throw new HttpError(404, 'That comment is gone');
+ return row;
+ }
+
+ /** The author, the play's owner, or an admin. Its notifications go with it. */
+ app.delete('/comments/:id', async (req) => {
+ const viewer = requireUser(req);
+ const { id } = params.parse(req.params);
+ const { comment, ownerId } = await findComment(id);
+ if (comment.userId !== viewer.id && ownerId !== viewer.id && viewer.role !== 'admin') {
+ throw new HttpError(403, "You can't delete that comment");
+ }
+ await db.delete(schema.comments).where(eq(schema.comments.id, id));
+ await publishNowPlaying(ownerId);
+ return { ok: true };
+ });
+
+ app.post(
+ '/comments/:id/report',
+ { config: { rateLimit: { max: 10, timeWindow: '1 hour' } } },
+ async (req) => {
+ const viewer = requireUser(req);
+ const { id } = params.parse(req.params);
+ const { comment } = await findComment(id);
+ await visiblePlay(viewer.id, comment.listenId);
+ if (comment.userId === viewer.id) throw new HttpError(400, "You can't report your own comment");
+ const input = reportSchema.parse(req.body);
+ await db.insert(schema.reports).values({
+ reporterId: viewer.id,
+ reportedId: comment.userId,
+ reason: input.reason,
+ note: input.note || null,
+ commentId: comment.id,
+ commentBody: comment.body,
+ });
+ return { ok: true };
+ },
+ );
+}
+
+/** When someone's blocked, their comments on each other's plays go too. */
+export async function removeCommentsBetween(a: string, b: string) {
+ const playsOf = (userId: string) => db.select({ id: schema.listens.id }).from(schema.listens).where(eq(schema.listens.userId, userId));
+ await db
+ .delete(schema.comments)
+ .where(
+ or(
+ and(eq(schema.comments.userId, a), inArray(schema.comments.listenId, playsOf(b))),
+ and(eq(schema.comments.userId, b), inArray(schema.comments.listenId, playsOf(a))),
+ ),
+ );
+}
diff --git a/apps/api/src/routes/safety.ts b/apps/api/src/routes/safety.ts
index aabf626..7303b1b 100644
--- a/apps/api/src/routes/safety.ts
+++ b/apps/api/src/routes/safety.ts
@@ -11,6 +11,7 @@ import { HttpError } from '../lib/http';
import { FOLLOWS_CHANGED_CHANNEL, redis } from '../lib/redis';
import { removeNotificationsBetween } from '../services/notifications';
import { toUserSummary } from '../services/nowPlaying';
+import { removeCommentsBetween } from './comments';
import { removeReactionsBetween } from './reactions';
async function findUser(username: string) {
@@ -49,6 +50,7 @@ export async function safetyRoutes(app: FastifyInstance) {
);
});
await removeReactionsBetween(me.id, them.id);
+ await removeCommentsBetween(me.id, them.id);
await removeNotificationsBetween(me.id, them.id);
await followsChanged(me.id, them.id);
return { ok: true };
diff --git a/apps/api/src/services/notifications.ts b/apps/api/src/services/notifications.ts
index 00f046b..30b075a 100644
--- a/apps/api/src/services/notifications.ts
+++ b/apps/api/src/services/notifications.ts
@@ -34,6 +34,7 @@ export async function notify(input: {
listenId?: string | null;
emoji?: string | null;
message?: string | null;
+ commentId?: string | null;
}): Promise {
if (input.userId === input.actorId) return null;
const n = schema.notifications;
@@ -91,6 +92,7 @@ export async function toNotificationDTOs(rows: Row[]): Promise> {
+ if (listenIds.length === 0) return new Map();
+ const rows = await db
+ .select({ listenId: schema.comments.listenId, n: count() })
+ .from(schema.comments)
+ .innerJoin(schema.users, eq(schema.users.id, schema.comments.userId))
+ .where(and(inArray(schema.comments.listenId, listenIds), isNull(schema.users.suspendedAt)))
+ .groupBy(schema.comments.listenId);
+ return new Map(rows.map((r) => [r.listenId, r.n]));
+}
+
/** Phones report changes and a heartbeat every few minutes; if they go quiet (app killed, no signal), stop showing "listening now". */
export const DEVICE_STALE_MS = 9 * 60_000;
const PUSH_SOURCES = new Set(['android_media_session']);
@@ -63,7 +75,8 @@ export async function getNowPlaying(userIds: string[]): Promise
const l = listenIds.get(np.userId);
return l && l.trackId === np.trackId ? l.id : null;
};
- const reactions = await getReactions(rows.map((r) => listenFor(r.np)).filter((id): id is string => Boolean(id)));
+ const shownListens = rows.map((r) => listenFor(r.np)).filter((id): id is string => Boolean(id));
+ const [reactions, commentCounts] = await Promise.all([getReactions(shownListens), getCommentCounts(shownListens)]);
const now = Date.now();
return rows
.map(({ np, user }) => ({ np, user, track: tracks.get(np.trackId)! }))
@@ -78,6 +91,7 @@ export async function getNowPlaying(userIds: string[]): Promise
updatedAt: np.updatedAt.toISOString(),
listenId: listenFor(np),
reactions: reactions.get(listenFor(np) ?? '') ?? [],
+ commentCount: commentCounts.get(listenFor(np) ?? '') ?? 0,
}))
.sort((a, b) => Number(b.isPlaying) - Number(a.isPlaying) || b.updatedAt.localeCompare(a.updatedAt));
}
@@ -108,7 +122,11 @@ async function recordListen(userId: string, trackId: string, source: NowPlayingP
.offset(LISTEN_HISTORY_LIMIT - 1)
.limit(1);
if (cutoff) {
- await db.delete(schema.listens).where(and(eq(schema.listens.userId, userId), lt(schema.listens.startedAt, cutoff.startedAt)));
+ // Plays with comments stay, so conversations don't vanish as new songs come in.
+ const commented = db.select({ one: schema.comments.id }).from(schema.comments).where(eq(schema.comments.listenId, schema.listens.id));
+ await db
+ .delete(schema.listens)
+ .where(and(eq(schema.listens.userId, userId), lt(schema.listens.startedAt, cutoff.startedAt), notExists(commented)));
}
}
@@ -122,13 +140,19 @@ export async function getRecentPlays(userId: string, limit = 10): Promise p.trackId)), getReactions(plays.map((p) => p.id))]);
+ const ids = plays.map((p) => p.id);
+ const [tracks, reactions, commentCounts] = await Promise.all([
+ getTrackDTOs(plays.map((p) => p.trackId)),
+ getReactions(ids),
+ getCommentCounts(ids),
+ ]);
return plays.map((p) => ({
listenId: p.id,
track: tracks.get(p.trackId)!,
source: p.source,
playedAt: p.startedAt.toISOString(),
reactions: reactions.get(p.id) ?? [],
+ commentCount: commentCounts.get(p.id) ?? 0,
}));
}
diff --git a/apps/api/test/comments.test.ts b/apps/api/test/comments.test.ts
new file mode 100644
index 0000000..c6ccee7
--- /dev/null
+++ b/apps/api/test/comments.test.ts
@@ -0,0 +1,116 @@
+import { describe, expect, it } from 'vitest';
+import { applyPollResult, LISTEN_HISTORY_LIMIT } from '../src/services/nowPlaying';
+import { bearer, createUser, follow, useTestApp } from './helpers';
+
+const app = useTestApp();
+const song = (title: string) => ({ track: { title, artist: 'Massive Attack' }, isPlaying: true, progressMs: null });
+
+/** Alice plays a song. Bob follows her and she follows him back; Carol follows her, but she doesn't follow Carol. */
+async function setup() {
+ const alice = await createUser(app(), 'alice');
+ const bob = await createUser(app(), 'bob');
+ const carol = await createUser(app(), 'carol');
+ await follow(app(), bob.token, 'alice');
+ await follow(app(), alice.token, 'bob');
+ await follow(app(), carol.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, carol, listenId: feed[0].listenId as string };
+}
+const comment = (token: string, listenId: string, body: string) =>
+ app().inject({ method: 'POST', url: `/listens/${listenId}/comments`, headers: bearer(token), payload: { body } });
+const thread = (token: string, listenId: string) => app().inject({ url: `/listens/${listenId}/comments`, headers: bearer(token) });
+/** Comment and reply notifications (setup's follows make some too). */
+const notifications = async (token: string) =>
+ ((await app().inject({ url: '/notifications', headers: bearer(token) })).json().notifications as { type: string; message: string }[]).filter(
+ (n) => n.type === 'comment' || n.type === 'reply',
+ );
+
+describe('comments', () => {
+ it('lets the player and people they follow comment, oldest first, with a count on the feed', async () => {
+ const { alice, bob, carol, listenId } = await setup();
+ expect((await comment(bob.token, listenId, ' what a tune ')).json()).toMatchObject({ body: 'what a tune', canDelete: true });
+ await comment(alice.token, listenId, 'right?');
+
+ const res = await comment(carol.token, listenId, 'hi');
+ expect(res.statusCode).toBe(403);
+ expect(res.json().error).toBe('Only people @alice follows can comment on their songs');
+
+ const forCarol = (await thread(carol.token, listenId)).json();
+ expect(forCarol).toMatchObject({ canComment: false, owner: { username: 'alice' }, track: { title: 'Teardrop' } });
+ expect(forCarol.comments.map((c: { body: string; canDelete: boolean }) => [c.body, c.canDelete])).toEqual([
+ ['what a tune', false],
+ ['right?', false],
+ ]);
+ expect((await thread(alice.token, listenId)).json().comments.every((c: { canDelete: boolean }) => c.canDelete)).toBe(true);
+
+ const feed = (await app().inject({ url: '/feed', headers: bearer(carol.token) })).json();
+ expect(feed[0].commentCount).toBe(2);
+ });
+
+ it('rejects empty and overlong comments', async () => {
+ const { bob, listenId } = await setup();
+ expect((await comment(bob.token, listenId, ' ')).statusCode).toBe(400);
+ expect((await comment(bob.token, listenId, 'x'.repeat(501))).statusCode).toBe(400);
+ });
+
+ it('notifies the player, and earlier commenters about replies', async () => {
+ const { alice, bob, listenId } = await setup();
+ const dave = await createUser(app(), 'dave');
+ await follow(app(), alice.token, 'dave');
+
+ await comment(bob.token, listenId, 'what a tune');
+ await comment(dave.token, listenId, 'agreed');
+ await comment(alice.token, listenId, 'thanks both');
+
+ expect((await notifications(alice.token)).map((n) => [n.type, n.message])).toEqual([
+ ['comment', 'agreed'],
+ ['comment', 'what a tune'],
+ ]);
+ expect((await notifications(bob.token)).map((n) => [n.type, n.message])).toEqual([
+ ['reply', 'thanks both'],
+ ['reply', 'agreed'],
+ ]);
+ const [latest] = (await app().inject({ url: '/notifications', headers: bearer(bob.token) })).json().notifications;
+ expect(latest).toMatchObject({ type: 'reply', listenId });
+ });
+
+ it('can be deleted by the author, the player or an admin (taking its notifications along), not anyone else', async () => {
+ const { alice, bob, carol, listenId } = await setup();
+ const { id } = (await comment(bob.token, listenId, 'first')).json();
+ const del = (token: string) => app().inject({ method: 'DELETE', url: `/comments/${id}`, headers: bearer(token) });
+ expect((await del(carol.token)).statusCode).toBe(403);
+ expect(await notifications(alice.token)).toHaveLength(1);
+ expect((await del(alice.token)).statusCode).toBe(200);
+ expect((await thread(bob.token, listenId)).json().comments).toEqual([]);
+ expect(await notifications(alice.token)).toEqual([]);
+ });
+
+ it('can be reported, keeping what it said for the admins', async () => {
+ const { bob, carol, listenId } = await setup();
+ const { id } = (await comment(bob.token, listenId, 'rude words')).json();
+ const report = (token: string) =>
+ app().inject({ method: 'POST', url: `/comments/${id}/report`, headers: bearer(token), payload: { reason: 'harassment' } });
+ expect((await report(bob.token)).statusCode).toBe(400);
+ expect((await report(carol.token)).statusCode).toBe(200);
+ });
+
+ it('goes away with a block, both ways', async () => {
+ const { alice, bob, listenId } = await setup();
+ await comment(bob.token, listenId, 'hello');
+ await app().inject({ method: 'POST', url: '/users/bob/block', headers: bearer(alice.token), payload: {} });
+ expect((await thread(bob.token, listenId)).statusCode).toBe(404);
+ expect((await thread(alice.token, listenId)).json().comments).toEqual([]);
+ });
+
+ it('keeps plays with comments when old history is trimmed', async () => {
+ const { alice, bob, listenId } = await setup();
+ await comment(bob.token, listenId, 'keep this');
+ for (let i = 0; i < LISTEN_HISTORY_LIMIT + 2; i++) {
+ await applyPollResult(alice.user.id, 'lastfm', song(`Song ${i}`));
+ }
+ const res = await thread(bob.token, listenId);
+ expect(res.statusCode).toBe(200);
+ expect(res.json().comments).toHaveLength(1);
+ });
+});
diff --git a/apps/api/test/notifications.test.ts b/apps/api/test/notifications.test.ts
index 74e645a..e37e68e 100644
--- a/apps/api/test/notifications.test.ts
+++ b/apps/api/test/notifications.test.ts
@@ -76,8 +76,8 @@ describe('notifications', () => {
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 });
+ const me = (await app().inject({ method: 'PATCH', url: '/me', headers: bearer(alice.token), payload: { notifyPrefs: { follow: false, reply: false } } })).json();
+ expect(me.notifyPrefs).toEqual({ reaction: true, comment: true, reply: false, follow: false, share: true });
});
});
diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx
index 2c5932e..c627895 100644
--- a/apps/mobile/src/app/_layout.tsx
+++ b/apps/mobile/src/app/_layout.tsx
@@ -109,6 +109,7 @@ function AppNavigator() {
+
diff --git a/apps/mobile/src/app/play/[id].tsx b/apps/mobile/src/app/play/[id].tsx
new file mode 100644
index 0000000..1307335
--- /dev/null
+++ b/apps/mobile/src/app/play/[id].tsx
@@ -0,0 +1,195 @@
+import { Stack, router, useLocalSearchParams } from 'expo-router';
+import { useState } from 'react';
+import { ActivityIndicator, Alert, FlatList, KeyboardAvoidingView, Platform, Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import useSWR from 'swr';
+import { COMMENT_MAX_LENGTH, timeAgo, type CommentDTO, type CommentThreadDTO } from '@musicshare/shared';
+import { AmbientBackground } from '@/components/AmbientBackground';
+import { Avatar } from '@/components/Avatar';
+import { Reactions } from '@/components/Reactions';
+import { TrackCard } from '@/components/TrackCard';
+import { Body, Card } from '@/components/ui';
+import { api } from '@/lib/api';
+import { showError } from '@/lib/errors';
+import { tapHaptic } from '@/lib/haptics';
+import { useSession } from '@/lib/session';
+import { colors, radius, space } from '@/lib/theme';
+
+/** One play and its comments: where the 💬 chip and comment notifications lead. */
+export default function PlayScreen() {
+ const { id } = useLocalSearchParams<{ id: string }>();
+ const { me } = useSession();
+ // Polled while open so other people's comments show up without refreshing.
+ const { data, error, mutate } = useSWR(id ? `/listens/${id}/comments` : null, { refreshInterval: 5000 });
+ const [draft, setDraft] = useState('');
+ const [sending, setSending] = useState(false);
+
+ const send = async () => {
+ const body = draft.trim();
+ if (!body || !id) return;
+ setSending(true);
+ try {
+ const comment = await api.addComment(id, { body });
+ tapHaptic();
+ setDraft('');
+ await mutate((t) => (t ? { ...t, comments: [...t.comments, comment] } : t), { revalidate: false });
+ } catch (err) {
+ showError(err, "Couldn't post that");
+ }
+ setSending(false);
+ };
+
+ const remove = (c: CommentDTO) =>
+ Alert.alert('Delete this comment?', undefined, [
+ { text: 'Cancel', style: 'cancel' },
+ {
+ text: 'Delete',
+ style: 'destructive',
+ onPress: async () => {
+ try {
+ await api.deleteComment(c.id);
+ await mutate((t) => (t ? { ...t, comments: t.comments.filter((x) => x.id !== c.id) } : t), { revalidate: false });
+ } catch (err) {
+ showError(err, "Couldn't delete it");
+ }
+ },
+ },
+ ]);
+
+ const options = (c: CommentDTO) => {
+ const mine = c.user.id === me?.id;
+ Alert.alert(c.user.displayName ?? c.user.username, undefined, [
+ ...(c.canDelete ? [{ text: 'Delete', style: 'destructive' as const, onPress: () => remove(c) }] : []),
+ ...(!mine ? [{ text: 'Report', onPress: () => router.push(`/report/${c.user.username}?comment=${c.id}`) }] : []),
+ { text: 'Cancel', style: 'cancel' as const },
+ ]);
+ };
+
+ if (error) {
+ return (
+
+
+ That play is gone.
+
+ );
+ }
+ if (!data) {
+ return (
+
+
+
+ );
+ }
+
+ const owner = data.owner.displayName ?? data.owner.username;
+ return (
+
+
+
+
+ c.id}
+ contentContainerStyle={styles.list}
+ keyboardShouldPersistTaps="handled"
+ ListHeaderComponent={
+
+ router.push(`/u/${data.owner.username}`)} style={styles.playedBy}>
+
+
+ {owner} played this · {timeAgo(data.playedAt)}
+
+
+
+
+
+ }
+ ListEmptyComponent={{data.canComment ? 'No comments yet. Say something!' : 'No comments yet.'}}
+ renderItem={({ item }) => (
+ options(item)} delayLongPress={300} style={styles.comment}>
+ router.push(`/u/${item.user.username}`)} hitSlop={4}>
+
+
+
+
+ {item.user.displayName ?? item.user.username} · {timeAgo(item.createdAt)}
+
+
+ {item.body}
+
+
+ options(item)} hitSlop={10} accessibilityLabel="Comment options">
+ ⋯
+
+
+ )}
+ />
+ {data.canComment ? (
+
+
+
+ {sending ? : Post }
+
+
+ ) : (
+
+
+ Only people {owner} follows can comment on their songs.
+
+
+ )}
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ safe: { flex: 1, backgroundColor: colors.bg },
+ center: { flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.bg },
+ list: { padding: space.lg, gap: space.md, paddingBottom: space.xl },
+ header: { gap: space.md, marginBottom: space.sm },
+ playedBy: { flexDirection: 'row', alignItems: 'center', gap: space.sm },
+ comment: { flexDirection: 'row', gap: space.md, alignItems: 'flex-start' },
+ name: { color: colors.text, fontWeight: '700' },
+ muted: { color: colors.muted, fontSize: 13 },
+ body: { color: colors.text, fontSize: 15, lineHeight: 21 },
+ more: { color: colors.muted, fontSize: 18, paddingHorizontal: space.xs },
+ composer: {
+ flexDirection: 'row',
+ alignItems: 'flex-end',
+ gap: space.sm,
+ paddingHorizontal: space.lg,
+ paddingVertical: space.sm,
+ borderTopWidth: StyleSheet.hairlineWidth,
+ borderTopColor: colors.border,
+ backgroundColor: colors.bg,
+ },
+ input: {
+ flex: 1,
+ maxHeight: 120,
+ color: colors.text,
+ fontSize: 15,
+ paddingHorizontal: space.md,
+ paddingVertical: space.sm,
+ borderRadius: radius.md,
+ backgroundColor: colors.surface,
+ borderWidth: 1,
+ borderColor: colors.border,
+ },
+ send: { paddingHorizontal: space.md, paddingVertical: space.sm + 2, borderRadius: radius.pill, backgroundColor: colors.accent },
+ sendText: { color: '#fff', fontWeight: '700' },
+ closed: { margin: space.lg, marginTop: 0 },
+});
diff --git a/apps/mobile/src/app/report/[username].tsx b/apps/mobile/src/app/report/[username].tsx
index 7b89bf1..9989e08 100644
--- a/apps/mobile/src/app/report/[username].tsx
+++ b/apps/mobile/src/app/report/[username].tsx
@@ -8,7 +8,8 @@ import { api } from '@/lib/api';
import { showError } from '@/lib/errors';
export default function ReportScreen() {
- const { username } = useLocalSearchParams<{ username: string }>();
+ // ?comment= reports one comment of theirs rather than their account.
+ const { username, comment } = useLocalSearchParams<{ username: string; comment?: string }>();
const [reason, setReason] = useState(null);
const [note, setNote] = useState('');
const [busy, setBusy] = useState(false);
@@ -18,7 +19,8 @@ export default function ReportScreen() {
if (!reason) return;
setBusy(true);
try {
- await api.report(username, { reason, note: note.trim() || null });
+ const input = { reason, note: note.trim() || null };
+ await (comment ? api.reportComment(comment, input) : api.report(username, input));
setSent(true);
} catch (err) {
showError(err, "Couldn't send the report");
@@ -38,7 +40,7 @@ export default function ReportScreen() {
return (
- What's wrong with @{username}'s account?
+ {comment ? `What's wrong with @${username}'s comment?` : `What's wrong with @${username}'s account?`}
{REPORT_REASONS.map((r) => (
setReason(r)} />
diff --git a/apps/mobile/src/components/NowPlayingCard.tsx b/apps/mobile/src/components/NowPlayingCard.tsx
index 4d23e0a..356e8dd 100644
--- a/apps/mobile/src/components/NowPlayingCard.tsx
+++ b/apps/mobile/src/components/NowPlayingCard.tsx
@@ -101,7 +101,7 @@ export function NowPlayingCard({
-
+
{expanded && (
diff --git a/apps/mobile/src/components/Reactions.tsx b/apps/mobile/src/components/Reactions.tsx
index ad8745b..c887812 100644
--- a/apps/mobile/src/components/Reactions.tsx
+++ b/apps/mobile/src/components/Reactions.tsx
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { FlatList, Modal, Pressable, StyleSheet, Text, TextInput, View, type ViewStyle } from 'react-native';
-import Animated, { FadeIn, FadeOut, ZoomIn } from 'react-native-reanimated';
+import { router } from 'expo-router';
+import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
import { EMOJI_PICKER_GROUPS, QUICK_REACTIONS, searchEmoji, type ReactionDTO } from '@musicshare/shared';
import { api } from '@/lib/api';
import { showError } from '@/lib/errors';
@@ -11,16 +12,19 @@ import { colors, radius, space } from '@/lib/theme';
/**
* Emoji reactions on one play. Tap an emoji to add yours (or take it back); "☺+"
* opens the quick six, and "+" the full picker. One reaction per person.
+ * With `commentCount`, a 💬 chip opens the play's comments.
*/
export function Reactions({
listenId,
reactions,
ownerId,
+ commentCount,
style,
}: {
listenId: string | null;
reactions: ReactionDTO[];
ownerId: string;
+ commentCount?: number;
style?: ViewStyle;
}) {
const { me } = useSession();
@@ -31,7 +35,8 @@ export function Reactions({
const [pickerOpen, setPickerOpen] = useState(false);
const canReact = Boolean(me && listenId && me.id !== ownerId);
- if (!listenId || (!canReact && shown.length === 0)) return null;
+ const showComments = commentCount !== undefined && Boolean(me);
+ if (!listenId || (!canReact && shown.length === 0 && !showComments)) return null;
const mine = me ? shown.find((r) => r.users.some((u) => u.id === me.id))?.emoji : undefined;
const choose = async (emoji: string) => {
@@ -50,7 +55,7 @@ export function Reactions({
{shown.map((r) => (
-
+
choose(r.emoji)}
@@ -73,6 +78,17 @@ export function Reactions({
+
)}
+ {showComments && (
+ router.push(`/play/${listenId}`)}
+ accessibilityRole="button"
+ accessibilityLabel={commentCount ? `${commentCount} comment${commentCount === 1 ? '' : 's'}` : 'Comments'}
+ hitSlop={6}
+ style={({ pressed }) => [styles.chip, pressed && { transform: [{ scale: 0.9 }] }]}>
+ 💬
+ {commentCount ? {commentCount} : null}
+
+ )}
{quickOpen && (
diff --git a/apps/mobile/src/components/RecentPlays.tsx b/apps/mobile/src/components/RecentPlays.tsx
index 7a135bb..30bdb02 100644
--- a/apps/mobile/src/components/RecentPlays.tsx
+++ b/apps/mobile/src/components/RecentPlays.tsx
@@ -47,7 +47,7 @@ export function RecentPlays({
{timeAgo(p.playedAt)}
-
+
))}
{credits.map((s) => (
diff --git a/apps/web/src/app/admin/page.tsx b/apps/web/src/app/admin/page.tsx
index 912d188..21fcc6f 100644
--- a/apps/web/src/app/admin/page.tsx
+++ b/apps/web/src/app/admin/page.tsx
@@ -71,6 +71,11 @@ export default function AdminPage() {
{REPORT_REASON_LABELS[r.reason as ReportReason] ?? r.reason}
{r.reporter && · reported by @{r.reporter.username} }
+ {r.comment && (
+
+ Their comment{r.comment.id ? '' : ' (since deleted)'}: {r.comment.body}
+
+ )}
{r.note && {r.note} }
{r.status === 'open' && (
diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css
index 75eb7e3..c512382 100644
--- a/apps/web/src/app/globals.css
+++ b/apps/web/src/app/globals.css
@@ -130,6 +130,24 @@ input:focus, textarea:focus, select:focus {
.reaction-emoji { font-size: 1.05rem; line-height: 1.3; }
.reaction-count { font-size: 0.78rem; font-variant-numeric: tabular-nums; }
.reaction-add { color: var(--muted); font-size: 1rem; }
+a.reaction { text-decoration: none; }
+
+/* Comments on a play (/play/[id]) */
+.played-by { display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; }
+.played-by-user { display: inline-flex; align-items: center; gap: 0.4rem; color: var(--text); }
+.comments { display: flex; flex-direction: column; gap: 0.9rem; }
+.comments h2 { margin: 0; }
+.comment { display: flex; gap: 0.75rem; align-items: flex-start; }
+.comment-main { flex: 1; min-width: 0; }
+.comment-main a { color: var(--text); }
+.comment-body { margin: 0.15rem 0 0; white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.45; }
+.comment-actions { display: flex; gap: 0.6rem; opacity: 0.6; }
+.comment:hover .comment-actions, .comment:focus-within .comment-actions { opacity: 1; }
+.link-button { background: none; border: 0; padding: 0; font: inherit; font-size: 0.8rem; color: var(--muted); cursor: pointer; }
+.link-button:hover { color: var(--text); text-decoration: underline; }
+.comment-form { display: flex; gap: 0.6rem; align-items: flex-end; }
+.comment-form textarea { flex: 1; resize: vertical; min-height: 2.6rem; }
+@media (max-width: 520px) { .comment-actions { opacity: 1; } }
.reaction-add span { font-size: 0.75rem; margin-left: -0.15rem; }
.reaction-add-wrap { position: static; } /* the quick menu anchors to the row, so it never runs off the card */
.reaction-quick { position: absolute; bottom: calc(100% + 0.4rem); left: 0; z-index: 30; display: flex; align-items: center; gap: 0.15rem; padding: 0.3rem; border-radius: 999px; background: #1a1535; border: 1px solid rgba(255, 255, 255, 0.14); box-shadow: 0 14px 30px -10px rgba(0, 0, 0, 0.7); animation: pop 0.18s ease-out; white-space: nowrap; }
diff --git a/apps/web/src/app/notifications/page.tsx b/apps/web/src/app/notifications/page.tsx
index 881bbac..d19fcad 100644
--- a/apps/web/src/app/notifications/page.tsx
+++ b/apps/web/src/app/notifications/page.tsx
@@ -8,7 +8,7 @@ import { notificationPath, notificationText, timeAgo, type NotificationListDTO }
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. */
+/** Reactions, comments, 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();
diff --git a/apps/web/src/app/play/[id]/page.tsx b/apps/web/src/app/play/[id]/page.tsx
new file mode 100644
index 0000000..aae1599
--- /dev/null
+++ b/apps/web/src/app/play/[id]/page.tsx
@@ -0,0 +1,145 @@
+'use client';
+
+import Link from 'next/link';
+import { useParams } from 'next/navigation';
+import { useState } from 'react';
+import useSWR from 'swr';
+import { COMMENT_MAX_LENGTH, timeAgo, type CommentDTO, type CommentThreadDTO } from '@musicshare/shared';
+import { Avatar } from '@/components/Avatar';
+import { ReportForm } from '@/components/ProfileMenu';
+import { Reactions } from '@/components/Reactions';
+import { TrackCard } from '@/components/TrackCard';
+import { client, fetcher, useMe } from '@/lib/api';
+
+/** One play and its comments: where the 💬 link and comment notifications lead. */
+export default function PlayPage() {
+ const { id } = useParams<{ id: string }>();
+ const { me, loggedOut } = useMe();
+ // Polled while open so other people's comments show up without reloading.
+ const { data, error, mutate } = useSWR
(me ? `/listens/${id}/comments` : null, fetcher, { refreshInterval: 5000 });
+ const [draft, setDraft] = useState('');
+ const [sending, setSending] = useState(false);
+ const [problem, setProblem] = useState(null);
+ const [reporting, setReporting] = useState(null);
+
+ if (loggedOut) {
+ return (
+
+ Log in to see the comments.
+
+ );
+ }
+ if (error) return That play is gone.
;
+ if (!data) return null;
+
+ const owner = data.owner.displayName ?? data.owner.username;
+
+ const send = async (e: React.FormEvent) => {
+ e.preventDefault();
+ const body = draft.trim();
+ if (!body) return;
+ setSending(true);
+ setProblem(null);
+ try {
+ const comment = await client.addComment(id, { body });
+ setDraft('');
+ await mutate((t) => (t ? { ...t, comments: [...t.comments, comment] } : t), { revalidate: false });
+ } catch (err) {
+ setProblem((err as Error).message);
+ }
+ setSending(false);
+ };
+
+ const remove = async (c: CommentDTO) => {
+ if (!confirm('Delete this comment?')) return;
+ try {
+ await client.deleteComment(c.id);
+ await mutate((t) => (t ? { ...t, comments: t.comments.filter((x) => x.id !== c.id) } : t), { revalidate: false });
+ } catch (err) {
+ setProblem((err as Error).message);
+ }
+ };
+
+ return (
+
+
+
+
+ {owner}
+ {' '}
+ played this · {timeAgo(data.playedAt)}
+
+
+
+
+ Comments
+ {data.comments.length === 0 && {data.canComment ? 'No comments yet. Say something!' : 'No comments yet.'}
}
+ {data.comments.map((c) => (
+
+
+
+
+
+
+
+ {c.user.displayName ?? c.user.username}
+ {' '}
+ · {timeAgo(c.createdAt)}
+
+
{c.body}
+
+
+ {c.canDelete && (
+ remove(c)}>
+ Delete
+
+ )}
+ {c.user.id !== me?.id && (
+ setReporting(c)}>
+ Report
+
+ )}
+
+
+ ))}
+
+ {data.canComment ? (
+
+ ) : (
+ Only people {owner} follows can comment on their songs.
+ )}
+ {problem && {problem}
}
+
+
+ {reporting && (
+
setReporting(null)}
+ />
+ )}
+
+ );
+}
diff --git a/apps/web/src/app/privacy/page.tsx b/apps/web/src/app/privacy/page.tsx
index def6cde..f7065ef 100644
--- a/apps/web/src/app/privacy/page.tsx
+++ b/apps/web/src/app/privacy/page.tsx
@@ -3,7 +3,7 @@ import Link from 'next/link';
export const metadata: Metadata = { title: 'Privacy policy · musicshare' };
-const UPDATED = 'October 14, 2026';
+const UPDATED = 'October 15, 2026';
const CONTACT = 'app@musicshare.binarygnome.com';
export default function PrivacyPage() {
@@ -40,11 +40,16 @@ export default function PrivacyPage() {
“notification access”, but the app only reads the song, artist and album from music apps you allow.
It doesn't read or store your notifications, messages or anything from other apps.
+
+ Comments and reactions you leave on plays. Comments stay until you or the person who played the
+ song deletes them, or one of your accounts is deleted. Plays with comments are kept in their history so the
+ conversation isn't lost.
+
People: who you follow, who you've pinned, who you've blocked, and reports you send.
- Notifications: reactions, new followers and songs (with any note) sent to you, and your
+ Notifications: reactions, comments and replies, 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.
@@ -63,13 +68,17 @@ export default function PrivacyPage() {
people who aren't signed in.
Your followers see what you play in their feed, live.
+
+ Comments on a play can be read by signed-in people who can see that play. Only you and the people you follow can
+ write them, and you can delete any comment on your plays.
+
Your email address is never shown to other people. It's used to sign in, to send you confirmation and
password reset emails, and to contact you about your account.
- People you block can't find you or see your profile, and you stop following each other. They aren't
- told.
+ People you block can't find you or see your profile, and you stop following each other. Their comments on
+ your plays, and yours on theirs, are removed. They aren't told.
You can pause sharing at any time in Settings. While paused, nothing new you play is shared or
diff --git a/apps/web/src/components/NowPlayingCard.tsx b/apps/web/src/components/NowPlayingCard.tsx
index d5d9e84..93fdbe2 100644
--- a/apps/web/src/components/NowPlayingCard.tsx
+++ b/apps/web/src/components/NowPlayingCard.tsx
@@ -89,7 +89,7 @@ export function NowPlayingCard({
>
setExpanded((v) => !v)} />
-
+
{expanded && (
void }) {
+/** Report someone's account, or one of their comments (`commentId`). */
+export function ReportForm({ username, name, commentId, onClose }: { username: string; name: string; commentId?: string; onClose: () => void }) {
const [reason, setReason] = useState(null);
const [note, setNote] = useState('');
const [sent, setSent] = useState(false);
@@ -72,7 +73,8 @@ function ReportForm({ username, name, onClose }: { username: string; name: strin
e.preventDefault();
if (!reason) return;
try {
- await client.report(username, { reason, note: note.trim() || null });
+ const input = { reason, note: note.trim() || null };
+ await (commentId ? client.reportComment(commentId, input) : client.report(username, input));
setSent(true);
} catch (err) {
setError((err as Error).message);
@@ -90,7 +92,7 @@ function ReportForm({ username, name, onClose }: { username: string; name: strin
);
}
return (
-
+
What's wrong?
diff --git a/apps/web/src/components/Reactions.tsx b/apps/web/src/components/Reactions.tsx
index f49da74..fe394bb 100644
--- a/apps/web/src/components/Reactions.tsx
+++ b/apps/web/src/components/Reactions.tsx
@@ -1,5 +1,6 @@
'use client';
+import Link from 'next/link';
import { useEffect, useRef, useState } from 'react';
import { EMOJI_PICKER_GROUPS, QUICK_REACTIONS, searchEmoji, type ReactionDTO } from '@musicshare/shared';
import { client, useMe } from '@/lib/api';
@@ -9,7 +10,18 @@ import { Modal } from './ProfileMenu';
* Emoji reactions on one play. Tap an emoji to add yours (or take it back); "☺+"
* offers the quick six, and "More" opens the full picker. One reaction per person.
*/
-export function Reactions({ listenId, reactions, ownerId }: { listenId: string | null; reactions: ReactionDTO[]; ownerId: string }) {
+export function Reactions({
+ listenId,
+ reactions,
+ ownerId,
+ commentCount,
+}: {
+ listenId: string | null;
+ reactions: ReactionDTO[];
+ ownerId: string;
+ /** Shows a 💬 link to the play's comments. */
+ commentCount?: number;
+}) {
const { me } = useMe();
// The server's answer to our last tap, until fresh props arrive (e.g. a realtime update).
const [local, setLocal] = useState<{ from: ReactionDTO[]; value: ReactionDTO[] } | null>(null);
@@ -27,7 +39,8 @@ export function Reactions({ listenId, reactions, ownerId }: { listenId: string |
}, [quickOpen]);
const canReact = Boolean(me && listenId && me.id !== ownerId);
- if (!listenId || (!canReact && shown.length === 0)) return null;
+ const showComments = commentCount !== undefined && Boolean(me);
+ if (!listenId || (!canReact && shown.length === 0 && !showComments)) return null;
const mine = me ? shown.find((r) => r.users.some((u) => u.id === me.id))?.emoji : undefined;
const choose = async (emoji: string) => {
@@ -77,6 +90,16 @@ export function Reactions({ listenId, reactions, ownerId }: { listenId: string |
)}
)}
+ {showComments && (
+
+ 💬
+ {commentCount ? {commentCount} : null}
+
+ )}
{error && {error} }
{pickerOpen && setPickerOpen(false)} />}
diff --git a/apps/web/src/components/RecentPlays.tsx b/apps/web/src/components/RecentPlays.tsx
index cd78f20..22c9e21 100644
--- a/apps/web/src/components/RecentPlays.tsx
+++ b/apps/web/src/components/RecentPlays.tsx
@@ -48,7 +48,7 @@ export function RecentPlays({
) : (
{row}
)}
-
+
);
})}
diff --git a/packages/shared/src/client.ts b/packages/shared/src/client.ts
index 9be7f1d..889fee6 100644
--- a/packages/shared/src/client.ts
+++ b/packages/shared/src/client.ts
@@ -1,6 +1,8 @@
import type {
AdminReportDTO,
AdminStatsDTO,
+ CommentDTO,
+ CommentThreadDTO,
ConnectionDTO,
FollowListDTO,
ReactionDTO,
@@ -17,6 +19,7 @@ import type { AppUpdateDTO } from './updates';
import type {
ChangeEmailInput,
ChangePasswordInput,
+ CommentInput,
LoginInput,
RegisterInput,
ReportReason,
@@ -136,6 +139,14 @@ export function createApiClient({ baseUrl, getToken, credentials }: ApiClientOpt
react: (listenId: string, emoji: string) => request(`/listens/${q(listenId)}/reaction`, { method: 'PUT', body: { emoji } }),
unreact: (listenId: string) => request(`/listens/${q(listenId)}/reaction`, { method: 'DELETE' }),
+ /** A play with its comments. */
+ comments: (listenId: string) => request(`/listens/${q(listenId)}/comments`),
+ addComment: (listenId: string, input: CommentInput) =>
+ request(`/listens/${q(listenId)}/comments`, { method: 'POST', body: input }),
+ deleteComment: (id: string) => request<{ ok: true }>(`/comments/${q(id)}`, { method: 'DELETE' }),
+ reportComment: (id: string, input: { reason: ReportReason; note?: string | null }) =>
+ request<{ ok: true }>(`/comments/${q(id)}/report`, { body: input }),
+
searchTracks: (query: string) => request(`/tracks/search?q=${q(query)}`),
/** Null when no lyrics were found for the song. */
lyrics: (trackId: string) => request(`/tracks/${q(trackId)}/lyrics`),
diff --git a/packages/shared/src/dto.test.ts b/packages/shared/src/dto.test.ts
index 1c9aaf5..4a8d075 100644
--- a/packages/shared/src/dto.test.ts
+++ b/packages/shared/src/dto.test.ts
@@ -13,6 +13,7 @@ function item(username: string, opts: { pinnedAt?: string; isPlaying?: boolean;
updatedAt: opts.updatedAt ?? '2026-01-01T00:00:00.000Z',
listenId: null,
reactions: [],
+ commentCount: 0,
pinnedAt: opts.pinnedAt ?? null,
};
}
diff --git a/packages/shared/src/dto.ts b/packages/shared/src/dto.ts
index a9e4471..a5d3057 100644
--- a/packages/shared/src/dto.ts
+++ b/packages/shared/src/dto.ts
@@ -57,9 +57,10 @@ export interface NowPlayingDTO {
sourceUrl: string | null;
progressMs: number | null;
updatedAt: string;
- /** The play this is, for reactions (null for very old rows). */
+ /** The play this is, for reactions and comments (null for very old rows). */
listenId: string | null;
reactions: ReactionDTO[];
+ commentCount: number;
}
/** A feed entry. pinnedAt is set when the viewer pinned this friend. */
@@ -90,6 +91,29 @@ export interface RecentPlayDTO {
source: NowPlayingProvider;
playedAt: string;
reactions: ReactionDTO[];
+ commentCount: number;
+}
+
+/** A comment on a play. */
+export interface CommentDTO {
+ id: string;
+ user: UserSummaryDTO;
+ body: string;
+ createdAt: string;
+ /** The viewer wrote it, owns the play, or is an admin. */
+ canDelete: boolean;
+}
+
+/** A play and its comments, oldest first (GET /listens/:id/comments). */
+export interface CommentThreadDTO {
+ listenId: string;
+ owner: UserSummaryDTO;
+ track: TrackDTO;
+ playedAt: string;
+ reactions: ReactionDTO[];
+ comments: CommentDTO[];
+ /** False when the viewer can read but not write (only the player and people they follow can comment). */
+ canComment: boolean;
}
export interface ProfileDTO {
@@ -119,6 +143,8 @@ export interface AdminReportDTO {
createdAt: string;
reporter: UserSummaryDTO | null;
reported: UserSummaryDTO & { suspended: boolean };
+ /** When a comment was reported: what it said (kept even if it's since been deleted). */
+ comment: { id: string | null; body: string } | null;
}
export interface AdminStatsDTO {
diff --git a/packages/shared/src/notifications.test.ts b/packages/shared/src/notifications.test.ts
index 5819908..01d7e4f 100644
--- a/packages/shared/src/notifications.test.ts
+++ b/packages/shared/src/notifications.test.ts
@@ -17,11 +17,22 @@ describe('notifications', () => {
});
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');
+ expect(notificationPath({ type: 'share', actor, track, listenId: null })).toBe('/t/t1');
+ expect(notificationPath({ type: 'follow', actor, track: null, listenId: null })).toBe('/u/bob');
+ });
+
+ it('opens the play for comments, replies and reactions', () => {
+ expect(notificationPath({ type: 'comment', actor, track, listenId: 'l1' })).toBe('/play/l1');
+ expect(notificationPath({ type: 'reaction', actor, track, listenId: 'l1' })).toBe('/play/l1');
+ // The play was trimmed from history: fall back to the song.
+ expect(notificationPath({ type: 'reaction', actor, track, listenId: null })).toBe('/t/t1');
+ expect(notificationText({ type: 'reply', actor, track, emoji: null, message: 'same' })).toEqual({
+ title: 'Bob replied on Teardrop by Massive Attack',
+ body: 'same',
+ });
});
it('treats missing settings as on', () => {
- expect(notificationPrefs({ follow: false })).toEqual({ reaction: true, follow: false, share: true });
+ expect(notificationPrefs({ follow: false })).toEqual({ reaction: true, comment: true, reply: true, follow: false, share: true });
});
});
diff --git a/packages/shared/src/notifications.ts b/packages/shared/src/notifications.ts
index 67be48c..9e9959a 100644
--- a/packages/shared/src/notifications.ts
+++ b/packages/shared/src/notifications.ts
@@ -1,6 +1,6 @@
import type { TrackDTO, UserSummaryDTO } from './dto';
-export const NOTIFICATION_TYPES = ['reaction', 'follow', 'share'] as const;
+export const NOTIFICATION_TYPES = ['reaction', 'comment', 'reply', 'follow', 'share'] as const;
export type NotificationType = (typeof NOTIFICATION_TYPES)[number];
/** Which notifications get pushed to your phone/browser. */
@@ -8,6 +8,8 @@ export type NotificationPrefs = Record;
export const NOTIFICATION_PREF_LABELS: Record = {
reaction: 'Reactions to your songs',
+ comment: 'Comments on your songs',
+ reply: 'Replies where you commented',
follow: 'New followers',
share: 'Songs sent to you',
};
@@ -15,6 +17,8 @@ export const NOTIFICATION_PREF_LABELS: Record = {
/** Missing settings mean on. */
export const notificationPrefs = (stored: Partial | null | undefined): NotificationPrefs => ({
reaction: stored?.reaction ?? true,
+ comment: stored?.comment ?? true,
+ reply: stored?.reply ?? true,
follow: stored?.follow ?? true,
share: stored?.share ?? true,
});
@@ -24,6 +28,8 @@ export interface NotificationDTO {
type: NotificationType;
actor: UserSummaryDTO;
track: TrackDTO | null;
+ /** The play a reaction or comment was on, while it exists. */
+ listenId: string | null;
emoji: string | null;
message: string | null;
read: boolean;
@@ -47,6 +53,10 @@ export function notificationText(n: Pick): string {
+export function notificationPath(n: Pick): string {
+ if ((n.type === 'comment' || n.type === 'reply' || n.type === 'reaction') && n.listenId) return `/play/${n.listenId}`;
if ((n.type === 'share' || n.type === 'reaction') && n.track) return `/t/${n.track.id}`;
return `/u/${n.actor.username}`;
}
diff --git a/packages/shared/src/schemas.ts b/packages/shared/src/schemas.ts
index e3982bb..2530b94 100644
--- a/packages/shared/src/schemas.ts
+++ b/packages/shared/src/schemas.ts
@@ -53,7 +53,7 @@ export const updateMeSchema = z.object({
.nullable()
.optional(),
notifyPrefs: z
- .object({ reaction: z.boolean(), follow: z.boolean(), share: z.boolean() })
+ .object({ reaction: z.boolean(), comment: z.boolean(), reply: z.boolean(), follow: z.boolean(), share: z.boolean() })
.partial()
.optional(),
/** Mark onboarding as finished. */
@@ -112,6 +112,12 @@ export const REPORT_REASON_LABELS: Record = {
impersonation: 'Pretending to be someone else',
other: 'Something else',
};
+export const COMMENT_MAX_LENGTH = 500;
+export const commentSchema = z.object({
+ body: z.string().trim().min(1, 'Write something first').max(COMMENT_MAX_LENGTH, `Keep it under ${COMMENT_MAX_LENGTH} characters`),
+});
+export type CommentInput = z.infer;
+
export const reportSchema = z.object({
reason: z.enum(REPORT_REASONS),
note: z.string().trim().max(500).nullish(),