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:
@@ -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<ReactionDTO[]>(`/listens/${q(listenId)}/reaction`, { method: 'PUT', body: { emoji } }),
|
||||
unreact: (listenId: string) => request<ReactionDTO[]>(`/listens/${q(listenId)}/reaction`, { method: 'DELETE' }),
|
||||
|
||||
/** A play with its comments. */
|
||||
comments: (listenId: string) => request<CommentThreadDTO>(`/listens/${q(listenId)}/comments`),
|
||||
addComment: (listenId: string, input: CommentInput) =>
|
||||
request<CommentDTO>(`/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<TrackSearchResultDTO[]>(`/tracks/search?q=${q(query)}`),
|
||||
/** Null when no lyrics were found for the song. */
|
||||
lyrics: (trackId: string) => request<LyricsDTO | null>(`/tracks/${q(trackId)}/lyrics`),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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<ReportReason, string> = {
|
||||
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<typeof commentSchema>;
|
||||
|
||||
export const reportSchema = z.object({
|
||||
reason: z.enum(REPORT_REASONS),
|
||||
note: z.string().trim().max(500).nullish(),
|
||||
|
||||
Reference in New Issue
Block a user