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 && ( + + )} + {c.user.id !== me?.id && ( + + )} +
+
+ ))} + + {data.canComment ? ( +
+