Refine VPN client instructions and subscription refresh flow
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { api } from '../api.js';
|
||||
import {
|
||||
connectionAction,
|
||||
@@ -10,6 +11,55 @@ import {
|
||||
subscriptionUsage,
|
||||
} from '../utils/clientControls.js';
|
||||
import { formatBytes } from '../utils/format.js';
|
||||
import { instructionBlocks } from '../instructions.js';
|
||||
|
||||
function InstructionStep({ step }) {
|
||||
if (typeof step === 'string') return step;
|
||||
return (
|
||||
<>
|
||||
{step.before}
|
||||
<a href={step.link[1]} target="_blank" rel="noreferrer">{step.link[0]}</a>
|
||||
{step.after}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function InstructionBlock({ block, open, onToggle }) {
|
||||
return (
|
||||
<section
|
||||
className={`client-instruction-block${open ? ' is-open' : ''}`}
|
||||
style={{ viewTransitionName: `instruction-${block.id}` }}
|
||||
>
|
||||
<button
|
||||
className="client-instruction-summary"
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={onToggle}
|
||||
>
|
||||
<span>{block.label}</span>
|
||||
<strong>{block.title}</strong>
|
||||
<small>{block.summary}</small>
|
||||
<i aria-hidden="true" />
|
||||
</button>
|
||||
<div className="client-instruction-reveal" aria-hidden={!open} inert={!open ? true : undefined}>
|
||||
<div className="client-instruction-body">
|
||||
{block.paragraphs?.map((paragraph) => <p key={paragraph}>{paragraph}</p>)}
|
||||
{block.steps && (
|
||||
<ol>
|
||||
{block.steps.map((step) => (
|
||||
<li key={typeof step === 'string' ? step : step.link[1]}>
|
||||
<InstructionStep step={step} />
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
{block.code && <code>{block.code}</code>}
|
||||
{block.note && <p className="client-instruction-note">{block.note}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function ClientOverviewPage({
|
||||
state,
|
||||
@@ -21,7 +71,7 @@ export function ClientOverviewPage({
|
||||
pendingTag,
|
||||
setPendingTag,
|
||||
onFetchSubscription,
|
||||
onRefreshSubscriptionInfo,
|
||||
onRefreshSubscription,
|
||||
onForgetSubscription,
|
||||
onApply,
|
||||
onRestart,
|
||||
@@ -42,6 +92,8 @@ export function ClientOverviewPage({
|
||||
const [usageUpdated, setUsageUpdated] = useState(false);
|
||||
const [serverRevealVersion, setServerRevealVersion] = useState(0);
|
||||
const [serversLeaving, setServersLeaving] = useState(false);
|
||||
const [instructionsOpen, setInstructionsOpen] = useState(false);
|
||||
const [openInstructionId, setOpenInstructionId] = useState('');
|
||||
const subscriptionInputRef = useRef(null);
|
||||
const subscriptionRef = useRef(null);
|
||||
const copyTimerRef = useRef(null);
|
||||
@@ -53,6 +105,16 @@ export function ClientOverviewPage({
|
||||
const hasUsage = Boolean(
|
||||
state?.userInfo && ['upload', 'download', 'total', 'expire'].some((key) => key in state.userInfo),
|
||||
);
|
||||
const instructions = instructionBlocks({
|
||||
isGateway,
|
||||
host: gatewayAddress,
|
||||
port: state?.proxyPort || (isGateway ? 8080 : 8082),
|
||||
});
|
||||
const [instructionsIntro, ...instructionGuides] = instructions;
|
||||
const openInstruction = instructionGuides.find((block) => block.id === openInstructionId);
|
||||
const orderedInstructionGuides = openInstruction
|
||||
? [openInstruction, ...instructionGuides.filter((block) => block.id !== openInstructionId)]
|
||||
: instructionGuides;
|
||||
|
||||
useEffect(() => {
|
||||
setNow(Date.now());
|
||||
@@ -92,7 +154,10 @@ export function ClientOverviewPage({
|
||||
}, [editingSubscription]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasSubscription) setEditingSubscription(true);
|
||||
if (!hasSubscription) {
|
||||
setEditingSubscription(true);
|
||||
setInstructionsOpen(false);
|
||||
}
|
||||
}, [hasSubscription]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -114,10 +179,8 @@ export function ClientOverviewPage({
|
||||
|
||||
useEffect(() => {
|
||||
if (!state?.hasSubscription) return undefined;
|
||||
const refresh = () => onRefreshSubscriptionInfo().catch(() => {});
|
||||
refresh();
|
||||
const timer = setInterval(refresh, 60_000);
|
||||
return () => clearInterval(timer);
|
||||
onRefreshSubscription().catch(() => {});
|
||||
return undefined;
|
||||
}, [state?.hasSubscription]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -138,6 +201,15 @@ export function ClientOverviewPage({
|
||||
|
||||
useEffect(() => () => clearTimeout(copyTimerRef.current), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!instructionsOpen) return undefined;
|
||||
const closeOnEscape = (event) => {
|
||||
if (event.key === 'Escape') setInstructionsOpen(false);
|
||||
};
|
||||
document.addEventListener('keydown', closeOnEscape);
|
||||
return () => document.removeEventListener('keydown', closeOnEscape);
|
||||
}, [instructionsOpen]);
|
||||
|
||||
async function toggleConnection() {
|
||||
const action = connectionAction({ connected, selectedTag, configExists: state?.configExists });
|
||||
if (action?.type === 'stop') return onStop();
|
||||
@@ -170,11 +242,11 @@ export function ClientOverviewPage({
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshInfo() {
|
||||
async function refreshSubscription() {
|
||||
const startedAt = performance.now();
|
||||
setRefreshingInfo(true);
|
||||
try {
|
||||
await onRefreshSubscriptionInfo();
|
||||
await onRefreshSubscription();
|
||||
setUsageUpdated(false);
|
||||
requestAnimationFrame(() => setUsageUpdated(true));
|
||||
setTimeout(() => setUsageUpdated(false), 900);
|
||||
@@ -190,8 +262,36 @@ export function ClientOverviewPage({
|
||||
}
|
||||
}
|
||||
|
||||
function toggleInstruction(id) {
|
||||
const update = () => flushSync(() => {
|
||||
setOpenInstructionId((current) => current === id ? '' : id);
|
||||
});
|
||||
|
||||
if (!document.startViewTransition || matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
update();
|
||||
return;
|
||||
}
|
||||
|
||||
document.startViewTransition(update);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="client-shell">
|
||||
{hasSubscription && <button
|
||||
className={`client-instructions-toggle${instructionsOpen ? ' is-open' : ''}`}
|
||||
type="button"
|
||||
aria-expanded={instructionsOpen}
|
||||
aria-controls="client-instructions"
|
||||
aria-label={instructionsOpen ? 'Закрыть инструкции' : 'Как использовать'}
|
||||
onClick={() => setInstructionsOpen((open) => !open)}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="m3 5 7 7-7 7" />
|
||||
<path d="m7 5 7 7-7 7" />
|
||||
<path d="m11 5 7 7-7 7" />
|
||||
</svg>
|
||||
<span>Как использовать</span>
|
||||
</button>}
|
||||
<main className={`client-panel${showPower ? '' : ' is-setup'}`}>
|
||||
{showPower && (
|
||||
<section className="client-power-section" aria-labelledby="connection-title">
|
||||
@@ -304,10 +404,10 @@ export function ClientOverviewPage({
|
||||
<button
|
||||
className="client-subscription-refresh"
|
||||
type="button"
|
||||
aria-label="Обновить статистику подписки"
|
||||
title="Обновить статистику"
|
||||
aria-label="Обновить подписку"
|
||||
title="Обновить подписку"
|
||||
disabled={refreshingInfo}
|
||||
onClick={refreshInfo}
|
||||
onClick={refreshSubscription}
|
||||
>
|
||||
<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" />
|
||||
@@ -441,6 +541,35 @@ export function ClientOverviewPage({
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{hasSubscription && <aside
|
||||
id="client-instructions"
|
||||
className={`client-instructions${instructionsOpen ? ' is-open' : ''}`}
|
||||
aria-labelledby="instructions-title"
|
||||
aria-hidden={!instructionsOpen}
|
||||
inert={!instructionsOpen ? true : undefined}
|
||||
>
|
||||
<div className="client-instructions-sheet">
|
||||
<header className="client-instructions-header">
|
||||
<span>Подключение</span>
|
||||
<h2 id="instructions-title">Как использовать {isGateway ? 'Gateway' : 'прокси'}</h2>
|
||||
<div className="client-instructions-intro">
|
||||
{instructionsIntro.paragraphs.map((paragraph) => <p key={paragraph}>{paragraph}</p>)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="client-instruction-list">
|
||||
{orderedInstructionGuides.map((block) => (
|
||||
<InstructionBlock
|
||||
block={block}
|
||||
key={block.id}
|
||||
open={block.id === openInstructionId}
|
||||
onToggle={() => toggleInstruction(block.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</aside>}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user