chore(orbit): picker modal when multiple accounts match the link's server

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-24 18:49:55 +02:00
parent 6370b5fa3c
commit d912c4293b
8 changed files with 201 additions and 11 deletions
+69
View File
@@ -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(
<div
className="modal-overlay"
onClick={e => { if (e.target === e.currentTarget) cancel(); }}
role="dialog"
aria-modal="true"
aria-labelledby="orbit-account-picker-title"
style={{ alignItems: 'center', paddingTop: 0 }}
>
<div className="modal-content orbit-account-picker">
<button type="button" className="modal-close" onClick={cancel} aria-label={t('orbit.btnCancel')}>
<X size={18} />
</button>
<h3 id="orbit-account-picker-title" className="orbit-account-picker__title">
{t('orbit.accountPickerTitle')}
</h3>
<p className="orbit-account-picker__sub">
{t('orbit.accountPickerSub', { url: accounts[0]?.url ?? '' })}
</p>
<ul className="orbit-account-picker__list">
{accounts.map(a => (
<li key={a.id}>
<button
type="button"
className="orbit-account-picker__item"
onClick={() => pick(a)}
>
<User size={14} />
<span className="orbit-account-picker__user">{a.username}</span>
{a.name && <span className="orbit-account-picker__name">· {a.name}</span>}
</button>
</li>
))}
</ul>
<div className="orbit-account-picker__actions">
<button type="button" className="btn btn-ghost" onClick={cancel}>
{t('orbit.btnCancel')}
</button>
</div>
</div>
</div>,
document.body,
);
}
+11 -5
View File
@@ -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;
+12 -6
View File
@@ -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<string, string> = {
@@ -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;