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:
2026-09-25 17:48:22 -05:00
parent fb5a581a94
commit 1b70cef5be
43 changed files with 3371 additions and 72 deletions

View File

@@ -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}
/>