mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 14:05:41 +00:00
7c724a642f
* chore(eslint): add eslint toolchain and configs * fix(eslint): resolve gradual-config errors (rules-of-hooks, no-empty, prefer-const, …) * chore(eslint): clear unused vars in config, contexts, app and test * chore(eslint): clear unused vars in api and music-network * chore(eslint): clear unused vars in utils * chore(eslint): clear unused vars in store * chore(eslint): clear unused vars in cover and hooks * chore(eslint): clear unused vars in components * chore(eslint): clear unused vars in pages * chore(eslint): remove explicit any in src * chore(eslint): align react-hooks exhaustive-deps * chore(eslint): zero gradual config on src * chore(eslint): strict hook rules in store and utils * chore(eslint): strict hook rules in hooks and cover * chore(eslint): strict hook rules in components * chore(eslint): strict hook rules in pages and contexts * chore(eslint): document scripts ignore in eslint config * chore(eslint): add lint script and zero strict findings * chore(eslint): address review round 1 (gradual 0/0, per-site disable reasons) * chore(eslint): tighten two set-state disable comments (review round 2 LOW) * chore(nix): sync npmDepsHash with package-lock.json * docs(changelog): add Under the Hood entry for ESLint setup (PR #1165) --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
262 lines
10 KiB
TypeScript
262 lines
10 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
import { useLocation, useNavigate } from 'react-router-dom';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useAuthStore } from '../store/authStore';
|
|
import type { EntitySharePayloadV1 } from '../utils/share/shareLink';
|
|
import { decodeSharePayloadFromText } from '../utils/share/shareLink';
|
|
import { decodeServerMagicStringFromText } from '../utils/server/serverMagicString';
|
|
import { applySharePastePayload, applySharePasteQueue } from '../utils/share/applySharePaste';
|
|
import { shareQueueServerContext } from '../utils/share/shareServerOriginLabel';
|
|
import { showToast } from '../utils/ui/toast';
|
|
import { useShareQueuePreview } from '../hooks/useShareQueuePreview';
|
|
import ShareQueuePreviewModal from './search/ShareQueuePreviewModal';
|
|
import {
|
|
parseOrbitShareLink,
|
|
joinOrbitSession,
|
|
findSessionPlaylistId,
|
|
readOrbitState,
|
|
OrbitJoinError,
|
|
} from '../utils/orbit';
|
|
import { switchActiveServer } from '../utils/server/switchActiveServer';
|
|
import { useOrbitAccountPickerStore } from '../store/orbitAccountPickerStore';
|
|
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-`)
|
|
* outside text fields. Shares require login; invites open add-server (settings or login).
|
|
*/
|
|
type QueuePastePayload = Extract<EntitySharePayloadV1, { k: 'queue' }>;
|
|
|
|
export default function PasteClipboardHandler() {
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const { t } = useTranslation();
|
|
const isLoggedIn = useAuthStore(s => s.isLoggedIn);
|
|
const servers = useAuthStore(s => s.servers);
|
|
const activeServerId = useAuthStore(s => s.activeServerId);
|
|
const busy = useRef(false);
|
|
const [orbitConfirm, setOrbitConfirm] = useState<{ sid: string; host: string; name: string } | null>(null);
|
|
const [orbitInvalid, setOrbitInvalid] = useState(false);
|
|
const [queuePaste, setQueuePaste] = useState<QueuePastePayload | null>(null);
|
|
const [queuePasteBusy, setQueuePasteBusy] = useState(false);
|
|
const queuePreview = useShareQueuePreview(queuePaste, !!queuePaste);
|
|
const { label: queuePasteServerLabel, coverServer: queuePasteCoverServer } = useMemo(
|
|
() =>
|
|
queuePaste
|
|
? shareQueueServerContext(queuePaste.srv, servers, activeServerId)
|
|
: { label: null, coverServer: null },
|
|
[queuePaste, servers, activeServerId],
|
|
);
|
|
|
|
// `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) => {
|
|
const target = e.target as HTMLElement | null;
|
|
if (!target) return;
|
|
if (
|
|
target.tagName === 'INPUT' ||
|
|
target.tagName === 'TEXTAREA' ||
|
|
target.tagName === 'SELECT' ||
|
|
target.isContentEditable
|
|
) {
|
|
return;
|
|
}
|
|
const text = e.clipboardData?.getData('text/plain') ?? '';
|
|
|
|
// Orbit share link — handled before library shares.
|
|
const orbit = parseOrbitShareLink(text.trim());
|
|
if (orbit) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (!isLoggedIn) { showToast(t('orbit.toastLoginFirst'), 4000, 'info'); return; }
|
|
if (busy.current) return;
|
|
busy.current = true;
|
|
|
|
(async () => {
|
|
const active = useAuthStore.getState().getActiveServer();
|
|
const activeUrl = (active?.url ?? '').replace(/\/+$/, '');
|
|
const wantUrl = orbit.serverBase.replace(/\/+$/, '');
|
|
|
|
// Auto-switch to the link's target server if the user has an
|
|
// account registered for it. No account → clear error. Multiple
|
|
// accounts for the same URL → picker lets the user choose. The
|
|
// switch itself tears down any lingering orbit session (see
|
|
// switchActiveServer) so the join below starts clean.
|
|
if (activeUrl !== wantUrl) {
|
|
const candidates = useAuthStore.getState().servers
|
|
.filter(s => s.url.replace(/\/+$/, '') === wantUrl);
|
|
if (candidates.length === 0) {
|
|
showToast(t('orbit.toastNoAccountForServer', { url: wantUrl }), 5000, 'warning');
|
|
return;
|
|
}
|
|
const target = candidates.length === 1
|
|
? candidates[0]
|
|
: await useOrbitAccountPickerStore.getState().request(candidates);
|
|
if (!target) return; // cancelled
|
|
const switched = await switchActiveServer(target);
|
|
if (!switched) {
|
|
showToast(t('orbit.toastSwitchFailed', { url: wantUrl }), 5000, 'error');
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Preview the session state so the confirm dialog can show the
|
|
// host and session name. Failures surface the same error toasts
|
|
// the join would, without ever showing the confirm.
|
|
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;
|
|
}
|
|
|
|
const share = decodeSharePayloadFromText(text);
|
|
if (share) {
|
|
if (!isLoggedIn) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
showToast(t('sharePaste.notLoggedIn'), 4000, 'info');
|
|
return;
|
|
}
|
|
if (busy.current || queuePaste) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
return;
|
|
}
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (share.k === 'queue') {
|
|
if (share.ids.length === 0) {
|
|
showToast(t('sharePaste.genericError'), 5000, 'error');
|
|
return;
|
|
}
|
|
setQueuePaste(share);
|
|
return;
|
|
}
|
|
busy.current = true;
|
|
void applySharePastePayload(share, navigate, t, location).finally(() => {
|
|
busy.current = false;
|
|
});
|
|
return;
|
|
}
|
|
const invite = decodeServerMagicStringFromText(text);
|
|
if (!invite) return;
|
|
if (busy.current) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
return;
|
|
}
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
busy.current = true;
|
|
if (isLoggedIn) {
|
|
navigate('/settings', { state: { tab: 'server' as const, openAddServerInvite: invite } });
|
|
} else {
|
|
navigate('/login', { state: { openAddServerInvite: invite } });
|
|
}
|
|
queueMicrotask(() => {
|
|
busy.current = false;
|
|
});
|
|
};
|
|
document.addEventListener('paste', onPaste, true);
|
|
return () => document.removeEventListener('paste', onPaste, true);
|
|
// handleJoinError and the router location are captured by the onPaste closure;
|
|
// the global paste listener is intentionally not re-registered on every render
|
|
// or navigation, only when the auth/handler inputs below change.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [navigate, t, isLoggedIn, queuePaste]);
|
|
|
|
const closeQueuePaste = () => {
|
|
if (queuePasteBusy) return;
|
|
setQueuePaste(null);
|
|
};
|
|
|
|
const confirmQueuePaste = async () => {
|
|
if (!queuePaste || queuePasteBusy) return;
|
|
setQueuePasteBusy(true);
|
|
const ok = await applySharePasteQueue(queuePaste, t);
|
|
setQueuePasteBusy(false);
|
|
if (ok) setQueuePaste(null);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{queuePaste && (
|
|
<ShareQueuePreviewModal
|
|
open
|
|
onClose={closeQueuePaste}
|
|
payload={queuePaste}
|
|
preview={queuePreview}
|
|
shareServerLabel={queuePasteServerLabel}
|
|
coverServer={queuePasteCoverServer}
|
|
onEnqueue={() => void confirmQueuePaste()}
|
|
enqueueBusy={queuePasteBusy}
|
|
confirmLabel={t('sharePaste.playQueue')}
|
|
confirmBusyLabel={t('sharePaste.playQueueing')}
|
|
/>
|
|
)}
|
|
<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)}
|
|
/>
|
|
</>
|
|
);
|
|
}
|