feat(safety): report, block and hide chat lines (MODULE_API 1.21.0)
Some checks failed
PR Checks / client-build (pull_request) Successful in 51s
PR Checks / frozen-manifest (pull_request) Successful in 53s
PR Checks / server-tests (pull_request) Failing after -17s

USER_SAFETY.md step 3, the module half of report, block and hide.

- server/safety.js registers the rust.chat report target (snapshot for one
  reader, null when they could not have seen the line; staff's hide and
  unhide) and the steam game identity (ownersOf from rust_account_links).
- The events feed filters chat per reader, in the query: lines from a
  blocked account's Steam ids, the reader's unlinked Steam-ID blocks and the
  lines they hid. Signed-in readers get canBlock on each chat line (false for
  their own and for staff-linked players). Lines staff hid for everyone are
  left out for everyone but staff, who get them with hidden: true.
- rust_events gains hidden_at and hidden_by (ALTER ... IF NOT EXISTS).
- POST /admin/rust/chat/:id/hide and /unhide (admin, moderator), logged.
- The feed's chat lines get a menu: Report, Hide for me, Block player,
  and for staff Hide for everyone / Show to everyone again.
- coreApi ^1.21.0; ci/core-ref.json pinned to the website#233 merge;
  routes.manifest.json and swagger-fragment.json regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-10-30 09:04:08 -05:00
parent 1fb685fa0f
commit dd9b8b2711
24 changed files with 1182 additions and 25 deletions

View File

@@ -350,6 +350,21 @@ export const adminUserBackpacks = {
req(`/admin/users/${encodeURIComponent(userId)}/rust/backpacks${query({ serverId, steamId })}`),
}
// ── report, block and hide (USER_SAFETY.md §4.2) ───────────────────────────
// Core's routes, not this module's: the Reports queue, a member's blocks and
// their "Hide for me" list are core's and hold for every module and the forum.
// Each names an ITEM — `{ targetType: 'rust.chat', targetId }` — never a person;
// core asks `server/safety.js` who said it. The last two are this module's own:
// staff's hide for everyone, set and cleared where the line lives.
export const safety = {
report: ({ targetType, targetId, reason, detail }) =>
req('/player/reports', { method: 'POST', body: { targetType, targetId, reason, detail } }),
block: ({ targetType, targetId }) => req('/auth/me/blocks', { method: 'POST', body: { targetType, targetId } }),
hide: ({ targetType, targetId }) => req('/auth/me/hidden', { method: 'POST', body: { targetType, targetId } }),
hideForEveryone: (lineId) => req(`/admin/rust/chat/${encodeURIComponent(lineId)}/hide`, { method: 'POST' }),
unhideForEveryone: (lineId) => req(`/admin/rust/chat/${encodeURIComponent(lineId)}/unhide`, { method: 'POST' }),
}
// ── core's public event calendar ───────────────────────────────────────────
// The one CORE route this module reads, and core published `runId` on it for
// exactly this: a map marker holds a run id and nothing else, and the calendar is
@@ -382,6 +397,7 @@ export default {
adminUserPermissions,
adminUserEconomics,
adminUserBackpacks,
safety,
coreEvents,
BASE,
}

View File

@@ -0,0 +1,198 @@
// ── A chat line's menu: report, hide, block (USER_SAFETY.md §4.2) ──────────
//
// One small button at the end of each chat line in the feed, opening a row of
// actions under it. What it offers is `lib/safety.js`'s `chatActions`; what it
// does is core's (report, block, Hide for me) or this module's admin route
// (staff's hide for everyone). After anything that changes what the reader
// sees, the feed is asked again: the server is what filters it (S11), so the
// honest way to show a block is to re-read the feed, never to drop the line here.
//
// The wording follows core's forum menu, so a member reads one set of sentences
// wherever they report or block from.
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { useAuth } from '../core.js'
import api from '../api.js'
import { CHAT_TARGET, REPORT_REASONS, chatActions } from '../lib/safety.js'
export default function ChatMenu({ line, onChanged }) {
const { user } = useAuth()
const actions = chatActions(line, user)
const [mode, setMode] = useState('closed') // closed | open | report | block | done
const [reason, setReason] = useState('abuse')
const [detail, setDetail] = useState('')
const [message, setMessage] = useState(null)
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
if (!actions) return null
// Every move between the menu's states drops the last error: a refusal from
// one action ("you have already reported this") is not about the next.
const go = (next) => { setError(null); setMode(next) }
const target = { targetType: CHAT_TARGET, targetId: line.id }
const name = (line.frame && line.frame.name) || 'this player'
const run = async (call, { refetch = true, done = null } = {}) => {
setBusy(true)
setError(null)
try {
await call()
if (done) {
setMessage(done)
setMode('done')
} else {
setMode('closed')
}
if (refetch) await onChanged()
} catch (err) {
setError(err.message || 'Could not do that')
} finally {
setBusy(false)
}
}
const toggle = (
<button
type="button"
onClick={() => { setError(null); setMode(mode === 'closed' ? 'open' : 'closed') }}
aria-label={`Actions for ${name}’s line`}
aria-expanded={mode !== 'closed'}
title="Report, hide or block"
style={toggleStyle}
>
⋯
</button>
)
if (mode === 'closed') return toggle
return (
<>
{toggle}
<div className="sans" style={panelStyle}>
{mode === 'done' && <p style={noteStyle}>{message}</p>}
{mode === 'open' && actions.signIn && (
<p style={noteStyle}>
<Link to="/account/login" state={{ from: { pathname: window.location.pathname } }}>Sign in</Link>
{' '}to report or block.
</p>
)}
{mode === 'open' && !actions.signIn && (
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{actions.report && (
<button type="button" className="pill" onClick={() => go('report')}>Report</button>
)}
{actions.hide && (
<button type="button" className="pill" disabled={busy} onClick={() => run(() => api.safety.hide(target))}>
Hide for me
</button>
)}
{actions.block && (
<button type="button" className="pill" onClick={() => go('block')}>Block player</button>
)}
{actions.staff === 'hide' && (
<button type="button" className="pill" disabled={busy} onClick={() => run(() => api.safety.hideForEveryone(line.id))}>
Hide for everyone
</button>
)}
{actions.staff === 'unhide' && (
<button type="button" className="pill" disabled={busy} onClick={() => run(() => api.safety.unhideForEveryone(line.id))}>
Show to everyone again
</button>
)}
</div>
)}
{mode === 'report' && (
<form
onSubmit={(e) => {
e.preventDefault()
run(() => api.safety.report({ ...target, reason, detail: detail || undefined }), {
refetch: false,
done: 'Reported to site staff.',
})
}}
style={{ display: 'grid', gap: 8 }}
>
{/* A report changes nothing about the line, and says so: a member who
expects it to vanish and watches it stay will report it again. */}
<p style={noteStyle}>
This goes to <strong>site staff</strong>. Reporting does not hide or change anything — it asks a
staffer to look.
</p>
<label style={{ fontSize: '0.85rem' }}>
Reason
{' '}
<select className="input" value={reason} onChange={(e) => setReason(e.target.value)}>
{REPORT_REASONS.map(([value, text]) => (
<option key={value} value={value}>{text}</option>
))}
</select>
</label>
<textarea
className="textarea"
value={detail}
onChange={(e) => setDetail(e.target.value)}
placeholder="Anything a staffer should know (optional)"
maxLength={500}
rows={3}
/>
<div style={{ display: 'flex', gap: 8 }}>
<button type="submit" className="btn btn-primary btn-sq" disabled={busy}>Send report</button>
<button type="button" className="pill" onClick={() => go('open')}>Cancel</button>
</div>
</form>
)}
{mode === 'block' && (
<div style={{ display: 'grid', gap: 8 }}>
{/* S9: the blocked player is not told, and a member deciding whether
to block is usually deciding whether it will start something. */}
<p style={noteStyle}>
Block <strong>{name}</strong>? You won&rsquo;t see anything they write, here or in the app. They
aren&rsquo;t told. You can undo it from <em>Account → Blocked</em>.
</p>
<div style={{ display: 'flex', gap: 8 }}>
<button type="button" className="btn btn-primary btn-sq" disabled={busy} onClick={() => run(() => api.safety.block(target))}>
Block
</button>
<button type="button" className="pill" onClick={() => go('open')}>Cancel</button>
</div>
</div>
)}
{error && <p style={{ ...noteStyle, color: 'var(--danger, crimson)' }}>{error}</p>}
</div>
</>
)
}
const toggleStyle = {
flex: 'none',
marginLeft: 'auto',
background: 'transparent',
border: 0,
color: 'var(--dim)',
cursor: 'pointer',
fontSize: '1rem',
lineHeight: 1,
padding: '0 4px',
}
const panelStyle = {
flexBasis: '100%',
display: 'grid',
gap: 8,
marginTop: 6,
padding: 10,
border: '1px solid var(--line)',
borderRadius: 6,
fontSize: '0.85rem',
}
const noteStyle = { margin: 0, fontSize: '0.82rem', color: 'var(--muted)' }

View File

@@ -13,6 +13,7 @@
import { ErrorState, Loading } from '../core.js'
import Empty from './Empty.jsx'
import ChatMenu from './ChatMenu.jsx'
import { describe, FILTERS, kindsFor } from '../lib/feed.js'
import { ago, clock } from '../lib/format.js'
import usePolled from '../hooks/usePolled.js'
@@ -32,7 +33,7 @@ const TONE = {
export default function Feed({ serverId, wipeId, filter, onFilter }) {
const kinds = kindsFor(filter)
const { data, error, loading, at } = usePolled(
const { data, error, loading, at, refresh } = usePolled(
() => api.servers.events(serverId, { kinds, wipe: wipeId, limit: 100 }),
// The key is the QUESTION. Changing server, wipe or filter blanks the rows,
// because what is on screen is an answer to a different one; a poll tick
@@ -111,10 +112,14 @@ export default function Feed({ serverId, wipeId, filter, onFilter }) {
key={event.id}
style={{
display: 'flex',
flexWrap: 'wrap',
gap: 12,
alignItems: 'baseline',
padding: '7px 0',
borderBottom: '1px solid var(--line-soft, var(--line))',
// Staff's hidden line (S10): only staff ever get one, dimmed
// and marked so the hide can be checked and undone here.
opacity: event.hidden ? 0.6 : 1,
}}
>
<time
@@ -137,7 +142,17 @@ export default function Feed({ serverId, wipeId, filter, onFilter }) {
{line.detail}
</span>
)}
{event.hidden && (
<span className="sans" style={{ color: 'var(--mode-maint, #e6c26a)', fontSize: '0.74rem' }}>
{' · '}
Hidden by staff
</span>
)}
</span>
{/* Report, Hide for me, Block player, and staff's Hide for
everyone (USER_SAFETY.md §4.2). The server filtered the feed
for this reader, so after any of them the feed is re-read. */}
<ChatMenu line={event} onChanged={refresh} />
</li>
)
})}

56
client/src/lib/safety.js Normal file
View File

@@ -0,0 +1,56 @@
// ── Report, block and hide on a chat line: what the menu offers ────────────
//
// USER_SAFETY.md §4.2. Nothing here decides what a reader may SEE — the server
// already left out what they blocked or hid, and staff's hidden lines for
// everybody else (S11). This file only turns what the server said about a line
// into which buttons its menu shows, and it is pure so a DOM-less runner can ask
// all of it.
/** The report target a chat line is filed as. Registered by `server/safety.js`. */
export const CHAT_TARGET = 'rust.chat'
/**
* The reasons a report can give. Core's `content_reports.reason` enum, in the
* order and words core's own forum report uses, so a member reads one list
* wherever they report from.
*/
export const REPORT_REASONS = Object.freeze([
['abuse', 'Abusive or harassing'],
['spam', 'Spam'],
['sexual', 'Sexual content'],
['illegal', 'Illegal content'],
['impersonation', 'Impersonation'],
['other', 'Something else'],
])
/**
* The roles that hide a line for everyone: the two core's Reports queue is open
* to, and the two `POST /admin/rust/chat/:id/hide` admits. An editor writes
* content and moderates none.
*/
export const HIDE_ROLES = Object.freeze(['admin', 'moderator'])
/**
* What a chat line's menu offers this reader.
*
* signIn a signed-out reader gets one line telling them to sign in (S6)
* report any signed-in reader, on any line
* hide "Hide for me", likewise — a staff member's line included (S8)
* block only where the server said `canBlock` (S8, S13)
* staff 'hide' or 'unhide' for a reader who can hide for everyone (S10)
*
* @param {{ kind: string, canBlock?: boolean, hidden?: boolean }} line
* @param {{ role?: string } | null} user
*/
export function chatActions(line, user) {
if (!line || line.kind !== 'player.chat') return null
if (!user) return { signIn: true, report: false, hide: false, block: false, staff: null }
const canHideForAll = HIDE_ROLES.includes(user.role)
return {
signIn: false,
report: true,
hide: true,
block: line.canBlock === true,
staff: canHideForAll ? (line.hidden ? 'unhide' : 'hide') : null,
}
}