From d912c4293b0f974c13aa24bd4f9e97a58fcf0236 Mon Sep 17 00:00:00 2001 From: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com> Date: Fri, 24 Apr 2026 18:49:55 +0200 Subject: [PATCH] chore(orbit): picker modal when multiple accounts match the link's server Co-Authored-By: Claude Opus 4.7 (1M context) --- src/App.tsx | 2 + src/components/OrbitAccountPicker.tsx | 69 ++++++++++++++++++++++++ src/components/OrbitJoinModal.tsx | 16 ++++-- src/components/PasteClipboardHandler.tsx | 18 ++++--- src/locales/de.ts | 2 + src/locales/en.ts | 2 + src/store/orbitAccountPickerStore.ts | 39 ++++++++++++++ src/styles/components.css | 64 ++++++++++++++++++++++ 8 files changed, 201 insertions(+), 11 deletions(-) create mode 100644 src/components/OrbitAccountPicker.tsx create mode 100644 src/store/orbitAccountPickerStore.ts diff --git a/src/App.tsx b/src/App.tsx index d868c047..6ac5c4fd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -53,6 +53,7 @@ import ContextMenu from './components/ContextMenu'; import SongInfoModal from './components/SongInfoModal'; import DownloadFolderModal from './components/DownloadFolderModal'; import GlobalConfirmModal from './components/GlobalConfirmModal'; +import OrbitAccountPicker from './components/OrbitAccountPicker'; import { DragDropProvider } from './contexts/DragDropContext'; import TooltipPortal from './components/TooltipPortal'; import OverlayScrollArea from './components/OverlayScrollArea'; @@ -520,6 +521,7 @@ function AppShell() { + diff --git a/src/components/OrbitAccountPicker.tsx b/src/components/OrbitAccountPicker.tsx new file mode 100644 index 00000000..f467e547 --- /dev/null +++ b/src/components/OrbitAccountPicker.tsx @@ -0,0 +1,69 @@ +import { useEffect } from 'react'; +import { createPortal } from 'react-dom'; +import { X, User } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { useOrbitAccountPickerStore } from '../store/orbitAccountPickerStore'; + +/** + * Modal shown when joining an Orbit session and the user has more than + * one account for the target server URL. Lets them pick which account + * to switch to before the join flow continues. Mount once in App.tsx — + * any caller can invoke it via `useOrbitAccountPickerStore.request(...)`. + */ +export default function OrbitAccountPicker() { + const { t } = useTranslation(); + const { isOpen, accounts, pick, cancel } = useOrbitAccountPickerStore(); + + useEffect(() => { + if (!isOpen) return; + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') cancel(); }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [isOpen, cancel]); + + if (!isOpen) return null; + + return createPortal( +
{ if (e.target === e.currentTarget) cancel(); }} + role="dialog" + aria-modal="true" + aria-labelledby="orbit-account-picker-title" + style={{ alignItems: 'center', paddingTop: 0 }} + > +
+ +

+ {t('orbit.accountPickerTitle')} +

+

+ {t('orbit.accountPickerSub', { url: accounts[0]?.url ?? '' })} +

+
    + {accounts.map(a => ( +
  • + +
  • + ))} +
+
+ +
+
+
, + document.body, + ); +} diff --git a/src/components/OrbitJoinModal.tsx b/src/components/OrbitJoinModal.tsx index eacb3f64..c20a777f 100644 --- a/src/components/OrbitJoinModal.tsx +++ b/src/components/OrbitJoinModal.tsx @@ -10,6 +10,7 @@ import { joinOrbitSession, } from '../utils/orbit'; import { switchActiveServer } from '../utils/switchActiveServer'; +import { useOrbitAccountPickerStore } from '../store/orbitAccountPickerStore'; import { showToast } from '../utils/toast'; interface Props { @@ -49,15 +50,20 @@ export default function OrbitJoinModal({ onClose }: Props) { setBusy(true); try { // Auto-switch to the link's server if the user has an account for it. - // switch itself tears down any lingering orbit session. + // Multiple candidates → picker modal. switch tears down any lingering + // orbit session. if (activeUrl !== wantUrl) { - const targetServer = useAuthStore.getState().servers - .find(s => s.url.replace(/\/+$/, '') === wantUrl); - if (!targetServer) { + const candidates = useAuthStore.getState().servers + .filter(s => s.url.replace(/\/+$/, '') === wantUrl); + if (candidates.length === 0) { setError(t('orbit.toastNoAccountForServer', { url: wantUrl })); return; } - const switched = await switchActiveServer(targetServer); + const target = candidates.length === 1 + ? candidates[0] + : await useOrbitAccountPickerStore.getState().request(candidates); + if (!target) { setBusy(false); return; } + const switched = await switchActiveServer(target); if (!switched) { setError(t('orbit.toastSwitchFailed', { url: wantUrl })); return; diff --git a/src/components/PasteClipboardHandler.tsx b/src/components/PasteClipboardHandler.tsx index c580f2ff..7bd4e7c3 100644 --- a/src/components/PasteClipboardHandler.tsx +++ b/src/components/PasteClipboardHandler.tsx @@ -14,6 +14,7 @@ import { OrbitJoinError, } from '../utils/orbit'; import { switchActiveServer } from '../utils/switchActiveServer'; +import { useOrbitAccountPickerStore } from '../store/orbitAccountPickerStore'; import ConfirmModal from './ConfirmModal'; const ORBIT_JOIN_ERROR_KEYS: Record = { @@ -91,17 +92,22 @@ export default function PasteClipboardHandler() { 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. switch - // itself tears down any lingering orbit session (see + // 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 targetServer = useAuthStore.getState().servers - .find(s => s.url.replace(/\/+$/, '') === wantUrl); - if (!targetServer) { + 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 switched = await switchActiveServer(targetServer); + 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; diff --git a/src/locales/de.ts b/src/locales/de.ts index 58b855f3..6ae4a470 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -1548,6 +1548,8 @@ export const deTranslation = { toastSwitchServer: 'Wechsle erst zu {{url}} und füge den Link dann erneut ein', toastNoAccountForServer: 'Du hast keinen Zugang zu {{url}}. Bitte den Host um einen Einladungslink.', toastSwitchFailed: 'Wechsel zu {{url}} fehlgeschlagen', + accountPickerTitle: 'Welcher Account?', + accountPickerSub: 'Du hast mehrere Accounts für {{url}}. Wähle aus, mit welchem du der Session beitreten willst.', toastJoinFail: 'Session konnte nicht beigetreten werden', joinErrNotFound: 'Session nicht gefunden', joinErrEnded: 'Session wurde beendet', diff --git a/src/locales/en.ts b/src/locales/en.ts index 54fe3da5..9818af0f 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -1551,6 +1551,8 @@ export const enTranslation = { toastSwitchServer: 'Switch to {{url}} first, then paste again', toastNoAccountForServer: "You don't have access to {{url}}. Ask the host for an invite.", toastSwitchFailed: "Couldn't switch to {{url}}", + accountPickerTitle: 'Which account?', + accountPickerSub: 'You have more than one account for {{url}}. Pick the one to join the session as.', toastJoinFail: "Couldn't join session", joinErrNotFound: 'Session not found', joinErrEnded: 'Session has ended', diff --git a/src/store/orbitAccountPickerStore.ts b/src/store/orbitAccountPickerStore.ts new file mode 100644 index 00000000..74675dfa --- /dev/null +++ b/src/store/orbitAccountPickerStore.ts @@ -0,0 +1,39 @@ +import { create } from 'zustand'; +import type { ServerProfile } from './authStore'; + +let _resolve: ((server: ServerProfile | null) => void) | null = null; + +interface OrbitAccountPickerStore { + isOpen: boolean; + accounts: ServerProfile[]; + /** Open the picker with the given candidates. Resolves with the chosen + * server or null if the user cancels. */ + request: (accounts: ServerProfile[]) => Promise; + pick: (server: ServerProfile) => void; + cancel: () => void; +} + +export const useOrbitAccountPickerStore = create(set => ({ + isOpen: false, + accounts: [], + + request: (accounts) => + new Promise(resolve => { + // If another picker is already pending, treat the previous one as cancelled. + if (_resolve) _resolve(null); + _resolve = resolve; + set({ isOpen: true, accounts }); + }), + + pick: (server) => { + _resolve?.(server); + _resolve = null; + set({ isOpen: false }); + }, + + cancel: () => { + _resolve?.(null); + _resolve = null; + set({ isOpen: false }); + }, +})); diff --git a/src/styles/components.css b/src/styles/components.css index 8ffc791a..d7934c99 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -11918,6 +11918,70 @@ html[data-psy-native-hidden="true"] *::after { cursor: not-allowed; } +/* Account picker — modal for multi-account server switch on Orbit join. */ +.orbit-account-picker { + max-width: 420px; + width: min(420px, calc(100vw - 32px)); + padding: 22px 26px 20px; + position: relative; +} +.orbit-account-picker__title { + margin: 0 0 6px; + padding-right: 1.5rem; + font-size: 17px; + font-weight: 700; + color: var(--text-primary); + letter-spacing: -0.01em; +} +.orbit-account-picker__sub { + margin: 0 0 14px; + font-size: 13px; + color: var(--text-muted); + line-height: 1.5; +} +.orbit-account-picker__list { + list-style: none; + padding: 0; + margin: 0 0 14px; + display: flex; + flex-direction: column; + gap: 4px; +} +.orbit-account-picker__item { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 10px 12px; + font-size: 13px; + text-align: left; + background: color-mix(in srgb, var(--text-primary) 4%, transparent); + border: 1px solid color-mix(in srgb, var(--text-primary) 10%, transparent); + border-radius: var(--radius-sm); + color: var(--text-primary); + cursor: pointer; + transition: background 120ms ease, border-color 120ms ease; +} +.orbit-account-picker__item svg { + color: var(--accent); + flex-shrink: 0; +} +.orbit-account-picker__item:hover { + background: color-mix(in srgb, var(--accent) 12%, transparent); + border-color: color-mix(in srgb, var(--accent) 35%, transparent); +} +.orbit-account-picker__user { + font-weight: 600; +} +.orbit-account-picker__name { + color: var(--text-muted); + font-weight: 400; +} +.orbit-account-picker__actions { + display: flex; + justify-content: flex-end; +} + /* ── Start-session modal ────────────────────────────────────────── */ .orbit-start-overlay { align-items: center;