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:
@@ -71,6 +71,11 @@ export default function AdminPage() {
|
||||
<strong>{REPORT_REASON_LABELS[r.reason as ReportReason] ?? r.reason}</strong>
|
||||
{r.reporter && <span className="muted"> · reported by @{r.reporter.username}</span>}
|
||||
</p>
|
||||
{r.comment && (
|
||||
<p className="muted small">
|
||||
Their comment{r.comment.id ? '' : ' (since deleted)'}: <q>{r.comment.body}</q>
|
||||
</p>
|
||||
)}
|
||||
{r.note && <blockquote>{r.note}</blockquote>}
|
||||
<div className="row">
|
||||
{r.status === 'open' && (
|
||||
|
||||
@@ -130,6 +130,24 @@ input:focus, textarea:focus, select:focus {
|
||||
.reaction-emoji { font-size: 1.05rem; line-height: 1.3; }
|
||||
.reaction-count { font-size: 0.78rem; font-variant-numeric: tabular-nums; }
|
||||
.reaction-add { color: var(--muted); font-size: 1rem; }
|
||||
a.reaction { text-decoration: none; }
|
||||
|
||||
/* Comments on a play (/play/[id]) */
|
||||
.played-by { display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; }
|
||||
.played-by-user { display: inline-flex; align-items: center; gap: 0.4rem; color: var(--text); }
|
||||
.comments { display: flex; flex-direction: column; gap: 0.9rem; }
|
||||
.comments h2 { margin: 0; }
|
||||
.comment { display: flex; gap: 0.75rem; align-items: flex-start; }
|
||||
.comment-main { flex: 1; min-width: 0; }
|
||||
.comment-main a { color: var(--text); }
|
||||
.comment-body { margin: 0.15rem 0 0; white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.45; }
|
||||
.comment-actions { display: flex; gap: 0.6rem; opacity: 0.6; }
|
||||
.comment:hover .comment-actions, .comment:focus-within .comment-actions { opacity: 1; }
|
||||
.link-button { background: none; border: 0; padding: 0; font: inherit; font-size: 0.8rem; color: var(--muted); cursor: pointer; }
|
||||
.link-button:hover { color: var(--text); text-decoration: underline; }
|
||||
.comment-form { display: flex; gap: 0.6rem; align-items: flex-end; }
|
||||
.comment-form textarea { flex: 1; resize: vertical; min-height: 2.6rem; }
|
||||
@media (max-width: 520px) { .comment-actions { opacity: 1; } }
|
||||
.reaction-add span { font-size: 0.75rem; margin-left: -0.15rem; }
|
||||
.reaction-add-wrap { position: static; } /* the quick menu anchors to the row, so it never runs off the card */
|
||||
.reaction-quick { position: absolute; bottom: calc(100% + 0.4rem); left: 0; z-index: 30; display: flex; align-items: center; gap: 0.15rem; padding: 0.3rem; border-radius: 999px; background: #1a1535; border: 1px solid rgba(255, 255, 255, 0.14); box-shadow: 0 14px 30px -10px rgba(0, 0, 0, 0.7); animation: pop 0.18s ease-out; white-space: nowrap; }
|
||||
|
||||
@@ -8,7 +8,7 @@ import { notificationPath, notificationText, timeAgo, type NotificationListDTO }
|
||||
import { Avatar } from '@/components/Avatar';
|
||||
import { client, useMe } from '@/lib/api';
|
||||
|
||||
/** Reactions, new followers and songs sent to you, newest first. Opening it marks them read. */
|
||||
/** Reactions, comments, new followers and songs sent to you, newest first. Opening it marks them read. */
|
||||
export default function NotificationsPage() {
|
||||
const { me, loggedOut } = useMe();
|
||||
const { mutate } = useSWRConfig();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import Link from 'next/link';
|
||||
|
||||
export const metadata: Metadata = { title: 'Privacy policy · musicshare' };
|
||||
|
||||
const UPDATED = 'October 14, 2026';
|
||||
const UPDATED = 'October 15, 2026';
|
||||
const CONTACT = 'app@musicshare.binarygnome.com';
|
||||
|
||||
export default function PrivacyPage() {
|
||||
@@ -40,11 +40,16 @@ export default function PrivacyPage() {
|
||||
“notification access”, but the app only reads the song, artist and album from music apps you allow.
|
||||
It doesn't read or store your notifications, messages or anything from other apps.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Comments and reactions</strong> you leave on plays. Comments stay until you or the person who played the
|
||||
song deletes them, or one of your accounts is deleted. Plays with comments are kept in their history so the
|
||||
conversation isn't lost.
|
||||
</li>
|
||||
<li>
|
||||
<strong>People:</strong> who you follow, who you've pinned, who you've blocked, and reports you send.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Notifications:</strong> reactions, new followers and songs (with any note) sent to you, and your
|
||||
<strong>Notifications:</strong> reactions, comments and replies, new followers and songs (with any note) sent to you, and your
|
||||
notification settings. If you turn on push notifications, we store the push address your browser or
|
||||
phone gives us and its encryption keys.
|
||||
</li>
|
||||
@@ -63,13 +68,17 @@ export default function PrivacyPage() {
|
||||
people who aren't signed in.
|
||||
</li>
|
||||
<li>Your followers see what you play in their feed, live.</li>
|
||||
<li>
|
||||
Comments on a play can be read by signed-in people who can see that play. Only you and the people you follow can
|
||||
write them, and you can delete any comment on your plays.
|
||||
</li>
|
||||
<li>
|
||||
Your email address is never shown to other people. It's used to sign in, to send you confirmation and
|
||||
password reset emails, and to contact you about your account.
|
||||
</li>
|
||||
<li>
|
||||
People you block can't find you or see your profile, and you stop following each other. They aren't
|
||||
told.
|
||||
People you block can't find you or see your profile, and you stop following each other. Their comments on
|
||||
your plays, and yours on theirs, are removed. They aren't told.
|
||||
</li>
|
||||
<li>
|
||||
You can <strong>pause sharing</strong> at any time in Settings. While paused, nothing new you play is shared or
|
||||
|
||||
Reference in New Issue
Block a user