Comments on plays

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Y2iUsKEWDuRGjK5FPmcUP
This commit is contained in:
2026-10-15 13:32:14 -04:00
co-authored by Claude Opus 5.5
parent 31aed8dec4
commit 4402171f46
33 changed files with 2850 additions and 36 deletions
+13 -2
View File
@@ -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<NotificationType, boolean>;
export const NOTIFICATION_PREF_LABELS: Record<NotificationType, string> = {
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<NotificationType, string> = {
/** Missing settings mean on. */
export const notificationPrefs = (stored: Partial<NotificationPrefs> | 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<NotificationDTO, 'type' | 'actor' | 'tr
switch (n.type) {
case 'reaction':
return { title: `${who} reacted ${n.emoji ?? ''}`.trim(), body: `to ${song}` };
case 'comment':
return { title: `${who} commented on ${song}`, body: n.message ?? '' };
case 'reply':
return { title: `${who} replied on ${song}`, body: n.message ?? '' };
case 'follow':
return { title: `${who} followed you`, body: 'Follow back to see what they play.' };
case 'share':
@@ -55,7 +65,8 @@ export function notificationText(n: Pick<NotificationDTO, 'type' | 'actor' | 'tr
}
/** Where tapping it goes, as an app/web path. */
export function notificationPath(n: Pick<NotificationDTO, 'type' | 'actor' | 'track'>): string {
export function notificationPath(n: Pick<NotificationDTO, 'type' | 'actor' | 'track' | 'listenId'>): 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}`;
}