Refactor VPN proxy client implementation
This commit is contained in:
@@ -0,0 +1,557 @@
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
type RefObject,
|
||||
} from 'react';
|
||||
import { ERROR_DEFINITIONS } from '../../../shared/errors.js';
|
||||
import { operationBlocked } 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 { normalizeRequestError, type RequestError } from './requestError.js';
|
||||
|
||||
const SUBSCRIPTION_REVEAL_DELAY_MS = 1350;
|
||||
|
||||
interface SubscriptionState {
|
||||
status?: string;
|
||||
host?: string | null;
|
||||
userInfo?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
interface SubscriptionFeatureOptions {
|
||||
subscription?: SubscriptionState | null;
|
||||
subscriptionUrl: string;
|
||||
setSubscriptionUrl: (value: string) => void;
|
||||
operations: Record<string, { status?: string } | undefined>;
|
||||
error?: RequestError | null;
|
||||
serverCount: number;
|
||||
isGateway: boolean;
|
||||
gatewayDirect: boolean;
|
||||
validateSubscription: (url: string, options: { signal: AbortSignal }) => Promise<unknown>;
|
||||
onImport: () => Promise<unknown>;
|
||||
onRefresh: () => Promise<unknown>;
|
||||
onForget: () => Promise<unknown>;
|
||||
onDismissError: () => void;
|
||||
}
|
||||
|
||||
interface SubscriptionValidation {
|
||||
url: string;
|
||||
status: 'idle' | 'checking' | 'valid' | 'invalid';
|
||||
error: RequestError | null;
|
||||
}
|
||||
|
||||
function CloudTooltip({ children }: { children: ReactNode }) {
|
||||
return <span className="client-tooltip" role="tooltip">{children}</span>;
|
||||
}
|
||||
|
||||
export function useSubscriptionFeature({
|
||||
subscription,
|
||||
subscriptionUrl,
|
||||
setSubscriptionUrl,
|
||||
operations,
|
||||
error,
|
||||
serverCount,
|
||||
isGateway,
|
||||
gatewayDirect,
|
||||
validateSubscription,
|
||||
onImport,
|
||||
onRefresh,
|
||||
onForget,
|
||||
onDismissError,
|
||||
}: SubscriptionFeatureOptions) {
|
||||
const hasSubscription = subscription?.status === 'ready';
|
||||
const [editing, setEditing] = useState(!hasSubscription);
|
||||
const [contentReady, setContentReady] = useState(hasSubscription);
|
||||
const [validation, setValidation] = useState<SubscriptionValidation>({
|
||||
url: '',
|
||||
status: 'idle',
|
||||
error: null,
|
||||
});
|
||||
const [validationAttempt, setValidationAttempt] = useState(0);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [usageUpdated, setUsageUpdated] = useState(false);
|
||||
const [serverRevealVersion, setServerRevealVersion] = useState(0);
|
||||
const [serversLeaving, setServersLeaving] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const subscriptionRef = useRef<HTMLDivElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const toggleRef = useRef<HTMLButtonElement>(null);
|
||||
const closeRef = useRef<HTMLButtonElement>(null);
|
||||
const confirmingDeleteRef = useRef(confirmingDelete);
|
||||
const previousHasSubscriptionRef = useRef(hasSubscription);
|
||||
confirmingDeleteRef.current = confirmingDelete;
|
||||
|
||||
const usage = subscriptionUsage(subscription?.userInfo || undefined);
|
||||
const [displayedUsed, setDisplayedUsed] = useState(usage.used);
|
||||
const hasUsage = Boolean(
|
||||
subscription?.userInfo
|
||||
&& ['upload', 'download', 'total', 'expire'].some((key) => key in subscription.userInfo!),
|
||||
);
|
||||
const normalizedUrl = subscriptionUrl.trim();
|
||||
const currentValidation = validation.url === normalizedUrl ? validation : null;
|
||||
const localError = normalizedUrl && !isSubscriptionUrlValid(normalizedUrl)
|
||||
? { context: 'subscription', message: ERROR_DEFINITIONS.SUBSCRIPTION_INVALID.message }
|
||||
: null;
|
||||
const subscriptionError = currentValidation?.error
|
||||
|| localError
|
||||
|| (error?.context === 'subscription' ? error : null);
|
||||
const validationStatus = !normalizedUrl
|
||||
? 'idle'
|
||||
: subscriptionError || !isSubscriptionUrlValid(normalizedUrl)
|
||||
? 'invalid'
|
||||
: currentValidation?.status || 'checking';
|
||||
const waiting = hasSubscription && !contentReady;
|
||||
const importBlocked = operationBlocked(operations, 'subscriptionImport');
|
||||
const refreshBlocked = operationBlocked(operations, 'subscriptionRefresh');
|
||||
const deleteBlocked = operationBlocked(operations, 'subscriptionDelete');
|
||||
|
||||
useEffect(() => {
|
||||
if (editing) inputRef.current?.focus();
|
||||
}, [editing]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!normalizedUrl || !isSubscriptionUrlValid(normalizedUrl)) return undefined;
|
||||
const controller = new AbortController();
|
||||
setValidation({ url: normalizedUrl, status: 'checking', error: null });
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
await validateSubscription(normalizedUrl, { signal: controller.signal });
|
||||
setValidation({ url: normalizedUrl, status: 'valid', error: null });
|
||||
} catch (caught) {
|
||||
const requestError = normalizeRequestError(caught);
|
||||
if (requestError.name === 'AbortError') return;
|
||||
setValidation({
|
||||
url: normalizedUrl,
|
||||
status: 'invalid',
|
||||
error: {
|
||||
context: 'subscription',
|
||||
message: requestError.message,
|
||||
correlationId: requestError.correlationId,
|
||||
retry: requestError.retryable
|
||||
? () => setValidationAttempt((attempt) => attempt + 1)
|
||||
: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
}, 300);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, [normalizedUrl, validationAttempt, validateSubscription]);
|
||||
|
||||
useEffect(() => {
|
||||
const previouslyHadSubscription = previousHasSubscriptionRef.current;
|
||||
previousHasSubscriptionRef.current = hasSubscription;
|
||||
|
||||
if (!hasSubscription) {
|
||||
setContentReady(false);
|
||||
return undefined;
|
||||
}
|
||||
if (previouslyHadSubscription) {
|
||||
setContentReady(true);
|
||||
return undefined;
|
||||
}
|
||||
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
setContentReady(true);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => setContentReady(true), SUBSCRIPTION_REVEAL_DELAY_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [hasSubscription]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasSubscription) setEditing(true);
|
||||
}, [hasSubscription]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editing || !hasSubscription || subscriptionUrl) return undefined;
|
||||
const timer = setTimeout(() => setEditing(false), 5000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [editing, hasSubscription, subscriptionUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editing || !hasSubscription) return undefined;
|
||||
const closeOnOutsideClick = (event: PointerEvent) => {
|
||||
if (subscriptionRef.current?.contains(event.target as Node | null)) return;
|
||||
setSubscriptionUrl('');
|
||||
setEditing(false);
|
||||
};
|
||||
document.addEventListener('pointerdown', closeOnOutsideClick);
|
||||
return () => document.removeEventListener('pointerdown', closeOnOutsideClick);
|
||||
}, [editing, hasSubscription, setSubscriptionUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasSubscription) return undefined;
|
||||
onRefresh();
|
||||
return undefined;
|
||||
}, [hasSubscription]);
|
||||
|
||||
useEffect(() => {
|
||||
const from = displayedUsed;
|
||||
const to = usage.used;
|
||||
if (from === to) return undefined;
|
||||
const startedAt = performance.now();
|
||||
let frame: number;
|
||||
const tick = (now: number) => {
|
||||
const progress = Math.min(1, (now - startedAt) / 900);
|
||||
const eased = 1 - Math.pow(1 - progress, 4);
|
||||
setDisplayedUsed(from + (to - from) * eased);
|
||||
if (progress < 1) frame = requestAnimationFrame(tick);
|
||||
};
|
||||
frame = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [usage.used]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
const frame = requestAnimationFrame(() => closeRef.current?.focus());
|
||||
const closeSubscription = (event: PointerEvent | KeyboardEvent) => {
|
||||
if (confirmingDeleteRef.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', closeSubscription);
|
||||
document.addEventListener('keydown', closeSubscription);
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
document.removeEventListener('pointerdown', closeSubscription);
|
||||
document.removeEventListener('keydown', closeSubscription);
|
||||
requestAnimationFrame(() => {
|
||||
if (panelRef.current?.contains(document.activeElement)) toggleRef.current?.focus();
|
||||
});
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
async function submit() {
|
||||
if (validationStatus !== 'valid') return;
|
||||
if (!await onImport()) return;
|
||||
setSubscriptionUrl('');
|
||||
setEditing(false);
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const startedAt = performance.now();
|
||||
setRefreshing(true);
|
||||
try {
|
||||
if (!await onRefresh()) return;
|
||||
setUsageUpdated(false);
|
||||
requestAnimationFrame(() => setUsageUpdated(true));
|
||||
setTimeout(() => setUsageUpdated(false), 900);
|
||||
setServersLeaving(true);
|
||||
await new Promise((resolve) => setTimeout(
|
||||
resolve,
|
||||
420 + Math.min(7, Math.max(0, serverCount - 1)) * 90,
|
||||
));
|
||||
setServerRevealVersion((version) => version + 1);
|
||||
setServersLeaving(false);
|
||||
} finally {
|
||||
const elapsed = performance.now() - startedAt;
|
||||
const completeCyclesAt = Math.max(900, Math.ceil(elapsed / 900) * 900);
|
||||
await new Promise((resolve) => setTimeout(resolve, completeCyclesAt - elapsed));
|
||||
setRefreshing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function forget() {
|
||||
if (!await onForget()) return;
|
||||
setConfirmingDelete(false);
|
||||
}
|
||||
|
||||
function changeUrl(value: string) {
|
||||
if (error?.context === 'subscription') onDismissError();
|
||||
setValidation({ url: '', status: 'idle', error: null });
|
||||
setSubscriptionUrl(value);
|
||||
}
|
||||
|
||||
function cancelEditing() {
|
||||
if (!hasSubscription) return;
|
||||
setSubscriptionUrl('');
|
||||
setEditing(false);
|
||||
}
|
||||
|
||||
return {
|
||||
subscription,
|
||||
subscriptionUrl,
|
||||
hasSubscription,
|
||||
hasUsage,
|
||||
isGateway,
|
||||
gatewayDirect,
|
||||
editing,
|
||||
contentReady,
|
||||
waiting,
|
||||
open,
|
||||
confirmingDelete,
|
||||
refreshing,
|
||||
usageUpdated,
|
||||
usage,
|
||||
displayedUsed,
|
||||
validationStatus,
|
||||
normalizedUrl,
|
||||
error: subscriptionError,
|
||||
importBlocked,
|
||||
refreshBlocked,
|
||||
deleteBlocked,
|
||||
serversLeaving,
|
||||
serverRevealVersion,
|
||||
inputRef,
|
||||
subscriptionRef,
|
||||
panelRef,
|
||||
toggleRef,
|
||||
closeRef,
|
||||
toggle: () => setOpen((current) => !current),
|
||||
close: () => setOpen(false),
|
||||
edit: () => setEditing(true),
|
||||
changeUrl,
|
||||
cancelEditing,
|
||||
submit,
|
||||
refresh,
|
||||
requestDelete: () => setConfirmingDelete(true),
|
||||
cancelDelete: () => setConfirmingDelete(false),
|
||||
forget,
|
||||
};
|
||||
}
|
||||
|
||||
type SubscriptionFeatureController = ReturnType<typeof useSubscriptionFeature>;
|
||||
|
||||
interface SubscriptionToggleProps {
|
||||
feature: SubscriptionFeatureController;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
export function SubscriptionToggle({ feature, onToggle }: SubscriptionToggleProps) {
|
||||
return <button
|
||||
ref={feature.toggleRef}
|
||||
className={`client-instructions-toggle client-subscription-toggle${feature.open ? ' is-open' : ''}`}
|
||||
type="button"
|
||||
aria-expanded={feature.open}
|
||||
aria-controls="client-subscription-drawer"
|
||||
aria-label={feature.open ? 'Закрыть подписку' : 'Управление подпиской'}
|
||||
onClick={onToggle}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M5 5h14v14H5zM8 9h8M8 13h5" />
|
||||
</svg>
|
||||
<span>Подписка</span>
|
||||
</button>;
|
||||
}
|
||||
|
||||
interface SubscriptionPanelProps {
|
||||
feature: SubscriptionFeatureController;
|
||||
statusSlot?: ReactNode;
|
||||
serverSlot?: ReactNode;
|
||||
}
|
||||
|
||||
export function SubscriptionPanel({ feature, statusSlot, serverSlot }: SubscriptionPanelProps) {
|
||||
const {
|
||||
subscription,
|
||||
subscriptionUrl,
|
||||
hasSubscription,
|
||||
hasUsage,
|
||||
isGateway,
|
||||
gatewayDirect,
|
||||
editing,
|
||||
contentReady,
|
||||
waiting,
|
||||
open,
|
||||
refreshing,
|
||||
usageUpdated,
|
||||
usage,
|
||||
displayedUsed,
|
||||
validationStatus,
|
||||
normalizedUrl,
|
||||
error,
|
||||
importBlocked,
|
||||
refreshBlocked,
|
||||
deleteBlocked,
|
||||
inputRef,
|
||||
subscriptionRef,
|
||||
panelRef,
|
||||
closeRef,
|
||||
} = feature;
|
||||
|
||||
return <div
|
||||
ref={isGateway ? panelRef : undefined}
|
||||
id={isGateway ? 'client-subscription-drawer' : undefined}
|
||||
className={isGateway
|
||||
? `client-drawer client-subscription-drawer${open ? ' is-open' : ''}`
|
||||
: `client-form${waiting ? ' is-waiting' : ''}`}
|
||||
aria-label={isGateway ? 'Управление подпиской' : undefined}
|
||||
aria-hidden={isGateway ? !open : waiting}
|
||||
aria-disabled={gatewayDirect}
|
||||
inert={(isGateway && !open) || waiting || gatewayDirect ? true : undefined}
|
||||
>
|
||||
{isGateway && <button
|
||||
ref={closeRef}
|
||||
className="client-drawer-close"
|
||||
type="button"
|
||||
aria-label="Закрыть подписку"
|
||||
onClick={feature.close}
|
||||
>×</button>}
|
||||
<div className={`client-form-content${isGateway ? ' client-drawer-sheet client-subscription-sheet' : ''}`}>
|
||||
<div
|
||||
ref={subscriptionRef}
|
||||
className={`client-subscription ${editing ? 'is-editing' : ''}${editing && hasSubscription && !subscriptionUrl ? ' is-timing-out' : ''}`}
|
||||
>
|
||||
<div
|
||||
className="client-subscription-summary"
|
||||
aria-hidden={editing}
|
||||
inert={editing ? true : undefined}
|
||||
>
|
||||
<div className="client-subscription-heading">
|
||||
<span className="client-subscription-label">Ваша подписка</span>
|
||||
<span className="client-icon-tooltip client-tooltip-anchor">
|
||||
<button
|
||||
className="client-subscription-refresh"
|
||||
type="button"
|
||||
aria-label="Обновить подписку"
|
||||
disabled={refreshing || refreshBlocked}
|
||||
onClick={feature.refresh}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M21 12a9 9 0 0 0-15.2-6.5L3 8m0-5v5h5M3 12a9 9 0 0 0 15.2 6.5L21 16m0 5v-5h-5" />
|
||||
</svg>
|
||||
</button>
|
||||
<CloudTooltip>Обновить подписку</CloudTooltip>
|
||||
</span>
|
||||
<span className="client-icon-tooltip client-tooltip-anchor">
|
||||
<button
|
||||
className="client-subscription-delete"
|
||||
type="button"
|
||||
aria-label="Удалить подписку"
|
||||
disabled={deleteBlocked}
|
||||
onClick={feature.requestDelete}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path className="client-trash-lid" d="M4 7h16M9 7V4h6v3" />
|
||||
<path d="m6 7 1 13h10l1-13M10 11v5M14 11v5" />
|
||||
</svg>
|
||||
</button>
|
||||
<CloudTooltip>Удалить подписку</CloudTooltip>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="client-subscription-domain-button"
|
||||
type="button"
|
||||
tabIndex={editing ? -1 : 0}
|
||||
onClick={feature.edit}
|
||||
>
|
||||
<strong>{subscriptionDomain(subscription?.host)}</strong>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className={`client-subscription-edit is-${validationStatus}`}
|
||||
autoComplete="off"
|
||||
aria-hidden={!editing}
|
||||
inert={!editing ? true : undefined}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
feature.submit();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
id="subscription-url"
|
||||
type="url"
|
||||
inputMode="url"
|
||||
autoComplete="off"
|
||||
tabIndex={editing ? 0 : -1}
|
||||
aria-label="Ссылка подписки"
|
||||
placeholder="Вставьте ссылку подписки"
|
||||
className={subscriptionUrl ? 'has-value' : ''}
|
||||
value={subscriptionUrl}
|
||||
onChange={(event) => feature.changeUrl(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') feature.cancelEditing();
|
||||
}}
|
||||
/>
|
||||
{subscriptionUrl && (
|
||||
<span className="client-subscription-domain">
|
||||
{subscriptionDomain(subscriptionUrl)}
|
||||
</span>
|
||||
)}
|
||||
{normalizedUrl && (
|
||||
<button
|
||||
className="client-subscription-submit"
|
||||
type="submit"
|
||||
aria-live="polite"
|
||||
aria-label={validationStatus === 'valid'
|
||||
? 'Сохранить подписку'
|
||||
: validationStatus === 'checking'
|
||||
? 'Проверяем подписку'
|
||||
: error?.message || 'Ссылка подписки не распознана'}
|
||||
disabled={importBlocked || validationStatus !== 'valid'}
|
||||
>
|
||||
{validationStatus === 'valid'
|
||||
? '✓'
|
||||
: validationStatus === 'checking' ? '…' : '×'}
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
{statusSlot}
|
||||
</div>
|
||||
|
||||
{hasSubscription && contentReady && hasUsage && (
|
||||
<section className={`client-usage${usageUpdated ? ' is-updated' : ''}`} aria-label="Статистика подписки">
|
||||
<span>Использовано</span>
|
||||
<strong>
|
||||
{formatBytes(displayedUsed)}
|
||||
<small> / {usage.total ? formatBytes(usage.total) : 'без лимита'}</small>
|
||||
</strong>
|
||||
{usage.percent !== null && (
|
||||
<div
|
||||
className="client-usage-bar"
|
||||
role="progressbar"
|
||||
aria-label="Использованный трафик"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={Math.round(usage.percent)}
|
||||
>
|
||||
<i style={{ width: `${usage.percent}%` }} />
|
||||
</div>
|
||||
)}
|
||||
<div className="client-usage-details">
|
||||
{usage.expiresAt && !Number.isNaN(usage.expiresAt.getTime()) && (
|
||||
<span>
|
||||
до {usage.expiresAt.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long' })}
|
||||
{' · '}{subscriptionDaysLeft(usage.expiresAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{serverSlot}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function SubscriptionDeleteDialog({ feature }: { feature: SubscriptionFeatureController }) {
|
||||
return <ConfirmationDialog
|
||||
open={feature.confirmingDelete}
|
||||
id="delete-subscription"
|
||||
kicker="Необратимое действие"
|
||||
title="Удалить подписку?"
|
||||
description="Harbor остановит VPN и удалит сохранённую подписку. Приложения с локальным прокси потеряют соединение до добавления новой подписки."
|
||||
cancelLabel="Отмена"
|
||||
confirmLabel="Удалить"
|
||||
busy={feature.deleteBlocked}
|
||||
onCancel={feature.cancelDelete}
|
||||
onConfirm={feature.forget}
|
||||
/>;
|
||||
}
|
||||
Reference in New Issue
Block a user