mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
exp(orbit): iterative refinements
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,10 +7,15 @@ interface ConfirmModalProps {
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel: string;
|
||||
cancelLabel: string;
|
||||
/**
|
||||
* Cancel button label. Omit (together with `onCancel`) to render the
|
||||
* modal as a single-button info dialog — Esc / outside-click / X then
|
||||
* also resolve via `onConfirm`.
|
||||
*/
|
||||
cancelLabel?: string;
|
||||
danger?: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export default function ConfirmModal({
|
||||
@@ -23,15 +28,17 @@ export default function ConfirmModal({
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmModalProps) {
|
||||
const dismiss = onCancel ?? onConfirm;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onCancel();
|
||||
if (e.key === 'Escape') dismiss();
|
||||
else if (e.key === 'Enter') onConfirm();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [open, onCancel, onConfirm]);
|
||||
}, [open, dismiss, onConfirm]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
@@ -42,7 +49,7 @@ export default function ConfirmModal({
|
||||
return createPortal(
|
||||
<div
|
||||
className="modal-overlay"
|
||||
onClick={onCancel}
|
||||
onClick={dismiss}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
style={{ alignItems: 'center', paddingTop: 0 }}
|
||||
@@ -52,7 +59,7 @@ export default function ConfirmModal({
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{ maxWidth: '380px' }}
|
||||
>
|
||||
<button className="modal-close" onClick={onCancel} aria-label={cancelLabel}>
|
||||
<button className="modal-close" onClick={dismiss} aria-label={cancelLabel ?? confirmLabel}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
<h3 style={{ marginBottom: '0.5rem', fontFamily: 'var(--font-display)' }}>{title}</h3>
|
||||
@@ -60,10 +67,12 @@ export default function ConfirmModal({
|
||||
{message}
|
||||
</p>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
|
||||
<button className="btn btn-ghost" onClick={onCancel} autoFocus>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button className="btn btn-primary" style={confirmStyle} onClick={onConfirm}>
|
||||
{cancelLabel && onCancel && (
|
||||
<button className="btn btn-ghost" onClick={onCancel} autoFocus>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-primary" style={confirmStyle} onClick={onConfirm} autoFocus={!cancelLabel}>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,9 @@ import { leaveOrbitSession } from '../utils/orbit';
|
||||
*
|
||||
* Shown when:
|
||||
* - `phase === 'ended'` (host closed the session; guest sees it)
|
||||
* - `phase === 'error' && errorMessage === 'kicked'` (host removed us)
|
||||
* - `phase === 'error' && errorMessage === 'kicked'` (host permanently banned us)
|
||||
* - `phase === 'error' && errorMessage === 'removed'` (host soft-removed us;
|
||||
* re-join via invite link still works)
|
||||
*
|
||||
* "OK" cleans up the guest-side outbox + resets the local store.
|
||||
*/
|
||||
@@ -20,14 +22,21 @@ export default function OrbitExitModal() {
|
||||
const sessionName = useOrbitStore(s => s.state?.name);
|
||||
const hostName = useOrbitStore(s => s.state?.host);
|
||||
|
||||
const isEnded = phase === 'ended';
|
||||
const isKicked = phase === 'error' && errorMessage === 'kicked';
|
||||
if (!isEnded && !isKicked) return null;
|
||||
const isEnded = phase === 'ended';
|
||||
const isKicked = phase === 'error' && errorMessage === 'kicked';
|
||||
const isRemoved = phase === 'error' && errorMessage === 'removed';
|
||||
if (!isEnded && !isKicked && !isRemoved) return null;
|
||||
|
||||
const title = isKicked ? t('orbit.exitKickedTitle') : t('orbit.exitEndedTitle');
|
||||
const body = isKicked
|
||||
? t('orbit.exitKickedBody', { host: hostName ?? '', name: sessionName ?? '' })
|
||||
: t('orbit.exitEndedBody', { name: sessionName ?? '' });
|
||||
const title = isKicked
|
||||
? t('orbit.exitKickedTitle')
|
||||
: isRemoved
|
||||
? t('orbit.exitRemovedTitle')
|
||||
: t('orbit.exitEndedTitle');
|
||||
const body = isKicked
|
||||
? t('orbit.exitKickedBody', { host: hostName ?? '', name: sessionName ?? '' })
|
||||
: isRemoved
|
||||
? t('orbit.exitRemovedBody', { host: hostName ?? '', name: sessionName ?? '' })
|
||||
: t('orbit.exitEndedBody', { name: sessionName ?? '' });
|
||||
|
||||
const onOk = async () => {
|
||||
try {
|
||||
|
||||
@@ -14,16 +14,19 @@ import CachedImage from './CachedImage';
|
||||
* Orbit — guest-side queue view.
|
||||
*
|
||||
* Rendered in place of the normal QueuePanel contents while `role === 'guest'`.
|
||||
* Read-only: shows the host's current track + every guest-submitted suggestion
|
||||
* (including ours). No reorder / remove / save — those belong to the host.
|
||||
* Read-only: shows the host's current track + the host's actual upcoming
|
||||
* play queue (`state.playQueue`, capped at `ORBIT_PLAY_QUEUE_LIMIT`). No
|
||||
* reorder / remove / save — those belong to the host.
|
||||
*
|
||||
* Track metadata is resolved lazily via `getSong` and cached locally so the
|
||||
* list doesn't flicker while the 2.5 s state tick refreshes `state.queue`.
|
||||
* list doesn't flicker while the 2.5 s state tick refreshes the snapshot.
|
||||
*/
|
||||
export default function OrbitGuestQueue() {
|
||||
const { t } = useTranslation();
|
||||
const state = useOrbitStore(s => s.state);
|
||||
const queueItems = state?.queue ?? [];
|
||||
const queueItems = state?.playQueue ?? [];
|
||||
const totalUpcoming = state?.playQueueTotal ?? queueItems.length;
|
||||
const truncatedBy = Math.max(0, totalUpcoming - queueItems.length);
|
||||
const currentTrack = state?.currentTrack ?? null;
|
||||
|
||||
// Local song cache — keyed by trackId. Survives parent re-renders triggered
|
||||
@@ -103,19 +106,20 @@ export default function OrbitGuestQueue() {
|
||||
)}
|
||||
|
||||
<div className="orbit-guest-queue__section-head">
|
||||
{t('orbit.guestSuggestions')} <span className="orbit-guest-queue__count">{queueItems.length}</span>
|
||||
{t('orbit.guestUpNext')} <span className="orbit-guest-queue__count">{totalUpcoming}</span>
|
||||
</div>
|
||||
|
||||
<div className="orbit-guest-queue__list">
|
||||
{queueItems.length === 0 && (
|
||||
<div className="orbit-guest-queue__empty">{t('orbit.guestEmpty')}</div>
|
||||
<div className="orbit-guest-queue__empty">{t('orbit.guestUpNextEmpty')}</div>
|
||||
)}
|
||||
|
||||
{queueItems.map((q, i) => {
|
||||
const song = songs[q.trackId];
|
||||
const isHostPick = q.addedBy === state.host;
|
||||
return (
|
||||
<div
|
||||
key={`${q.addedBy}-${q.addedAt}-${q.trackId}-${i}`}
|
||||
key={`${q.trackId}-${i}`}
|
||||
className="orbit-guest-queue__item"
|
||||
>
|
||||
{song?.coverArt ? (
|
||||
@@ -135,13 +139,21 @@ export default function OrbitGuestQueue() {
|
||||
<div className="orbit-guest-queue__track-artist">
|
||||
{song?.artist ?? ''}
|
||||
</div>
|
||||
<div className="orbit-guest-queue__submitter">
|
||||
{t('orbit.guestSubmitter', { user: q.addedBy })}
|
||||
</div>
|
||||
{!isHostPick && (
|
||||
<div className="orbit-guest-queue__submitter">
|
||||
{t('orbit.guestSubmitter', { user: q.addedBy })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{truncatedBy > 0 && (
|
||||
<div className="orbit-guest-queue__more">
|
||||
{t('orbit.guestUpNextMore', { count: truncatedBy })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="orbit-guest-queue__footer">{t('orbit.guestFooter')}</div>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Crown, UserMinus, Copy, Check } from 'lucide-react';
|
||||
import { Crown, UserMinus, ShieldOff, Copy, Check } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOrbitStore } from '../store/orbitStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { kickOrbitParticipant, buildOrbitShareLink } from '../utils/orbit';
|
||||
import { kickOrbitParticipant, removeOrbitParticipant, buildOrbitShareLink } from '../utils/orbit';
|
||||
import ConfirmModal from './ConfirmModal';
|
||||
|
||||
interface Props {
|
||||
/** Anchor — we position the popover directly below its bottom-right. */
|
||||
@@ -29,6 +30,7 @@ export default function OrbitParticipantsPopover({ anchorRef, onClose }: Props)
|
||||
const sessionId = useOrbitStore(s => s.sessionId);
|
||||
const popRef = useRef<HTMLDivElement>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [confirm, setConfirm] = useState<{ user: string; mode: 'remove' | 'ban' } | null>(null);
|
||||
const nowMs = Date.now();
|
||||
|
||||
const shareLink = role === 'host' && sessionId
|
||||
@@ -44,22 +46,28 @@ export default function OrbitParticipantsPopover({ anchorRef, onClose }: Props)
|
||||
} catch { /* silent */ }
|
||||
};
|
||||
|
||||
// Close on outside click / Escape.
|
||||
// Close on outside click / Escape — unless a confirm dialog is open
|
||||
// (otherwise outside-clicking the modal would dismiss the popover too,
|
||||
// and re-opening would lose the in-flight confirm context).
|
||||
useEffect(() => {
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (confirm) return;
|
||||
const t = e.target as Node | null;
|
||||
if (popRef.current?.contains(t)) return;
|
||||
if (anchorRef.current?.contains(t)) return;
|
||||
onClose();
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (confirm) return;
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('mousedown', onDown);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [anchorRef, onClose]);
|
||||
}, [anchorRef, onClose, confirm]);
|
||||
|
||||
if (!state) return null;
|
||||
|
||||
@@ -73,11 +81,16 @@ export default function OrbitParticipantsPopover({ anchorRef, onClose }: Props)
|
||||
}
|
||||
: { display: 'none' };
|
||||
|
||||
const onKick = (username: string) => {
|
||||
void kickOrbitParticipant(username);
|
||||
const onConfirm = async () => {
|
||||
if (!confirm) return;
|
||||
const { user, mode } = confirm;
|
||||
setConfirm(null);
|
||||
if (mode === 'remove') await removeOrbitParticipant(user);
|
||||
else await kickOrbitParticipant(user);
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<div ref={popRef} className="orbit-participants-pop" style={style} role="menu">
|
||||
{shareLink && (
|
||||
<div className="orbit-participants-pop__invite">
|
||||
@@ -116,19 +129,47 @@ export default function OrbitParticipantsPopover({ anchorRef, onClose }: Props)
|
||||
<span className="orbit-participants-pop__name">{p.user}</span>
|
||||
<span className="orbit-participants-pop__meta">{joinedFor(p.joinedAt, nowMs)}</span>
|
||||
{role === 'host' && (
|
||||
<button
|
||||
type="button"
|
||||
className="orbit-participants-pop__kick"
|
||||
onClick={() => onKick(p.user)}
|
||||
data-tooltip={t('orbit.participantsKickTooltip')}
|
||||
aria-label={t('orbit.participantsKickAria', { user: p.user })}
|
||||
>
|
||||
<UserMinus size={12} />
|
||||
</button>
|
||||
<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>
|
||||
))}
|
||||
</div>,
|
||||
</div>
|
||||
<ConfirmModal
|
||||
open={!!confirm}
|
||||
title={confirm?.mode === 'ban'
|
||||
? t('orbit.confirmBanTitle')
|
||||
: t('orbit.confirmRemoveTitle')}
|
||||
message={confirm?.mode === 'ban'
|
||||
? t('orbit.confirmBanBody', { user: confirm?.user ?? '' })
|
||||
: t('orbit.confirmRemoveBody', { user: confirm?.user ?? '' })}
|
||||
confirmLabel={confirm?.mode === 'ban'
|
||||
? t('orbit.confirmBanConfirm')
|
||||
: t('orbit.confirmRemoveConfirm')}
|
||||
cancelLabel={t('orbit.confirmCancel')}
|
||||
danger={confirm?.mode === 'ban'}
|
||||
onConfirm={() => { void onConfirm(); }}
|
||||
onCancel={() => setConfirm(null)}
|
||||
/>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { estimateLivePosition } from '../api/orbit';
|
||||
import OrbitParticipantsPopover from './OrbitParticipantsPopover';
|
||||
import OrbitExitModal from './OrbitExitModal';
|
||||
import OrbitSettingsPopover from './OrbitSettingsPopover';
|
||||
import ConfirmModal from './ConfirmModal';
|
||||
|
||||
/**
|
||||
* Orbit — top-strip session indicator.
|
||||
@@ -45,6 +46,7 @@ export default function OrbitSessionBar() {
|
||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||
const [peopleOpen, setPeopleOpen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [confirmLeave, setConfirmLeave] = useState(false);
|
||||
const peopleBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const settingsBtnRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
@@ -80,7 +82,7 @@ export default function OrbitSessionBar() {
|
||||
&& state.currentTrack
|
||||
&& (driftMs == null || Math.abs(driftMs) > CATCH_UP_DRIFT_THRESHOLD_MS);
|
||||
|
||||
const onExit = async () => {
|
||||
const performExit = async () => {
|
||||
try {
|
||||
if (role === 'host') await endOrbitSession();
|
||||
else if (role === 'guest') await leaveOrbitSession();
|
||||
@@ -90,6 +92,17 @@ export default function OrbitSessionBar() {
|
||||
}
|
||||
};
|
||||
|
||||
const onExit = () => {
|
||||
// Guests in an active session get a confirm — leaving is voluntary and
|
||||
// a fat-finger shouldn't drop them out. Host-end and post-end/kicked
|
||||
// dismissals exit immediately (the session is already over there).
|
||||
if (role === 'guest' && phase === 'active') {
|
||||
setConfirmLeave(true);
|
||||
return;
|
||||
}
|
||||
void performExit();
|
||||
};
|
||||
|
||||
const onCatchUp = async () => {
|
||||
if (!state.currentTrack) return;
|
||||
const trackId = state.currentTrack.trackId;
|
||||
@@ -200,6 +213,15 @@ export default function OrbitSessionBar() {
|
||||
/>
|
||||
)}
|
||||
<OrbitExitModal />
|
||||
<ConfirmModal
|
||||
open={confirmLeave}
|
||||
title={t('orbit.confirmLeaveTitle')}
|
||||
message={t('orbit.confirmLeaveBody', { name: state.name, host: state.host })}
|
||||
confirmLabel={t('orbit.confirmLeaveConfirm')}
|
||||
cancelLabel={t('orbit.confirmCancel')}
|
||||
onConfirm={() => { setConfirmLeave(false); void performExit(); }}
|
||||
onCancel={() => setConfirmLeave(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
@@ -6,7 +6,23 @@ import { decodeSharePayloadFromText } from '../utils/shareLink';
|
||||
import { decodeServerMagicStringFromText } from '../utils/serverMagicString';
|
||||
import { applySharePastePayload } from '../utils/applySharePaste';
|
||||
import { showToast } from '../utils/toast';
|
||||
import { parseOrbitShareLink, joinOrbitSession, OrbitJoinError } from '../utils/orbit';
|
||||
import {
|
||||
parseOrbitShareLink,
|
||||
joinOrbitSession,
|
||||
findSessionPlaylistId,
|
||||
readOrbitState,
|
||||
OrbitJoinError,
|
||||
} from '../utils/orbit';
|
||||
import ConfirmModal from './ConfirmModal';
|
||||
|
||||
const ORBIT_JOIN_ERROR_KEYS: Record<string, string> = {
|
||||
'not-found': 'orbit.joinErrNotFound',
|
||||
'ended': 'orbit.joinErrEnded',
|
||||
'full': 'orbit.joinErrFull',
|
||||
'kicked': 'orbit.joinErrKicked',
|
||||
'no-user': 'orbit.joinErrNoUser',
|
||||
'server-error': 'orbit.joinErrServerError',
|
||||
};
|
||||
|
||||
/**
|
||||
* Global paste: library share links (`psysonic2-`) and server invites (`psysonic1-`)
|
||||
@@ -17,6 +33,33 @@ export default function PasteClipboardHandler() {
|
||||
const { t } = useTranslation();
|
||||
const isLoggedIn = useAuthStore(s => s.isLoggedIn);
|
||||
const busy = useRef(false);
|
||||
const [orbitConfirm, setOrbitConfirm] = useState<{ sid: string; host: string; name: string } | null>(null);
|
||||
const [orbitInvalid, setOrbitInvalid] = useState(false);
|
||||
|
||||
// `not-found` and `ended` collapse into a single "link no longer valid"
|
||||
// dialog — from the guest's POV both mean the same thing: the invite
|
||||
// doesn't lead anywhere any more. Other reasons stay as toasts because
|
||||
// they're actionable (full → wait, kicked → talk to host, etc.).
|
||||
const handleJoinError = (reason: string | null, fallback?: string) => {
|
||||
if (reason === 'not-found' || reason === 'ended') {
|
||||
setOrbitInvalid(true);
|
||||
return;
|
||||
}
|
||||
const i18nKey = reason ? ORBIT_JOIN_ERROR_KEYS[reason] : null;
|
||||
showToast(i18nKey ? t(i18nKey) : (fallback ?? t('orbit.toastJoinFail')), 4000, 'error');
|
||||
};
|
||||
|
||||
const runOrbitJoin = (sid: string) => {
|
||||
if (busy.current) return;
|
||||
busy.current = true;
|
||||
joinOrbitSession(sid)
|
||||
.then(() => showToast(t('orbit.toastJoined'), 2500, 'info'))
|
||||
.catch(err => {
|
||||
if (err instanceof OrbitJoinError) handleJoinError(err.reason, err.message);
|
||||
else handleJoinError(null);
|
||||
})
|
||||
.finally(() => { busy.current = false; });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const onPaste = (e: ClipboardEvent) => {
|
||||
@@ -46,25 +89,21 @@ export default function PasteClipboardHandler() {
|
||||
return;
|
||||
}
|
||||
if (busy.current) return;
|
||||
|
||||
// Preview the session state so the confirm dialog can show the host
|
||||
// and session name. Failures (session vanished / ended / unreachable)
|
||||
// surface the same error toasts the join would, without ever showing
|
||||
// the confirm.
|
||||
busy.current = true;
|
||||
joinOrbitSession(orbit.sid)
|
||||
.then(() => showToast(t('orbit.toastJoined'), 2500, 'info'))
|
||||
.catch(err => {
|
||||
if (err instanceof OrbitJoinError) {
|
||||
const key: Record<string, string> = {
|
||||
'not-found': 'orbit.joinErrNotFound',
|
||||
'ended': 'orbit.joinErrEnded',
|
||||
'full': 'orbit.joinErrFull',
|
||||
'kicked': 'orbit.joinErrKicked',
|
||||
'no-user': 'orbit.joinErrNoUser',
|
||||
'server-error': 'orbit.joinErrServerError',
|
||||
};
|
||||
const i18nKey = key[err.reason];
|
||||
showToast(i18nKey ? t(i18nKey) : err.message, 4000, 'error');
|
||||
} else {
|
||||
showToast(t('orbit.toastJoinFail'), 4000, 'error');
|
||||
}
|
||||
})
|
||||
(async () => {
|
||||
const playlistId = await findSessionPlaylistId(orbit.sid);
|
||||
if (!playlistId) { handleJoinError('not-found'); return; }
|
||||
const state = await readOrbitState(playlistId);
|
||||
if (!state) { handleJoinError('not-found'); return; }
|
||||
if (state.ended) { handleJoinError('ended'); return; }
|
||||
setOrbitConfirm({ sid: orbit.sid, host: state.host, name: state.name });
|
||||
})()
|
||||
.catch(() => handleJoinError(null))
|
||||
.finally(() => { busy.current = false; });
|
||||
return;
|
||||
}
|
||||
@@ -113,5 +152,31 @@ export default function PasteClipboardHandler() {
|
||||
return () => document.removeEventListener('paste', onPaste, true);
|
||||
}, [navigate, t, isLoggedIn]);
|
||||
|
||||
return null;
|
||||
return (
|
||||
<>
|
||||
<ConfirmModal
|
||||
open={!!orbitConfirm}
|
||||
title={t('orbit.confirmJoinTitle')}
|
||||
message={t('orbit.confirmJoinBody', {
|
||||
host: orbitConfirm?.host ?? '',
|
||||
name: orbitConfirm?.name ?? '',
|
||||
})}
|
||||
confirmLabel={t('orbit.confirmJoinConfirm')}
|
||||
cancelLabel={t('orbit.confirmCancel')}
|
||||
onConfirm={() => {
|
||||
const sid = orbitConfirm?.sid;
|
||||
setOrbitConfirm(null);
|
||||
if (sid) runOrbitJoin(sid);
|
||||
}}
|
||||
onCancel={() => setOrbitConfirm(null)}
|
||||
/>
|
||||
<ConfirmModal
|
||||
open={orbitInvalid}
|
||||
title={t('orbit.invalidLinkTitle')}
|
||||
message={t('orbit.invalidLinkBody')}
|
||||
confirmLabel={t('orbit.exitOk')}
|
||||
onConfirm={() => setOrbitInvalid(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user