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
164 lines
6.6 KiB
JavaScript
164 lines
6.6 KiB
JavaScript
// ── 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>
|
||
)}
|
||
</>
|
||
)
|
||
}
|