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
173 lines
6.9 KiB
JavaScript
173 lines
6.9 KiB
JavaScript
// ── The feed: what happened on one server ─────────────────────────────────
|
|
//
|
|
// Rows come from `/public/rust/servers/:id/events`, which serves a default-deny
|
|
// ALLOWLIST (`server/catalogue.js`). Everything carrying an IP address, a
|
|
// player's report about another player, or the grid square somebody's base is in
|
|
// is stored and never answered here — so this component cannot leak one by
|
|
// forgetting to filter, which is the point of the boundary living on the server.
|
|
//
|
|
// It polls (org lead, phase 4): every twenty seconds while the tab is visible,
|
|
// paused when it is not. `usePolled` keeps the rows on screen across a refresh —
|
|
// see the comment at the top of that file for why core's `useAsync` cannot do
|
|
// this job.
|
|
|
|
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'
|
|
import api from '../api.js'
|
|
import { hiddenMessage } from './Online.jsx'
|
|
|
|
const TONE = {
|
|
kill: 'var(--accent-bright)',
|
|
death: 'var(--muted)',
|
|
join: 'var(--mode-live, #5fb98a)',
|
|
leave: 'var(--dim)',
|
|
chat: 'var(--text)',
|
|
server: 'var(--mode-maint, #e6c26a)',
|
|
other: 'var(--muted)',
|
|
}
|
|
|
|
export default function Feed({ serverId, wipeId, filter, onFilter }) {
|
|
const kinds = kindsFor(filter)
|
|
|
|
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
|
|
// does not, because it is the same question asked again.
|
|
{ key: `${serverId}|${wipeId || ''}|${filter}`, intervalMs: 20_000 },
|
|
)
|
|
|
|
const events = data ? data.events : []
|
|
|
|
return (
|
|
<div>
|
|
<div
|
|
className="sans"
|
|
style={{ display: 'flex', flexWrap: 'wrap', gap: 10, alignItems: 'center', marginBottom: 16 }}
|
|
>
|
|
<label style={{ color: 'var(--dim)', fontSize: '0.78rem' }}>
|
|
Showing{' '}
|
|
<select
|
|
value={filter}
|
|
onChange={(e) => onFilter(e.target.value)}
|
|
style={selectStyle}
|
|
>
|
|
{FILTERS.map((f) => (
|
|
<option key={f.id} value={f.id}>{f.label}</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
|
|
{/* What a refresh is FOR: saying when the page last managed one. Without
|
|
it a feed that stopped updating looks exactly like a quiet server. */}
|
|
{at && (
|
|
<span style={{ color: 'var(--dim)', fontSize: '0.74rem' }}>updated {ago(at)}</span>
|
|
)}
|
|
{error && (
|
|
<span style={{ color: 'var(--mode-maint, #e6c26a)', fontSize: '0.74rem' }}>
|
|
the last refresh failed — showing what we had
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{loading && <Loading />}
|
|
|
|
{/* An error with nothing to fall back on is the only case that takes over
|
|
the panel. A failed REFRESH keeps the rows and says so in the line
|
|
above, because a site whose premise is "it renders while the game is
|
|
off" must not blank itself the first time a request does. */}
|
|
{error && !data && <ErrorState error={error} />}
|
|
|
|
{/* Below the operator's presence audience the server withholds every item
|
|
that names a player who was on — the killfeed, chat, joins — and keeps
|
|
only the server's own story. Said once, above the rows, so a thin feed
|
|
reads as withheld rather than as a quiet server. */}
|
|
{data && data.presenceHidden && (
|
|
<p className="sans" style={{ color: 'var(--dim)', fontSize: '0.8rem', marginTop: 0 }}>
|
|
Joins, deaths and chat are not shown. {hiddenMessage(data.presenceAudience, 'what players did')}
|
|
</p>
|
|
)}
|
|
|
|
{data && events.length === 0 && (
|
|
<Empty
|
|
title="Nothing here yet"
|
|
message={
|
|
data.presenceHidden
|
|
? 'Nothing this server has reported about itself matches.'
|
|
: 'Nothing this server has reported matches. A server that has just been added has no history until it says something.'
|
|
}
|
|
/>
|
|
)}
|
|
|
|
{events.length > 0 && (
|
|
<ol style={{ listStyle: 'none', margin: 0, padding: 0 }}>
|
|
{events.map((event) => {
|
|
const line = describe(event)
|
|
return (
|
|
<li
|
|
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
|
|
className="sans"
|
|
dateTime={new Date(event.t).toISOString()}
|
|
title={new Date(event.t).toLocaleString()}
|
|
style={{ flex: 'none', color: 'var(--dim)', fontSize: '0.74rem', minWidth: '5.6rem' }}
|
|
>
|
|
{clock(event.t)}
|
|
</time>
|
|
<span style={{ color: TONE[line.tone] || 'var(--muted)', fontSize: '0.92rem' }}>
|
|
{line.actor && <strong style={{ color: 'var(--ink)' }}>{line.actor}</strong>}
|
|
{line.actor && (line.join || ' ')}
|
|
{line.verb}
|
|
{line.subject && ' '}
|
|
{line.subject && <strong style={{ color: 'var(--ink)' }}>{line.subject}</strong>}
|
|
{line.detail && (
|
|
<span className="sans" style={{ color: 'var(--dim)', fontSize: '0.76rem' }}>
|
|
{' · '}
|
|
{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>
|
|
)
|
|
})}
|
|
</ol>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const selectStyle = {
|
|
background: 'var(--panel-flat, transparent)',
|
|
color: 'var(--text)',
|
|
border: '1px solid var(--line)',
|
|
borderRadius: 'var(--radius-input, 6px)',
|
|
padding: '3px 8px',
|
|
fontSize: '0.78rem',
|
|
}
|