mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
fix spectator page, add discord ping filtering
This commit is contained in:
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-Bu0ya-aM.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-B8zLJlAS.css">
|
||||
<script type="module" crossorigin src="/assets/index-B4HXdpZh.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-B5WmdT7o.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -37,6 +37,16 @@ const client = new Client({
|
||||
|
||||
const channelCache = new Map();
|
||||
|
||||
function sanitizeMentions(text) {
|
||||
if (!text) return '';
|
||||
return String(text)
|
||||
// strip mention tokens
|
||||
.replace(/<(@[!&]?\d+|#\d+)>/g, '[ping removed]')
|
||||
// neutralize everyone/here
|
||||
.replace(/@everyone/gi, '[everyone]')
|
||||
.replace(/@here/gi, '[here]');
|
||||
}
|
||||
|
||||
function formatRoverStatus(rover) {
|
||||
if (!rover) return 'Unknown rover';
|
||||
const percent = rover.batteryState?.percentDisplay;
|
||||
@@ -80,11 +90,11 @@ async function fetchChannel(id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function sendToChannel(id, content, options = {}) {
|
||||
async function sendToChannel(id, content, options = {}, allowedMentions = { parse: [] }) {
|
||||
const channel = await fetchChannel(id);
|
||||
if (!channel) return;
|
||||
try {
|
||||
await channel.send({ content, ...options });
|
||||
await channel.send({ content: sanitizeMentions(content), allowedMentions, ...options });
|
||||
} catch (err) {
|
||||
logger.warn('Failed to send Discord message', { id, error: err.message });
|
||||
}
|
||||
@@ -115,37 +125,61 @@ async function handleStatusCommand(message, roverId) {
|
||||
const roster = getRoster();
|
||||
if (!roverId) {
|
||||
const summary = roster.map((r) => formatRoverStatus(r)).join('\n') || 'No rovers online.';
|
||||
await message.reply(summary.slice(0, 1900));
|
||||
await message.reply({
|
||||
content: sanitizeMentions(summary.slice(0, 1900)),
|
||||
allowedMentions: { parse: [], repliedUser: false },
|
||||
});
|
||||
return;
|
||||
}
|
||||
const rover = findRover(roverId);
|
||||
await message.reply(formatRoverStatus(rover).slice(0, 1900));
|
||||
await message.reply({
|
||||
content: sanitizeMentions(formatRoverStatus(rover).slice(0, 1900)),
|
||||
allowedMentions: { parse: [], repliedUser: false },
|
||||
});
|
||||
}
|
||||
|
||||
async function handleLockCommand(message, roverId, locked) {
|
||||
if (!roverId) {
|
||||
await message.reply('Specify a rover ID. Example: `rover lock alpha`');
|
||||
await message.reply({
|
||||
content: 'Specify a rover ID. Example: `rs lock alpha`',
|
||||
allowedMentions: { parse: [], repliedUser: false },
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
lockRover(roverId, locked, { reason: 'discord' });
|
||||
await message.reply(`${locked ? 'Locked' : 'Unlocked'} ${roverId}.`);
|
||||
await message.reply({
|
||||
content: sanitizeMentions(`${locked ? 'Locked' : 'Unlocked'} ${roverId}.`),
|
||||
allowedMentions: { parse: [], repliedUser: false },
|
||||
});
|
||||
} catch (err) {
|
||||
await message.reply(`Failed: ${err.message}`);
|
||||
await message.reply({
|
||||
content: sanitizeMentions(`Failed: ${err.message}`),
|
||||
allowedMentions: { parse: [], repliedUser: false },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleModeCommand(message, mode) {
|
||||
const next = String(mode || '').toLowerCase();
|
||||
if (!Object.values(MODES).includes(next)) {
|
||||
await message.reply('Invalid mode. Use one of: open, turns, admin, lockdown.');
|
||||
await message.reply({
|
||||
content: 'Invalid mode. Use one of: open, turns, admin, lockdown.',
|
||||
allowedMentions: { parse: [], repliedUser: false },
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setMode(next, null, { force: true });
|
||||
await message.reply(`Mode set to ${next}.`);
|
||||
await message.reply({
|
||||
content: sanitizeMentions(`Mode set to ${next}.`),
|
||||
allowedMentions: { parse: [], repliedUser: false },
|
||||
});
|
||||
} catch (err) {
|
||||
await message.reply(`Failed to set mode: ${err.message}`);
|
||||
await message.reply({
|
||||
content: sanitizeMentions(`Failed to set mode: ${err.message}`),
|
||||
allowedMentions: { parse: [], repliedUser: false },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,7 +258,8 @@ async function announce({ channelId, content, pingRoleId, color, title, descript
|
||||
if (!channelId) return;
|
||||
const prefix = pingRoleId ? `<@&${pingRoleId}> ` : '';
|
||||
const embed = buildEmbed({ title, description, color });
|
||||
await sendToChannel(channelId, `${prefix}${content || ''}`.trim(), { embeds: [embed] });
|
||||
const allowedMentions = pingRoleId ? { roles: [pingRoleId], parse: [] } : { parse: [] };
|
||||
await sendToChannel(channelId, `${prefix}${content || ''}`.trim(), { embeds: [embed] }, allowedMentions);
|
||||
}
|
||||
|
||||
function handleBusEvent(event) {
|
||||
@@ -360,7 +395,7 @@ function handleChatBridgeOutbound(event) {
|
||||
if (!bridgeChannelId) return;
|
||||
const line = formatChatLine(payload);
|
||||
const text = line.length > 1900 ? `${line.slice(0, 1897)}...` : line;
|
||||
sendToChannel(bridgeChannelId, text);
|
||||
sendToChannel(bridgeChannelId, text, {}, { parse: [] });
|
||||
}
|
||||
|
||||
client.on('messageCreate', async (message) => {
|
||||
|
||||
@@ -25,7 +25,7 @@ function displayName(message) {
|
||||
return message.nickname || message.socketId?.slice(0, 6) || 'unknown';
|
||||
}
|
||||
|
||||
export default function ChatPanel() {
|
||||
export default function ChatPanel({ hideInput = false, hideSpectatorNotice = false }) {
|
||||
const { session } = useSession();
|
||||
const { messages, sendMessage, registerInputRef, onInputFocus, onInputBlur, blurChat } = useChat();
|
||||
const [draft, setDraft] = useState('');
|
||||
@@ -42,7 +42,7 @@ export default function ChatPanel() {
|
||||
|
||||
async function handleSend(event) {
|
||||
event.preventDefault();
|
||||
if (!canChat) return;
|
||||
if (!canChat || hideInput) return;
|
||||
const clean = draft.trim();
|
||||
if (!clean) return;
|
||||
setSending(true);
|
||||
@@ -58,12 +58,12 @@ export default function ChatPanel() {
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel-section space-y-0.5 text-base">
|
||||
<section className="panel-section flex h-full flex-col space-y-0.5 text-base">
|
||||
{/* <div className="flex items-center justify-between text-sm text-slate-400">
|
||||
<span>Chat</span>
|
||||
<span className="text-xs text-slate-500">{sorted.length}</span>
|
||||
</div> */}
|
||||
<div className="surface h-48 overflow-y-auto space-y-0.25" ref={listRef}>
|
||||
<div className="surface flex-1 overflow-y-auto space-y-0.25" ref={listRef}>
|
||||
{sorted.length === 0 ? (
|
||||
<p className="text-sm text-slate-500">No messages yet.</p>
|
||||
) : (
|
||||
@@ -99,21 +99,23 @@ export default function ChatPanel() {
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<form className="flex gap-0.5" onSubmit={handleSend}>
|
||||
<input
|
||||
className="field-input flex-1"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onFocus={onInputFocus}
|
||||
onBlur={onInputBlur}
|
||||
ref={(el) => registerInputRef(el)}
|
||||
placeholder={canChat ? 'Type a message…' : 'Spectators cannot chat'}
|
||||
disabled={!canChat}
|
||||
/>
|
||||
<button type="submit" disabled={!canChat || sending} className="button-dark disabled:opacity-50">
|
||||
{sending ? '...' : 'Send'}
|
||||
</button>
|
||||
</form>
|
||||
{!hideInput && (
|
||||
<form className="flex gap-0.5" onSubmit={handleSend}>
|
||||
<input
|
||||
className="field-input flex-1"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onFocus={onInputFocus}
|
||||
onBlur={onInputBlur}
|
||||
ref={(el) => registerInputRef(el)}
|
||||
placeholder={canChat ? 'Type a message…' : hideSpectatorNotice ? '' : 'Spectators cannot chat'}
|
||||
disabled={!canChat}
|
||||
/>
|
||||
<button type="submit" disabled={!canChat || sending} className="button-dark disabled:opacity-50">
|
||||
{sending ? '...' : 'Send'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import AuthPanel from './AuthPanel.jsx';
|
||||
import { useSession } from '../context/SessionContext.jsx';
|
||||
import DiscordInviteButton from './DiscordInviteButton.jsx';
|
||||
import ChatPanel from './ChatPanel.jsx';
|
||||
import NicknameForm from './NicknameForm.jsx';
|
||||
|
||||
const PRIVILEGED_ROLES = new Set(['admin', 'lockdown', 'lockdown-admin']);
|
||||
const RESTRICTED_MODES = new Set(['admin', 'lockdown']);
|
||||
@@ -46,6 +48,9 @@ export default function ModeGateOverlay() {
|
||||
<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
|
||||
<ChatPanel />
|
||||
<NicknameForm />
|
||||
{/* <p className="text-xs text-slate-500">
|
||||
Your controls are paused until access is granted. You will automatically regain the interface once the mode
|
||||
changes or after a successful login.
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSession } from '../context/SessionContext.jsx';
|
||||
import { useSettingsNamespace } from '../settings/index.js';
|
||||
|
||||
export default function NicknameForm({ compact = false }) {
|
||||
const { session, setNickname } = useSession();
|
||||
const { value, save } = useSettingsNamespace('profile', { nickname: '' });
|
||||
const [nicknameInput, setNicknameInput] = useState(value.nickname || '');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const canSetNickname = session?.role !== 'spectator';
|
||||
|
||||
useEffect(() => {
|
||||
setNicknameInput(value.nickname || '');
|
||||
}, [value.nickname]);
|
||||
|
||||
async function handleSave(event) {
|
||||
event.preventDefault();
|
||||
if (!canSetNickname) return;
|
||||
const trimmed = (nicknameInput || '').trim().slice(0, 32);
|
||||
if (!trimmed) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await setNickname(trimmed);
|
||||
save({ nickname: trimmed });
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="flex gap-0.5" onSubmit={handleSave}>
|
||||
<input
|
||||
className="field-input flex-1"
|
||||
value={nicknameInput}
|
||||
onChange={(e) => setNicknameInput(e.target.value)}
|
||||
maxLength={32}
|
||||
placeholder="Enter a nickname"
|
||||
disabled={!canSetNickname}
|
||||
/>
|
||||
<button type="submit" disabled={!canSetNickname || saving} className="button-dark disabled:opacity-50">
|
||||
{saving ? 'Saving…' : compact ? 'Set' : 'Save'}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,12 @@ function normalizeOrientation(value, fallback) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export default function RoomCameraPanel({ defaultOrientation = 'horizontal', orientation: forcedOrientation }) {
|
||||
export default function RoomCameraPanel({
|
||||
defaultOrientation = 'horizontal',
|
||||
orientation: forcedOrientation,
|
||||
hideLayoutToggle = false,
|
||||
hideHeader = false,
|
||||
}) {
|
||||
const { session } = useSession();
|
||||
const cameras = session?.roomCameras || [];
|
||||
const sourceDescriptors = cameras.map((camera) => ({ type: 'room', id: camera.id, key: `room:${camera.id}` }));
|
||||
@@ -34,7 +39,7 @@ export default function RoomCameraPanel({ defaultOrientation = 'horizontal', ori
|
||||
: orientation;
|
||||
const containerClass =
|
||||
effectiveOrientation === 'vertical' ? 'flex flex-col gap-0.5' : 'grid gap-0.5 md:grid-cols-2';
|
||||
const showLayoutToggle = !forcedOrientation && cameras.length > 0;
|
||||
const showLayoutToggle = !hideLayoutToggle && !forcedOrientation && cameras.length > 0;
|
||||
|
||||
if (cameras.length === 0) {
|
||||
return <EmptyState />;
|
||||
@@ -42,29 +47,31 @@ export default function RoomCameraPanel({ defaultOrientation = 'horizontal', ori
|
||||
|
||||
return (
|
||||
<section className="panel-section space-y-0.5 text-base">
|
||||
<header className="flex flex-wrap items-center justify-between gap-0.5 text-sm text-slate-400">
|
||||
<div className="flex items-center gap-1">
|
||||
<p>Room cameras</p>
|
||||
<span className="text-xs text-slate-500">{cameras.length}</span>
|
||||
</div>
|
||||
{showLayoutToggle && (
|
||||
<div className="flex items-center gap-0.5 text-xs">
|
||||
<span className="text-slate-500">Layout:</span>
|
||||
<div className="inline-flex overflow-hidden rounded border border-slate-700">
|
||||
{ORIENTATIONS.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
className={`px-1 py-0.5 ${effectiveOrientation === option ? 'bg-slate-600 text-white' : 'bg-transparent text-slate-400 hover:text-white'}`}
|
||||
onClick={() => setOrientation(option)}
|
||||
>
|
||||
{option === 'vertical' ? 'Vertical' : 'Grid'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{!hideHeader && (
|
||||
<header className="flex flex-wrap items-center justify-between gap-0.5 text-sm text-slate-400">
|
||||
<div className="flex items-center gap-1">
|
||||
<p>Room cameras</p>
|
||||
<span className="text-xs text-slate-500">{cameras.length}</span>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
{showLayoutToggle && (
|
||||
<div className="flex items-center gap-0.5 text-xs">
|
||||
<span className="text-slate-500">Layout:</span>
|
||||
<div className="inline-flex overflow-hidden rounded border border-slate-700">
|
||||
{ORIENTATIONS.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
className={`px-1 py-0.5 ${effectiveOrientation === option ? 'bg-slate-600 text-white' : 'bg-transparent text-slate-400 hover:text-white'}`}
|
||||
onClick={() => setOrientation(option)}
|
||||
>
|
||||
{option === 'vertical' ? 'Vertical' : 'Grid'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
)}
|
||||
<div className={containerClass}>
|
||||
{cameras.map((camera) => {
|
||||
const key = `room:${camera.id}`;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
||||
import { useSession } from '../context/SessionContext.jsx';
|
||||
import { useSettingsNamespace } from '../settings/index.js';
|
||||
import { useSocket } from '../context/SocketContext.jsx';
|
||||
import NicknameForm from './NicknameForm.jsx';
|
||||
|
||||
function roleColors(role) {
|
||||
switch (role) {
|
||||
@@ -25,11 +26,9 @@ function formatLabel(user, selfId) {
|
||||
return base;
|
||||
}
|
||||
|
||||
export default function UserListPanel() {
|
||||
export default function UserListPanel({ hideNicknameForm = false, hideHeader = false }) {
|
||||
const { session, setNickname } = useSession();
|
||||
const { value, save } = useSettingsNamespace('profile', { nickname: '' });
|
||||
const [nicknameInput, setNicknameInput] = useState(value.nickname || '');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const { value } = useSettingsNamespace('profile', { nickname: '' });
|
||||
const lastSyncedSocketRef = useRef(null);
|
||||
const socket = useSocket();
|
||||
const canSetNickname = session?.role !== 'spectator';
|
||||
@@ -39,20 +38,17 @@ export default function UserListPanel() {
|
||||
const turnQueues = session?.turnQueues || {};
|
||||
const roster = session?.roster || [];
|
||||
|
||||
useEffect(() => {
|
||||
setNicknameInput(value.nickname || '');
|
||||
}, [value.nickname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canSetNickname) return;
|
||||
if (!session?.socketId) return;
|
||||
const nicknameInput = value.nickname || '';
|
||||
if (!nicknameInput) return;
|
||||
if (session.socketId === lastSyncedSocketRef.current) return;
|
||||
const currentId = session.socketId;
|
||||
setNickname(nicknameInput).then(() => {
|
||||
lastSyncedSocketRef.current = currentId;
|
||||
}).catch(() => {});
|
||||
}, [canSetNickname, nicknameInput, session?.socketId, setNickname]);
|
||||
}, [canSetNickname, session?.socketId, setNickname, value.nickname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!socket) return undefined;
|
||||
@@ -95,50 +91,25 @@ export default function UserListPanel() {
|
||||
return Math.ceil(ms / 1000);
|
||||
}, []);
|
||||
|
||||
async function handleSave(event) {
|
||||
event.preventDefault();
|
||||
if (!canSetNickname) return;
|
||||
const trimmed = (nicknameInput || '').trim().slice(0, 32);
|
||||
if (!trimmed) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await setNickname(trimmed);
|
||||
save({ nickname: trimmed });
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel-section space-y-0.5 text-base">
|
||||
<div className="space-y-0.5">
|
||||
{/* <p className="text-sm text-slate-400">Nickname</p> */}
|
||||
<form className="flex gap-0.5" onSubmit={handleSave}>
|
||||
<input
|
||||
className="field-input flex-1"
|
||||
value={nicknameInput}
|
||||
onChange={(e) => setNicknameInput(e.target.value)}
|
||||
maxLength={32}
|
||||
placeholder="Enter a nickname"
|
||||
disabled={!canSetNickname}
|
||||
/>
|
||||
<button type="submit" disabled={!canSetNickname || saving} className="button-dark disabled:opacity-50">
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</form>
|
||||
{!canSetNickname && <p className="text-xs text-slate-500">Spectators cannot set nicknames.</p>}
|
||||
</div>
|
||||
<section className="panel-section flex h-full flex-col space-y-0.5 text-base">
|
||||
{!hideNicknameForm && (
|
||||
<div className="space-y-0.5">
|
||||
<NicknameForm />
|
||||
{!canSetNickname && <p className="text-xs text-slate-500">Spectators cannot set nicknames.</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center justify-between text-sm text-slate-400">
|
||||
<span>{isTurnsMode ? 'Turn queues' : 'Users'}</span>
|
||||
<span className="text-xs text-slate-500">
|
||||
{isTurnsMode ? Object.keys(turnQueues || {}).length : sorted.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="surface h-48 overflow-y-auto space-y-0.25">
|
||||
{!hideHeader && (
|
||||
<div className="flex items-center justify-between text-sm text-slate-400">
|
||||
<span>{isTurnsMode ? 'Turn queues' : 'Users'}</span>
|
||||
<span className="text-xs text-slate-500">
|
||||
{isTurnsMode ? Object.keys(turnQueues || {}).length : sorted.length}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="surface flex-1 min-h-[12rem] overflow-y-auto space-y-0.25">
|
||||
{isTurnsMode ? (
|
||||
Object.keys(turnQueues || {}).length === 0 ? (
|
||||
<p className="text-sm text-slate-500">No turn queues yet.</p>
|
||||
|
||||
@@ -87,9 +87,13 @@ function RoverRow({ roster, frames, videoSources, session }) {
|
||||
function SecondaryRow() {
|
||||
return (
|
||||
<section className="grid grid-cols-1 gap-0.5 lg:grid-cols-[2fr_1fr_1fr]">
|
||||
<RoomCameraPanel defaultOrientation="horizontal" />
|
||||
<UserListPanel />
|
||||
<ChatPanel />
|
||||
<RoomCameraPanel defaultOrientation="horizontal" hideLayoutToggle hideHeader />
|
||||
<div className="flex flex-col">
|
||||
<UserListPanel hideNicknameForm hideHeader />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<ChatPanel hideInput hideSpectatorNotice />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user