mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
admin reason!!
This commit is contained in:
@@ -15,4 +15,5 @@ server/package-lock.json
|
||||
package-lock.json
|
||||
server/data/discord-guilds.json
|
||||
server/data/community-goal.json
|
||||
server/data/admin-reason.json
|
||||
server/data
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -11,8 +11,8 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
||||
<title>Multi Roomba Rover</title>
|
||||
<script type="module" crossorigin src="/assets/index-VteUBvT4.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D0hMW4Jw.css">
|
||||
<script type="module" crossorigin src="/assets/index-BeAEzK0v.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-G3PbGrn6.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const io = require('../globals/io');
|
||||
const logger = require('../globals/logger').child('adminReasonService');
|
||||
const { isAdmin } = require('./roleService');
|
||||
const { publishEvent } = require('./eventBus');
|
||||
|
||||
const DATA_DIR = path.join(__dirname, '..', '..', 'data');
|
||||
const STORE_PATH = path.join(DATA_DIR, 'admin-reason.json');
|
||||
const MAX_REASON_LENGTH = 240;
|
||||
|
||||
let cache = null;
|
||||
|
||||
function loadStore() {
|
||||
if (cache) return cache;
|
||||
try {
|
||||
const raw = fs.readFileSync(STORE_PATH, 'utf8');
|
||||
cache = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
if (err.code !== 'ENOENT') {
|
||||
logger.warn('Failed to load admin reason', err.message);
|
||||
}
|
||||
cache = null;
|
||||
}
|
||||
return cache;
|
||||
}
|
||||
|
||||
function saveStore(next) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
fs.writeFileSync(STORE_PATH, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
|
||||
cache = next;
|
||||
}
|
||||
|
||||
function normalizeText(input) {
|
||||
if (typeof input !== 'string') return '';
|
||||
return input.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function getAdminReason() {
|
||||
return loadStore();
|
||||
}
|
||||
|
||||
function setAdminReason(text, meta = {}) {
|
||||
const clean = normalizeText(text);
|
||||
if (!clean) {
|
||||
throw new Error('Reason text required');
|
||||
}
|
||||
if (clean.length > MAX_REASON_LENGTH) {
|
||||
throw new Error(`Reason too long (max ${MAX_REASON_LENGTH} chars)`);
|
||||
}
|
||||
const payload = {
|
||||
text: clean,
|
||||
updatedAt: Date.now(),
|
||||
updatedBy: meta.by || null,
|
||||
};
|
||||
saveStore(payload);
|
||||
publishEvent({ source: 'adminReason', type: 'adminReason.updated', payload });
|
||||
return payload;
|
||||
}
|
||||
|
||||
function clearAdminReason(meta = {}) {
|
||||
const payload = {
|
||||
text: null,
|
||||
updatedAt: Date.now(),
|
||||
updatedBy: meta.by || null,
|
||||
};
|
||||
saveStore(payload);
|
||||
publishEvent({ source: 'adminReason', type: 'adminReason.updated', payload });
|
||||
return payload;
|
||||
}
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
socket.on('adminReason:set', ({ text } = {}, cb = () => {}) => {
|
||||
if (!isAdmin(socket)) {
|
||||
cb({ error: 'Not authorized' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result =
|
||||
text == null || String(text).trim() === ''
|
||||
? clearAdminReason({ by: socket?.data?.user?.username || socket?.id })
|
||||
: setAdminReason(text, { by: socket?.data?.user?.username || socket?.id });
|
||||
cb({ success: true, reason: result });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
getAdminReason,
|
||||
setAdminReason,
|
||||
clearAdminReason,
|
||||
MAX_REASON_LENGTH,
|
||||
};
|
||||
@@ -4,10 +4,12 @@ const io = require('../globals/io');
|
||||
const logger = require('../globals/logger').child('chatService');
|
||||
const { publishEvent, subscribe } = require('./eventBus');
|
||||
const { getRole } = require('./roleService');
|
||||
const { getMode, MODES } = require('./modeManager');
|
||||
const { describeAssignment } = require('./assignmentService');
|
||||
const roverManager = require('./roverManager');
|
||||
const { getNickname } = require('./nicknameService');
|
||||
const { issueCommand } = require('./commandService');
|
||||
const { getAdminReason } = require('./adminReasonService');
|
||||
|
||||
const RATE_LIMIT_WINDOW_MS = 8000;
|
||||
const RATE_LIMIT_MAX = 5;
|
||||
@@ -37,6 +39,9 @@ const typingBySocket = new Map(); // socketId -> boolean
|
||||
const TYPING_START_NOTE = 72;
|
||||
const TYPING_SEND_NOTE = 79;
|
||||
const TYPING_NOTE_DURATION = 8;
|
||||
const ACCESS_NOTICE_COOLDOWN_MS = 60000;
|
||||
const ACCESS_KEYWORD_RE = /\b(drive|roomba)\b/i;
|
||||
let lastAccessNoticeAt = 0;
|
||||
|
||||
function withinRateLimit(socketId) {
|
||||
const now = Date.now();
|
||||
@@ -102,6 +107,7 @@ function buildMessage(socket, text, meta = {}) {
|
||||
discordUserAvatarUrl: meta.discordUserAvatarUrl || null,
|
||||
text,
|
||||
tts: meta.tts || null,
|
||||
system: Boolean(meta.system),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -195,6 +201,46 @@ function normalizeTtsOptions(raw = {}) {
|
||||
return { speak, engine, voice, pitch };
|
||||
}
|
||||
|
||||
function buildAccessNoticeText(mode, reasonText) {
|
||||
const label = mode === MODES.LOCKDOWN ? 'lockdown' : 'admin';
|
||||
const reason = reasonText ? ` Reason: ${reasonText}` : '';
|
||||
return `Heads up: the server is in ${label} mode.${reason}`;
|
||||
}
|
||||
|
||||
function shouldSendAccessNotice(message) {
|
||||
if (!message?.text || message.system) return false;
|
||||
const mode = getMode();
|
||||
if (mode !== MODES.ADMIN && mode !== MODES.LOCKDOWN) return false;
|
||||
if (!ACCESS_KEYWORD_RE.test(message.text)) return false;
|
||||
const now = Date.now();
|
||||
if (now - lastAccessNoticeAt < ACCESS_NOTICE_COOLDOWN_MS) return false;
|
||||
lastAccessNoticeAt = now;
|
||||
return true;
|
||||
}
|
||||
|
||||
function sendSystemMessage(text) {
|
||||
const normalized = normalizeUserText(text);
|
||||
const clean = normalized.trim();
|
||||
if (!clean) return null;
|
||||
const safe = clean.length > 256 ? `${clean.slice(0, 253)}...` : clean;
|
||||
const message = buildMessage(null, safe, {
|
||||
nickname: 'Rover Bot',
|
||||
role: 'user',
|
||||
fromDiscord: false,
|
||||
system: true,
|
||||
});
|
||||
broadcastMessage(message);
|
||||
return message;
|
||||
}
|
||||
|
||||
function maybeSendAccessNotice(message) {
|
||||
if (!shouldSendAccessNotice(message)) return;
|
||||
const reason = getAdminReason()?.text || '';
|
||||
const mode = getMode();
|
||||
const notice = buildAccessNoticeText(mode, reason);
|
||||
sendSystemMessage(notice);
|
||||
}
|
||||
|
||||
function handleIncoming({ text, tts } = {}, socket, cb = () => {}) {
|
||||
const role = getRole(socket);
|
||||
// if (role === 'spectator') {
|
||||
@@ -233,6 +279,7 @@ function handleIncoming({ text, tts } = {}, socket, cb = () => {}) {
|
||||
logger.info('Chat message', { socket: socket.id, roverId: message.roverId });
|
||||
playTypingNote(roverId, TYPING_SEND_NOTE, socket?.id);
|
||||
broadcastMessage(message);
|
||||
maybeSendAccessNotice(message);
|
||||
maybeSpeak(socket, message, ttsOptions);
|
||||
cb({ success: true });
|
||||
}
|
||||
@@ -306,6 +353,7 @@ function sendExternalMessage({
|
||||
});
|
||||
logger.info('External chat message', { roverId, nickname });
|
||||
broadcastMessage(message);
|
||||
maybeSendAccessNotice(message);
|
||||
return message;
|
||||
}
|
||||
|
||||
@@ -383,4 +431,5 @@ module.exports = {
|
||||
sendExternalMessage,
|
||||
sendExternalTyping,
|
||||
buildTypingPayload,
|
||||
sendSystemMessage,
|
||||
};
|
||||
|
||||
@@ -21,6 +21,7 @@ const { getActiveDrivers } = require('./turnService');
|
||||
const { getNickname } = require('./nicknameService');
|
||||
const { tryTriggerReplay } = require('./replayService');
|
||||
const { getCommunityGoal, setCommunityGoal, clearCommunityGoal } = require('./communityGoalService');
|
||||
const { getAdminReason, setAdminReason, clearAdminReason } = require('./adminReasonService');
|
||||
const {
|
||||
getGuildConfig,
|
||||
listGuildConfigs,
|
||||
@@ -267,6 +268,7 @@ function formatHelp() {
|
||||
'`rs lock <id>` — lock a rover',
|
||||
'`rs unlock <id>` — unlock a rover',
|
||||
'`rs mode <open|turns|admin|lockdown>` — change server mode',
|
||||
'`rs reason [text|clear]` — show or set admin mode reason',
|
||||
'`rs goal [text|clear]` — show or set community goal',
|
||||
'`ts` — show time status',
|
||||
].join('\n');
|
||||
@@ -447,8 +449,9 @@ async function handleLockCommand(message, roverId, locked) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleModeCommand(message, mode) {
|
||||
const next = String(mode || '').toLowerCase();
|
||||
async function handleModeCommand(message, tokens = []) {
|
||||
const next = String(tokens.shift() || '').toLowerCase();
|
||||
const reasonText = tokens.join(' ').trim();
|
||||
if (!Object.values(MODES).includes(next)) {
|
||||
await message.reply({
|
||||
content: 'Invalid mode. Use one of: open, turns, admin, lockdown.',
|
||||
@@ -459,6 +462,9 @@ async function handleModeCommand(message, mode) {
|
||||
try {
|
||||
const role = isLockdownAdminUser(message.author?.id) ? 'lockdown' : 'admin';
|
||||
setMode(next, { data: { role, user: { username: `discord:${message.author?.username || 'unknown'}` } } });
|
||||
if (reasonText) {
|
||||
setAdminReason(reasonText, { by: message.author?.id || null });
|
||||
}
|
||||
await message.reply({
|
||||
content: sanitizeMentions(`Mode set to ${next}.`),
|
||||
allowedMentions: { parse: [], repliedUser: false },
|
||||
@@ -471,6 +477,57 @@ async function handleModeCommand(message, mode) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReasonCommand(message, tokens) {
|
||||
const query = tokens.join(' ').trim();
|
||||
const lower = query.toLowerCase();
|
||||
if (!query) {
|
||||
const reason = getAdminReason();
|
||||
const text = reason?.text ? reason.text : null;
|
||||
await message.reply({
|
||||
content: text ? `Admin mode reason: ${sanitizeMentions(text)}` : 'No admin mode reason set.',
|
||||
allowedMentions: { parse: [], repliedUser: false },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isAdminUser(message.author.id)) {
|
||||
await message.reply({
|
||||
content: 'Only admins can update the admin mode reason.',
|
||||
allowedMentions: { parse: [], repliedUser: false },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (lower === 'clear') {
|
||||
try {
|
||||
clearAdminReason({ by: message.author?.id || null });
|
||||
await message.reply({
|
||||
content: 'Admin mode reason cleared.',
|
||||
allowedMentions: { parse: [], repliedUser: false },
|
||||
});
|
||||
} catch (err) {
|
||||
await message.reply({
|
||||
content: sanitizeMentions(`Failed to clear reason: ${err.message}`),
|
||||
allowedMentions: { parse: [], repliedUser: false },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setAdminReason(query, { by: message.author?.id || null });
|
||||
await message.reply({
|
||||
content: sanitizeMentions(`Admin mode reason set: ${query}`),
|
||||
allowedMentions: { parse: [], repliedUser: false },
|
||||
});
|
||||
} catch (err) {
|
||||
await message.reply({
|
||||
content: sanitizeMentions(`Failed to set reason: ${err.message}`),
|
||||
allowedMentions: { parse: [], repliedUser: false },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGoalCommand(message, tokens) {
|
||||
const query = tokens.join(' ').trim();
|
||||
const lower = query.toLowerCase();
|
||||
@@ -699,7 +756,7 @@ async function handleCommand(message) {
|
||||
const isLockdownAdmin = isLockdownAdminUser(message.author.id);
|
||||
const isBridgeAdmin = action === 'bridge' ? canManageBridge(message) : false;
|
||||
const mode = getMode();
|
||||
const moderationActions = new Set(['lock', 'unlock', 'mode', 'goal']);
|
||||
const moderationActions = new Set(['lock', 'unlock', 'mode', 'goal', 'reason']);
|
||||
|
||||
if (
|
||||
!isAdmin &&
|
||||
@@ -709,7 +766,8 @@ async function handleCommand(message) {
|
||||
action !== 'help' &&
|
||||
action !== 'replay' &&
|
||||
action !== 'bridge' &&
|
||||
action !== 'goal'
|
||||
action !== 'goal' &&
|
||||
action !== 'reason'
|
||||
) {
|
||||
return; // ignore non-admins for privileged commands
|
||||
}
|
||||
@@ -745,11 +803,14 @@ async function handleCommand(message) {
|
||||
await handleLockCommand(message, tokens[0], false);
|
||||
break;
|
||||
case 'mode':
|
||||
await handleModeCommand(message, tokens[0]);
|
||||
await handleModeCommand(message, tokens);
|
||||
break;
|
||||
case 'goal':
|
||||
await handleGoalCommand(message, tokens);
|
||||
break;
|
||||
case 'reason':
|
||||
await handleReasonCommand(message, tokens);
|
||||
break;
|
||||
default:
|
||||
await message.reply(formatHelp());
|
||||
break;
|
||||
|
||||
@@ -14,10 +14,13 @@ const { getReplaySources } = require('./replaySourceService');
|
||||
const { getHealthSnapshot } = require('./healthService');
|
||||
const { loadConfig } = require('../helpers/configLoader');
|
||||
const { getCommunityGoal } = require('./communityGoalService');
|
||||
const { getAdminReason } = require('./adminReasonService');
|
||||
const { subscribe } = require('./eventBus');
|
||||
|
||||
const discordInvite = loadConfig().discord?.invite || null;
|
||||
const kofiLink = loadConfig().kofi?.link || null;
|
||||
const config = loadConfig();
|
||||
const discordInvite = config.discord?.invite || null;
|
||||
const kofiLink = config.kofi?.link || null;
|
||||
const serverTimezone = config.timezone || null;
|
||||
logger.info('Discord invite loaded:', discordInvite ? 'present' : 'not configured');
|
||||
logger.info('Ko-fi link loaded:', kofiLink ? 'present' : 'not configured');
|
||||
|
||||
@@ -59,10 +62,12 @@ function buildSession(socket) {
|
||||
replaySources: getReplaySources(),
|
||||
health: getHealthSnapshot(),
|
||||
communityGoal: getCommunityGoal(),
|
||||
adminReason: getAdminReason(),
|
||||
users,
|
||||
discord: {
|
||||
invite: discordInvite,
|
||||
},
|
||||
timezone: serverTimezone,
|
||||
kofi: {
|
||||
link: kofiLink,
|
||||
},
|
||||
@@ -217,6 +222,11 @@ subscribe('communityGoal.updated', () => {
|
||||
syncAll();
|
||||
});
|
||||
|
||||
subscribe('adminReason.updated', () => {
|
||||
logger.info('Admin reason updated; syncing all clients');
|
||||
syncAll();
|
||||
});
|
||||
|
||||
// sync all sockets 20 seconds
|
||||
setInterval(() => {
|
||||
logger.info('Periodic session sync for all clients');
|
||||
|
||||
@@ -10,13 +10,16 @@ const MODES = [
|
||||
];
|
||||
|
||||
export default function AdminPanel() {
|
||||
const { session, lockRover, setMode, requestControl, setCommunityGoal, adminLogs } = useSession();
|
||||
const { session, lockRover, setMode, requestControl, setCommunityGoal, setAdminReason, adminLogs } = useSession();
|
||||
const roster = useMemo(() => session?.roster ?? [], [session?.roster]);
|
||||
const [lockStates, setLockStates] = useState({});
|
||||
const health = session?.health || null;
|
||||
const currentGoal = session?.communityGoal?.text || '';
|
||||
const goalUpdatedAt = session?.communityGoal?.updatedAt || null;
|
||||
const [goalDraft, setGoalDraft] = useState(currentGoal);
|
||||
const currentReason = session?.adminReason?.text || '';
|
||||
const reasonUpdatedAt = session?.adminReason?.updatedAt || null;
|
||||
const [reasonDraft, setReasonDraft] = useState(currentReason);
|
||||
|
||||
const isAdmin =
|
||||
session?.role === 'admin' ||
|
||||
@@ -67,10 +70,30 @@ export default function AdminPanel() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleReasonSave = async () => {
|
||||
try {
|
||||
await setAdminReason(reasonDraft);
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReasonClear = async () => {
|
||||
try {
|
||||
await setAdminReason(null);
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setGoalDraft(currentGoal);
|
||||
}, [currentGoal]);
|
||||
|
||||
useEffect(() => {
|
||||
setReasonDraft(currentReason);
|
||||
}, [currentReason]);
|
||||
|
||||
const lockMap = useMemo(() => {
|
||||
const map = {};
|
||||
roster.forEach((rover) => {
|
||||
@@ -116,6 +139,28 @@ export default function AdminPanel() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||
<span>Admin mode reason</span>
|
||||
{reasonUpdatedAt ? (
|
||||
<span>Updated {new Date(reasonUpdatedAt).toLocaleString()}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<textarea
|
||||
value={reasonDraft}
|
||||
onChange={(event) => setReasonDraft(event.target.value)}
|
||||
placeholder="Set an admin mode reason"
|
||||
className="field-input text-sm min-h-[3.5rem]"
|
||||
/>
|
||||
<div className="flex gap-0.5 text-xs">
|
||||
<button type="button" onClick={handleReasonSave} className="button-dark">
|
||||
Set reason
|
||||
</button>
|
||||
<button type="button" onClick={handleReasonClear} className="button-danger">
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RoverRoster
|
||||
roster={roster}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import AuthPanel from './AuthPanel.jsx';
|
||||
import { useSession } from '../context/SessionContext.jsx';
|
||||
import DiscordInviteButton from './DiscordInviteButton.jsx';
|
||||
@@ -18,8 +19,8 @@ function getModeDetails(mode = 'admin') {
|
||||
}
|
||||
return {
|
||||
title: 'Admin mode active',
|
||||
description:
|
||||
'The server is currently in admin mode. Only admins can access the interface.',
|
||||
// description:
|
||||
// 'The server is currently in admin mode. Only admins can access the interface.',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -27,8 +28,30 @@ export default function ModeGateOverlay() {
|
||||
const { session } = useSession();
|
||||
const mode = session?.mode;
|
||||
const role = session?.role;
|
||||
const reason = session?.adminReason?.text || '';
|
||||
const reasonUpdatedAt = session?.adminReason?.updatedAt || null;
|
||||
const timezone = session?.timezone || 'UTC';
|
||||
const restricted = RESTRICTED_MODES.has(mode);
|
||||
const privileged = mode === 'lockdown' ? LOCKDOWN_ROLES.has(role) : PRIVILEGED_ROLES.has(role);
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
|
||||
const serverTime = useMemo(() => {
|
||||
try {
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: timezone,
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
}).format(now);
|
||||
} catch (err) {
|
||||
return now.toLocaleTimeString();
|
||||
}
|
||||
}, [now, timezone]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setNow(new Date()), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
if (!restricted || privileged) {
|
||||
return null;
|
||||
@@ -43,13 +66,25 @@ export default function ModeGateOverlay() {
|
||||
<p className="text-lg font-semibold">{details.title}</p>
|
||||
<p className="text-sm text-slate-300">{details.description}</p>
|
||||
</div>
|
||||
<div className="surface-muted space-y-0.5">
|
||||
<p className="text-[0.7rem] tracking-wide text-slate-400">Reason for locking:</p>
|
||||
<p className="text-lg font-semibold text-slate-100">
|
||||
{reason ? reason : 'No reason set.'}
|
||||
</p>
|
||||
<p className="text-center text-sm text-slate-300">Server time: {serverTime}</p>
|
||||
{reasonUpdatedAt ? (
|
||||
<p className="text-[0.7rem] text-slate-500">
|
||||
Updated {new Date(reasonUpdatedAt).toLocaleString()}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="surface-muted">
|
||||
<AuthPanel />
|
||||
</div>
|
||||
<div className='w-full justify-center items-center'>
|
||||
<DiscordInviteButton text='Join our Discord server for updates!'/>
|
||||
</div>
|
||||
You can use the chat from here though :3
|
||||
You can still use the chat while the server is locked:
|
||||
{/* set max height of this box */}
|
||||
<div className='max-h-80 overflow-y-auto'>
|
||||
<ChatPanel />
|
||||
|
||||
@@ -19,6 +19,7 @@ const SessionContext = createContext({
|
||||
setNickname: async () => {},
|
||||
triggerReplay: async () => {},
|
||||
setCommunityGoal: async () => {},
|
||||
setAdminReason: async () => {},
|
||||
});
|
||||
|
||||
function useAckEmitter(socket) {
|
||||
@@ -115,6 +116,7 @@ export function SessionProvider({ children }) {
|
||||
setNickname: (nickname) => emitWithAck('nickname:set', { nickname }),
|
||||
triggerReplay: (sources = []) => emitWithAck('replay:trigger', { sources }),
|
||||
setCommunityGoal: (text) => emitWithAck('communityGoal:set', { text }),
|
||||
setAdminReason: (text) => emitWithAck('adminReason:set', { text }),
|
||||
pushAlert: (alert) =>
|
||||
setAlerts((prev) => [
|
||||
...prev.slice(-49),
|
||||
|
||||
Reference in New Issue
Block a user