mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
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:
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Crown, User, UserMinus, ShieldOff } from 'lucide-react';
|
||||
import { Crown, User, UserMinus, ShieldOff, Mic, MicOff } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOrbitStore } from '../store/orbitStore';
|
||||
import { kickOrbitParticipant, removeOrbitParticipant } from '../utils/orbit';
|
||||
import { kickOrbitParticipant, removeOrbitParticipant, setOrbitSuggestionBlocked } from '../utils/orbit';
|
||||
import ConfirmModal from './ConfirmModal';
|
||||
|
||||
interface Props {
|
||||
@@ -90,35 +90,50 @@ export default function OrbitParticipantsPopover({ anchorRef, onClose }: Props)
|
||||
<div className="orbit-participants-pop__empty">{t('orbit.participantsEmpty')}</div>
|
||||
)}
|
||||
|
||||
{state.participants.map(p => (
|
||||
<div key={p.user} className="orbit-participants-pop__row">
|
||||
<User size={13} />
|
||||
<span className="orbit-participants-pop__name">{p.user}</span>
|
||||
<span className="orbit-participants-pop__meta">{joinedFor(p.joinedAt, nowMs)}</span>
|
||||
{role === 'host' && (
|
||||
<div className="orbit-participants-pop__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="orbit-participants-pop__kick"
|
||||
onClick={() => setConfirm({ user: p.user, mode: 'remove' })}
|
||||
data-tooltip={t('orbit.participantsRemoveTooltip')}
|
||||
aria-label={t('orbit.participantsRemoveAria', { user: p.user })}
|
||||
>
|
||||
<UserMinus size={12} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="orbit-participants-pop__kick orbit-participants-pop__kick--ban"
|
||||
onClick={() => setConfirm({ user: p.user, mode: 'ban' })}
|
||||
data-tooltip={t('orbit.participantsBanTooltip')}
|
||||
aria-label={t('orbit.participantsBanAria', { user: p.user })}
|
||||
>
|
||||
<ShieldOff size={12} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{state.participants.map(p => {
|
||||
const isMuted = state.suggestionBlocked?.includes(p.user) ?? false;
|
||||
return (
|
||||
<div key={p.user} className="orbit-participants-pop__row">
|
||||
<User size={13} />
|
||||
<span className="orbit-participants-pop__name">{p.user}</span>
|
||||
<span className="orbit-participants-pop__meta">{joinedFor(p.joinedAt, nowMs)}</span>
|
||||
{role === 'host' && (
|
||||
<div className="orbit-participants-pop__actions">
|
||||
<button
|
||||
type="button"
|
||||
className={`orbit-participants-pop__kick${isMuted ? ' is-active' : ''}`}
|
||||
onClick={() => { void setOrbitSuggestionBlocked(p.user, !isMuted); }}
|
||||
data-tooltip={isMuted ? t('orbit.participantsUnmuteTooltip') : t('orbit.participantsMuteTooltip')}
|
||||
aria-label={isMuted
|
||||
? t('orbit.participantsUnmuteAria', { user: p.user })
|
||||
: t('orbit.participantsMuteAria', { user: p.user })}
|
||||
aria-pressed={isMuted}
|
||||
>
|
||||
{isMuted ? <MicOff size={12} /> : <Mic size={12} />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="orbit-participants-pop__kick"
|
||||
onClick={() => setConfirm({ user: p.user, mode: 'remove' })}
|
||||
data-tooltip={t('orbit.participantsRemoveTooltip')}
|
||||
aria-label={t('orbit.participantsRemoveAria', { user: p.user })}
|
||||
>
|
||||
<UserMinus size={12} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="orbit-participants-pop__kick orbit-participants-pop__kick--ban"
|
||||
onClick={() => setConfirm({ user: p.user, mode: 'ban' })}
|
||||
data-tooltip={t('orbit.participantsBanTooltip')}
|
||||
aria-label={t('orbit.participantsBanAria', { user: p.user })}
|
||||
>
|
||||
<ShieldOff size={12} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<ConfirmModal
|
||||
open={!!confirm}
|
||||
|
||||
Reference in New Issue
Block a user