feat(safety): report, block and hide, MODULE_API 1.21.0
USER_SAFETY.md step 2 (core), with S11-S14 decided while building it. - Reports: POST /player/reports takes any target type, core's forum types or a module's registered ones. One resolver (model/safety/targets.js) answers "what is this item, for this reader" and 404s an item the reader cannot see. Every report stores content_reports.snapshot; the queue shows a module item from it. - Hide for everyone: POST /admin/moderation/reports/:id/hide hides the item through its own moderation (forum hide as staff, or the module's hide) and actions the report with actioned_with = 'hide'. - Blocks (user_blocks) name an item, never a person, and land on the author's account first (S12); an unlinked game identity is blocked as itself and becomes an account block once linked (S14). Staff cannot be blocked; a block on someone later promoted stops applying (S8). - Hide for me (user_hidden_items), /auth/me/blocks and /auth/me/hidden. - Forum reads filter blocked authors and hidden items on the server and carry canBlock per thread and post. - MODULE_API 1.21.0: api.registerReportTargets, api.registerGameIdentities, ctx.safety.forViewer and ctx.safety.staffAmong, so a module filters its own per-viewer feeds on the server (S11) and marks lines canBlock (S13). - Web: Hide for me / Block author in the forum menus, a Blocked section on both account pages, Hide for everyone in the Reports queue. - Tests, OpenAPI, route/guard/engagement manifests regenerated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
@@ -126,6 +126,10 @@ export default function TeamForumPanel({ externalId, moduleId }) {
|
||||
imageMode={imageMode}
|
||||
onBack={() => openThread(null)}
|
||||
onChanged={() => loadThread(team.slug, thread.id)}
|
||||
onGone={async () => {
|
||||
await loadThreads(team.slug)
|
||||
openThread(null)
|
||||
}}
|
||||
onModerate={async (action) => {
|
||||
await api.teamForumModerate(team.slug, thread.id, { action })
|
||||
await loadThreads(team.slug)
|
||||
@@ -311,7 +315,7 @@ function GuestManager({ slug }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ThreadView({ slug, thread, canModerate, imageMode, onBack, onChanged, onModerate }) {
|
||||
function ThreadView({ slug, thread, canModerate, imageMode, onBack, onChanged, onGone, onModerate }) {
|
||||
// A clock that ticks, so an edit control whose deadline passed while the page
|
||||
// sat open goes away instead of becoming a button that fails. It only ever
|
||||
// REMOVES an offer — the server decides whether an edit happens, and re-derives
|
||||
@@ -386,6 +390,15 @@ function ThreadView({ slug, thread, canModerate, imageMode, onBack, onChanged, o
|
||||
targetId={thread.id}
|
||||
label="Report this thread"
|
||||
/>
|
||||
{/* A thread this reader hides, or whose author they block, is gone
|
||||
from their view, so the panel goes back to the list. */}
|
||||
<SafetyControls
|
||||
targetType="team_forum_thread"
|
||||
targetId={thread.id}
|
||||
author={thread.author}
|
||||
canBlock={thread.canBlock}
|
||||
onDone={onGone}
|
||||
/>
|
||||
{canModerate && (
|
||||
<>
|
||||
<button type="button" className="pill" onClick={() => onModerate(thread.pinned ? 'unpin' : 'pin')}>
|
||||
@@ -515,6 +528,15 @@ function PostView({ slug, post, canModerate, now, onChanged }) {
|
||||
label="Report"
|
||||
/>
|
||||
)}
|
||||
{!post.mine && (
|
||||
<SafetyControls
|
||||
targetType="team_forum_post"
|
||||
targetId={post.id}
|
||||
author={post.author}
|
||||
canBlock={post.canBlock}
|
||||
onDone={onChanged}
|
||||
/>
|
||||
)}
|
||||
{canModerate && (
|
||||
<>
|
||||
<button type="button" className="pill" onClick={() => moderate(post.status === 'hidden' ? 'unhide' : 'hide')}>
|
||||
@@ -529,6 +551,76 @@ function PostView({ slug, post, canModerate, now, onChanged }) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* "Hide for me" and "Block author" (USER_SAFETY.md §1).
|
||||
*
|
||||
* Both are the reader's own tools and both are saved on their account, so they
|
||||
* hold on every device and in the app. Neither files a report or changes the
|
||||
* item for anyone else, and the blocked person is not told (S9) — the
|
||||
* confirmation says so, because a member deciding whether to block someone is
|
||||
* usually deciding whether it will start something.
|
||||
*
|
||||
* `canBlock` is the server's answer (S8: never yourself, a deleted account or
|
||||
* staff); this offers Block only when it is true. "Hide for me" works on any
|
||||
* single item, a staff member's included.
|
||||
*/
|
||||
function SafetyControls({ targetType, targetId, author, canBlock, onDone }) {
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const run = async (call) => {
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
await call({ targetType, targetId })
|
||||
setConfirming(false)
|
||||
await onDone()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not do that')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (confirming) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid', gap: 8, marginTop: 8, padding: 12, width: '100%',
|
||||
border: '1px solid var(--rule, #ccc)', borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
<p className="sans" style={{ fontSize: '0.85rem', margin: 0 }}>
|
||||
Block <strong>{author}</strong>? You won’t see anything they write, here or in the app.
|
||||
They aren’t told. You can undo it from <em>Account → Blocked</em>.
|
||||
</p>
|
||||
{error && <p className="sans" style={{ color: 'var(--danger, crimson)', fontSize: '0.85rem', margin: 0 }}>{error}</p>}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="button" className="btn btn-primary btn-sq" disabled={busy} onClick={() => run(api.blockAuthor)}>
|
||||
Block
|
||||
</button>
|
||||
<button type="button" className="pill" onClick={() => setConfirming(false)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" className="pill" disabled={busy} onClick={() => run(api.hideItem)}>
|
||||
Hide for me
|
||||
</button>
|
||||
{canBlock && (
|
||||
<button type="button" className="pill" onClick={() => setConfirming(true)}>
|
||||
Block author
|
||||
</button>
|
||||
)}
|
||||
{error && <span className="sans" style={{ color: 'var(--danger, crimson)', fontSize: '0.8rem' }}>{error}</span>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The report control — the first user-facing report flow this site has ever had.
|
||||
*
|
||||
|
||||
@@ -11,6 +11,12 @@
|
||||
// that the two files can drift, so a test asserts they agree
|
||||
// (client/test/moduleRegistry.test.js) rather than trusting a bump to remember
|
||||
// both.
|
||||
// 1.21.0 — report, block and hide reach a module's content (USER_SAFETY.md
|
||||
// §4.1): `api.registerReportTargets`, `api.registerGameIdentities`,
|
||||
// `ctx.safety`. Server-side: a module filters its own feeds per reader on the
|
||||
// server (S11), and its client half calls core's ordinary `/player/reports` and
|
||||
// `/auth/me/blocks` routes through the shared request primitive. This file bumps
|
||||
// for the reason at the top.
|
||||
// 1.20.0 — `api.onUserDeleting` (ACCOUNT_DELETION.md §4.1, AD10): a module
|
||||
// unlinks a person's game links before their account is deleted. Server-side
|
||||
// only. This file bumps for the reason at the top.
|
||||
@@ -108,4 +114,4 @@
|
||||
// but the two halves state ONE version: a module declares a single coreApi range
|
||||
// and is served one chunk, so a client that claimed 1.0.0 while the server
|
||||
// answered 1.1.0 would be two answers to one question.
|
||||
export const MODULE_API_VERSION = '1.20.0'
|
||||
export const MODULE_API_VERSION = '1.21.0'
|
||||
|
||||
Reference in New Issue
Block a user