mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
driver removal information finaly!
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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -78,8 +78,8 @@
|
||||
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
|
||||
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
|
||||
<title>Roomba Rover</title>
|
||||
<script type="module" crossorigin src="/assets/index-BnsUBjx0.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DFtJPk7c.css">
|
||||
<script type="module" crossorigin src="/assets/index-Buubck0y.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DaeBPi7X.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -13,6 +13,37 @@ const assignments = new Map(); // socketId -> roverId
|
||||
const waiting = new Set(); // socketIds waiting for placement
|
||||
const assignmentEvents = new EventEmitter();
|
||||
|
||||
function normalizeRemovalMessage(message, fallback) {
|
||||
/*
|
||||
Removal notices are shown directly in the driving UI, so the server trims
|
||||
caller-provided text before emitting it. Keeping this normalization close to
|
||||
the release helper makes every forced-removal path use the same readable
|
||||
fallback instead of forcing each caller to duplicate defensive string checks.
|
||||
*/
|
||||
const clean = String(message || '').trim();
|
||||
return clean || fallback;
|
||||
}
|
||||
|
||||
function emitRemovalNotice(socket, notice = {}) {
|
||||
/*
|
||||
The browser may lose its rover assignment in the same server tick that the
|
||||
reason is generated. Sending a dedicated event before releasing control lets
|
||||
the client preserve the explanation even after normal session sync says the
|
||||
user no longer has an assigned rover.
|
||||
*/
|
||||
if (!socket) return;
|
||||
const roverId = String(notice.roverId || '').trim() || null;
|
||||
const message = normalizeRemovalMessage(notice.message, 'You were removed from the rover.');
|
||||
socket.emit('session:roverRemovalNotice', {
|
||||
roverId,
|
||||
title: normalizeRemovalMessage(notice.title, 'Removed from rover'),
|
||||
message,
|
||||
reasonCode: String(notice.reasonCode || 'removed').trim() || 'removed',
|
||||
actor: notice.actor || null,
|
||||
ts: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
socketRefs.set(socket.id, socket);
|
||||
socket.on('disconnect', () => {
|
||||
@@ -176,6 +207,18 @@ function forceRelease(roverId, socketId) {
|
||||
assignmentEvents.emit('update', socketId);
|
||||
}
|
||||
|
||||
function forceReleaseWithNotice(roverId, socketId, notice = {}) {
|
||||
/*
|
||||
This is the one public path for moderation-style removals. It deliberately
|
||||
emits the explanation before forceRelease mutates assignment state, because
|
||||
session sync listeners can update the UI immediately after the release and
|
||||
the UI needs the reason to already be in local state.
|
||||
*/
|
||||
const socket = socketRefs.get(socketId) || io.sockets.sockets.get(socketId);
|
||||
emitRemovalNotice(socket, { ...notice, roverId });
|
||||
forceRelease(roverId, socketId);
|
||||
}
|
||||
|
||||
function pickRover(socket, options = {}) {
|
||||
const mode = getMode();
|
||||
if (mode === MODES.ADMIN || mode === MODES.LOCKDOWN) {
|
||||
@@ -266,6 +309,7 @@ module.exports = {
|
||||
assignmentEvents,
|
||||
describeAssignment,
|
||||
forceRelease,
|
||||
forceReleaseWithNotice,
|
||||
rerollAssignments,
|
||||
getAssignedRover: (socketId) => assignments.get(socketId) || null,
|
||||
moveAssignment: (socket, roverId, { releasePrevious = true } = {}) => {
|
||||
|
||||
@@ -12,6 +12,7 @@ function formatHelp() {
|
||||
'`rs bridge mode <global|private>` — change chat bridge mode',
|
||||
'`rs bridge off` — disable chat bridge for this server',
|
||||
'`rs lights <status|lock|unlock>` — show or change room light lock state',
|
||||
'`rs kick <user> [reason]` — remove a user from their current rover; use `user | reason` for multi-word names',
|
||||
'`rs lock <rover>` — lock a rover; rover names can be fuzzy',
|
||||
'`rs unlock <rover>` — unlock a rover; rover names can be fuzzy',
|
||||
'`rs mode <open|turns|admin|lockdown>` — change server mode',
|
||||
|
||||
@@ -13,6 +13,7 @@ const { createDeterCommand } = require('./deter');
|
||||
const { createBridgeCommand } = require('./bridge');
|
||||
const { createTimeStatusCommand } = require('./timeStatus');
|
||||
const { createLightsCommand } = require('./lights');
|
||||
const { createKickCommand } = require('./kick');
|
||||
|
||||
function createCommandHandlers(deps) {
|
||||
const {
|
||||
@@ -35,6 +36,7 @@ function createCommandHandlers(deps) {
|
||||
const handleBridgeCommand = createBridgeCommand(deps);
|
||||
const handleTimeStatusCommand = createTimeStatusCommand(deps);
|
||||
const handleLightsCommand = createLightsCommand(deps);
|
||||
const handleKickCommand = createKickCommand(deps);
|
||||
|
||||
async function handleCommand(message) {
|
||||
if (message.author.bot) return;
|
||||
@@ -58,7 +60,7 @@ function createCommandHandlers(deps) {
|
||||
// lockdown mode narrows them from normal admins to lockdown admins. Room
|
||||
// light locking belongs here because it can force the physical room lights
|
||||
// on and disables ordinary Home Assistant room controls for everyone else.
|
||||
const moderationActions = new Set(['lock', 'unlock', 'mode', 'goal', 'reason', 'verify', 'deter', 'lights']);
|
||||
const moderationActions = new Set(['lock', 'unlock', 'mode', 'goal', 'reason', 'verify', 'deter', 'lights', 'kick']);
|
||||
|
||||
if (!isAdmin && action !== '' && action !== 'status' && action !== 'help' && action !== 'replay' && action !== 'bridge' && action !== 'goal' && action !== 'reason' && action !== 'verify' && action !== 'deter') {
|
||||
await message.reply({ content: 'Only admins can run that command.', allowedMentions: { parse: [], repliedUser: false } });
|
||||
@@ -82,6 +84,8 @@ function createCommandHandlers(deps) {
|
||||
return handleBridgeCommand(message, tokens);
|
||||
case 'lights':
|
||||
return handleLightsCommand(message, tokens);
|
||||
case 'kick':
|
||||
return handleKickCommand(message, rest);
|
||||
case 'lock':
|
||||
return handleLockCommand(message, rest, true);
|
||||
case 'unlock':
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
// Discord Kick Command
|
||||
// Purpose: Removes a connected user from their current rover without applying any persistent moderation state.
|
||||
// Scope: Resolves an online driver, sends them a UI-visible reason, and releases their current rover assignment.
|
||||
const Fuse = require('fuse.js');
|
||||
|
||||
const DEFAULT_KICK_REASON = 'Removed from rover by admin.';
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
|
||||
function normalizeSearchText(value) {
|
||||
return normalizeText(value).toLowerCase().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
function splitSelectorAndReason(rawText) {
|
||||
const text = normalizeText(rawText);
|
||||
if (!text) return { selector: '', reason: '' };
|
||||
const pipeIndex = text.indexOf('|');
|
||||
if (pipeIndex >= 0) {
|
||||
/*
|
||||
A pipe delimiter is the escape hatch for multi-word nicknames. Without a
|
||||
delimiter the command intentionally treats the first token as the selector
|
||||
so quick admin commands stay short: `rs kick bob being reckless`.
|
||||
*/
|
||||
return {
|
||||
selector: normalizeText(text.slice(0, pipeIndex)),
|
||||
reason: normalizeText(text.slice(pipeIndex + 1)),
|
||||
};
|
||||
}
|
||||
const parts = text.split(/\s+/);
|
||||
return {
|
||||
selector: normalizeText(parts.shift()),
|
||||
reason: normalizeText(parts.join(' ')),
|
||||
};
|
||||
}
|
||||
|
||||
function buildKickCandidates({ io, roverManager, assignmentService, getNickname }) {
|
||||
return Array.from(io.sockets.sockets.values())
|
||||
.map((socket) => {
|
||||
const socketId = normalizeText(socket?.id);
|
||||
const assignedRoverId = assignmentService?.getAssignedRover?.(socketId) || null;
|
||||
const primaryRoverId = roverManager.getPrimaryRoverForSocket(socketId);
|
||||
const roverId = assignedRoverId || primaryRoverId || null;
|
||||
if (!socketId || !roverId) return null;
|
||||
const nickname = normalizeText(getNickname(socket));
|
||||
const username = normalizeText(socket?.data?.user?.username);
|
||||
return {
|
||||
socket,
|
||||
socketId,
|
||||
roverId,
|
||||
nickname,
|
||||
username,
|
||||
label: nickname || username || socketId.slice(0, 6),
|
||||
searchSocketId: normalizeSearchText(socketId),
|
||||
searchShortSocketId: normalizeSearchText(socketId.slice(0, 6)),
|
||||
searchNickname: normalizeSearchText(nickname),
|
||||
searchUsername: normalizeSearchText(username),
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function resolveKickTarget(selector, candidates) {
|
||||
const query = normalizeSearchText(selector);
|
||||
if (!query) return { error: 'Specify a user to kick. Example: `rs kick nickname reason`' };
|
||||
const exact = candidates.filter((entry) => (
|
||||
entry.searchSocketId === query ||
|
||||
entry.searchShortSocketId === query ||
|
||||
entry.searchNickname === query ||
|
||||
entry.searchUsername === query
|
||||
));
|
||||
if (exact.length === 1) return { target: exact[0] };
|
||||
if (exact.length > 1) {
|
||||
return { error: `User matched multiple drivers: ${exact.map((entry) => entry.label).join(', ')}.` };
|
||||
}
|
||||
const fuse = new Fuse(candidates, {
|
||||
includeScore: true,
|
||||
threshold: 0.38,
|
||||
ignoreLocation: true,
|
||||
keys: [
|
||||
{ name: 'nickname', weight: 0.7 },
|
||||
{ name: 'username', weight: 0.2 },
|
||||
{ name: 'socketId', weight: 0.1 },
|
||||
],
|
||||
});
|
||||
const results = fuse.search(selector);
|
||||
if (!results.length) return { error: 'User not found among current rover drivers.' };
|
||||
const first = results[0];
|
||||
const second = results[1];
|
||||
if (second && Math.abs(Number(second.score || 0) - Number(first.score || 0)) < 0.08) {
|
||||
return {
|
||||
error: `User matched multiple drivers: ${results.slice(0, 5).map((entry) => entry.item.label).join(', ')}.`,
|
||||
};
|
||||
}
|
||||
return { target: first.item };
|
||||
}
|
||||
|
||||
function createKickCommand({ io, roverManager, getNickname, sanitizeMentions }) {
|
||||
return async function handleKickCommand(message, rawText) {
|
||||
const { selector, reason } = splitSelectorAndReason(rawText);
|
||||
const assignmentService = require('../../assignmentService');
|
||||
const candidates = buildKickCandidates({
|
||||
io,
|
||||
roverManager,
|
||||
assignmentService,
|
||||
getNickname,
|
||||
});
|
||||
const resolved = resolveKickTarget(selector, candidates);
|
||||
if (resolved.error) {
|
||||
await message.reply({ content: sanitizeMentions(resolved.error), allowedMentions: { parse: [], repliedUser: false } });
|
||||
return;
|
||||
}
|
||||
const target = resolved.target;
|
||||
const removalReason = reason || DEFAULT_KICK_REASON;
|
||||
/*
|
||||
The command deliberately calls the notice-aware release helper instead of
|
||||
roverManager.releaseControl. That keeps admin kicks aligned with automated
|
||||
removals and gives the driver a stable explanation in the video panel.
|
||||
*/
|
||||
assignmentService.forceReleaseWithNotice(target.roverId, target.socketId, {
|
||||
title: 'Removed by admin',
|
||||
message: removalReason,
|
||||
reasonCode: 'admin-kick',
|
||||
actor: message.author?.id || null,
|
||||
});
|
||||
await message.reply({
|
||||
content: sanitizeMentions(`Removed ${target.label} from ${target.roverId}: ${removalReason}`),
|
||||
allowedMentions: { parse: [], repliedUser: false },
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createKickCommand,
|
||||
};
|
||||
@@ -34,6 +34,7 @@ const {
|
||||
managerEvents,
|
||||
backoffTimers,
|
||||
dockGuardStates,
|
||||
dockProtectionStrikeStates,
|
||||
privateButtonStates,
|
||||
privateNoUsersSince,
|
||||
privateSafetyTimers,
|
||||
@@ -154,6 +155,7 @@ const sensorPipeline = createSensorPipeline({
|
||||
rovers,
|
||||
managerEvents,
|
||||
dockGuardStates,
|
||||
dockProtectionStrikeStates,
|
||||
backoffTimers,
|
||||
privateButtonStates,
|
||||
privateSafetyTimers,
|
||||
|
||||
@@ -8,6 +8,7 @@ function createSensorPipeline(deps) {
|
||||
rovers,
|
||||
managerEvents,
|
||||
dockGuardStates,
|
||||
dockProtectionStrikeStates,
|
||||
backoffTimers,
|
||||
privateButtonStates,
|
||||
privateSafetyTimers,
|
||||
@@ -38,6 +39,9 @@ function createSensorPipeline(deps) {
|
||||
shouldApplyPrivateSensorSafety,
|
||||
} = deps;
|
||||
|
||||
const DOCK_PROTECTION_MAX_STRIKES = 3;
|
||||
const DOCK_PROTECTION_STRIKE_RESET_MS = 5 * 60 * 1000;
|
||||
|
||||
function getPrivateSafetyState(roverId) {
|
||||
if (!privateSafetyStates.has(roverId)) {
|
||||
privateSafetyStates.set(roverId, {
|
||||
@@ -416,6 +420,68 @@ function createSensorPipeline(deps) {
|
||||
);
|
||||
}
|
||||
|
||||
function getDockProtectionStrikeState(socketId) {
|
||||
/*
|
||||
The strike record is keyed by driver socket because the moderation action
|
||||
removes a person from control, not a rover from service. The last rover is
|
||||
still tracked so "three in a row" means repeated bump-off-dock incidents
|
||||
by the same browser session without a different rover resetting context.
|
||||
*/
|
||||
const key = String(socketId || '').trim();
|
||||
if (!key) return null;
|
||||
if (!dockProtectionStrikeStates.has(key)) {
|
||||
dockProtectionStrikeStates.set(key, {
|
||||
count: 0,
|
||||
lastRoverId: null,
|
||||
updatedAt: 0,
|
||||
});
|
||||
}
|
||||
return dockProtectionStrikeStates.get(key);
|
||||
}
|
||||
|
||||
function recordDockProtectionStrike(suspect) {
|
||||
const socketId = String(suspect?.socketId || '').trim();
|
||||
const roverId = String(suspect?.roverId || '').trim();
|
||||
const state = getDockProtectionStrikeState(socketId);
|
||||
if (!state || !roverId) return 0;
|
||||
const now = Date.now();
|
||||
const previousIsFresh = state.updatedAt && now - state.updatedAt <= DOCK_PROTECTION_STRIKE_RESET_MS;
|
||||
/*
|
||||
Consecutive protection hits should punish repeated behavior, not stale
|
||||
memory from some unrelated rover interaction. Switching suspect rover
|
||||
resets the count because the dock-protection heuristic has a different
|
||||
physical context and should earn its own three-strike sequence.
|
||||
*/
|
||||
state.count = previousIsFresh && state.lastRoverId === roverId ? state.count + 1 : 1;
|
||||
state.lastRoverId = roverId;
|
||||
state.updatedAt = now;
|
||||
return state.count;
|
||||
}
|
||||
|
||||
function clearDockProtectionStrikes(socketId) {
|
||||
const key = String(socketId || '').trim();
|
||||
if (key) dockProtectionStrikeStates.delete(key);
|
||||
}
|
||||
|
||||
function removeDriverForDockProtection(suspect, strikes) {
|
||||
const socketId = String(suspect?.socketId || '').trim();
|
||||
const roverId = String(suspect?.roverId || '').trim();
|
||||
if (!socketId || !roverId) return false;
|
||||
const assignmentService = require('../assignmentService');
|
||||
/*
|
||||
Release through assignmentService so rover membership, turn queues, and
|
||||
the browser-facing removal notice all move together. Directly editing
|
||||
roverManager sets here would skip queue cleanup and produce stale UI.
|
||||
*/
|
||||
assignmentService.forceReleaseWithNotice(roverId, socketId, {
|
||||
title: 'Removed for dock protection',
|
||||
message: `You were removed from ${roverId} after triggering bump-off-dock protection ${strikes} times in a row.`,
|
||||
reasonCode: 'dock-protection',
|
||||
});
|
||||
clearDockProtectionStrikes(socketId);
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleIdleUndock(undockedRecord) {
|
||||
if (!undockedRecord || undockedRecord.drivers.size > 0) return;
|
||||
const now = Date.now();
|
||||
@@ -435,10 +501,11 @@ function createSensorPipeline(deps) {
|
||||
const suspectRecord = rovers.get(suspect.roverId);
|
||||
if (!suspectRecord) return;
|
||||
const bumpRecent = suspectRecord.lastBumpAt && now - suspectRecord.lastBumpAt <= DOCK_GUARD_WINDOW_MS;
|
||||
const strikes = recordDockProtectionStrike(suspect);
|
||||
sendAlert({
|
||||
color: ALERT_COLOR,
|
||||
title: 'Dock protection',
|
||||
message: `${undockedRecord.id} undocked while idle; stopping ${suspect.roverId}.`,
|
||||
message: `${undockedRecord.id} undocked while idle; stopping ${suspect.roverId}${strikes ? ` (${strikes}/${DOCK_PROTECTION_MAX_STRIKES})` : ''}.`,
|
||||
});
|
||||
try {
|
||||
issueCommand(suspect.roverId, { type: 'drive', driveDirect: { left: 0, right: 0 } });
|
||||
@@ -449,6 +516,13 @@ function createSensorPipeline(deps) {
|
||||
setDriveCooldown(suspect.roverId, DOCK_GUARD_WINDOW_MS);
|
||||
if (bumpRecent) nudgeRover(suspect.roverId, 'backward');
|
||||
else nudgeRover(suspect.roverId, 'forward');
|
||||
if (strikes >= DOCK_PROTECTION_MAX_STRIKES && removeDriverForDockProtection(suspect, strikes)) {
|
||||
sendAlert({
|
||||
color: ALERT_COLOR,
|
||||
title: 'Driver removed',
|
||||
message: `${suspect.socketId} removed from ${suspect.roverId} after ${strikes} dock-protection triggers.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleSensorFrame(roverId, frame) {
|
||||
|
||||
@@ -9,6 +9,7 @@ const spectatorSockets = new Set();
|
||||
const managerEvents = new EventEmitter();
|
||||
const backoffTimers = new Map();
|
||||
const dockGuardStates = new Map();
|
||||
const dockProtectionStrikeStates = new Map();
|
||||
const privateButtonStates = new Map();
|
||||
const privateNoUsersSince = new Map();
|
||||
const privateSafetyTimers = new Map();
|
||||
@@ -21,6 +22,7 @@ module.exports = {
|
||||
managerEvents,
|
||||
backoffTimers,
|
||||
dockGuardStates,
|
||||
dockProtectionStrikeStates,
|
||||
privateButtonStates,
|
||||
privateNoUsersSince,
|
||||
privateSafetyTimers,
|
||||
|
||||
@@ -11,9 +11,18 @@ function stopRover(roverId) {
|
||||
}
|
||||
}
|
||||
|
||||
function removeDriverCompletely(roverId, socketId) {
|
||||
function removeDriverCompletely(roverId, socketId, notice = null) {
|
||||
try {
|
||||
const assignmentService = require('../assignmentService');
|
||||
/*
|
||||
Turn-service removals should explain themselves to the affected browser
|
||||
when a caller provides notice metadata. Plain forceRelease remains the
|
||||
fallback for old internal cleanup paths that only need to mutate state.
|
||||
*/
|
||||
if (notice && typeof assignmentService.forceReleaseWithNotice === 'function') {
|
||||
assignmentService.forceReleaseWithNotice(roverId, socketId, notice);
|
||||
return;
|
||||
}
|
||||
assignmentService.forceRelease(roverId, socketId);
|
||||
} catch (err) {
|
||||
// best effort; log elsewhere if needed
|
||||
|
||||
@@ -290,12 +290,17 @@ function handleIdleTimeout(roverId, expectedDriver) {
|
||||
const skips = incrementSkip(roverId, expectedDriver);
|
||||
stopRover(roverId);
|
||||
if (skips >= MAX_IDLE_SKIPS) {
|
||||
const removalMessage = `You were removed from ${roverId} after ${skips} idle skips because no driving input was detected during your turns.`;
|
||||
sendAlert({
|
||||
color: ALERT_COLOR,
|
||||
title: 'Driver removed',
|
||||
message: `${expectedDriver} removed from ${roverId} after ${skips} idle skips`,
|
||||
});
|
||||
removeDriverCompletely(roverId, expectedDriver);
|
||||
removeDriverCompletely(roverId, expectedDriver, {
|
||||
title: 'Removed for inactivity',
|
||||
message: removalMessage,
|
||||
reasonCode: 'idle-removal',
|
||||
});
|
||||
return;
|
||||
}
|
||||
sendAlert({
|
||||
|
||||
@@ -11,6 +11,46 @@ import LowBatteryOverlay from '../HudOverlays/LowBatteryOverlay/index.jsx';
|
||||
import DriverBottomStrip from '../HudOverlays/DriverBottomStrip/index.jsx';
|
||||
import HudChatInput from '../HudOverlays/HudChatInput/index.jsx';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import { useSharedClock } from '../../hooks/useSharedClock.js';
|
||||
|
||||
const REMOVAL_NOTICE_VISIBLE_MS = 2 * 60 * 1000;
|
||||
|
||||
function EmptyDriverVideoNotice() {
|
||||
const removalNotice = useSessionSelector((state) => state.roverRemovalNotice || null);
|
||||
const now = useSharedClock(1000, Boolean(removalNotice?.receivedAt));
|
||||
const noticeAgeMs = removalNotice?.receivedAt ? now - removalNotice.receivedAt : Infinity;
|
||||
/*
|
||||
Removal explanations should feel immediate and contextual. After a short
|
||||
window, falling back to the neutral no-rover state avoids showing an old
|
||||
moderation/safety message during unrelated later waiting periods.
|
||||
*/
|
||||
const showRemovalNotice = Boolean(removalNotice?.message && noticeAgeMs <= REMOVAL_NOTICE_VISIBLE_MS);
|
||||
const title = showRemovalNotice ? removalNotice.title || 'Removed from rover' : 'No rover assigned';
|
||||
const message = showRemovalNotice
|
||||
? removalNotice.message
|
||||
: 'You are not currently assigned to a rover.';
|
||||
|
||||
return (
|
||||
<CardFrame hideHeader className="shrink-0">
|
||||
<div className="panel-muted flex aspect-[4/3] items-center justify-center p-4 text-center">
|
||||
<div
|
||||
className={`mx-auto flex max-w-md flex-col gap-1 rounded border px-4 py-3 ${
|
||||
showRemovalNotice
|
||||
? 'border-amber-300/60 bg-amber-950/35 text-amber-50'
|
||||
: 'border-slate-700/70 bg-slate-950/35 text-slate-300'
|
||||
}`}
|
||||
>
|
||||
<div className={showRemovalNotice ? 'text-sm font-semibold text-amber-100' : 'text-sm font-semibold text-slate-200'}>
|
||||
{title}
|
||||
</div>
|
||||
<div className={showRemovalNotice ? 'text-sm text-amber-50/90' : 'text-sm text-slate-400'}>
|
||||
{message}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DriverVideo({ layoutFormat = 'desktop' }) {
|
||||
const roverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
||||
@@ -18,18 +58,7 @@ export default function DriverVideo({ layoutFormat = 'desktop' }) {
|
||||
const lastControlIntentAt = useControlSelector((control) => control.state.lastControlIntentAt);
|
||||
|
||||
if (!roverId) {
|
||||
return (
|
||||
<CardFrame hideHeader className="shrink-0">
|
||||
<div className="panel-muted content-center text-center text-sm text-slate-400 aspect-[4/3]">
|
||||
<p>You are not assigned to a rover.</p>
|
||||
<p className="mt-0">
|
||||
<a href="/spectate" className="text-blue-400 underline hover:text-blue-500">
|
||||
Click here to visit the spectator page.
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</CardFrame>
|
||||
);
|
||||
return <EmptyDriverVideoNotice />;
|
||||
}
|
||||
|
||||
const mobileHud = layoutFormat !== 'desktop';
|
||||
|
||||
@@ -19,6 +19,7 @@ const INITIAL_STATE = {
|
||||
latestReplay: null,
|
||||
latestRequestedReplay: null,
|
||||
duplicateIdentityBlock: null,
|
||||
roverRemovalNotice: null,
|
||||
};
|
||||
|
||||
const SessionContext = createContext(null);
|
||||
@@ -304,6 +305,31 @@ export function SessionProvider({ children }) {
|
||||
},
|
||||
}));
|
||||
}
|
||||
function handleRoverRemovalNotice(payload = {}) {
|
||||
/*
|
||||
Removal reasons arrive as socket events because the next normal session
|
||||
sync only says "not assigned". Keeping the explanation outside the
|
||||
session tree lets the no-rover video panel tell the user why control was
|
||||
removed after admin, safety, or idle-removal actions.
|
||||
*/
|
||||
const message =
|
||||
typeof payload?.message === 'string' && payload.message.trim()
|
||||
? payload.message.trim()
|
||||
: 'You were removed from the rover.';
|
||||
const title =
|
||||
typeof payload?.title === 'string' && payload.title.trim()
|
||||
? payload.title.trim()
|
||||
: 'Removed from rover';
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
roverRemovalNotice: {
|
||||
...payload,
|
||||
title,
|
||||
message,
|
||||
receivedAt: Date.now(),
|
||||
},
|
||||
}));
|
||||
}
|
||||
socket.on('session:sync', handleSession);
|
||||
socket.on('log:init', handleLogInit);
|
||||
socket.on('log:entry', handleLogEntry);
|
||||
@@ -317,6 +343,7 @@ export function SessionProvider({ children }) {
|
||||
socket.on('replay:ready', handleReplayReady);
|
||||
socket.on('replay:failed', handleReplayFailed);
|
||||
socket.on('session:duplicateIdentity', handleDuplicateIdentity);
|
||||
socket.on('session:roverRemovalNotice', handleRoverRemovalNotice);
|
||||
return () => {
|
||||
socket.off('session:sync', handleSession);
|
||||
socket.off('log:init', handleLogInit);
|
||||
@@ -331,6 +358,7 @@ export function SessionProvider({ children }) {
|
||||
socket.off('replay:ready', handleReplayReady);
|
||||
socket.off('replay:failed', handleReplayFailed);
|
||||
socket.off('session:duplicateIdentity', handleDuplicateIdentity);
|
||||
socket.off('session:roverRemovalNotice', handleRoverRemovalNotice);
|
||||
};
|
||||
}, [setState, socket]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user