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:
@@ -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<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user