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 { Drawer } from '../../ui/Drawer.js'; import { RailAction } from '../../ui/RailAction.js'; import type { RequestError } from './requestError.js'; interface ProfileError extends RequestError { profileId?: string; } interface ServerPickerRenderState { disabled: boolean; leaving: boolean; revealVersion: number; anchorServerId: string; selectedServerId: string; } interface SubscriptionFeatureOptions { profiles: ProfileSnapshot[]; selection: StateSnapshot['selection']; connected: boolean; isGateway: boolean; gatewayDirect: boolean; operations: OperationRegistrySnapshot; error?: ProfileError | null; onAdd: (label: string, url: string) => Promise; onRefresh: (profileId: string) => Promise; onForget: (profileId: string, mode: 'delete' | 'stop-and-delete') => Promise; onDismissError: () => void; } 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, onRefresh, onForget, onDismissError, }: SubscriptionFeatureOptions) { const [open, setOpen] = useState(false); const [expanded, setExpanded] = useState([]); const [closing, setClosing] = useState([]); const [visited, setVisited] = useState([]); const [adding, setAdding] = useState(profiles.length === 0); const [addClosing, setAddClosing] = useState(false); const [url, setUrl] = 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 addFormRef = useRef(null); const collapseTimersRef = useRef(new Map>()); const addCloseTimerRef = useRef | null>(null); const deleteIdRef = useRef(deleteId); const previousProfileCountRef = useRef(profiles.length); deleteIdRef.current = deleteId; const normalizedUrl = url.trim(); const addError = !error?.profileId && error?.context === 'subscription' ? error : null; const validationStatus = !normalizedUrl ? 'idle' : isSubscriptionUrlValid(normalizedUrl) ? 'valid' : 'invalid'; const deleteProfile = profiles.find((profile) => profile.id === deleteId) || null; const deleteStopsVpn = connected && selection.appliedProfileId === deleteId; useEffect(() => { const ids = new Set(profiles.map((profile) => profile.id)); setExpanded((current) => current.filter((id) => ids.has(id))); setClosing((current) => current.filter((id) => ids.has(id))); setVisited((current) => current.filter((id) => ids.has(id))); if (profiles.length === 0) { setAdding(true); setAddClosing(false); 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); }; 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]); useEffect(() => { if (!adding || profiles.length === 0) return undefined; const closeAdd = (event: PointerEvent | KeyboardEvent) => { if (event.type === 'keydown' && (event as KeyboardEvent).key !== 'Escape') return; const target = event.target as Node | null; if (event.type !== 'keydown' && addFormRef.current?.contains(target)) return; resetAdd(Boolean(target && panelRef.current?.contains(target))); }; document.addEventListener('pointerdown', closeAdd); document.addEventListener('keydown', closeAdd); return () => { document.removeEventListener('pointerdown', closeAdd); document.removeEventListener('keydown', closeAdd); }; }, [adding, profiles.length]); useEffect(() => () => { for (const timer of collapseTimersRef.current.values()) clearTimeout(timer); if (addCloseTimerRef.current) clearTimeout(addCloseTimerRef.current); }, []); function resetAdd(restoreFocus = true) { if (!profiles.length) { setUrl(''); return; } setAdding(false); const finish = () => { setAddClosing(false); setUrl(''); addCloseTimerRef.current = null; if (restoreFocus) requestAnimationFrame(() => ( document.getElementById('client-profile-add-trigger')?.focus() )); }; if (matchMedia('(prefers-reduced-motion: reduce)').matches) { finish(); return; } setAddClosing(true); if (addCloseTimerRef.current) clearTimeout(addCloseTimerRef.current); addCloseTimerRef.current = setTimeout(finish, 320); } function showAdd() { if (addCloseTimerRef.current) clearTimeout(addCloseTimerRef.current); addCloseTimerRef.current = null; setAddClosing(false); setAdding(true); onDismissError(); } async function addProfile() { if (validationStatus !== 'valid') return; const host = new URL(normalizedUrl).hostname; const usedLabels = new Set(profiles.map((profile) => profile.label.toLocaleLowerCase('ru-RU'))); let label = host; for (let suffix = 2; usedLabels.has(label.toLocaleLowerCase('ru-RU')); suffix += 1) { label = `${host} ${suffix}`; } if (!await onAdd(label, normalizedUrl)) return; resetAdd(); } 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(''); } function toggleProfile(profileId: string) { const activeTimer = collapseTimersRef.current.get(profileId); if (activeTimer) clearTimeout(activeTimer); collapseTimersRef.current.delete(profileId); setVisited((current) => current.includes(profileId) ? current : [...current, profileId]); if (!expanded.includes(profileId)) { setClosing((current) => current.filter((id) => id !== profileId)); setExpanded((current) => current.includes(profileId) ? current : [...current, profileId]); return; } setExpanded((current) => current.filter((id) => id !== profileId)); if (matchMedia('(prefers-reduced-motion: reduce)').matches) return; setClosing((current) => current.includes(profileId) ? current : [...current, profileId]); collapseTimersRef.current.set(profileId, setTimeout(() => { setClosing((current) => current.filter((id) => id !== profileId)); collapseTimersRef.current.delete(profileId); }, 950)); } return { profiles, selection, connected, isGateway, gatewayDirect, operations, error, open, expanded, closing, visited, adding, addClosing, url, validationStatus, addError, deleteProfile, deleteStopsVpn, refreshingIds, revealVersions, panelRef, toggleRef, closeRef, labelRef, addFormRef, addBlocked: operationBlocked(operations, 'profileAdd'), refreshBlocked: operationBlocked(operations, 'profileRefresh'), deleteBlocked: operationBlocked(operations, 'profileDelete'), toggle: () => setOpen((current) => !current), close: () => setOpen(false), showAdd, cancelAdd: () => resetAdd(), setUrl: (value: string) => { setUrl(value); onDismissError(); }, addProfile, toggleProfile, requestDelete: (profileId: string) => setDeleteId(profileId), cancelDelete: () => setDeleteId(''), confirmDelete, refresh, }; } type SubscriptionFeatureController = ReturnType; export function SubscriptionToggle({ feature, onToggle, }: { feature: SubscriptionFeatureController; onToggle: () => void; }) { return ; } function AddProfileForm({ feature }: { feature: SubscriptionFeatureController }) { const message = feature.validationStatus === 'invalid' ? ERROR_DEFINITIONS.SUBSCRIPTION_INVALID.message : feature.addError?.message || ''; return
{ event.preventDefault(); feature.addProfile(); }} > feature.setUrl(event.target.value)} /> {message &&
{message}
}
{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 closing = feature.closing.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 visibleServerId = applied ? feature.selection.appliedServerId : desired ? profile.desiredServerId : ''; const visibleServer = profileServer(profile, visibleServerId) || (applied && feature.selection.appliedServerSnapshot?.id === visibleServerId ? feature.selection.appliedServerSnapshot : null); const visibleServerLocation = [visibleServer?.city, visibleServer?.country] .filter((value): value is string => typeof value === 'string' && Boolean(value)) .join(' · '); const localStatus = profileError?.message || (profile.subscription.status === 'stale' ? `Последнее обновление не удалось. Данные от ${profile.subscription.fetchedAt ? new Date(profile.subscription.fetchedAt).toLocaleString('ru-RU', { dateStyle: 'short', timeStyle: 'short' }) : 'предыдущей загрузки'}.` : ''); const profileDomain = subscriptionDomain(profile.subscription.host); const profileDetails = `Серверов: ${profile.servers.length}${profile.subscription.fetchedAt ? ` · обновлено ${new Date(profile.subscription.fetchedAt).toLocaleDateString('ru-RU')}` : ''}`; return
{(applied || desired) && }
{localStatus &&
{localStatus}
} {visibleServer && } {visited &&
{!visibleServerId &&

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

} {renderServerPicker(profile, { disabled: controlsBlocked, leaving: false, revealVersion: feature.revealVersions[profile.id] || 0, anchorServerId: visibleServer?.id || '', selectedServerId: visibleServerId, })}
}
; } export function SubscriptionPanel({ feature, statusSlot, renderServerPicker, }: { feature: SubscriptionFeatureController; statusSlot?: ReactNode; renderServerPicker: (profile: ProfileSnapshot, state: ServerPickerRenderState) => ReactNode; }) { const drawerOpen = feature.open && feature.profiles.length > 0; return <> {feature.profiles.length === 0 &&
Подписки Добавьте первую подписку
}

ПОДПИСКИ

{statusSlot}
{feature.profiles.map((profile) => )}
{(feature.adding || feature.addClosing) && feature.profiles.length > 0 ? : }
; } export function SubscriptionDeleteDialog({ feature }: { feature: SubscriptionFeatureController }) { return ; }