Refactor VPN proxy client implementation
This commit is contained in:
@@ -0,0 +1,430 @@
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
} from 'react';
|
||||
import {
|
||||
autoServer,
|
||||
filterServers,
|
||||
groupServers,
|
||||
parseServerPingResults,
|
||||
SERVER_RESULT_WINDOW,
|
||||
} from './serverPickerModel.js';
|
||||
import type { HarborServer } from '../../../shared/contracts/state.js';
|
||||
|
||||
type PickerServer = HarborServer & {
|
||||
country?: string;
|
||||
city?: string;
|
||||
provider?: string;
|
||||
};
|
||||
|
||||
const FAVORITES_KEY = 'harbor-server-favorites';
|
||||
const RECENT_KEY = 'harbor-server-recent';
|
||||
const AUTO_KEY = 'harbor-server-auto';
|
||||
const SIMPLE_SERVER_LIMIT = 5;
|
||||
|
||||
interface PingResult {
|
||||
id?: string;
|
||||
latency?: number | null;
|
||||
ok?: boolean;
|
||||
error?: unknown;
|
||||
checkedAt?: string;
|
||||
checking?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
type PingState = Record<string, PingResult | undefined>;
|
||||
|
||||
function readList(key: string) {
|
||||
try {
|
||||
const value = JSON.parse(localStorage.getItem(key) || '[]');
|
||||
return Array.isArray(value) ? value.map(String) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function write(key: string, value: string | string[]) {
|
||||
try {
|
||||
localStorage.setItem(key, typeof value === 'string' ? value : JSON.stringify(value));
|
||||
} catch {
|
||||
// Preferences remain available for this session.
|
||||
}
|
||||
}
|
||||
|
||||
function readAuto() {
|
||||
try {
|
||||
return localStorage.getItem(AUTO_KEY) === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function serverHealthText(ping?: PingResult) {
|
||||
if (ping?.error) return 'Проверка недоступна';
|
||||
if (ping?.ok) return `${ping.latency} мс`;
|
||||
return ping ? 'Недоступен' : null;
|
||||
}
|
||||
|
||||
function ServerHealth({ ping, fallback }: { ping?: PingResult; fallback?: string }) {
|
||||
const health = fallback || serverHealthText(ping);
|
||||
if (!health && !ping?.checking) return null;
|
||||
|
||||
return <small
|
||||
className={`client-server-health${ping?.checking ? ' is-checking' : ''}`}
|
||||
title={ping?.checkedAt || undefined}
|
||||
aria-label={ping?.checking ? 'Проверяем пинг' : health || undefined}
|
||||
>
|
||||
<span aria-hidden="true">{health}</span>
|
||||
<svg className="client-server-health-checking" 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>
|
||||
</small>;
|
||||
}
|
||||
|
||||
function ServerCheckButton({
|
||||
checking,
|
||||
disabled,
|
||||
onClick,
|
||||
}: {
|
||||
checking: boolean;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return <button
|
||||
className={`client-server-check client-tooltip-anchor${checking ? ' is-checking' : ''}`}
|
||||
type="button"
|
||||
aria-label={checking ? 'Проверяем пинг серверов' : 'Проверить пинг серверов'}
|
||||
disabled={checking || disabled}
|
||||
onClick={onClick}
|
||||
>
|
||||
<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>
|
||||
<span className="client-tooltip" role="tooltip">{checking ? 'Проверяем пинг…' : 'Проверить пинг'}</span>
|
||||
</button>;
|
||||
}
|
||||
|
||||
function ServerRow({
|
||||
server,
|
||||
selected,
|
||||
favorite,
|
||||
ping,
|
||||
disabled,
|
||||
index,
|
||||
onSelect,
|
||||
onFavorite,
|
||||
}: {
|
||||
server: PickerServer;
|
||||
selected: boolean;
|
||||
favorite?: boolean;
|
||||
ping?: PingResult;
|
||||
disabled: boolean;
|
||||
index: number;
|
||||
onSelect: (id: string) => unknown;
|
||||
onFavorite?: (id: string) => void;
|
||||
}) {
|
||||
const health = ping?.checking ? 'Проверяем пинг' : serverHealthText(ping);
|
||||
|
||||
return <div className={`client-server-row${selected ? ' is-selected' : ''}${onFavorite ? ' has-favorite' : ''}`}>
|
||||
<button
|
||||
className={`client-server${selected ? ' is-selected' : ''}`}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-pressed={selected}
|
||||
aria-label={`${server.label}, ${server.host}:${server.port}${health ? `, ${health}` : ''}`}
|
||||
style={{ '--server-index': Math.min(index, 7) } as CSSProperties}
|
||||
onClick={() => onSelect(server.id)}
|
||||
>
|
||||
<strong>{server.label}</strong>
|
||||
</button>
|
||||
{(ping || onFavorite) && <div className="client-server-meta">
|
||||
<ServerHealth ping={ping} />
|
||||
{onFavorite && <button
|
||||
className={`client-server-favorite${favorite ? ' is-active' : ''}`}
|
||||
type="button"
|
||||
aria-pressed={favorite}
|
||||
aria-label={`${favorite ? 'Убрать из избранного' : 'Добавить в избранное'}: ${server.label}`}
|
||||
onClick={() => onFavorite(server.id)}
|
||||
>★</button>}
|
||||
</div>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
interface ServerPickerProps {
|
||||
pingServers: (ids: string[]) => Promise<unknown>;
|
||||
servers: PickerServer[];
|
||||
selectedServerId: string;
|
||||
disabled: boolean;
|
||||
prompt: boolean;
|
||||
leaving: boolean;
|
||||
revealVersion: number;
|
||||
onSelect: (id: string) => unknown;
|
||||
}
|
||||
|
||||
export function ServerPicker({
|
||||
pingServers,
|
||||
servers,
|
||||
selectedServerId,
|
||||
disabled,
|
||||
prompt,
|
||||
leaving,
|
||||
revealVersion,
|
||||
onSelect,
|
||||
}: ServerPickerProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [advanced, setAdvanced] = useState(false);
|
||||
const [view, setView] = useState<'all' | 'favorites' | 'recent'>('all');
|
||||
const [page, setPage] = useState(0);
|
||||
const [favorites, setFavorites] = useState(() => readList(FAVORITES_KEY));
|
||||
const [recent, setRecent] = useState(() => readList(RECENT_KEY));
|
||||
const [autoActive, setAutoActive] = useState(readAuto);
|
||||
const [collapsed, setCollapsed] = useState<string[]>([]);
|
||||
const [pings, setPings] = useState<PingState>({});
|
||||
const [checking, setChecking] = useState(false);
|
||||
const serverKey = servers.map(({ id }) => id).join('|');
|
||||
|
||||
useEffect(() => {
|
||||
setPage(0);
|
||||
}, [query, view, serverKey]);
|
||||
|
||||
const selected = servers.find(({ id }) => id === selectedServerId);
|
||||
const filtered = useMemo<PickerServer[]>(() => {
|
||||
const found = filterServers(servers, query) as PickerServer[];
|
||||
if (view === 'favorites') return found.filter(({ id }) => favorites.includes(id));
|
||||
if (view === 'recent') return recent.flatMap((id) => found.find((server) => server.id === id) || []);
|
||||
return found;
|
||||
}, [servers, query, view, favorites, recent]);
|
||||
const results = filtered.filter(({ id }) => id !== selectedServerId);
|
||||
const pageCount = Math.max(1, Math.ceil(results.length / SERVER_RESULT_WINDOW));
|
||||
const visible = results.slice(page * SERVER_RESULT_WINDOW, (page + 1) * SERVER_RESULT_WINDOW);
|
||||
const grouped = servers.length >= 10;
|
||||
|
||||
useEffect(() => {
|
||||
setPage((current) => Math.min(current, pageCount - 1));
|
||||
}, [pageCount]);
|
||||
|
||||
function toggleFavorite(id: string) {
|
||||
setFavorites((current) => {
|
||||
const next = current.includes(id) ? current.filter((item) => item !== id) : [id, ...current];
|
||||
write(FAVORITES_KEY, next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function select(id: string, automatic = false) {
|
||||
setAutoActive(automatic);
|
||||
write(AUTO_KEY, String(automatic));
|
||||
if (!automatic) {
|
||||
setRecent((current) => {
|
||||
const next = [id, ...current.filter((item) => item !== id)].slice(0, 5);
|
||||
write(RECENT_KEY, next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
onSelect(id);
|
||||
}
|
||||
|
||||
async function checkVisible() {
|
||||
const ids = [...new Set([selectedServerId, ...visible.map(({ id }) => id)].filter(Boolean))].slice(0, 30);
|
||||
if (!ids.length) return;
|
||||
const startedAt = performance.now();
|
||||
setChecking(true);
|
||||
setPings((current) => ({
|
||||
...current,
|
||||
...Object.fromEntries(ids.map((id) => [id, { ...current[id], checking: true }])),
|
||||
}));
|
||||
try {
|
||||
const results = parseServerPingResults(await pingServers(ids)) as PingResult[];
|
||||
setPings((current) => ({
|
||||
...current,
|
||||
...Object.fromEntries(results.map((result) => [result.id, { ...result, checking: true }])),
|
||||
}));
|
||||
} catch {
|
||||
setPings((current) => ({
|
||||
...current,
|
||||
...Object.fromEntries(ids.map((id) => [id, {
|
||||
error: true,
|
||||
checking: true,
|
||||
checkedAt: new Date().toISOString(),
|
||||
}])),
|
||||
}));
|
||||
} finally {
|
||||
await new Promise((resolve) => setTimeout(resolve, Math.max(0, 900 - (performance.now() - startedAt))));
|
||||
setPings((current) => ({
|
||||
...current,
|
||||
...Object.fromEntries(ids.map((id) => [id, { ...current[id], checking: false }])),
|
||||
}));
|
||||
setChecking(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (servers.length === 1) {
|
||||
return <section className="client-servers" aria-label="Выберите сервер">
|
||||
{prompt && <span className="client-server-prompt">Выберите сервер</span>}
|
||||
<div className="client-server-toolbar is-single">
|
||||
<span className="client-server-toolbar-title">Список серверов</span>
|
||||
<ServerCheckButton checking={checking} onClick={checkVisible} />
|
||||
</div>
|
||||
<div className="client-server-grid">
|
||||
<ServerRow
|
||||
server={servers[0]}
|
||||
selected={servers[0].id === selectedServerId}
|
||||
favorite={false}
|
||||
ping={pings[servers[0].id]}
|
||||
disabled={disabled}
|
||||
index={0}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
|
||||
const renderRows = (items: PickerServer[], offset = 0) => items.map((server, index) => (
|
||||
<ServerRow
|
||||
key={server.id}
|
||||
server={server}
|
||||
selected={!autoActive && server.id === selectedServerId}
|
||||
favorite={favorites.includes(server.id)}
|
||||
ping={pings[server.id]}
|
||||
disabled={disabled}
|
||||
index={offset + index}
|
||||
onSelect={select}
|
||||
onFavorite={toggleFavorite}
|
||||
/>
|
||||
));
|
||||
|
||||
const simpleServers = [
|
||||
...(selected ? [selected] : []),
|
||||
...servers.filter(({ id }) => id !== selectedServerId),
|
||||
].slice(0, SIMPLE_SERVER_LIMIT);
|
||||
|
||||
return <section className="client-servers is-scalable" aria-label="Выберите сервер">
|
||||
{prompt && <span className="client-server-prompt">Выберите сервер</span>}
|
||||
<div className="client-server-toolbar">
|
||||
<span className="client-server-toolbar-title">Список серверов</span>
|
||||
<ServerCheckButton checking={checking} disabled={!servers.length} onClick={checkVisible} />
|
||||
<button
|
||||
className={`client-server-mode-toggle${advanced ? ' is-open' : ''}`}
|
||||
type="button"
|
||||
aria-expanded={advanced}
|
||||
aria-label={advanced ? 'Скрыть поиск и фильтры' : 'Показать поиск и фильтры'}
|
||||
onClick={() => setAdvanced((current) => !current)}
|
||||
>
|
||||
<span>Поиск и фильтры</span>
|
||||
<svg viewBox="0 0 12 8" aria-hidden="true"><path d="m1 1 5 5 5-5" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="client-server-mode-panels">
|
||||
<div
|
||||
className={`client-server-mode-panel is-simple${advanced ? '' : ' is-open'}`}
|
||||
aria-hidden={advanced}
|
||||
inert={advanced ? true : undefined}
|
||||
>
|
||||
<div className="client-server-mode-panel-inner">
|
||||
<div className={`client-server-scroll${leaving ? ' is-leaving' : ''}`} key={`simple:${serverKey}:${revealVersion}`}>
|
||||
<div className="client-server-grid">
|
||||
{simpleServers.map((server, index) => <ServerRow
|
||||
key={server.id}
|
||||
server={server}
|
||||
selected={server.id === selectedServerId}
|
||||
ping={pings[server.id]}
|
||||
disabled={disabled}
|
||||
index={index}
|
||||
onSelect={select}
|
||||
/>)}
|
||||
</div>
|
||||
{servers.length > simpleServers.length && <p className="client-server-overflow-note">
|
||||
Ещё {servers.length - simpleServers.length} — доступны через поиск
|
||||
</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`client-server-mode-panel is-advanced${advanced ? ' is-open' : ''}`}
|
||||
aria-hidden={!advanced}
|
||||
inert={!advanced ? true : undefined}
|
||||
>
|
||||
<div className="client-server-mode-panel-inner">
|
||||
<div className="client-server-tools">
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
aria-label="Найти сервер"
|
||||
placeholder="Поиск сервера"
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
<div className="client-server-filters" aria-label="Фильтр серверов">
|
||||
{([
|
||||
['all', 'Все'],
|
||||
['favorites', '★'],
|
||||
['recent', 'Недавние'],
|
||||
] as const).map(([id, label]) => <button
|
||||
type="button"
|
||||
className={view === id ? 'is-active' : ''}
|
||||
aria-pressed={view === id}
|
||||
key={id}
|
||||
onClick={() => setView(id)}
|
||||
>{label}</button>)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="client-server-pinned">
|
||||
<button
|
||||
className={`client-server-auto${autoActive ? ' is-selected' : ''}`}
|
||||
type="button"
|
||||
aria-pressed={autoActive}
|
||||
disabled={disabled || !servers.length}
|
||||
onClick={() => {
|
||||
const automatic = autoServer(servers);
|
||||
if (automatic) select(automatic.id, true);
|
||||
}}
|
||||
>
|
||||
<strong>Auto</strong>
|
||||
<ServerHealth
|
||||
ping={autoActive ? pings[selectedServerId] : undefined}
|
||||
fallback={autoActive ? undefined : 'Первый стабильный сервер'}
|
||||
/>
|
||||
</button>
|
||||
{selected && <ServerRow
|
||||
server={selected}
|
||||
selected
|
||||
favorite={favorites.includes(selected.id)}
|
||||
ping={pings[selected.id]}
|
||||
disabled={disabled}
|
||||
index={0}
|
||||
onSelect={select}
|
||||
onFavorite={toggleFavorite}
|
||||
/>}
|
||||
</div>
|
||||
|
||||
<div className={`client-server-scroll${leaving ? ' is-leaving' : ''}`} key={`advanced:${serverKey}:${revealVersion}`}>
|
||||
{!visible.length && <p className="client-server-empty">Серверы не найдены</p>}
|
||||
{grouped ? groupServers(visible).map(([group, items]) => {
|
||||
const isCollapsed = collapsed.includes(group);
|
||||
return <section className="client-server-group" key={group}>
|
||||
<button
|
||||
className="client-server-group-toggle"
|
||||
type="button"
|
||||
aria-expanded={!isCollapsed}
|
||||
onClick={() => setCollapsed((current) => current.includes(group)
|
||||
? current.filter((item) => item !== group)
|
||||
: [...current, group])}
|
||||
>{group} <small>{items.length}</small></button>
|
||||
{!isCollapsed && <div className="client-server-grid">{renderRows(items)}</div>}
|
||||
</section>;
|
||||
}) : <div className="client-server-grid">{renderRows(visible)}</div>}
|
||||
{pageCount > 1 && <nav className="client-server-pages" aria-label="Страницы серверов">
|
||||
<button className="client-server-more" type="button" disabled={page === 0} onClick={() => setPage((current) => current - 1)}>Назад</button>
|
||||
<span>{page + 1} / {pageCount}</span>
|
||||
<button className="client-server-more" type="button" disabled={page + 1 === pageCount} onClick={() => setPage((current) => current + 1)}>Дальше</button>
|
||||
</nav>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
Reference in New Issue
Block a user