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:
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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user