mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
moving audio gain perms around
This commit is contained in:
@@ -17,10 +17,8 @@ const MODES = [
|
||||
{ key: 'lockdown', label: 'Lockdown' },
|
||||
];
|
||||
|
||||
/*
|
||||
The same three gain keys drive both the global levels and the VIP boost hard
|
||||
caps, so both editors render from one list instead of six copied sliders.
|
||||
*/
|
||||
// These are the physical rover gain channels. Personal settings remain signed
|
||||
// percentages and never replace these server-owned base multipliers.
|
||||
const GAIN_FIELDS = [
|
||||
{ key: 'hornGain', label: 'Horn gain' },
|
||||
{ key: 'ttsGain', label: 'TTS gain' },
|
||||
@@ -109,7 +107,7 @@ export default function AdminPanelContent() {
|
||||
updateAllRovers,
|
||||
rebootServer,
|
||||
setAudioLevels,
|
||||
setUserAudioGainCaps,
|
||||
setPersonalAudioAdjustmentRange,
|
||||
setPrivateSafety,
|
||||
llmControl,
|
||||
overseerControl,
|
||||
@@ -132,12 +130,11 @@ export default function AdminPanelContent() {
|
||||
const reasonUpdatedAt = session?.adminReason?.updatedAt || null;
|
||||
const [reasonDraft, setReasonDraft] = useState(currentReason);
|
||||
const currentAudioLevels = session?.audioLevels || {};
|
||||
const currentUserGainCaps = currentAudioLevels.userGainCaps || {};
|
||||
const [audioLevelDraft, setAudioLevelDraft] = useState(
|
||||
() => normalizeGainDraft(currentAudioLevels, { hornGain: 1, ttsGain: 1, forwardGain: 1 }),
|
||||
);
|
||||
const [userGainCapDraft, setUserGainCapDraft] = useState(
|
||||
() => normalizeGainDraft(currentUserGainCaps, { hornGain: 0.5, ttsGain: 0.8, forwardGain: 0.4 }),
|
||||
const [maxAdjustmentDraft, setMaxAdjustmentDraft] = useState(
|
||||
() => Number(currentAudioLevels.maxPersonalAdjustmentPercent) || 0,
|
||||
);
|
||||
const [privateSafetyDrafts, setPrivateSafetyDrafts] = useState({});
|
||||
const [privateSafetyDirty, setPrivateSafetyDirty] = useState({});
|
||||
@@ -317,14 +314,9 @@ export default function AdminPanelContent() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleUserGainCapDraft = (key) => (event) => {
|
||||
const next = Number(event.target.value);
|
||||
setUserGainCapDraft((current) => ({ ...(current || {}), [key]: Number.isFinite(next) ? next : 0 }));
|
||||
};
|
||||
|
||||
const handleUserGainCapsSave = async () => {
|
||||
const handlePersonalAdjustmentRangeSave = async () => {
|
||||
try {
|
||||
await setUserAudioGainCaps(userGainCapDraft);
|
||||
await setPersonalAudioAdjustmentRange(maxAdjustmentDraft);
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
}
|
||||
@@ -356,9 +348,8 @@ export default function AdminPanelContent() {
|
||||
}, [currentAudioLevels.forwardGain, currentAudioLevels.hornGain, currentAudioLevels.ttsGain]);
|
||||
|
||||
useEffect(() => {
|
||||
setUserGainCapDraft(normalizeGainDraft(currentUserGainCaps, { hornGain: 0.5, ttsGain: 0.8, forwardGain: 0.4 }));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentUserGainCaps.forwardGain, currentUserGainCaps.hornGain, currentUserGainCaps.ttsGain]);
|
||||
setMaxAdjustmentDraft(Number(currentAudioLevels.maxPersonalAdjustmentPercent) || 0);
|
||||
}, [currentAudioLevels.maxPersonalAdjustmentPercent]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
@@ -462,7 +453,7 @@ export default function AdminPanelContent() {
|
||||
/>
|
||||
))}
|
||||
<p className="text-xs text-slate-500">
|
||||
These are the volume ceilings for ordinary users. Each user picks a 0-100% share of them.
|
||||
These are the base multipliers. Approved personal adjustments are calculated around these values.
|
||||
</p>
|
||||
<div className="flex gap-0.5 text-xs">
|
||||
<button type="button" onClick={handleAudioLevelsSave} className="button-dark">
|
||||
@@ -472,26 +463,32 @@ export default function AdminPanelContent() {
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||
<span>VIP gain boost hard caps</span>
|
||||
{session?.audioLevels?.capsUpdatedAt ? (
|
||||
<span>Updated {new Date(session.audioLevels.capsUpdatedAt).toLocaleString()}</span>
|
||||
<span>Maximum personal adjustment</span>
|
||||
{session?.audioLevels?.adjustmentRangeUpdatedAt ? (
|
||||
<span>Updated {new Date(session.audioLevels.adjustmentRangeUpdatedAt).toLocaleString()}</span>
|
||||
) : null}
|
||||
</div>
|
||||
{GAIN_FIELDS.map(({ key, label }) => (
|
||||
<GainSlider
|
||||
key={key}
|
||||
label={label}
|
||||
value={userGainCapDraft[key]}
|
||||
onChange={handleUserGainCapDraft(key)}
|
||||
<label className="grid gap-0.5 text-xs text-slate-200">
|
||||
<div className="flex items-center justify-between gap-0.5">
|
||||
<span>Allowed range in both directions</span>
|
||||
<span>±{Math.round(Number(maxAdjustmentDraft) || 0)}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
step="1"
|
||||
value={maxAdjustmentDraft}
|
||||
onChange={(event) => setMaxAdjustmentDraft(Number(event.target.value) || 0)}
|
||||
className="w-full accent-emerald-500"
|
||||
/>
|
||||
))}
|
||||
</label>
|
||||
<p className="text-xs text-slate-500">
|
||||
Ceilings for VIPs granted the boost with <code>rs gain grant <vip></code>. A boost never lowers
|
||||
someone's limit, so a cap below the global gain above has no effect.
|
||||
Users with the personal audio adjustment permission can reduce or increase each base level by this percentage.
|
||||
</p>
|
||||
<div className="flex gap-0.5 text-xs">
|
||||
<button type="button" onClick={handleUserGainCapsSave} className="button-dark">
|
||||
Apply boost caps
|
||||
<button type="button" onClick={handlePersonalAdjustmentRangeSave} className="button-dark">
|
||||
Apply adjustment range
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,134 +1,114 @@
|
||||
// Volume Settings Card
|
||||
// Purpose: Lets any user set their own horn, TTS, and mic-forward volume.
|
||||
// Scope: Renders the server-resolved ceilings; the server still owns every limit.
|
||||
// Personal Volume Adjustment Card
|
||||
// Purpose: Lets an approved user offset horn, text-to-speech, and microphone output around server-owned base levels.
|
||||
// Scope: Persists signed percentages in roverSettings while the server owns permission, clamping, and gain conversion.
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import { useSession } from '../../context/SessionContext.jsx';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
|
||||
/*
|
||||
Sliders are a 0-1 fraction of whichever ceiling the server resolved for this
|
||||
user, so the same three keys describe the fraction, the ceiling, and the gain
|
||||
the rover will actually apply.
|
||||
*/
|
||||
const GAIN_FIELDS = [
|
||||
{ key: 'hornGain', label: 'Horn volume' },
|
||||
{ key: 'ttsGain', label: 'Text-to-speech volume' },
|
||||
{ key: 'forwardGain', label: 'Microphone volume' },
|
||||
const ADJUSTMENT_FIELDS = [
|
||||
{ key: 'hornPercent', label: 'Horn volume' },
|
||||
{ key: 'ttsPercent', label: 'Text-to-speech volume' },
|
||||
{ key: 'forwardPercent', label: 'Microphone volume' },
|
||||
];
|
||||
|
||||
// Dragging a range input fires continuously; persist once the user settles.
|
||||
const DEFAULT_ADJUSTMENTS = { hornPercent: 0, ttsPercent: 0, forwardPercent: 0 };
|
||||
const COMMIT_DEBOUNCE_MS = 300;
|
||||
|
||||
function clampFraction(value, fallback = 1) {
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) return fallback;
|
||||
return Math.max(0, Math.min(1, num));
|
||||
function clampPercent(value, maximum) {
|
||||
const number = Number(value);
|
||||
const limit = Math.max(0, Number(maximum) || 0);
|
||||
if (!Number.isFinite(number)) return 0;
|
||||
return Math.round(Math.max(-limit, Math.min(limit, number)));
|
||||
}
|
||||
|
||||
function normalizeValues(raw) {
|
||||
const out = {};
|
||||
GAIN_FIELDS.forEach(({ key }) => {
|
||||
out[key] = clampFraction(raw?.[key], 1);
|
||||
function normalizeAdjustments(raw, maximum) {
|
||||
const normalized = {};
|
||||
ADJUSTMENT_FIELDS.forEach(({ key }) => {
|
||||
normalized[key] = clampPercent(raw?.[key], maximum);
|
||||
});
|
||||
return out;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function formatGain(value) {
|
||||
const num = Number(value);
|
||||
return `${Number.isFinite(num) ? num.toFixed(2) : '0.00'}x`;
|
||||
function formatPercent(value) {
|
||||
const number = Number(value) || 0;
|
||||
return `${number > 0 ? '+' : ''}${number}%`;
|
||||
}
|
||||
|
||||
export default function VolumeSettingsCard() {
|
||||
const { session, setUserAudioGains } = useSession();
|
||||
const audioGains = session?.audioGains || null;
|
||||
const serverValues = useMemo(() => normalizeValues(audioGains?.values), [audioGains?.values]);
|
||||
const ceilings = audioGains?.ceilings || {};
|
||||
const boostGranted = Boolean(audioGains?.boostGranted);
|
||||
|
||||
const [draft, setDraft] = useState(serverValues);
|
||||
const [error, setError] = useState(null);
|
||||
const commitTimerRef = useRef(null);
|
||||
const pendingRef = useRef(null);
|
||||
|
||||
/*
|
||||
The server is authoritative, so an accepted save or an admin-side change
|
||||
resyncs the sliders. Comparing the serialized values keeps a resync from
|
||||
fighting a drag that is already in flight.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (pendingRef.current) return;
|
||||
setDraft(serverValues);
|
||||
}, [serverValues]);
|
||||
|
||||
const commit = useCallback(
|
||||
async (next) => {
|
||||
pendingRef.current = next;
|
||||
try {
|
||||
await setUserAudioGains(next);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err?.message || 'Failed to save volume');
|
||||
setDraft(serverValues);
|
||||
} finally {
|
||||
pendingRef.current = null;
|
||||
}
|
||||
},
|
||||
[serverValues, setUserAudioGains],
|
||||
const { session, setPersonalAudioAdjustments } = useSession();
|
||||
const { value: savedAdjustments, save: saveAdjustments } = useSettingsNamespace(
|
||||
'audioAdjustments',
|
||||
DEFAULT_ADJUSTMENTS,
|
||||
);
|
||||
const serverState = session?.audioAdjustments || null;
|
||||
const allowed = Boolean(serverState?.allowed);
|
||||
const maximum = Math.max(0, Number(serverState?.maxAdjustmentPercent) || 0);
|
||||
const normalizedSaved = useMemo(
|
||||
() => normalizeAdjustments(savedAdjustments, maximum),
|
||||
[maximum, savedAdjustments],
|
||||
);
|
||||
const [draft, setDraft] = useState(normalizedSaved);
|
||||
const [error, setError] = useState(null);
|
||||
const timerRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(normalizedSaved);
|
||||
}, [normalizedSaved]);
|
||||
|
||||
const commit = useCallback(async (next) => {
|
||||
const normalized = normalizeAdjustments(next, maximum);
|
||||
// Saving first makes the cookie the durable source used by every reconnect
|
||||
// and session:identify update. The socket call applies it immediately to a
|
||||
// rover the current browser may already control.
|
||||
saveAdjustments(normalized);
|
||||
try {
|
||||
await setPersonalAudioAdjustments(normalized);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err?.message || 'Failed to apply volume adjustments');
|
||||
}
|
||||
}, [maximum, saveAdjustments, setPersonalAudioAdjustments]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
}, []);
|
||||
|
||||
const handleChange = (key) => (event) => {
|
||||
const next = clampFraction(event.target.value, 0);
|
||||
const nextDraft = { ...draft, [key]: next };
|
||||
setDraft(nextDraft);
|
||||
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
|
||||
commitTimerRef.current = setTimeout(() => commit(nextDraft), COMMIT_DEBOUNCE_MS);
|
||||
const next = { ...draft, [key]: clampPercent(event.target.value, maximum) };
|
||||
setDraft(next);
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(() => commit(next), COMMIT_DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
// Sliders would be misleading before the first session sync lands.
|
||||
if (!audioGains) return null;
|
||||
if (!serverState) return null;
|
||||
|
||||
return (
|
||||
<CardFrame title="Volume" className="lg:col-span-2" bodyClassName="space-y-1 p-1 text-sm">
|
||||
{GAIN_FIELDS.map(({ key, label }) => {
|
||||
const ceiling = Number.isFinite(Number(ceilings[key])) ? Number(ceilings[key]) : 0;
|
||||
const fraction = clampFraction(draft[key], 1);
|
||||
const muted = ceiling <= 0;
|
||||
return (
|
||||
<label
|
||||
key={key}
|
||||
className="mx-auto block w-full max-w-lg rounded bg-neutral-800/80 px-1.5 py-1 text-sm text-white"
|
||||
>
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1.5">
|
||||
<span className="min-w-0 font-semibold text-white">{label}</span>
|
||||
<span className="rounded bg-neutral-900 px-1 py-0.5 text-xs text-white">
|
||||
{Math.round(fraction * 100)}% · {formatGain(fraction * ceiling)}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={fraction}
|
||||
onChange={handleChange(key)}
|
||||
className="mt-1 w-full accent-emerald-500 disabled:opacity-50"
|
||||
disabled={muted}
|
||||
/>
|
||||
<span className="text-xs text-slate-400">
|
||||
{muted ? 'Muted by admin gain settings.' : `100% = ${formatGain(ceiling)} (your current limit)`}
|
||||
<CardFrame title="Personal volume adjustment" className="lg:col-span-2" bodyClassName="space-y-1 p-1 text-sm">
|
||||
{ADJUSTMENT_FIELDS.map(({ key, label }) => (
|
||||
<label key={key} className="mx-auto block w-full max-w-lg rounded bg-neutral-800/80 px-1.5 py-1 text-white">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1.5">
|
||||
<span className="min-w-0 font-semibold text-white">{label}</span>
|
||||
<span className="rounded bg-neutral-900 px-1 py-0.5 text-xs text-white">
|
||||
{formatPercent(draft[key])}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
<p className="mx-auto w-full max-w-lg text-xs leading-snug text-white">
|
||||
{boostGranted
|
||||
? 'You have a raised volume limit. 100% is the admin-set hard cap for boosted users rather than the normal global gain.'
|
||||
: 'Your limit is the global gain an admin has set. Admins can raise it per user.'}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={-maximum}
|
||||
max={maximum}
|
||||
step="1"
|
||||
value={clampPercent(draft[key], maximum)}
|
||||
onChange={handleChange(key)}
|
||||
className="mt-1 w-full accent-emerald-500 disabled:opacity-50"
|
||||
disabled={!allowed || maximum <= 0}
|
||||
/>
|
||||
<span className="text-xs text-slate-400">
|
||||
{allowed
|
||||
? `Allowed range: -${maximum}% to +${maximum}%. Center is no adjustment.`
|
||||
: 'An administrator must approve personal volume adjustments for your user.'}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
{error && <p className="mx-auto w-full max-w-lg text-xs text-rose-300">{error}</p>}
|
||||
</CardFrame>
|
||||
);
|
||||
|
||||
@@ -342,8 +342,8 @@ export function SessionProvider({ children }) {
|
||||
const actions = useMemo(
|
||||
() => ({
|
||||
login: (username, password) => emitWithAck('auth:login', { username, password }),
|
||||
identifySession: ({ cookieUserId, fingerprintId, nickname, overseerEnabled, identitySurface } = {}) =>
|
||||
emitWithAck('session:identify', { cookieUserId, fingerprintId, nickname, overseerEnabled, identitySurface }),
|
||||
identifySession: ({ cookieUserId, fingerprintId, nickname, audioAdjustments, overseerEnabled, identitySurface } = {}) =>
|
||||
emitWithAck('session:identify', { cookieUserId, fingerprintId, nickname, audioAdjustments, overseerEnabled, identitySurface }),
|
||||
setRole: (role) => emitWithAck('session:setRole', { role }),
|
||||
requestControl: (roverId, options = {}) =>
|
||||
emitWithAck('session:requestControl', { roverId, ...options }),
|
||||
@@ -399,13 +399,10 @@ export function SessionProvider({ children }) {
|
||||
readyMicWhip: (roverId) => emitWithAck('audio:micWhipReady', { roverId }),
|
||||
stopMicWhip: (roverId) => emitWithAck('audio:micWhipStop', { roverId }),
|
||||
setAudioLevels: (levels = {}) => emitWithAck('audioLevels:set', levels),
|
||||
/*
|
||||
Personal volume is a 0-1 fraction of whichever ceiling the server has
|
||||
resolved for this user, so the browser never sends an absolute gain and
|
||||
cannot widen its own limits.
|
||||
*/
|
||||
setUserAudioGains: (gains = {}) => emitWithAck('audioLevels:setUserGains', gains),
|
||||
setUserAudioGainCaps: (caps = {}) => emitWithAck('audioLevels:setUserCaps', caps),
|
||||
setPersonalAudioAdjustments: (adjustments = {}) =>
|
||||
emitWithAck('audioLevels:setPersonalAdjustments', adjustments),
|
||||
setPersonalAudioAdjustmentRange: (maxAdjustmentPercent) =>
|
||||
emitWithAck('audioLevels:setPersonalAdjustmentRange', { maxAdjustmentPercent }),
|
||||
setPrivateSafety: (roverId, safety = {}) =>
|
||||
emitWithAck('session:privateSafety:set', { roverId, safety }),
|
||||
ptzClaim: () => emitWithAck('ptzCamera:claim'),
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
removeSignal,
|
||||
setDeterrence,
|
||||
setMuted,
|
||||
setPermission,
|
||||
setVerified,
|
||||
updateFeatureState,
|
||||
} from './identityDatabaseApi.js';
|
||||
@@ -307,9 +308,33 @@ function RawRecordCard({ user }) {
|
||||
);
|
||||
}
|
||||
|
||||
function PermissionsCard({ user, permissions, onPermission }) {
|
||||
const grantedKeys = new Set((user?.permissions || []).map((permission) => permission.key));
|
||||
return (
|
||||
<CardFrame title="Permissions" bodyClassName="grid gap-0.5 p-0.5 text-sm md:grid-cols-2">
|
||||
{permissions.map((permission) => (
|
||||
<label key={permission.key} className="surface space-y-0.5 px-1 py-0.75 text-slate-100">
|
||||
<span className="flex items-center gap-0.5 font-semibold">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3.5 w-3.5 accent-emerald-500"
|
||||
checked={grantedKeys.has(permission.key)}
|
||||
onChange={(event) => onPermission(permission.key, event.target.checked)}
|
||||
/>
|
||||
{permission.label}
|
||||
</span>
|
||||
<span className="block text-xs text-slate-400">{permission.description}</span>
|
||||
<code className="block text-[0.68rem] text-lime-300">{permission.key}</code>
|
||||
</label>
|
||||
))}
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
export default function IdentityDatabasePanel() {
|
||||
const socket = useSocket();
|
||||
const [users, setUsers] = useState([]);
|
||||
const [permissions, setPermissions] = useState([]);
|
||||
const [selectedUser, setSelectedUser] = useState(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [filter, setFilter] = useState('all');
|
||||
@@ -321,6 +346,7 @@ export default function IdentityDatabasePanel() {
|
||||
try {
|
||||
const resp = await listUsers(socket);
|
||||
setUsers(resp.users || []);
|
||||
setPermissions(resp.permissions || []);
|
||||
if (selectedUser?.id) {
|
||||
const updated = await getUser(socket, selectedUser.id);
|
||||
setSelectedUser(updated.user || null);
|
||||
@@ -380,6 +406,11 @@ export default function IdentityDatabasePanel() {
|
||||
runMutation((userId) => setDeterrence(socket, userId, enabled, reason), 'Deterrence updated.');
|
||||
const handleMuted = (enabled) =>
|
||||
runMutation((userId) => setMuted(socket, userId, enabled), 'Mute updated.');
|
||||
const handlePermission = (permissionKey, enabled) =>
|
||||
runMutation(
|
||||
(userId) => setPermission(socket, userId, permissionKey, enabled),
|
||||
'Permission updated.',
|
||||
);
|
||||
const handleSaveFeature = (namespace, value) =>
|
||||
runMutation((userId) => updateFeatureState(socket, userId, namespace, value), 'Feature state saved.');
|
||||
const handleDeleteFeature = (namespace) =>
|
||||
@@ -407,6 +438,7 @@ export default function IdentityDatabasePanel() {
|
||||
<TabList>
|
||||
<Tab id="signals">Signals</Tab>
|
||||
<Tab id="status">Status</Tab>
|
||||
<Tab id="permissions">Permissions</Tab>
|
||||
<Tab id="features">Feature state</Tab>
|
||||
<Tab id="raw">Raw JSON</Tab>
|
||||
</TabList>
|
||||
@@ -417,6 +449,9 @@ export default function IdentityDatabasePanel() {
|
||||
<TabPanel id="status">
|
||||
<StatusCard user={selectedUser} onVerified={handleVerified} onDeterrence={handleDeterrence} onMuted={handleMuted} />
|
||||
</TabPanel>
|
||||
<TabPanel id="permissions">
|
||||
<PermissionsCard user={selectedUser} permissions={permissions} onPermission={handlePermission} />
|
||||
</TabPanel>
|
||||
<TabPanel id="features">
|
||||
<FeatureStateCard user={selectedUser} onSaveFeature={handleSaveFeature} onDeleteFeature={handleDeleteFeature} />
|
||||
</TabPanel>
|
||||
|
||||
@@ -41,6 +41,10 @@ export function setMuted(socket, userId, enabled) {
|
||||
return emitIdentityAdmin(socket, 'identityAdmin:setMuted', { userId, enabled });
|
||||
}
|
||||
|
||||
export function setPermission(socket, userId, permissionKey, enabled) {
|
||||
return emitIdentityAdmin(socket, 'identityAdmin:setPermission', { userId, permissionKey, enabled });
|
||||
}
|
||||
|
||||
export function updateFeatureState(socket, userId, namespace, value) {
|
||||
return emitIdentityAdmin(socket, 'identityAdmin:updateFeatureState', { userId, namespace, value });
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ export function userMatchesQuery(user, query) {
|
||||
...(user?.nicknames || []),
|
||||
...(user?.knownIps || []),
|
||||
...(user?.featureNamespaces || []),
|
||||
...(user?.permissions || []).map((permission) => permission.key),
|
||||
].join(' ').toLowerCase();
|
||||
return haystack.includes(needle);
|
||||
}
|
||||
|
||||
@@ -19,9 +19,17 @@ export default function useUserIdentitySync({ identitySurface = 'passive' } = {}
|
||||
'overseerPreference',
|
||||
{ enabled: false },
|
||||
);
|
||||
const { value: audioAdjustments, status: audioAdjustmentsStatus } = useSettingsNamespace('audioAdjustments', {
|
||||
hornPercent: 0,
|
||||
ttsPercent: 0,
|
||||
forwardPercent: 0,
|
||||
});
|
||||
|
||||
const ready =
|
||||
identityStatus === 'ready' && profileStatus === 'ready' && overseerPreferenceStatus === 'ready';
|
||||
identityStatus === 'ready'
|
||||
&& profileStatus === 'ready'
|
||||
&& overseerPreferenceStatus === 'ready'
|
||||
&& audioAdjustmentsStatus === 'ready';
|
||||
const cookieUserId = (identity?.cookieUserId || '').trim();
|
||||
const nickname = (profile?.nickname || '').trim();
|
||||
const overseerEnabled = Boolean(overseerPreference?.enabled);
|
||||
@@ -40,6 +48,7 @@ export default function useUserIdentitySync({ identitySurface = 'passive' } = {}
|
||||
cookieUserId,
|
||||
fingerprintId: await getBrowserFingerprintId(),
|
||||
nickname,
|
||||
audioAdjustments,
|
||||
overseerEnabled,
|
||||
identitySurface: normalizedIdentitySurface,
|
||||
});
|
||||
@@ -55,6 +64,7 @@ export default function useUserIdentitySync({ identitySurface = 'passive' } = {}
|
||||
*/
|
||||
}
|
||||
}, [
|
||||
audioAdjustments,
|
||||
connected,
|
||||
cookieUserId,
|
||||
identifySession,
|
||||
@@ -75,7 +85,7 @@ export default function useUserIdentitySync({ identitySurface = 'passive' } = {}
|
||||
*/
|
||||
if (!ready || !connected || !socket?.id) return;
|
||||
sendIdentify();
|
||||
}, [ready, connected, socket?.id, cookieUserId, nickname, overseerEnabled, normalizedIdentitySurface, sendIdentify]);
|
||||
}, [ready, connected, socket?.id, cookieUserId, nickname, audioAdjustments, overseerEnabled, normalizedIdentitySurface, sendIdentify]);
|
||||
|
||||
useEffect(() => {
|
||||
/*
|
||||
|
||||
@@ -36,6 +36,9 @@ async function buildSocketIdentity() {
|
||||
cookieUserId: String(currentSettings?.identity?.cookieUserId || '').trim(),
|
||||
fingerprintId,
|
||||
nickname: String(currentSettings?.profile?.nickname || '').trim(),
|
||||
// Signed percentages are harmless browser preferences. The server resolves
|
||||
// identity first, then enforces permission and range before rover output.
|
||||
audioAdjustments: currentSettings?.audioAdjustments || {},
|
||||
overseerEnabled: Boolean(currentSettings?.overseerPreference?.enabled),
|
||||
identitySurface: getIdentitySurface(),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user