mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
roverquesting
This commit is contained in:
Binary file not shown.
@@ -17,6 +17,7 @@ require('./src/services/roverConnectionService');
|
|||||||
require('./src/services/assignmentService');
|
require('./src/services/assignmentService');
|
||||||
require('./src/services/nicknameService');
|
require('./src/services/nicknameService');
|
||||||
require('./src/services/verificationService');
|
require('./src/services/verificationService');
|
||||||
|
require('./src/services/privateRoverAccessRequestService');
|
||||||
require('./src/services/chatService');
|
require('./src/services/chatService');
|
||||||
require('./src/services/llmCommentaryService');
|
require('./src/services/llmCommentaryService');
|
||||||
require('./src/services/communityGoalService');
|
require('./src/services/communityGoalService');
|
||||||
|
|||||||
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-status-bar-style" content="black-translucent" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
||||||
<title>Multi Roomba Rover</title>
|
<title>Multi Roomba Rover</title>
|
||||||
<script type="module" crossorigin src="/assets/index-Dmezqb_N.js"></script>
|
<script type="module" crossorigin src="/assets/index-dMtKgicC.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-CwUXm7Ls.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-BPze86Ff.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ const {
|
|||||||
listVerifiedUsers,
|
listVerifiedUsers,
|
||||||
removeVerifiedUser,
|
removeVerifiedUser,
|
||||||
} = require('./verificationService');
|
} = require('./verificationService');
|
||||||
|
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const discordConfig = config.discord || {};
|
const discordConfig = config.discord || {};
|
||||||
const enabled = Boolean(discordConfig.token);
|
const enabled = Boolean(discordConfig.token);
|
||||||
@@ -1504,6 +1503,49 @@ async function sendVerificationRequestDms(event) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function sendPrivateRoverAccessRequestDms(event) {
|
||||||
|
const payload = event?.payload || {};
|
||||||
|
const requestId = payload.id;
|
||||||
|
if (!requestId) return;
|
||||||
|
const adminIdsToNotify = Array.from(lockdownAdminIds);
|
||||||
|
if (!adminIdsToNotify.length) {
|
||||||
|
logger.warn('No lockdown admins configured for private rover access request DM', { requestId });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const requester = payload.requester || {};
|
||||||
|
const createdAt = payload.createdAt ? new Date(payload.createdAt).toLocaleString() : 'unknown';
|
||||||
|
const content = [
|
||||||
|
'**Private Rover Access Request**',
|
||||||
|
`Request ID: \`${requestId}\``,
|
||||||
|
`Rover: ${sanitizeMentions(payload.roverName || payload.roverId || 'unknown')} (\`${payload.roverId || 'unknown'}\`)`,
|
||||||
|
`Requester: ${sanitizeMentions(requester.nickname || requester.socketId || 'unknown')}`,
|
||||||
|
`Role: \`${requester.role || 'unknown'}\``,
|
||||||
|
`Verified: \`${requester.isVerified ? 'yes' : 'no'}\``,
|
||||||
|
`Identity key: \`${requester.cookieUserId || 'unknown'}\``,
|
||||||
|
`IP: \`${requester.ip || 'unknown'}\``,
|
||||||
|
`Created: ${createdAt}`,
|
||||||
|
'',
|
||||||
|
'Open the rover manually in the admin UI if approved.',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
adminIdsToNotify.map(async (adminId) => {
|
||||||
|
try {
|
||||||
|
const user = await client.users.fetch(String(adminId));
|
||||||
|
if (!user) return;
|
||||||
|
const dm = await user.createDM();
|
||||||
|
await dm.send({ content, allowedMentions: { parse: [] } });
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Failed to DM lockdown admin for private rover access request', {
|
||||||
|
requestId,
|
||||||
|
adminId,
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleVerificationReaction(reaction, user) {
|
async function handleVerificationReaction(reaction, user) {
|
||||||
if (!reaction || !user || user.bot) return;
|
if (!reaction || !user || user.bot) return;
|
||||||
const emoji = reaction.emoji?.name;
|
const emoji = reaction.emoji?.name;
|
||||||
@@ -1659,6 +1701,7 @@ client.once('ready', () => {
|
|||||||
|
|
||||||
subscribe('*', handleBusEvent);
|
subscribe('*', handleBusEvent);
|
||||||
subscribe('verification.requested', sendVerificationRequestDms);
|
subscribe('verification.requested', sendVerificationRequestDms);
|
||||||
|
subscribe('privateRoverAccess.requested', sendPrivateRoverAccessRequestDms);
|
||||||
subscribe('chat:message', handleChatBridgeOutbound);
|
subscribe('chat:message', handleChatBridgeOutbound);
|
||||||
subscribe('chat:typing', handleChatTypingOutbound);
|
subscribe('chat:typing', handleChatTypingOutbound);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
const crypto = require('crypto');
|
||||||
|
const EventEmitter = require('events');
|
||||||
|
const io = require('../globals/io');
|
||||||
|
const logger = require('../globals/logger').child('privateRoverAccessRequest');
|
||||||
|
const { publishEvent } = require('./eventBus');
|
||||||
|
const roverManager = require('./roverManager');
|
||||||
|
const { getNickname } = require('./nicknameService');
|
||||||
|
const { getRole, isLockdownAdmin } = require('./roleService');
|
||||||
|
const { getSocketIp, normalizeIp } = require('../helpers/ipResolver');
|
||||||
|
|
||||||
|
const requestEvents = new EventEmitter();
|
||||||
|
const REQUEST_COOLDOWN_MS = 15 * 1000;
|
||||||
|
|
||||||
|
const pendingRequests = new Map(); // requestId -> request
|
||||||
|
const pendingByRequesterRover = new Map(); // `${requesterKey}:${roverId}` -> requestId
|
||||||
|
const lastRequestAtByRequester = new Map(); // requesterKey -> ts
|
||||||
|
|
||||||
|
function normalizeRoverId(value) {
|
||||||
|
return String(value || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRequesterKey(socket) {
|
||||||
|
const cookieUserId = String(socket?.data?.cookieUserId || '').trim().toLowerCase();
|
||||||
|
if (cookieUserId) return `cookie:${cookieUserId}`;
|
||||||
|
return `socket:${socket?.id || 'unknown'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isClosedPrivateRoverRecord(record) {
|
||||||
|
return Boolean(record?.private?.enabled && !record?.privateOpen);
|
||||||
|
}
|
||||||
|
|
||||||
|
function listClosedPrivateRovers() {
|
||||||
|
const records = Array.from(roverManager.rovers.values())
|
||||||
|
.filter((record) => isClosedPrivateRoverRecord(record))
|
||||||
|
.map((record) => ({
|
||||||
|
id: String(record.id),
|
||||||
|
name: record.meta?.name || record.id,
|
||||||
|
color: record.meta?.color || null,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => String(a.name).localeCompare(String(b.name)));
|
||||||
|
return records;
|
||||||
|
}
|
||||||
|
|
||||||
|
function listPendingForRequester(socket) {
|
||||||
|
const requesterKey = buildRequesterKey(socket);
|
||||||
|
const pending = [];
|
||||||
|
for (const request of pendingRequests.values()) {
|
||||||
|
if (request.requesterKey !== requesterKey) continue;
|
||||||
|
if (request.status !== 'pending') continue;
|
||||||
|
pending.push({
|
||||||
|
id: request.id,
|
||||||
|
roverId: request.roverId,
|
||||||
|
roverName: request.roverName,
|
||||||
|
createdAt: request.createdAt,
|
||||||
|
status: request.status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
pending.sort((a, b) => b.createdAt - a.createdAt);
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStateForSocket(socket) {
|
||||||
|
return {
|
||||||
|
requestableRovers: listClosedPrivateRovers(),
|
||||||
|
pendingRequests: listPendingForRequester(socket),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPendingRequest(request, reason = 'resolved') {
|
||||||
|
if (!request || request.status !== 'pending') return;
|
||||||
|
request.status = reason;
|
||||||
|
request.resolvedAt = Date.now();
|
||||||
|
pendingByRequesterRover.delete(`${request.requesterKey}:${request.roverId}`);
|
||||||
|
requestEvents.emit('change', { reason, requestId: request.id, roverId: request.roverId });
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPendingForRover(roverId, reason = 'resolved') {
|
||||||
|
const target = normalizeRoverId(roverId);
|
||||||
|
for (const request of pendingRequests.values()) {
|
||||||
|
if (request.status !== 'pending') continue;
|
||||||
|
if (String(request.roverId) !== target) continue;
|
||||||
|
clearPendingRequest(request, reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRequest(socket, roverIdRaw) {
|
||||||
|
if (!socket?.id) {
|
||||||
|
throw new Error('Socket required');
|
||||||
|
}
|
||||||
|
const roverId = normalizeRoverId(roverIdRaw);
|
||||||
|
if (!roverId) {
|
||||||
|
throw new Error('roverId required');
|
||||||
|
}
|
||||||
|
const record = roverManager.rovers.get(roverId);
|
||||||
|
if (!record) {
|
||||||
|
throw new Error('Unknown rover');
|
||||||
|
}
|
||||||
|
if (!record?.private?.enabled) {
|
||||||
|
throw new Error('Rover is not private');
|
||||||
|
}
|
||||||
|
if (record.privateOpen) {
|
||||||
|
throw new Error('Private rover is already open');
|
||||||
|
}
|
||||||
|
if (isLockdownAdmin(socket)) {
|
||||||
|
throw new Error('Lockdown admins can open private rovers directly');
|
||||||
|
}
|
||||||
|
|
||||||
|
const requesterKey = buildRequesterKey(socket);
|
||||||
|
const dedupeKey = `${requesterKey}:${roverId}`;
|
||||||
|
const existingId = pendingByRequesterRover.get(dedupeKey);
|
||||||
|
if (existingId) {
|
||||||
|
const existing = pendingRequests.get(existingId);
|
||||||
|
if (existing && existing.status === 'pending') {
|
||||||
|
return { request: existing, isNew: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const lastAt = Number(lastRequestAtByRequester.get(requesterKey) || 0);
|
||||||
|
if (lastAt && now - lastAt < REQUEST_COOLDOWN_MS) {
|
||||||
|
const remaining = Math.ceil((REQUEST_COOLDOWN_MS - (now - lastAt)) / 1000);
|
||||||
|
throw new Error(`Please wait ${remaining}s before sending another request`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = {
|
||||||
|
id: `prr_${crypto.randomBytes(8).toString('hex')}`,
|
||||||
|
status: 'pending',
|
||||||
|
createdAt: now,
|
||||||
|
resolvedAt: null,
|
||||||
|
requesterKey,
|
||||||
|
roverId,
|
||||||
|
roverName: record.meta?.name || record.id,
|
||||||
|
requester: {
|
||||||
|
socketId: socket.id,
|
||||||
|
nickname: getNickname(socket) || null,
|
||||||
|
role: getRole(socket),
|
||||||
|
isVerified: Boolean(socket?.data?.isVerified),
|
||||||
|
cookieUserId: String(socket?.data?.cookieUserId || '').trim().toLowerCase() || null,
|
||||||
|
ip: normalizeIp(getSocketIp(socket)) || null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
pendingRequests.set(request.id, request);
|
||||||
|
pendingByRequesterRover.set(dedupeKey, request.id);
|
||||||
|
lastRequestAtByRequester.set(requesterKey, now);
|
||||||
|
|
||||||
|
publishEvent({
|
||||||
|
source: 'privateRoverAccessRequest',
|
||||||
|
type: 'privateRoverAccess.requested',
|
||||||
|
payload: request,
|
||||||
|
});
|
||||||
|
requestEvents.emit('change', {
|
||||||
|
reason: 'created',
|
||||||
|
requestId: request.id,
|
||||||
|
roverId: request.roverId,
|
||||||
|
socketId: socket.id,
|
||||||
|
});
|
||||||
|
logger.info('Private rover access requested', {
|
||||||
|
requestId: request.id,
|
||||||
|
roverId: request.roverId,
|
||||||
|
socketId: socket.id,
|
||||||
|
});
|
||||||
|
return { request, isNew: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
roverManager.managerEvents.on('private', ({ roverId, open } = {}) => {
|
||||||
|
if (!roverId) return;
|
||||||
|
if (open) {
|
||||||
|
clearPendingForRover(roverId, 'opened');
|
||||||
|
}
|
||||||
|
requestEvents.emit('change', {
|
||||||
|
reason: 'private_state',
|
||||||
|
roverId: String(roverId),
|
||||||
|
open: Boolean(open),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
roverManager.managerEvents.on('rover', ({ roverId, action } = {}) => {
|
||||||
|
if (!roverId) return;
|
||||||
|
if (action === 'removed') {
|
||||||
|
clearPendingForRover(roverId, 'rover_removed');
|
||||||
|
}
|
||||||
|
requestEvents.emit('change', {
|
||||||
|
reason: 'rover',
|
||||||
|
roverId: String(roverId),
|
||||||
|
action: action || null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
io.on('connection', (socket) => {
|
||||||
|
function handleRequest({ roverId } = {}, cb = () => {}) {
|
||||||
|
try {
|
||||||
|
const { request, isNew } = createRequest(socket, roverId);
|
||||||
|
cb({
|
||||||
|
success: true,
|
||||||
|
requestId: request.id,
|
||||||
|
status: request.status,
|
||||||
|
existing: !isNew,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
cb({ error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.on('privateRover:requestAccess', handleRequest);
|
||||||
|
socket.on('session:privateRover:requestAccess', handleRequest);
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
requestEvents,
|
||||||
|
getStateForSocket,
|
||||||
|
createRequest,
|
||||||
|
};
|
||||||
|
|
||||||
@@ -135,11 +135,21 @@ function getPrivateSafety(record) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function shouldApplyPrivateSafety(record, socket) {
|
function shouldApplyPrivateSafety(record, socket) {
|
||||||
if (!isPrivateRecord(record) || !isPrivateOpen(record)) return false;
|
if (!isPrivateRecord(record)) return false;
|
||||||
if (isLockdownAdmin(socket)) return false;
|
if (isLockdownAdmin(socket)) return false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldApplyPrivateSensorSafety(record) {
|
||||||
|
if (!isPrivateRecord(record)) return false;
|
||||||
|
const activeDrivers = turnService.getActiveDrivers();
|
||||||
|
const activeDriverId = activeDrivers?.[record.id];
|
||||||
|
if (!activeDriverId) return false;
|
||||||
|
const activeSocket = io.sockets.sockets.get(activeDriverId);
|
||||||
|
if (!activeSocket) return true;
|
||||||
|
return !isLockdownAdmin(activeSocket);
|
||||||
|
}
|
||||||
|
|
||||||
function isRoverVisibleToSocket(record, socket) {
|
function isRoverVisibleToSocket(record, socket) {
|
||||||
if (!record) return false;
|
if (!record) return false;
|
||||||
if (!isPrivateRecord(record)) return true;
|
if (!isPrivateRecord(record)) return true;
|
||||||
@@ -639,7 +649,7 @@ function evaluatePrivateSafety(record, sensors) {
|
|||||||
const currentBump = bump;
|
const currentBump = bump;
|
||||||
const currentCliff = cliff;
|
const currentCliff = cliff;
|
||||||
|
|
||||||
if (!isPrivateRecord(record) || !isPrivateOpen(record)) {
|
if (!shouldApplyPrivateSensorSafety(record)) {
|
||||||
state.blockedUntil = 0;
|
state.blockedUntil = 0;
|
||||||
state.lastOvercurrent = currentOver;
|
state.lastOvercurrent = currentOver;
|
||||||
state.lastBump = currentBump;
|
state.lastBump = currentBump;
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ const {
|
|||||||
getIdentitySummary,
|
getIdentitySummary,
|
||||||
verificationEvents,
|
verificationEvents,
|
||||||
} = require('./verificationService');
|
} = require('./verificationService');
|
||||||
|
const {
|
||||||
|
getStateForSocket: getPrivateRoverAccessStateForSocket,
|
||||||
|
requestEvents: privateRoverAccessRequestEvents,
|
||||||
|
} = require('./privateRoverAccessRequestService');
|
||||||
const { getReplayState, replayEvents } = require('./replayService');
|
const { getReplayState, replayEvents } = require('./replayService');
|
||||||
const { getReplaySources } = require('./replaySourceService');
|
const { getReplaySources } = require('./replaySourceService');
|
||||||
const { getHealthSnapshot } = require('./healthService');
|
const { getHealthSnapshot } = require('./healthService');
|
||||||
@@ -128,6 +132,7 @@ function buildSession(socket) {
|
|||||||
},
|
},
|
||||||
identity: getIdentitySummary(socket),
|
identity: getIdentitySummary(socket),
|
||||||
verification: getVerificationStateForSocket(socket),
|
verification: getVerificationStateForSocket(socket),
|
||||||
|
privateRoverAccess: getPrivateRoverAccessStateForSocket(socket),
|
||||||
isVerified: Boolean(socket?.data?.isVerified),
|
isVerified: Boolean(socket?.data?.isVerified),
|
||||||
audioForward: getAudioForwardState(),
|
audioForward: getAudioForwardState(),
|
||||||
audioLevels: getAudioLevels(),
|
audioLevels: getAudioLevels(),
|
||||||
@@ -214,6 +219,11 @@ managerEvents.on('privateSafety', ({ roverId }) => {
|
|||||||
syncAll();
|
syncAll();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
privateRoverAccessRequestEvents.on('change', (event = {}) => {
|
||||||
|
logger.info('Private rover access request state changed', event.reason || 'unknown');
|
||||||
|
syncAll();
|
||||||
|
});
|
||||||
|
|
||||||
managerEvents.on('driver', ({ socketId }) => {
|
managerEvents.on('driver', ({ socketId }) => {
|
||||||
if (!socketId) return;
|
if (!socketId) return;
|
||||||
const socket = io.sockets.sockets.get(socketId);
|
const socket = io.sockets.sockets.get(socketId);
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ import { COOKIE_KEY_REGEX, flowWrapClass } from './vip/constants.js';
|
|||||||
import VipAudioUploadCard from './vip/VipAudioUploadCard.jsx';
|
import VipAudioUploadCard from './vip/VipAudioUploadCard.jsx';
|
||||||
import VipVerificationCard from './vip/VipVerificationCard.jsx';
|
import VipVerificationCard from './vip/VipVerificationCard.jsx';
|
||||||
import VipIdentityCard from './vip/VipIdentityCard.jsx';
|
import VipIdentityCard from './vip/VipIdentityCard.jsx';
|
||||||
|
import VipPrivateRoverAccessCard from './vip/VipPrivateRoverAccessCard.jsx';
|
||||||
|
|
||||||
export default function VipPanel() {
|
export default function VipPanel() {
|
||||||
const {
|
const {
|
||||||
session,
|
session,
|
||||||
identifySession,
|
identifySession,
|
||||||
requestVerification,
|
requestVerification,
|
||||||
|
requestPrivateRoverAccess,
|
||||||
playUploadedAudio,
|
playUploadedAudio,
|
||||||
stopUploadedAudio,
|
stopUploadedAudio,
|
||||||
startMicWhip,
|
startMicWhip,
|
||||||
@@ -24,6 +26,8 @@ export default function VipPanel() {
|
|||||||
const nickname = (profile?.nickname || '').trim();
|
const nickname = (profile?.nickname || '').trim();
|
||||||
const isVerified = Boolean(session?.isVerified);
|
const isVerified = Boolean(session?.isVerified);
|
||||||
const pendingRequestId = session?.verification?.pendingRequestId || null;
|
const pendingRequestId = session?.verification?.pendingRequestId || null;
|
||||||
|
const requestablePrivateRovers = session?.privateRoverAccess?.requestableRovers || [];
|
||||||
|
const pendingPrivateRoverRequests = session?.privateRoverAccess?.pendingRequests || [];
|
||||||
const ownRoverId = String(session?.assignment?.roverId || '').trim();
|
const ownRoverId = String(session?.assignment?.roverId || '').trim();
|
||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
|
|
||||||
@@ -84,6 +88,16 @@ export default function VipPanel() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<VipPrivateRoverAccessCard
|
||||||
|
requestableRovers={requestablePrivateRovers}
|
||||||
|
pendingRequests={pendingPrivateRoverRequests}
|
||||||
|
requestPrivateRoverAccess={requestPrivateRoverAccess}
|
||||||
|
onMessage={setMessage}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<VipIdentityCard
|
<VipIdentityCard
|
||||||
currentStoredKey={currentStoredKey}
|
currentStoredKey={currentStoredKey}
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { flowWrapClass, innerFlowClass } from './constants.js';
|
||||||
|
|
||||||
|
export default function VipPrivateRoverAccessCard({
|
||||||
|
requestableRovers = [],
|
||||||
|
pendingRequests = [],
|
||||||
|
requestPrivateRoverAccess,
|
||||||
|
onMessage,
|
||||||
|
fullWidth = false,
|
||||||
|
}) {
|
||||||
|
const [pendingByRover, setPendingByRover] = useState({});
|
||||||
|
const pendingMap = useMemo(() => {
|
||||||
|
const next = new Map();
|
||||||
|
(pendingRequests || []).forEach((entry) => {
|
||||||
|
if (!entry?.roverId) return;
|
||||||
|
next.set(String(entry.roverId), entry);
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
}, [pendingRequests]);
|
||||||
|
|
||||||
|
const wrapClass = fullWidth ? 'w-full' : flowWrapClass;
|
||||||
|
|
||||||
|
const handleRequest = async (roverId) => {
|
||||||
|
if (!roverId) return;
|
||||||
|
setPendingByRover((prev) => ({ ...prev, [roverId]: true }));
|
||||||
|
onMessage?.('');
|
||||||
|
try {
|
||||||
|
const response = await requestPrivateRoverAccess?.(roverId);
|
||||||
|
if (response?.existing) {
|
||||||
|
onMessage?.('You already have a pending request for that rover.');
|
||||||
|
} else {
|
||||||
|
onMessage?.('Private rover access request sent to lockdown admins.');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
onMessage?.(err.message || 'Failed to send request.');
|
||||||
|
} finally {
|
||||||
|
setPendingByRover((prev) => ({ ...prev, [roverId]: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={`surface text-sm text-slate-300 ${wrapClass}`}>
|
||||||
|
<div className={innerFlowClass}>
|
||||||
|
<p className="text-sm text-slate-300">Private rover access requests</p>
|
||||||
|
{requestableRovers.length === 0 ? (
|
||||||
|
<p className="text-xs text-slate-500">No closed private rovers are available to request right now.</p>
|
||||||
|
) : (
|
||||||
|
<div className="w-full space-y-0.5">
|
||||||
|
{requestableRovers.map((rover) => {
|
||||||
|
const roverId = String(rover.id);
|
||||||
|
const inFlight = Boolean(pendingByRover[roverId]);
|
||||||
|
const pending = pendingMap.get(roverId);
|
||||||
|
return (
|
||||||
|
<div key={roverId} className="surface-muted flex items-center justify-between gap-0.5 px-1 py-0.5">
|
||||||
|
<div className="min-w-0 text-left">
|
||||||
|
<p className="truncate text-xs font-semibold text-slate-100">{rover.name || roverId}</p>
|
||||||
|
<p className="truncate text-[0.7rem] text-slate-500">{roverId}</p>
|
||||||
|
</div>
|
||||||
|
{pending ? (
|
||||||
|
<span className="rounded bg-amber-700/30 px-1 py-0.5 text-[0.7rem] text-amber-200">
|
||||||
|
Pending
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRequest(roverId)}
|
||||||
|
disabled={inFlight}
|
||||||
|
className="button-dark text-xs disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{inFlight ? 'Sending...' : 'Request'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -21,6 +21,7 @@ const SessionContext = createContext({
|
|||||||
homeAssistantSetLightColor: async () => {},
|
homeAssistantSetLightColor: async () => {},
|
||||||
setNickname: async () => {},
|
setNickname: async () => {},
|
||||||
requestVerification: async () => {},
|
requestVerification: async () => {},
|
||||||
|
requestPrivateRoverAccess: async () => {},
|
||||||
triggerReplay: async () => {},
|
triggerReplay: async () => {},
|
||||||
setCommunityGoal: async () => {},
|
setCommunityGoal: async () => {},
|
||||||
setAdminReason: async () => {},
|
setAdminReason: async () => {},
|
||||||
@@ -142,6 +143,8 @@ export function SessionProvider({ children }) {
|
|||||||
emitWithAck('homeAssistant:lightColor', { entityId, rgbColor }),
|
emitWithAck('homeAssistant:lightColor', { entityId, rgbColor }),
|
||||||
setNickname: (nickname) => emitWithAck('nickname:set', { nickname }),
|
setNickname: (nickname) => emitWithAck('nickname:set', { nickname }),
|
||||||
requestVerification: () => emitWithAck('verification:request'),
|
requestVerification: () => emitWithAck('verification:request'),
|
||||||
|
requestPrivateRoverAccess: (roverId) =>
|
||||||
|
emitWithAck('session:privateRover:requestAccess', { roverId }),
|
||||||
triggerReplay: (sources = []) => emitWithAck('replay:trigger', { sources }),
|
triggerReplay: (sources = []) => emitWithAck('replay:trigger', { sources }),
|
||||||
setCommunityGoal: (text) => emitWithAck('communityGoal:set', { text }),
|
setCommunityGoal: (text) => emitWithAck('communityGoal:set', { text }),
|
||||||
setAdminReason: (text) => emitWithAck('adminReason:set', { text }),
|
setAdminReason: (text) => emitWithAck('adminReason:set', { text }),
|
||||||
|
|||||||
Reference in New Issue
Block a user