feat(rust): chat titles, BetterChat group styles, the voice and popups (phase 17)
PLAN.md §33, D134-D143. Protocol 12. - Chat titles (D135-D137): per-server rules (stat, top N, text, colour) that rank the current wipe, and a mode (first | all | up to N). Worked out once in model/titles and read three ways: pushed whole to the game by a new titleSync loop (on change, restart or wipe), and on every leaderboard row as `titles`. Admin: PUT /servers/:id/titles. - Group styles (D138, D139): a site group may carry all twelve BetterChat fields (rust_perm_group_chat). They ride perm.sync with `expect` from the pushed ledger, which gains a value column; a field changed in game is a `chat-field` drift row with the game's value, adopted into the style or put back. A withdrawn style is one `chat-group` retirement, never for `default`, cleared from the ledger only once BetterChat removed it. - The voice (D140): one fleet setting naming a styled group; news and rust.announce chat lines carry its format and the plugin says them with no sender. Admin: GET/PUT /voice. - Popups (D141, D142): rust.announce gains `delivery` (still version 1, from rust.options.delivery); each server gains news_delivery beside the news switch; `popup-unavailable` is not retried. - GET /servers/:id/integrations reads, live, which optional mods a server has loaded. README lists BetterChat and PopupNotifications as optional. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
@@ -132,6 +132,11 @@ export const admin = {
|
||||
// picture to draw one — which stalls that game for seconds (D109).
|
||||
fetchMap: (id) => req(`/admin/rust/servers/${encodeURIComponent(id)}/map/fetch`, { method: 'POST' }),
|
||||
renderMap: (id) => req(`/admin/rust/servers/${encodeURIComponent(id)}/map/render`, { method: 'POST' }),
|
||||
// Phase 17: a server's chat titles, what optional mods it has, and the voice.
|
||||
saveTitles: (id, body) => req(`/admin/rust/servers/${encodeURIComponent(id)}/titles`, { method: 'PUT', body }),
|
||||
integrations: (id) => req(`/admin/rust/servers/${encodeURIComponent(id)}/integrations`),
|
||||
voice: () => req('/admin/rust/voice'),
|
||||
saveVoice: (group) => req('/admin/rust/voice', { method: 'PUT', body: { group } }),
|
||||
}
|
||||
|
||||
// ── admin · permissions (R2) ──────────────────────────────────────────────
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
import { ErrorState, Loading, useAsync } from '../core.js'
|
||||
import Empty from './Empty.jsx'
|
||||
import { ago, count, duration, shortId } from '../lib/format.js'
|
||||
import { ago, contrastInk, count, duration, shortId } from '../lib/format.js'
|
||||
import api from '../api.js'
|
||||
|
||||
// `sort` is the API's own vocabulary (`kills`, `deaths`, `npcKills`, `playtime`),
|
||||
@@ -101,6 +101,26 @@ export default function Leaderboard({ serverId, wipeId, sort, onSort }) {
|
||||
of their id rather than as a blank: the row is real, and a
|
||||
nameless one reads as a rendering fault. */}
|
||||
<strong style={{ color: 'var(--ink)' }}>{row.name || shortId(row.steamId)}</strong>
|
||||
{/* The chat titles this player holds now (phase 17, D137) — the
|
||||
same ones the game shows, ranked on the current wipe whichever
|
||||
wipe this table is showing. Absent on an older module. */}
|
||||
{(row.titles || []).map((title, i) => (
|
||||
<span
|
||||
key={`${title.text}-${i}`}
|
||||
className="sans"
|
||||
style={{
|
||||
marginLeft: 6,
|
||||
padding: '1px 6px',
|
||||
borderRadius: 999,
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 600,
|
||||
background: title.color,
|
||||
color: contrastInk(title.color),
|
||||
}}
|
||||
>
|
||||
{title.text}
|
||||
</span>
|
||||
))}
|
||||
</td>
|
||||
{COLUMNS.map((column) => (
|
||||
<td key={column.key} style={{ ...cell, textAlign: 'right' }}>
|
||||
|
||||
@@ -156,4 +156,24 @@ function toMillis(value) {
|
||||
return Number.isNaN(parsed) ? null : parsed
|
||||
}
|
||||
|
||||
export default { ago, clock, day, duration, count, prefab, shortId, nextWipe }
|
||||
/**
|
||||
* The ink that reads on a background of `hex` — black or white, whichever has
|
||||
* the higher WCAG contrast. A chat title's colour is the operator's, chosen for
|
||||
* a dark game chat, and this page is drawn in the reader's theme: yellow text on
|
||||
* a white page is unreadable, so the colour becomes the chip and the text is
|
||||
* picked for it (phase 17, D137). Anything that is not `#rrggbb` gets black on
|
||||
* the caller's fallback.
|
||||
*/
|
||||
export function contrastInk(hex) {
|
||||
const m = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(String(hex || ''))
|
||||
if (!m) return '#000000'
|
||||
const linear = (c) => {
|
||||
const v = parseInt(c, 16) / 255
|
||||
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4
|
||||
}
|
||||
const L = 0.2126 * linear(m[1]) + 0.7152 * linear(m[2]) + 0.0722 * linear(m[3])
|
||||
// Contrast against white is 1.05 / (L + 0.05); against black (L + 0.05) / 0.05.
|
||||
return (L + 0.05) / 0.05 >= 1.05 / (L + 0.05) ? '#000000' : '#ffffff'
|
||||
}
|
||||
|
||||
export default { ago, clock, day, duration, count, prefab, shortId, nextWipe, contrastInk }
|
||||
|
||||
163
client/src/routes/admin/ChatStyle.jsx
Normal file
163
client/src/routes/admin/ChatStyle.jsx
Normal file
@@ -0,0 +1,163 @@
|
||||
// ── Admin · Rust · Permissions — a group's chat style (phase 17, D138) ─────
|
||||
//
|
||||
// All twelve of BetterChat's group fields, on a group this site authors. A style
|
||||
// is the whole of a BetterChat group or nothing, so the editor always shows all
|
||||
// twelve, starting from BetterChat's own defaults.
|
||||
//
|
||||
// Three things the section says out loud, because each looks like success from
|
||||
// here:
|
||||
//
|
||||
// • a server where BetterChat is not loaded holds nothing yet — the style
|
||||
// lands when BetterChat does, at the next sync;
|
||||
// • a field somebody changed in game is NOT overwritten — it shows under
|
||||
// "Changed in game", to adopt or put back;
|
||||
// • a field BetterChat refused (`InvalidValue`) is named with its server.
|
||||
//
|
||||
// Removing a style removes the group from BetterChat on the next sync (D139),
|
||||
// which is BetterChat's own `chat group remove` — it has no quieter way.
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
const LABELS = {
|
||||
Priority: 'Priority (lower wins when a player is in several groups)',
|
||||
Title: 'Title',
|
||||
TitleColor: 'Title colour',
|
||||
TitleSize: 'Title size',
|
||||
TitleHidden: 'Hide the title',
|
||||
TitleHiddenIfNotPrimary: 'Hide the title unless this is the player’s main group',
|
||||
UsernameColor: 'Name colour',
|
||||
UsernameSize: 'Name size',
|
||||
MessageColor: 'Message colour',
|
||||
MessageSize: 'Message size',
|
||||
ChatFormat: 'Chat format',
|
||||
ConsoleFormat: 'Console format',
|
||||
}
|
||||
|
||||
/** BetterChat's defaults for this group, from the field list the server sent. */
|
||||
function defaultsFor(group, fields) {
|
||||
const out = {}
|
||||
for (const f of fields) out[f.name] = f.default === null ? '' : f.default
|
||||
out.Title = group === 'default' ? '[Player]' : `[${group}]`
|
||||
return out
|
||||
}
|
||||
|
||||
/** What each server's last sync said about this group's style. */
|
||||
function styleNotes(group, servers) {
|
||||
const notes = []
|
||||
for (const s of servers) {
|
||||
const chat = s.report && s.report.chat
|
||||
if (!chat) continue
|
||||
if (chat.loaded === false) {
|
||||
notes.push(`${s.serverId}: BetterChat is not loaded, so nothing is styled there yet.`)
|
||||
continue
|
||||
}
|
||||
for (const f of chat.failed || []) {
|
||||
if (f.group !== group) continue
|
||||
notes.push(`${s.serverId}: BetterChat refused ${f.field || 'the group'} (${f.reason}).`)
|
||||
}
|
||||
}
|
||||
return notes
|
||||
}
|
||||
|
||||
export default function ChatStyleSection({ group, fields, servers, busy, onSave }) {
|
||||
const [editing, setEditing] = useState(null)
|
||||
|
||||
if (!fields || !fields.length) return null
|
||||
|
||||
const notes = group.chat ? styleNotes(group.name, servers) : []
|
||||
|
||||
const set = (name, value) => setEditing((e) => ({ ...e, [name]: value }))
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="field-label" style={{ marginTop: 18 }}>
|
||||
Chat style (BetterChat)
|
||||
</div>
|
||||
{!editing && !group.chat && (
|
||||
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '4px 0' }}>
|
||||
No chat style. BetterChat, where it is installed, styles this group’s members as it does anybody else.
|
||||
</p>
|
||||
)}
|
||||
{!editing && group.chat && (
|
||||
<p className="sans" style={{ fontSize: '0.82rem', margin: '4px 0' }}>
|
||||
<span style={{ color: /^#[0-9a-f]{6}$/i.test(group.chat.TitleColor) ? group.chat.TitleColor : undefined, fontWeight: 600 }}>
|
||||
{group.chat.TitleHidden === 'true' ? '(title hidden)' : group.chat.Title}
|
||||
</span>{' '}
|
||||
<span className="dim" style={{ fontSize: '0.76rem' }}>
|
||||
priority {group.chat.Priority} · <code>{group.chat.ChatFormat}</code>
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
{notes.map((n) => (
|
||||
<p key={n} className="sans" style={{ color: '#d08a2a', fontSize: '0.76rem', margin: '2px 0' }}>{n}</p>
|
||||
))}
|
||||
|
||||
{!editing && (
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 6 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={busy}
|
||||
onClick={() => setEditing({ ...defaultsFor(group.name, fields), ...(group.chat || {}) })}
|
||||
>
|
||||
{group.chat ? 'Edit the style' : 'Give it a chat style'}
|
||||
</button>
|
||||
{group.chat && (
|
||||
<button type="button" className="btn btn-ghost" disabled={busy} onClick={() => onSave(null)}>
|
||||
Remove the style
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<form
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault()
|
||||
if (await onSave(editing)) setEditing(null)
|
||||
}}
|
||||
className="sans"
|
||||
style={{ display: 'grid', gap: 8, marginTop: 8, fontSize: '0.82rem' }}
|
||||
>
|
||||
{fields.map((f) => (
|
||||
<label key={f.name} style={{ display: 'grid', gridTemplateColumns: 'minmax(160px, 1fr) 2fr', gap: 8, alignItems: 'center' }}>
|
||||
<span>{LABELS[f.name] || f.name}</span>
|
||||
{f.type === 'bool' ? (
|
||||
<select className="input" value={editing[f.name]} onChange={(e) => set(f.name, e.target.value)}>
|
||||
<option value="false">No</option>
|
||||
<option value="true">Yes</option>
|
||||
</select>
|
||||
) : f.type === 'color' ? (
|
||||
<span style={{ display: 'flex', gap: 6 }}>
|
||||
<input
|
||||
type="color"
|
||||
value={/^#[0-9a-f]{6}$/i.test(editing[f.name]) ? editing[f.name] : '#ffffff'}
|
||||
onChange={(e) => set(f.name, e.target.value)}
|
||||
aria-label={LABELS[f.name]}
|
||||
style={{ width: 36, height: 28, padding: 0, border: 'none', background: 'none' }}
|
||||
/>
|
||||
<input className="input" value={editing[f.name]} onChange={(e) => set(f.name, e.target.value)} style={{ flex: 1 }} />
|
||||
</span>
|
||||
) : (
|
||||
<input
|
||||
className="input"
|
||||
type={f.type === 'int' || f.type === 'size' ? 'number' : 'text'}
|
||||
value={editing[f.name]}
|
||||
onChange={(e) => set(f.name, e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
<p className="dim" style={{ fontSize: '0.74rem', margin: 0 }}>
|
||||
A format must hold <code>{'{Message}'}</code> exactly once; <code>{'{Title}'}</code> and <code>{'{Username}'}</code>{' '}
|
||||
are where the title and name go. A hand edit in game is reported rather than overwritten.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="submit" className="btn" disabled={busy}>Save the style</button>
|
||||
<button type="button" className="btn btn-ghost" disabled={busy} onClick={() => setEditing(null)}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
224
client/src/routes/admin/ChatTitles.jsx
Normal file
224
client/src/routes/admin/ChatTitles.jsx
Normal file
@@ -0,0 +1,224 @@
|
||||
// ── Admin · Rust · Servers — chat titles and the voice (phase 17) ─────────
|
||||
//
|
||||
// Two things the servers page gained with BetterChat and PopupNotifications
|
||||
// becoming optional (PLAN.md §33):
|
||||
//
|
||||
// • **A server's chat titles** (D135, D136): rules that rank the current wipe,
|
||||
// in the operator's order, and how many a player shows. Saved with their own
|
||||
// PUT, because they are not part of the server row — an operator retitling
|
||||
// "Top Killer" must not have to re-type a sidecar address.
|
||||
// • **The voice** (D140): the one styled permission group news and event lines
|
||||
// are said in. A fleet setting, so it sits once at the foot of the page.
|
||||
//
|
||||
// Neither needs BetterChat to save. Titles are held by the plugin until BetterChat
|
||||
// arrives, and the voice is said by our own plugin whether BetterChat is there or
|
||||
// not; the "What this server has" line says which is which.
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
import { ErrorState, Loading, useAsync } from '../../core.js'
|
||||
import api from '../../api.js'
|
||||
import { contrastInk } from '../../lib/format.js'
|
||||
|
||||
const STATS = [
|
||||
{ id: 'kills', label: 'Kills' },
|
||||
{ id: 'npckills', label: 'NPC kills' },
|
||||
{ id: 'playtime', label: 'Playtime' },
|
||||
]
|
||||
|
||||
const MODES = [
|
||||
{ id: 'first', label: 'The first title they earn', hint: 'The rule highest in the list wins.' },
|
||||
{ id: 'all', label: 'Every title they earn' },
|
||||
{ id: 'upto', label: 'Up to a number of titles', hint: 'In list order.' },
|
||||
]
|
||||
|
||||
const statLabel = (id) => (STATS.find((s) => s.id === id) || { label: id }).label
|
||||
|
||||
/** One line summarising a server's titles, for its row on the list. */
|
||||
export function titlesSummary(titles, push) {
|
||||
const rules = (titles && titles.rules) || []
|
||||
if (!rules.length) return 'No chat titles.'
|
||||
const list = rules.map((r) => `${r.text} (top ${r.topN} ${statLabel(r.stat).toLowerCase()})`).join(', ')
|
||||
const shown = push ? ` Last pushed: ${push.count} player${push.count === 1 ? '' : 's'} hold one${push.betterChat ? '' : ' — BetterChat is not loaded, so they are not showing in game yet'}.` : ''
|
||||
return `Chat titles: ${list}.${shown}`
|
||||
}
|
||||
|
||||
/** A title as the leaderboard will show it. */
|
||||
function Chip({ text, color }) {
|
||||
return (
|
||||
<span
|
||||
className="sans"
|
||||
style={{ padding: '1px 6px', borderRadius: 999, fontSize: '0.72rem', fontWeight: 600, background: color, color: contrastInk(color) }}
|
||||
>
|
||||
{text || '…'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function TitlesForm({ server, onSaved, onCancel }) {
|
||||
const start = server.titles || { mode: 'first', max: 2, rules: [] }
|
||||
const [mode, setMode] = useState(start.mode)
|
||||
const [max, setMax] = useState(start.max)
|
||||
const [rules, setRules] = useState(start.rules.map((r) => ({ ...r })))
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const setRule = (i, key) => (e) => {
|
||||
const value = e.target.value
|
||||
setRules((list) => list.map((r, n) => (n === i ? { ...r, [key]: key === 'topN' ? Number(value) : value } : r)))
|
||||
}
|
||||
const move = (i, by) => setRules((list) => {
|
||||
const next = [...list]
|
||||
const [r] = next.splice(i, 1)
|
||||
next.splice(i + by, 0, r)
|
||||
return next
|
||||
})
|
||||
|
||||
const save = async (e) => {
|
||||
e.preventDefault()
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.admin.saveTitles(server.id, { mode, max: Number(max), rules })
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
setError(err.message || 'That did not save.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={save} className="panel" style={{ padding: '16px 18px', display: 'grid', gap: 12, marginBottom: 18 }}>
|
||||
<h2 className="display" style={{ fontSize: '1.05rem', margin: 0, color: 'var(--head)' }}>Chat titles on {server.name}</h2>
|
||||
<p className="sans dim" style={{ fontSize: '0.78rem', margin: 0 }}>
|
||||
Each rule gives a title to the top players on this wipe for one stat. A stat of zero earns nothing, so a fresh wipe
|
||||
has no titles until somebody plays. Titles show in game chat when BetterChat is installed, and beside the name on
|
||||
the leaderboard here and in the app. Up to ten rules; a title is at most 24 characters and cannot carry markup.
|
||||
</p>
|
||||
|
||||
{rules.map((r, i) => (
|
||||
<div key={i} className="sans" style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center', fontSize: '0.84rem' }}>
|
||||
<span className="dim" style={{ width: 18 }}>{i + 1}</span>
|
||||
<span>Top</span>
|
||||
<input type="number" min={1} max={10} value={r.topN} onChange={setRule(i, 'topN')} style={{ ...inputStyle, width: 60 }} aria-label="How many players" />
|
||||
<select value={r.stat} onChange={setRule(i, 'stat')} style={inputStyle} aria-label="Stat">
|
||||
{STATS.map((s) => <option key={s.id} value={s.id}>{s.label}</option>)}
|
||||
</select>
|
||||
<span>earn</span>
|
||||
<input value={r.text} onChange={setRule(i, 'text')} maxLength={24} required style={{ ...inputStyle, width: 160 }} aria-label="Title" />
|
||||
<input type="color" value={r.color} onChange={setRule(i, 'color')} aria-label="Colour" style={{ width: 36, height: 28, padding: 0, border: 'none', background: 'none' }} />
|
||||
<Chip text={r.text} color={r.color} />
|
||||
<span style={{ marginLeft: 'auto', display: 'flex', gap: 4 }}>
|
||||
<button type="button" className="btn" disabled={i === 0} onClick={() => move(i, -1)} aria-label="Move up">↑</button>
|
||||
<button type="button" className="btn" disabled={i === rules.length - 1} onClick={() => move(i, 1)} aria-label="Move down">↓</button>
|
||||
<button type="button" className="btn" onClick={() => setRules((list) => list.filter((_, n) => n !== i))}>Remove</button>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{rules.length < 10 && (
|
||||
<div>
|
||||
<button type="button" className="btn" onClick={() => setRules((list) => [...list, { stat: 'kills', topN: 1, text: '', color: '#ffaa55' }])}>
|
||||
Add a rule
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="sans" style={{ display: 'flex', flexWrap: 'wrap', gap: 12, alignItems: 'center', fontSize: '0.84rem' }}>
|
||||
<label style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
A player shows
|
||||
<select value={mode} onChange={(e) => setMode(e.target.value)} style={inputStyle}>
|
||||
{MODES.map((m) => <option key={m.id} value={m.id}>{m.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
{mode === 'upto' && (
|
||||
<label style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
at most
|
||||
<input type="number" min={1} max={5} value={max} onChange={(e) => setMax(e.target.value)} style={{ ...inputStyle, width: 60 }} />
|
||||
</label>
|
||||
)}
|
||||
<span className="dim" style={{ fontSize: '0.74rem' }}>{(MODES.find((m) => m.id === mode) || {}).hint || ''}</span>
|
||||
</div>
|
||||
|
||||
<div className="sans" style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<button type="submit" className="btn" disabled={busy}>{busy ? 'Saving…' : 'Save titles'}</button>
|
||||
<button type="button" className="btn" onClick={onCancel} disabled={busy}>Cancel</button>
|
||||
{error && <span style={{ color: '#d08a2a', fontSize: '0.8rem' }}>{error}</span>}
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
/** What a server says it has loaded, as one sentence (§33.2 `integrations`). */
|
||||
export function integrationsLine(r) {
|
||||
if (!r || !r.integrations) {
|
||||
return r && r.ok === false
|
||||
? `The game could not be asked (${r.status}).`
|
||||
: 'This server’s plugin is older than protocol 12, so it cannot say which optional mods it has.'
|
||||
}
|
||||
const one = (name, x, without) => (x && x.loaded ? `${name} ${x.version || ''} is loaded`.trim() : `${name} is not loaded — ${without}`)
|
||||
return `${one('BetterChat', r.integrations.betterChat, 'titles and group styles wait for it')}. ${one('PopupNotifications', r.integrations.popupNotifications, 'a popup is refused, and chat still works')}.`
|
||||
}
|
||||
|
||||
export function VoiceCard() {
|
||||
const [reloads, setReloads] = useState(0)
|
||||
const { data, error: loadError } = useAsync(() => api.admin.voice(), [reloads])
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
if (loadError) return <ErrorState error={loadError} />
|
||||
if (!data) return <Loading />
|
||||
|
||||
const choose = async (group) => {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.admin.saveVoice(group)
|
||||
setReloads((n) => n + 1)
|
||||
} catch (err) {
|
||||
setError(err.message || 'That did not save.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const current = data.options.find((o) => o.group === data.voice)
|
||||
|
||||
return (
|
||||
<section className="panel sans" style={{ padding: '16px 18px', marginBottom: 18, fontSize: '0.84rem' }}>
|
||||
<h2 className="display" style={{ fontSize: '1.05rem', margin: '0 0 6px', color: 'var(--head)' }}>Announcement voice</h2>
|
||||
<p className="dim" style={{ fontSize: '0.78rem', margin: '0 0 10px' }}>
|
||||
News posts and event announcements said in game chat can wear the title and colours of one permission group that
|
||||
has a chat style. The line has no sender, so no player’s name appears. It works whether or not BetterChat is
|
||||
installed. Popups are plain text.
|
||||
</p>
|
||||
<label style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
Say them as
|
||||
<select value={data.voice} onChange={(e) => choose(e.target.value)} disabled={busy} style={inputStyle}>
|
||||
<option value="">Plain chat</option>
|
||||
{data.options.map((o) => <option key={o.group} value={o.group}>{o.group} — {o.title}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
{data.voice && !current && (
|
||||
<p style={{ color: '#d08a2a', fontSize: '0.78rem', margin: '8px 0 0' }}>
|
||||
The group “{data.voice}” no longer has a chat style, so lines are said in plain chat until it has one again or
|
||||
another voice is chosen.
|
||||
</p>
|
||||
)}
|
||||
{current && <p className="dim" style={{ fontSize: '0.74rem', margin: '8px 0 0' }}>The line: <code>{current.format}</code></p>}
|
||||
{!data.options.length && (
|
||||
<p className="dim" style={{ fontSize: '0.76rem', margin: '8px 0 0' }}>No group has a chat style yet. Give one a style under Permissions.</p>
|
||||
)}
|
||||
{error && <p style={{ color: '#d08a2a', fontSize: '0.8rem', margin: '8px 0 0' }}>{error}</p>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const inputStyle = {
|
||||
background: 'var(--panel-flat, transparent)',
|
||||
color: 'var(--text)',
|
||||
border: '1px solid var(--line)',
|
||||
borderRadius: 'var(--radius-input, 6px)',
|
||||
padding: '5px 8px',
|
||||
fontSize: '0.84rem',
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import { useCallback, useState } from 'react'
|
||||
import { ErrorState, Loading, useAsync } from '../../core.js'
|
||||
import { ago } from '../../lib/format.js'
|
||||
import api from '../../api.js'
|
||||
import ChatStyleSection from './ChatStyle.jsx'
|
||||
|
||||
const FLEET = '*'
|
||||
|
||||
@@ -159,6 +160,28 @@ function ServerState({ row, onSync, busy }) {
|
||||
function DriftRow({ row, onAdopt, onRevoke, busy }) {
|
||||
const subject = row.username ? `${row.username} (${row.subject})` : row.subject
|
||||
|
||||
// Phase 17: a field of a group's chat style somebody changed in game. Adopt
|
||||
// takes the game's value into the style; Revoke puts the site's value back.
|
||||
if (row.kind === 'chat-field') {
|
||||
return (
|
||||
<Row>
|
||||
<span style={{ minWidth: 0, flex: 1 }}>
|
||||
<strong style={{ fontWeight: 500 }}>{row.object}</strong>{' '}
|
||||
<span className="dim" style={{ fontSize: '0.78rem' }}>
|
||||
of group {row.subject}’s chat style is <code>{row.detail === null ? '(empty)' : row.detail}</code> in game ·{' '}
|
||||
{row.serverId} · seen {ago(row.firstSeen)}
|
||||
</span>
|
||||
</span>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => onAdopt(row)} disabled={busy}>
|
||||
Adopt
|
||||
</button>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => onRevoke(row)} disabled={busy}>
|
||||
Put ours back
|
||||
</button>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Row>
|
||||
<span style={{ minWidth: 0, flex: 1 }}>
|
||||
@@ -199,7 +222,7 @@ function pendingSet(servers) {
|
||||
return pending
|
||||
}
|
||||
|
||||
function GroupCard({ group, catalogue, servers, pending, onChanged, setError }) {
|
||||
function GroupCard({ group, catalogue, servers, pending, chatFields, onChanged, setError }) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [member, setMember] = useState('')
|
||||
const [permission, setPermission] = useState('')
|
||||
@@ -227,6 +250,29 @@ function GroupCard({ group, catalogue, servers, pending, onChanged, setError })
|
||||
}),
|
||||
)
|
||||
|
||||
// The style is saved with the group, like its permissions (D138). Answers
|
||||
// whether it saved, so the editor stays open on a refusal and shows why.
|
||||
const saveStyle = async (chat) => {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await api.adminPermissions.saveGroup(group.name, {
|
||||
title: group.title,
|
||||
rank: group.rank,
|
||||
scope: group.scope,
|
||||
permissions: group.permissions,
|
||||
chat,
|
||||
})
|
||||
await onChanged()
|
||||
return true
|
||||
} catch (err) {
|
||||
setError(err.message || 'That style did not save.')
|
||||
return false
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={group.title || group.name}
|
||||
@@ -358,6 +404,8 @@ function GroupCard({ group, catalogue, servers, pending, onChanged, setError })
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<ChatStyleSection group={group} fields={chatFields} servers={servers} busy={busy} onSave={saveStyle} />
|
||||
|
||||
{servers.length > 1 && group.scope !== FLEET && (
|
||||
<p className="sans dim" style={{ fontSize: '0.74rem', margin: '10px 0 0' }}>
|
||||
This group exists on {group.scope} only. The other servers never receive it.
|
||||
@@ -453,7 +501,8 @@ export default function Permissions() {
|
||||
<p className="sans dim" style={{ fontSize: '0.8rem', marginTop: 0 }}>
|
||||
Nothing here is undone automatically. <strong>Adopt</strong> records it as the site’s
|
||||
own, so it survives the next wipe; <strong>Revoke</strong> removes it from the game on
|
||||
the next sync.
|
||||
the next sync. A chat style field changed in game is adopted into the style — which then
|
||||
reaches every server the group does — or put back to the site’s value.
|
||||
</p>
|
||||
{data.drift.map((row) => (
|
||||
<DriftRow
|
||||
@@ -564,6 +613,7 @@ export default function Permissions() {
|
||||
catalogue={data.catalogue || []}
|
||||
servers={servers}
|
||||
pending={pendingSet(servers)}
|
||||
chatFields={data.chatFields || []}
|
||||
onChanged={reload}
|
||||
setError={setError}
|
||||
/>
|
||||
|
||||
@@ -20,12 +20,16 @@
|
||||
//
|
||||
// Test and Delete act at once rather than on Save — they are questions put to a
|
||||
// sidecar and a removal, not settings.
|
||||
//
|
||||
// Phase 17 added a server's chat titles and the announcement voice, in
|
||||
// `ChatTitles.jsx`, each saved on its own.
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
import { ErrorState, Loading, useAsync } from '../../core.js'
|
||||
import api from '../../api.js'
|
||||
import { ago, nextWipe } from '../../lib/format.js'
|
||||
import { TitlesForm, VoiceCard, integrationsLine, titlesSummary } from './ChatTitles.jsx'
|
||||
|
||||
const RULES = [
|
||||
{ id: 'none', label: 'No schedule', hint: 'Nothing is forecast, not even the monthly forced wipe.' },
|
||||
@@ -139,6 +143,7 @@ export default function ServerSettings() {
|
||||
const [error, setError] = useState('')
|
||||
const [notes, setNotes] = useState({})
|
||||
const [confirming, setConfirming] = useState(null)
|
||||
const [titling, setTitling] = useState(null)
|
||||
|
||||
if (loadError) return <ErrorState error={loadError} />
|
||||
if (!data) return <Loading />
|
||||
@@ -178,6 +183,16 @@ export default function ServerSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
const mods = async (server) => {
|
||||
setNotes((n) => ({ ...n, [server.id]: 'Asking the game…' }))
|
||||
try {
|
||||
const line = integrationsLine(await api.admin.integrations(server.id))
|
||||
setNotes((n) => ({ ...n, [server.id]: line }))
|
||||
} catch (err) {
|
||||
setNotes((n) => ({ ...n, [server.id]: err.message || 'The game could not be asked.' }))
|
||||
}
|
||||
}
|
||||
|
||||
const remove = async (server) => {
|
||||
setConfirming(null)
|
||||
try {
|
||||
@@ -219,9 +234,12 @@ export default function ServerSettings() {
|
||||
? `Next wipe ${nextWipe(s.nextWipe)}, from ${SOURCE[s.nextWipe.source] || s.nextWipe.source}.`
|
||||
: 'No wipe schedule set.'}
|
||||
</div>
|
||||
<div className="dim" style={{ fontSize: '0.76rem', marginTop: 2 }}>{titlesSummary(s.titles, s.titlePush)}</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 8 }}>
|
||||
<button type="button" className="btn" onClick={() => { setError(''); setForm(formFrom(s)) }}>Edit</button>
|
||||
<button type="button" className="btn" onClick={() => { setError(''); setTitling(null); setForm(formFrom(s)) }}>Edit</button>
|
||||
<button type="button" className="btn" onClick={() => test(s)}>Test the sidecar</button>
|
||||
<button type="button" className="btn" onClick={() => { setForm(null); setTitling(s) }}>Chat titles</button>
|
||||
<button type="button" className="btn" onClick={() => mods(s)}>Optional mods</button>
|
||||
{confirming === s.id ? (
|
||||
<>
|
||||
<button type="button" className="btn" onClick={() => remove(s)}>Delete {s.name} and everything recorded about it</button>
|
||||
@@ -244,6 +262,17 @@ export default function ServerSettings() {
|
||||
{form && (
|
||||
<ServerForm form={form} set={set} busy={busy} error={error} onSave={save} onCancel={() => setForm(null)} />
|
||||
)}
|
||||
|
||||
{titling && (
|
||||
<TitlesForm
|
||||
key={titling.id}
|
||||
server={titling}
|
||||
onSaved={() => { setTitling(null); setReloads((n) => n + 1) }}
|
||||
onCancel={() => setTitling(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<VoiceCard />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -100,6 +100,7 @@ export default function Visibility() {
|
||||
const [clanRoster, setClanRoster] = useState('members')
|
||||
const [servers, setServers] = useState({})
|
||||
const [news, setNews] = useState({})
|
||||
const [delivery, setDelivery] = useState({})
|
||||
const [mapFleet, setMapFleet] = useState({})
|
||||
const [mapServers, setMapServers] = useState({})
|
||||
const [busy, setBusy] = useState(false)
|
||||
@@ -114,6 +115,7 @@ export default function Visibility() {
|
||||
setClanRoster((state.clans && state.clans.roster) || 'members')
|
||||
setServers(Object.fromEntries(state.presence.servers.map((s) => [s.id, s.override || INHERIT])))
|
||||
setNews(Object.fromEntries(((state.news && state.news.servers) || []).map((s) => [s.id, Boolean(s.on)])))
|
||||
setDelivery(Object.fromEntries(((state.news && state.news.servers) || []).map((s) => [s.id, s.delivery || 'chat'])))
|
||||
if (state.map) {
|
||||
setMapFleet({ ...state.map.fleet })
|
||||
setMapServers(Object.fromEntries(state.map.servers.map((s) => [s.id, mapOverridesToForm(s.overrides)])))
|
||||
@@ -136,10 +138,11 @@ export default function Visibility() {
|
||||
const dirtyClans = clanRoster !== clans.roster
|
||||
const newsRows = (data.news && data.news.servers) || []
|
||||
const dirtyNews = newsRows.filter((s) => Boolean(news[s.id]) !== Boolean(s.on))
|
||||
const dirtyDelivery = newsRows.filter((s) => (delivery[s.id] || 'chat') !== (s.delivery || 'chat'))
|
||||
const mapCard = data.map || null
|
||||
const mapChanges = mapCard ? mapDiff(mapCard, mapFleet, mapServers) : null
|
||||
const dirtyMap = Boolean(mapChanges)
|
||||
const dirty = dirtyFleet || dirtyServers.length > 0 || dirtyClans || dirtyNews.length > 0 || dirtyMap
|
||||
const dirty = dirtyFleet || dirtyServers.length > 0 || dirtyClans || dirtyNews.length > 0 || dirtyDelivery.length > 0 || dirtyMap
|
||||
|
||||
const effective = (id) => servers[id] || fleet
|
||||
const widened = fleet !== 'staff' || rows.some((s) => effective(s.id) !== 'staff')
|
||||
@@ -157,6 +160,7 @@ export default function Visibility() {
|
||||
body.servers = Object.fromEntries(dirtyServers.map((s) => [s.id, servers[s.id] || null]))
|
||||
}
|
||||
if (dirtyNews.length) body.news = Object.fromEntries(dirtyNews.map((s) => [s.id, Boolean(news[s.id])]))
|
||||
if (dirtyDelivery.length) body.newsDelivery = Object.fromEntries(dirtyDelivery.map((s) => [s.id, delivery[s.id] || 'chat']))
|
||||
if (dirtyMap) body.map = mapChanges
|
||||
load(await api.adminVisibility.save(body))
|
||||
setSaved(true)
|
||||
@@ -255,7 +259,8 @@ export default function Visibility() {
|
||||
<Card title="News in game chat" subtitle="a published news post, said in each server’s chat">
|
||||
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 8px' }}>
|
||||
When a news post is published, its title is said in the chat of every server switched on
|
||||
here. A server that is down when a post is published is skipped rather than told late.
|
||||
here. A server that is down when a post is published is skipped rather than told late. A popup
|
||||
needs PopupNotifications on that server; one without it refuses the post and says why.
|
||||
</p>
|
||||
{newsRows.length === 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.82rem', margin: 0 }}>No servers are configured yet.</p>
|
||||
@@ -285,6 +290,17 @@ export default function Visibility() {
|
||||
{!s.enabled && <span className="dim" style={{ fontSize: '0.74rem' }}> · disabled</span>}
|
||||
</span>
|
||||
<span className="dim" style={{ fontSize: '0.78rem' }}>{news[s.id] ? 'says news' : 'off'}</span>
|
||||
{/* D142: where the post goes on this server when it is switched on. */}
|
||||
<select
|
||||
value={delivery[s.id] || 'chat'}
|
||||
onChange={(e) => setDelivery((prev) => ({ ...prev, [s.id]: e.target.value }))}
|
||||
disabled={!news[s.id]}
|
||||
aria-label={`Where news goes on ${s.name}`}
|
||||
style={{ marginLeft: 'auto', background: 'var(--panel-flat, transparent)', color: 'var(--text)', border: '1px solid var(--line)', borderRadius: 'var(--radius-input, 6px)', padding: '3px 6px', fontSize: '0.8rem' }}
|
||||
>
|
||||
<option value="chat">in chat</option>
|
||||
<option value="popup">as a popup</option>
|
||||
</select>
|
||||
</label>
|
||||
))}
|
||||
</Card>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { ago, clock, count, day, duration, nextWipe, prefab, shortId } from '../src/lib/format.js'
|
||||
import { ago, clock, contrastInk, count, day, duration, nextWipe, prefab, shortId } from '../src/lib/format.js'
|
||||
|
||||
const NOW = Date.parse('2026-09-16T12:00:00Z')
|
||||
|
||||
@@ -107,3 +107,11 @@ test('the next wipe: the reader’s own clock, how far away, and whether it move
|
||||
assert.equal(nextWipe(null, now), null)
|
||||
assert.equal(nextWipe({ at: 'soon', source: 'rule' }, now), null)
|
||||
})
|
||||
|
||||
test('a title chip’s ink is whichever of black and white reads on its colour', () => {
|
||||
assert.equal(contrastInk('#ffff00'), '#000000')
|
||||
assert.equal(contrastInk('#FFAA55'), '#000000')
|
||||
assert.equal(contrastInk('#1a1a8c'), '#ffffff')
|
||||
assert.equal(contrastInk('#ff0000'), '#000000')
|
||||
assert.equal(contrastInk('red'), '#000000', 'not a hex colour: black, on the caller’s fallback')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user