feat(safety): report, block and hide chat lines (MODULE_API 1.21.0)
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:
@@ -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,
|
||||
}
|
||||
|
||||
198
client/src/components/ChatMenu.jsx
Normal file
198
client/src/components/ChatMenu.jsx
Normal 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’t see anything they write, here or in the app. They
|
||||
aren’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)' }
|
||||
@@ -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
56
client/src/lib/safety.js
Normal 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user