- 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
135 lines
5.0 KiB
TypeScript
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't be able to find you, see your profile or what you're playing, and you'll stop following each
|
|
other. They aren'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'll take a look. If they'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'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>
|
|
);
|
|
}
|