Files
musicshare/apps/web/src/app/play/[id]/page.tsx
T
chrisandClaude Opus 5.5 4402171f46 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
2026-10-15 13:32:14 -04:00

146 lines
5.2 KiB
TypeScript

'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<CommentThreadDTO>(me ? `/listens/${id}/comments` : null, fetcher, { refreshInterval: 5000 });
const [draft, setDraft] = useState('');
const [sending, setSending] = useState(false);
const [problem, setProblem] = useState<string | null>(null);
const [reporting, setReporting] = useState<CommentDTO | null>(null);
if (loggedOut) {
return (
<p className="muted">
<Link href="/login">Log in</Link> to see the comments.
</p>
);
}
if (error) return <p className="muted">That play is gone.</p>;
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 (
<div className="stack">
<p className="muted played-by">
<Link href={`/u/${data.owner.username}`} className="played-by-user">
<Avatar user={data.owner} size={28} />
<strong>{owner}</strong>
</Link>{' '}
played this · {timeAgo(data.playedAt)}
</p>
<section className="card">
<TrackCard track={data.track} preferred={me?.preferredService} />
<Reactions listenId={data.listenId} reactions={data.reactions} ownerId={data.owner.id} />
</section>
<section className="card comments">
<h2>Comments</h2>
{data.comments.length === 0 && <p className="muted">{data.canComment ? 'No comments yet. Say something!' : 'No comments yet.'}</p>}
{data.comments.map((c) => (
<div key={c.id} className="comment">
<Link href={`/u/${c.user.username}`}>
<Avatar user={c.user} size={34} />
</Link>
<div className="comment-main">
<div className="muted small">
<Link href={`/u/${c.user.username}`}>
<strong>{c.user.displayName ?? c.user.username}</strong>
</Link>{' '}
· {timeAgo(c.createdAt)}
</div>
<p className="comment-body">{c.body}</p>
</div>
<div className="comment-actions">
{c.canDelete && (
<button type="button" className="link-button" onClick={() => remove(c)}>
Delete
</button>
)}
{c.user.id !== me?.id && (
<button type="button" className="link-button" onClick={() => setReporting(c)}>
Report
</button>
)}
</div>
</div>
))}
{data.canComment ? (
<form className="comment-form" onSubmit={send}>
<textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
// Enter posts; Shift+Enter is a new line.
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
e.currentTarget.form?.requestSubmit();
}
}}
placeholder={data.owner.id === me?.id ? 'Comment on your song' : `Comment on ${owner}'s song`}
maxLength={COMMENT_MAX_LENGTH}
rows={2}
/>
<button className="btn btn-primary" disabled={!draft.trim() || sending}>
Post
</button>
</form>
) : (
<p className="muted small">Only people {owner} follows can comment on their songs.</p>
)}
{problem && <p className="error">{problem}</p>}
</section>
{reporting && (
<ReportForm
username={reporting.user.username}
name={reporting.user.displayName ?? reporting.user.username}
commentId={reporting.id}
onClose={() => setReporting(null)}
/>
)}
</div>
);
}