- 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
146 lines
5.2 KiB
TypeScript
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>
|
|
);
|
|
}
|