feat(orbit): per-guest suggestion mute + global pending-cap setting

Two anti-spam knobs the host can dial during a live session:

1. Per-guest suggestion mute — Mic / MicOff toggle next to the
   kick/ban buttons in the participants popover. Symmetric (re-enable
   later). State lives in OrbitState.suggestionBlocked: string[]; the
   guest reads it and disables its own Suggest controls so the user
   sees a clear "muted" state instead of silent failures. Host-side
   sweep also drops their outbox entries as a safety net.

2. Max pending approvals cap — number input in the session-settings
   popover, default 0 (= unlimited so existing sessions are unaffected).
   When set, the host sweep stops folding new outbox entries into the
   approval list once the cap is reached. The OrbitQueueHead surfaces
   "X / Y pending" so guests can see when they're getting close.

State changes are additive on the wire — both fields are optional, with
parseOrbitState defaulting them, so older clients keep working.

evaluateOrbitSuggestGate() centralises the guest-side allow/block check
shared between useOrbitSongRowBehavior and the ContextMenu Add-to-Session
items, plus suggestOrbitTrack as a defensive last line.

i18n: en + de + fr + nl + zh + nb + ru + es.

Also fixes an earlier dedupe-key collision: the (user, trackId) cache
keys were missing their separator (NULL byte slipped in during the
previous patch), so two tracks could share a key and one of them
silently overwrite the other. Restored the space separator.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-25 01:21:51 +02:00
parent 6ca678547b
commit cfb7e7f6c1
17 changed files with 428 additions and 57 deletions
+103 -3
View File
@@ -456,7 +456,49 @@ export async function leaveOrbitSession(): Promise<void> {
* consume it and publish the authoritative queue update in the state blob.
* No state mutation here — the guest never touches canonical state.
*/
/** Why a guest's suggestion would be blocked, in priority order. `null` means
* the suggestion can proceed. */
export type OrbitSuggestGateReason = 'not-guest' | 'muted' | 'cap-reached' | null;
/**
* Evaluate whether the local guest is allowed to send a new suggestion right
* now — used by both the UI (to disable buttons / show toasts) and
* {@link suggestOrbitTrack} as a defensive check.
*
* Mute check uses the host-pushed `state.suggestionBlocked` list. Cap check
* is approximate from the guest's POV: it counts every non-host entry in
* `state.queue` plus the local `pendingSuggestions` list that hasn't been
* reconciled yet. We can't subtract host-side merged/declined sets — the
* guest never sees them — so the count is conservative (over-, not under-).
*/
export function evaluateOrbitSuggestGate(): { allowed: boolean; reason: OrbitSuggestGateReason } {
const { role, state, pendingSuggestions } = useOrbitStore.getState();
if (role !== 'guest' || !state) return { allowed: false, reason: 'not-guest' };
const username = useAuthStore.getState().getActiveServer()?.username ?? '';
if (state.suggestionBlocked?.includes(username)) {
return { allowed: false, reason: 'muted' };
}
const cap = state.settings?.maxPending ?? 0;
if (cap > 0) {
const inState = state.queue.filter(q => q.addedBy !== state.host).length;
const total = inState + pendingSuggestions.length;
if (total >= cap) return { allowed: false, reason: 'cap-reached' };
}
return { allowed: true, reason: null };
}
export class OrbitSuggestBlockedError extends Error {
constructor(public readonly reason: Exclude<OrbitSuggestGateReason, null>) {
super(`Suggestion blocked: ${reason}`);
this.name = 'OrbitSuggestBlockedError';
}
}
export async function suggestOrbitTrack(trackId: string): Promise<void> {
const gate = evaluateOrbitSuggestGate();
if (!gate.allowed && gate.reason && gate.reason !== 'not-guest') {
throw new OrbitSuggestBlockedError(gate.reason);
}
const { role, outboxPlaylistId, sessionId } = useOrbitStore.getState();
if (role !== 'guest') throw new Error('Not joined to a session as a guest');
if (!outboxPlaylistId || !sessionId) throw new Error('No outbox bound');
@@ -853,6 +895,40 @@ export async function removeOrbitParticipant(username: string): Promise<void> {
} catch { /* best-effort */ }
}
/**
* Host: mute/unmute a participant's track suggestions.
*
* Symmetric — pass `blocked: true` to add the username to
* `state.suggestionBlocked`, `false` to remove it. The participant remains
* in the session and continues to appear in the participants list; only new
* outbox entries are silently dropped during the host's sweep. The guest UI
* reads the same flag and disables its own Suggest controls so the user
* sees a clear "muted" state instead of silent failures.
*
* No-op outside host role, when the session isn't active, when the target
* is the host themselves, or when the toggle wouldn't change anything.
*/
export async function setOrbitSuggestionBlocked(username: string, blocked: boolean): Promise<void> {
const store = useOrbitStore.getState();
if (store.role !== 'host') return;
const state = store.state;
const sessionPlaylistId = store.sessionPlaylistId;
if (!state || !sessionPlaylistId) return;
if (username === state.host) return;
const current = state.suggestionBlocked ?? [];
const isBlocked = current.includes(username);
if (blocked === isBlocked) return;
const nextList = blocked
? [...current, username]
: current.filter(u => u !== username);
const nextState: OrbitState = { ...state, suggestionBlocked: nextList };
useOrbitStore.getState().setState(nextState);
try { await writeOrbitState(sessionPlaylistId, nextState); }
catch { /* best-effort; next host tick will re-push state */ }
}
/**
* Fold sweep results into an updated `OrbitState`.
*
@@ -868,6 +944,7 @@ export function applyOutboxSnapshotsToState(
state: OrbitState,
snapshots: OutboxSnapshot[],
nowMs: number = Date.now(),
opts?: { mergedKeys?: ReadonlySet<string>; declinedKeys?: ReadonlySet<string> },
): OrbitState {
// ── Queue additions ──
// Guest outboxes are append-only from the host's POV — the host reads the
@@ -877,18 +954,41 @@ export function applyOutboxSnapshotsToState(
// balloons indefinitely. A user re-suggesting the same track after it
// lands/plays is a rare enough case to live with for now.
const existingKeys = new Set<string>(
state.queue.map(q => `${q.addedBy}${q.trackId}`),
state.queue.map(q => `${q.addedBy} ${q.trackId}`),
);
if (state.currentTrack) {
existingKeys.add(`${state.currentTrack.addedBy}${state.currentTrack.trackId}`);
existingKeys.add(`${state.currentTrack.addedBy} ${state.currentTrack.trackId}`);
}
// Compute remaining slots under the host's pending-approval cap. Items the
// host has merged or declined have left the approval queue and don't
// occupy a slot any more — without those sets we conservatively count
// every non-host queue entry as pending. Plus drop any new suggestion
// from a user the host has muted.
const blocked = new Set(state.suggestionBlocked ?? []);
const cap = state.settings?.maxPending ?? 0;
const merged = opts?.mergedKeys ?? new Set<string>();
const declined = opts?.declinedKeys ?? new Set<string>();
const pendingCount = cap > 0
? state.queue.filter(q =>
q.addedBy !== state.host
&& !merged.has(suggestionKey(q))
&& !declined.has(suggestionKey(q))
).length
: 0;
let remaining = cap > 0 ? Math.max(0, cap - pendingCount) : Infinity;
const newItems: OrbitQueueItem[] = [];
outer:
for (const snap of snapshots) {
if (blocked.has(snap.user)) continue;
for (const trackId of snap.trackIds) {
const key = `${snap.user}${trackId}`;
if (remaining <= 0) break outer;
const key = `${snap.user} ${trackId}`;
if (existingKeys.has(key)) continue;
existingKeys.add(key);
newItems.push({ trackId, addedBy: snap.user, addedAt: nowMs });
remaining--;
}
}