Files
musicshare/apps/web/src/components/ProfileMenu.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

135 lines
5.0 KiB
TypeScript

'use client';
import { useEffect, useRef, useState } from 'react';
import { REPORT_REASONS, REPORT_REASON_LABELS, type ProfileDTO, type ReportReason } from '@musicshare/shared';
import { client } from '@/lib/api';
/** "⋯" on someone else's profile: block/unblock and report. */
export function ProfileMenu({ profile, onChanged }: { profile: ProfileDTO; onChanged: () => void }) {
const [open, setOpen] = useState(false);
const [reporting, setReporting] = useState(false);
const [confirmBlock, setConfirmBlock] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const name = profile.user.displayName ?? profile.user.username;
useEffect(() => {
if (!open) return;
const close = (e: MouseEvent) => !ref.current?.contains(e.target as Node) && setOpen(false);
document.addEventListener('mousedown', close);
return () => document.removeEventListener('mousedown', close);
}, [open]);
const toggleBlock = async () => {
setOpen(false);
setConfirmBlock(false);
await (profile.isBlocked ? client.unblock(profile.user.username) : client.block(profile.user.username));
onChanged();
};
return (
<div className="menu" ref={ref}>
<button className="btn" aria-haspopup="menu" aria-expanded={open} aria-label="More options" onClick={() => setOpen((v) => !v)}>
⋯
</button>
{open && (
<div className="menu-list" role="menu">
<button role="menuitem" onClick={() => (profile.isBlocked ? toggleBlock() : (setOpen(false), setConfirmBlock(true)))}>
{profile.isBlocked ? `Unblock ${name}` : `Block ${name}`}
</button>
<button role="menuitem" className="danger" onClick={() => (setOpen(false), setReporting(true))}>
Report {name}
</button>
</div>
)}
{confirmBlock && (
<Modal onClose={() => setConfirmBlock(false)} title={`Block ${name}?`}>
<p>
They won&apos;t be able to find you, see your profile or what you&apos;re playing, and you&apos;ll stop following each
other. They aren&apos;t told.
</p>
<div className="row">
<button className="btn btn-danger" onClick={toggleBlock}>
Block
</button>
<button className="btn" onClick={() => setConfirmBlock(false)}>
Cancel
</button>
</div>
</Modal>
)}
{reporting && <ReportForm username={profile.user.username} name={name} onClose={() => setReporting(false)} />}
</div>
);
}
/** Report someone's account, or one of their comments (`commentId`). */
export function ReportForm({ username, name, commentId, onClose }: { username: string; name: string; commentId?: string; onClose: () => void }) {
const [reason, setReason] = useState<ReportReason | null>(null);
const [note, setNote] = useState('');
const [sent, setSent] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!reason) return;
try {
const input = { reason, note: note.trim() || null };
await (commentId ? client.reportComment(commentId, input) : client.report(username, input));
setSent(true);
} catch (err) {
setError((err as Error).message);
}
};
if (sent) {
return (
<Modal onClose={onClose} title="Thanks for letting us know">
<p>We&apos;ll take a look. If they&apos;re bothering you, you can also block them from this menu.</p>
<button className="btn btn-primary" onClick={onClose}>
Done
</button>
</Modal>
);
}
return (
<Modal onClose={onClose} title={commentId ? `Report ${name}'s comment` : `Report ${name}`}>
<form className="form" onSubmit={submit}>
<fieldset className="choices">
<legend className="muted">What&apos;s wrong?</legend>
{REPORT_REASONS.map((r) => (
<label key={r} className="checkbox">
<input type="radio" name="reason" checked={reason === r} onChange={() => setReason(r)} /> {REPORT_REASON_LABELS[r]}
</label>
))}
</fieldset>
<label>
Anything else? (optional)
<textarea value={note} onChange={(e) => setNote(e.target.value)} maxLength={500} rows={3} />
</label>
{error && <p className="error">{error}</p>}
<div className="row">
<button className="btn btn-danger" disabled={!reason}>
Send report
</button>
<button type="button" className="btn" onClick={onClose}>
Cancel
</button>
</div>
</form>
</Modal>
);
}
export function Modal({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) {
const ref = useRef<HTMLDialogElement>(null);
useEffect(() => {
ref.current?.showModal();
}, []);
return (
<dialog ref={ref} className="modal card" onClose={onClose} onClick={(e) => e.target === ref.current && onClose()}>
<h2>{title}</h2>
{children}
</dialog>
);
}