import { useEffect, useRef, useState, type ReactNode, } from 'react'; import { ERROR_DEFINITIONS } from '../../../shared/errors.js'; import type { ProfileSnapshot, StateSnapshot } from '../../../shared/contracts/state.js'; import { operationBlocked, type OperationKey, type OperationRegistrySnapshot, } from '../../state/operations.js'; import { isSubscriptionUrlValid, subscriptionDaysLeft, subscriptionDomain, subscriptionUsage, } from '../../utils/clientControls.js'; import { formatBytes } from '../../utils/format.js'; import { ConfirmationDialog } from '../../ui/ConfirmationDialog.js'; import type { RequestError } from './requestError.js'; interface ProfileError extends RequestError { profileId?: string; } interface ServerPickerRenderState { disabled: boolean; leaving: boolean; revealVersion: number; } interface SubscriptionFeatureOptions { profiles: ProfileSnapshot[]; selection: StateSnapshot['selection']; connected: boolean; isGateway: boolean; gatewayDirect: boolean; operations: OperationRegistrySnapshot; error?: ProfileError | null; onAdd: (label: string, url: string) => Promise; onRename: (profileId: string, label: string) => Promise; onRefresh: (profileId: string) => Promise; onForget: (profileId: string, mode: 'delete' | 'stop-and-delete') => Promise; onActivate: (profileId: string) => Promise; onDismissError: () => void; } const foldLabel = (value: string) => value.trim().toLocaleLowerCase('ru-RU'); function operationText(key: OperationKey) { return ({ profileAdd: 'Добавляем подписку…', profileRename: 'Переименовываем подписку…', profileSelect: 'Сохраняем выбор сервера…', profileActivate: 'Переключаем подписку…', profileRefresh: 'Обновляем подписку…', profileDelete: 'Удаляем подписку…', serverApply: 'Применяем сервер…', } as Partial>)[key] || ''; } function profileServer(profile: ProfileSnapshot | undefined, serverId: string) { return profile?.servers.find((server) => server.id === serverId) || null; } export function useSubscriptionFeature({ profiles, selection, connected, isGateway, gatewayDirect, operations, error, onAdd, onRename, onRefresh, onForget, onActivate, onDismissError, }: SubscriptionFeatureOptions) { const [open, setOpen] = useState(false); const [expanded, setExpanded] = useState([]); const [visited, setVisited] = useState([]); const [adding, setAdding] = useState(profiles.length === 0); const [label, setLabel] = useState(''); const [url, setUrl] = useState(''); const [renamingId, setRenamingId] = useState(''); const [renameLabel, setRenameLabel] = useState(''); const [menuId, setMenuId] = useState(''); const [deleteId, setDeleteId] = useState(''); const [refreshingIds, setRefreshingIds] = useState([]); const [revealVersions, setRevealVersions] = useState>({}); const panelRef = useRef(null); const toggleRef = useRef(null); const closeRef = useRef(null); const labelRef = useRef(null); const addInvokerRef = useRef(null); const deleteIdRef = useRef(deleteId); const previousProfileCountRef = useRef(profiles.length); deleteIdRef.current = deleteId; const normalizedUrl = url.trim(); const normalizedLabel = label.trim(); const duplicateLabel = Boolean(normalizedLabel && profiles.some( (profile) => foldLabel(profile.label) === foldLabel(normalizedLabel), )); const addError = !error?.profileId && error?.context === 'subscription' ? error : null; const validationStatus = !normalizedUrl ? 'idle' : isSubscriptionUrlValid(normalizedUrl) ? 'valid' : 'invalid'; const renameDuplicate = Boolean(renameLabel.trim() && profiles.some((profile) => ( profile.id !== renamingId && foldLabel(profile.label) === foldLabel(renameLabel) ))); const deleteProfile = profiles.find((profile) => profile.id === deleteId) || null; const deleteStopsVpn = connected && selection.appliedProfileId === deleteId; const activeOperation = (Object.entries(operations) as Array<[ OperationKey, OperationRegistrySnapshot[OperationKey], ]>).find(([, operation]) => operation?.status === 'running'); useEffect(() => { const ids = new Set(profiles.map((profile) => profile.id)); setExpanded((current) => current.filter((id) => ids.has(id))); setVisited((current) => current.filter((id) => ids.has(id))); if (profiles.length === 0) { setAdding(true); if (previousProfileCountRef.current > 0) setOpen(false); } if (previousProfileCountRef.current === 0 && profiles.length > 0) { const first = profiles[0].id; setOpen(true); setAdding(false); setExpanded((current) => current.includes(first) ? current : [...current, first]); setVisited((current) => current.includes(first) ? current : [...current, first]); } previousProfileCountRef.current = profiles.length; }, [profiles, isGateway]); useEffect(() => { if (!adding || (profiles.length > 0 && !open)) return undefined; const frame = requestAnimationFrame(() => labelRef.current?.focus()); return () => cancelAnimationFrame(frame); }, [adding, open, profiles.length, isGateway]); useEffect(() => { if (!open) return undefined; const frame = requestAnimationFrame(() => closeRef.current?.focus()); const closeDrawer = (event: PointerEvent | KeyboardEvent) => { if (deleteIdRef.current) return; if (event.type === 'keydown' && (event as KeyboardEvent).key !== 'Escape') return; const target = event.target as Node | null; if (event.type !== 'keydown' && ( panelRef.current?.contains(target) || toggleRef.current?.contains(target) )) return; setOpen(false); setMenuId(''); }; document.addEventListener('pointerdown', closeDrawer); document.addEventListener('keydown', closeDrawer); return () => { cancelAnimationFrame(frame); document.removeEventListener('pointerdown', closeDrawer); document.removeEventListener('keydown', closeDrawer); requestAnimationFrame(() => { if (panelRef.current?.contains(document.activeElement)) toggleRef.current?.focus(); }); }; }, [open]); function resetAdd() { setLabel(''); setUrl(''); if (profiles.length) setAdding(false); const invoker = addInvokerRef.current; addInvokerRef.current = null; if (invoker) requestAnimationFrame(() => invoker.focus()); } async function addProfile() { if (!normalizedLabel || duplicateLabel || validationStatus !== 'valid') return; if (!await onAdd(normalizedLabel, normalizedUrl)) return; resetAdd(); } function startRename(profile: ProfileSnapshot) { setMenuId(''); setRenamingId(profile.id); setRenameLabel(profile.label); onDismissError(); } function cancelRename() { const profileId = renamingId; setRenamingId(''); requestAnimationFrame(() => document.getElementById(`client-profile-menu-${profileId}`)?.focus()); } async function saveRename() { const next = renameLabel.trim(); if (!next || renameDuplicate) return; if (!await onRename(renamingId, next)) return; cancelRename(); } async function refresh(profileId: string) { const startedAt = performance.now(); setRefreshingIds((current) => current.includes(profileId) ? current : [...current, profileId]); try { if (!await onRefresh(profileId)) return; setRevealVersions((current) => ({ ...current, [profileId]: (current[profileId] || 0) + 1, })); } finally { const elapsed = performance.now() - startedAt; const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches; const completeAt = reduced ? elapsed : Math.max(900, Math.ceil(elapsed / 900) * 900); await new Promise((resolve) => setTimeout(resolve, Math.max(0, completeAt - elapsed))); setRefreshingIds((current) => current.filter((id) => id !== profileId)); } } async function confirmDelete() { if (!deleteProfile) return; if (!await onForget(deleteProfile.id, deleteStopsVpn ? 'stop-and-delete' : 'delete')) return; setDeleteId(''); setMenuId(''); } function toggleProfile(profileId: string) { setVisited((current) => current.includes(profileId) ? current : [...current, profileId]); setExpanded((current) => current.includes(profileId) ? current.filter((id) => id !== profileId) : [...current, profileId]); } return { profiles, selection, connected, isGateway, gatewayDirect, operations, error, open, expanded, visited, adding, label, url, validationStatus, duplicateLabel, addError, renamingId, renameLabel, renameDuplicate, menuId, deleteProfile, deleteStopsVpn, activeOperation, refreshingIds, revealVersions, panelRef, toggleRef, closeRef, labelRef, addBlocked: operationBlocked(operations, 'profileAdd'), renameBlocked: operationBlocked(operations, 'profileRename'), refreshBlocked: operationBlocked(operations, 'profileRefresh'), deleteBlocked: operationBlocked(operations, 'profileDelete'), activateBlocked: operationBlocked(operations, 'profileActivate'), toggle: () => setOpen((current) => !current), close: () => { setOpen(false); setMenuId(''); }, showAdd: () => { addInvokerRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null; setAdding(true); setMenuId(''); onDismissError(); }, cancelAdd: resetAdd, setLabel: (value: string) => { setLabel(value); onDismissError(); }, setUrl: (value: string) => { setUrl(value); onDismissError(); }, addProfile, toggleProfile, startRename, setRenameLabel, cancelRename, saveRename, toggleMenu: (profileId: string) => setMenuId((current) => current === profileId ? '' : profileId), requestDelete: (profileId: string) => setDeleteId(profileId), cancelDelete: () => setDeleteId(''), confirmDelete, refresh, onActivate, }; } type SubscriptionFeatureController = ReturnType; export function SubscriptionToggle({ feature, onToggle, }: { feature: SubscriptionFeatureController; onToggle: () => void; }) { return ; } function AddProfileForm({ feature }: { feature: SubscriptionFeatureController }) { const message = feature.duplicateLabel ? 'Такое имя уже используется.' : feature.validationStatus === 'invalid' ? ERROR_DEFINITIONS.SUBSCRIPTION_INVALID.message : feature.addError?.message || ''; return
{ event.preventDefault(); feature.addProfile(); }} >
{message || '\u00a0'}
{feature.profiles.length > 0 && }
; } function ProfileUsage({ profile }: { profile: ProfileSnapshot }) { const usage = subscriptionUsage(profile.subscription.userInfo); const expires = usage.expiresAt && !Number.isNaN(usage.expiresAt.getTime()) ? subscriptionDaysLeft(usage.expiresAt) : ''; if (!usage.used && !usage.total && !expires) return null; return {formatBytes(usage.used)}{usage.total ? ` / ${formatBytes(usage.total)}` : ''}{expires ? ` · ${expires}` : ''} ; } function ProfileGroup({ feature, profile, renderServerPicker, }: { feature: SubscriptionFeatureController; profile: ProfileSnapshot; renderServerPicker: (profile: ProfileSnapshot, state: ServerPickerRenderState) => ReactNode; }) { const expanded = feature.expanded.includes(profile.id); const visited = feature.visited.includes(profile.id); const applied = feature.connected && !feature.gatewayDirect && feature.selection.appliedProfileId === profile.id; const desired = !feature.connected && !feature.gatewayDirect && feature.selection.desiredProfileId === profile.id; const profileError = feature.error?.profileId === profile.id ? feature.error : null; const refreshing = feature.refreshingIds.includes(profile.id) || feature.operations.profileRefresh?.target === profile.id; const controlsBlocked = operationBlocked(feature.operations, 'profileSelect'); const selectedServer = profileServer(profile, profile.desiredServerId); return
{feature.menuId === profile.id &&
}
{feature.renamingId === profile.id &&
{ event.preventDefault(); feature.saveRename(); }} > feature.setRenameLabel(event.target.value)} onKeyDown={(event) => { if (event.key !== 'Escape') return; event.stopPropagation(); feature.cancelRename(); }} /> {feature.renameDuplicate ? 'Такое имя уже используется.' : ''}
}
{profileError?.message || (profile.subscription.status === 'stale' ? `Последнее обновление не удалось. Данные от ${profile.subscription.fetchedAt ? new Date(profile.subscription.fetchedAt).toLocaleString('ru-RU', { dateStyle: 'short', timeStyle: 'short' }) : 'предыдущей загрузки'}.` : '\u00a0')}
{visited &&
{!applied && feature.selection.desiredProfileId !== profile.id && } {!profile.desiredServerId &&

Выберите сервер этой подписки

} {renderServerPicker(profile, { disabled: controlsBlocked, leaving: false, revealVersion: feature.revealVersions[profile.id] || 0, })}
}
; } export function SubscriptionPanel({ feature, statusSlot, renderServerPicker, }: { feature: SubscriptionFeatureController; statusSlot?: ReactNode; renderServerPicker: (profile: ProfileSnapshot, state: ServerPickerRenderState) => ReactNode; }) { const currentProfileId = feature.connected ? feature.selection.appliedProfileId : feature.selection.desiredProfileId; const currentProfile = feature.profiles.find((profile) => profile.id === currentProfileId); const currentServerId = feature.connected ? feature.selection.appliedServerId : currentProfile?.desiredServerId || ''; const currentServer = profileServer(currentProfile, currentServerId) || (feature.connected && feature.selection.appliedServerSnapshot?.id === currentServerId ? feature.selection.appliedServerSnapshot : null); const currentLabel = feature.gatewayDirect ? 'Gateway · сервер не определён' : currentProfile && currentServer ? `${currentProfile.label} · ${currentServer.label}` : 'сервер не выбран'; const drawerOpen = feature.open && feature.profiles.length > 0; return <> {feature.profiles.length === 0 &&
Подписки Добавьте первую подписку
}

ПОДПИСКИ

{feature.activeOperation ? operationText(feature.activeOperation[0]) : '\u00a0'}
{feature.connected || feature.gatewayDirect ? 'Сейчас работает:' : 'Выбрано:'} {currentLabel}
{statusSlot} {feature.adding && feature.profiles.length > 0 && }
{feature.profiles.map((profile) => )}
; } export function SubscriptionDeleteDialog({ feature }: { feature: SubscriptionFeatureController }) { return ; }