Prototype administrative discussion mute controls
Let owners/operators mute or unmute new public/private sends from a human seat while retaining history, incoming messages, gameplay and credentials. Fence metadata and durable audit writes with live authority, membership and versions. Preserve exact admin/message retries and inherit restrictions through seat recovery. Validation: types/lint, 5431 offline tests, build, 52 HTTP integration tests, 24 administrator HTTP groups, independent SQL/privacy/UI review and desktop/mobile refresh acceptance. All 2230 prior games and 4746 seats preserved. Additive migration 0016. Verified runtime fingerprint 588967fbb9c2e3b6abe2990c4fce96ee69e2256fb7e3ee416314b87593cebba9; documentation-only grammar/button-label review gives b5e57bdd26812debede91c588e38f247f1fa895ad23e7ae30bc8550eedcd90ec. New-revision deployed acceptance remains open; full admin/rules scope is unfinished.
This commit is contained in:
1 parent
2cddf03932
commit
160fdc1f30
26 files changed
+3008
-33
No files matched your search
@@ -4,7 +4,7 @@ import { useEffect, useId, useRef, useState } from 'react';
|
||||
import type { GameView } from '@/game/engine';
|
||||
import { faction } from '@/game/catalog';
|
||||
import { Button } from './ui/button';
|
||||
import { requestJson, requestMayHaveCompleted } from '@/lib/client-request';
|
||||
import { ClientRequestError, requestJson, requestMayHaveCompleted } from '@/lib/client-request';
|
||||
import {
|
||||
TALK_LIMIT,
|
||||
clearTalkDraft,
|
||||
@@ -24,7 +24,8 @@ export function TableTalk({ game }: { game: GameView }) {
|
||||
const [text, setText] = useState('');
|
||||
const [record, setRecord] = useState<TalkDraft | null>(null);
|
||||
const [pageChannel, setPageChannel] = useState<string | null>(null);
|
||||
const [page, setPage] = useState<TalkPage>({ messages: [], before: null });
|
||||
const [page, setPage] = useState<TalkPage>({ messages: [], before: null, muted: false });
|
||||
const [muted, setMuted] = useState(false);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -104,6 +105,7 @@ export function TableTalk({ game }: { game: GameView }) {
|
||||
!readingHistory.current
|
||||
) {
|
||||
setPage(result);
|
||||
setMuted(result.muted === true);
|
||||
setPageChannel(url);
|
||||
setReadError('');
|
||||
}
|
||||
@@ -120,7 +122,7 @@ export function TableTalk({ game }: { game: GameView }) {
|
||||
readingHistory.current = false;
|
||||
// oxlint-disable-next-line react/react-compiler -- Reset saved-history browsing when the external conversation subscription changes.
|
||||
setHistory(false);
|
||||
setPage({ messages: [], before: null });
|
||||
setPage({ messages: [], before: null, muted: false });
|
||||
void read();
|
||||
return () => {
|
||||
controller.abort();
|
||||
@@ -138,11 +140,14 @@ export function TableTalk({ game }: { game: GameView }) {
|
||||
const generation = channelGeneration.current;
|
||||
try {
|
||||
const result = await requestJson<TalkPage>(pageUrl(shownPage.before));
|
||||
if (generation === channelGeneration.current)
|
||||
if (generation === channelGeneration.current) {
|
||||
setMuted(result.muted === true);
|
||||
setPage((current) => ({
|
||||
before: result.before,
|
||||
messages: mergeTalkPages(result.messages, current.messages),
|
||||
muted: result.muted,
|
||||
}));
|
||||
}
|
||||
} catch {
|
||||
if (generation === channelGeneration.current)
|
||||
setReadError('Older messages could not be loaded. Try again.');
|
||||
@@ -152,7 +157,7 @@ export function TableTalk({ game }: { game: GameView }) {
|
||||
}
|
||||
}
|
||||
async function submit() {
|
||||
if (sending.current || !ready || (closed && !record)) return;
|
||||
if (sending.current || !ready || ((closed || muted) && !record)) return;
|
||||
const draft = record ?? {
|
||||
id: randomId(),
|
||||
recipientId: recipient || null,
|
||||
@@ -188,6 +193,7 @@ export function TableTalk({ game }: { game: GameView }) {
|
||||
setRefresh((value) => value + 1);
|
||||
} catch (error) {
|
||||
if (!mounted.current) return;
|
||||
if (error instanceof ClientRequestError && error.code === 'SEAT_DISCUSSION_MUTED') setMuted(true);
|
||||
if (dispatched && (wasRetry || requestMayHaveCompleted(error))) {
|
||||
setNotice(
|
||||
'Message not confirmed. Retry the exact message, or check the conversation before discarding its retry details.',
|
||||
@@ -316,6 +322,7 @@ export function TableTalk({ game }: { game: GameView }) {
|
||||
<p className="muted">No messages loaded in this conversation.</p>
|
||||
)}
|
||||
{closed && <p className="notice">This room is closed. Discussion history remains available; an exact saved message can still be confirmed.</p>}
|
||||
{muted && <p className="notice">An administrator muted discussion for this seat. You can still read messages and play. An exact saved message can still be confirmed; new messages are available after unmuting. <Button variant="outline" onClick={() => setRefresh(value => value + 1)}>Refresh discussion availability</Button></p>}
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
@@ -332,7 +339,7 @@ export function TableTalk({ game }: { game: GameView }) {
|
||||
rows={3}
|
||||
maxLength={TALK_LIMIT}
|
||||
value={text}
|
||||
disabled={busy || !!record || closed}
|
||||
disabled={busy || !!record || closed || muted}
|
||||
onChange={(event) => setText(event.target.value)}
|
||||
aria-describedby={`${field}-count`}
|
||||
/>
|
||||
@@ -343,7 +350,7 @@ export function TableTalk({ game }: { game: GameView }) {
|
||||
type="submit"
|
||||
disabled={
|
||||
!ready ||
|
||||
(closed && !record) ||
|
||||
((closed || muted) && !record) ||
|
||||
busy ||
|
||||
!text.trim() ||
|
||||
(!record && !!recipient && !target)
|
||||
|
||||
Reference in new issue
Block a user