Files
Module-Rust/client/src/routes/admin/ChatStyle.jsx
wtclaude 1b70cef5be 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
2026-09-25 17:48:22 -05:00

164 lines
6.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── 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>
)}
</>
)
}