Files
psysonic/src/components/PasteClipboardHandler.tsx
T
Psychotoxical 7c724a642f chore(eslint): add ESLint toolchain and clean src to strict 0/0 (#1165)
* 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>
2026-06-24 01:33:34 +02:00

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)}
/>
</>
);
}