summaryrefslogtreecommitdiff
path: root/src/modules
diff options
context:
space:
mode:
authorLarvan2 <[email protected]>2026-03-15 15:01:57 +0800
committerLarvan2 <[email protected]>2026-03-15 15:01:57 +0800
commit0e420859f5f7011ba124c965d8319bf3bf4c5fe3 (patch)
tree2fc344b757e119ebae6e0b6243121fddba61603c /src/modules
parent17c4d2855ffb6914fcbece27367bafdd27a4c182 (diff)
refactor: reorganize code
Diffstat (limited to 'src/modules')
-rw-r--r--src/modules/about/hooks.ts8
-rw-r--r--src/modules/about/utils.ts30
-rw-r--r--src/modules/backend/hooks.ts114
-rw-r--r--src/modules/backend/utils.ts64
-rw-r--r--src/modules/config/hooks.ts184
-rw-r--r--src/modules/config/utils.ts54
-rw-r--r--src/modules/connections/hooks.ts200
-rw-r--r--src/modules/connections/utils.ts220
-rw-r--r--src/modules/home/hooks.ts36
-rw-r--r--src/modules/home/utils.ts5
-rw-r--r--src/modules/logs/hooks.ts68
-rw-r--r--src/modules/logs/utils.ts9
-rw-r--r--src/modules/proxies/hooks.ts282
-rw-r--r--src/modules/proxies/utils.ts58
-rw-r--r--src/modules/rules/hooks.ts108
-rw-r--r--src/modules/rules/utils.ts24
16 files changed, 1464 insertions, 0 deletions
diff --git a/src/modules/about/hooks.ts b/src/modules/about/hooks.ts
new file mode 100644
index 0000000..925f2c4
--- /dev/null
+++ b/src/modules/about/hooks.ts
@@ -0,0 +1,8 @@
+import { useQuery } from 'react-query';
+
+import { fetchVersion } from '~/api/version';
+import { ClashAPIConfig } from '~/types';
+
+export function useAboutVersionQuery(apiConfig: ClashAPIConfig) {
+ return useQuery(['/version', apiConfig], () => fetchVersion('/version', apiConfig));
+}
diff --git a/src/modules/about/utils.ts b/src/modules/about/utils.ts
new file mode 100644
index 0000000..b9d5af2
--- /dev/null
+++ b/src/modules/about/utils.ts
@@ -0,0 +1,30 @@
+type VersionData = {
+ version?: string;
+ premium?: boolean;
+ meta?: boolean;
+};
+
+export function getCoreVersionMeta(version?: VersionData) {
+ if (!version?.version) {
+ return null;
+ }
+
+ if (version.meta && version.premium) {
+ return {
+ name: 'sing-box',
+ link: 'https://github.com/SagerNet/sing-box',
+ };
+ }
+
+ if (version.meta) {
+ return {
+ name: 'Clash.Meta',
+ link: 'https://github.com/MetaCubeX/Clash.Meta',
+ };
+ }
+
+ return {
+ name: 'Clash',
+ link: 'https://github.com/Dreamacro/clash',
+ };
+}
diff --git a/src/modules/backend/hooks.ts b/src/modules/backend/hooks.ts
new file mode 100644
index 0000000..04e0c56
--- /dev/null
+++ b/src/modules/backend/hooks.ts
@@ -0,0 +1,114 @@
+import * as React from 'react';
+
+import { fetchConfigs } from '~/store/configs';
+import { closeModal } from '~/store/modals';
+import type { DispatchFn } from '~/store/types';
+import type { ClashAPIConfig } from '~/types';
+
+import { detectEmbeddedAPIBaseURL, normalizeAPIBaseURL, verifyAPIConfig } from './utils';
+
+const { useCallback, useEffect, useState } = React;
+
+export function useBackendConfigForm({
+ onAddConfig,
+}: {
+ onAddConfig: (config: ClashAPIConfig) => void;
+}) {
+ const [baseURL, setBaseURL] = useState('');
+ const [secret, setSecret] = useState('');
+ const [errMsg, setErrMsg] = useState('');
+
+ const handleInputOnChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
+ setErrMsg('');
+ const target = e.target;
+ const { name, value } = target;
+
+ switch (name) {
+ case 'baseURL':
+ setBaseURL(value);
+ break;
+ case 'secret':
+ setSecret(value);
+ break;
+ default:
+ throw new Error(`unknown input name ${name}`);
+ }
+ }, []);
+
+ const onConfirm = useCallback(() => {
+ const normalizedResult = normalizeAPIBaseURL(baseURL, window.location.protocol);
+ if ('error' in normalizedResult) {
+ setErrMsg(normalizedResult.error);
+ return;
+ }
+
+ const nextConfig = { baseURL: normalizedResult.baseURL, secret };
+ verifyAPIConfig(nextConfig).then(([status, message]) => {
+ if (status !== 0) {
+ setErrMsg(message ?? 'Failed to connect');
+ return;
+ }
+
+ onAddConfig(nextConfig);
+ });
+ }, [baseURL, onAddConfig, secret]);
+
+ const handleContentOnKeyDown = useCallback(
+ (e: React.KeyboardEvent<HTMLInputElement>) => {
+ if (
+ e.target instanceof Element &&
+ (!e.target.tagName || e.target.tagName.toUpperCase() !== 'INPUT')
+ ) {
+ return;
+ }
+
+ if (e.key !== 'Enter') return;
+
+ onConfirm();
+ },
+ [onConfirm]
+ );
+
+ useEffect(() => {
+ let isCancelled = false;
+
+ detectEmbeddedAPIBaseURL().then((detectedBaseURL) => {
+ if (!isCancelled && detectedBaseURL) {
+ setBaseURL(detectedBaseURL);
+ }
+ });
+
+ return () => {
+ isCancelled = true;
+ };
+ }, []);
+
+ return {
+ baseURL,
+ secret,
+ errMsg,
+ handleInputOnChange,
+ handleContentOnKeyDown,
+ onConfirm,
+ };
+}
+
+export function useBackendDiscovery({
+ apiConfig,
+ dispatch,
+}: {
+ apiConfig: ClashAPIConfig;
+ dispatch: DispatchFn;
+}) {
+ const closeAPIConfigModal = useCallback(() => {
+ dispatch(closeModal('apiConfig'));
+ }, [dispatch]);
+
+ useEffect(() => {
+ dispatch(fetchConfigs(apiConfig));
+ }, [apiConfig, dispatch]);
+
+ return {
+ closeAPIConfigModal,
+ };
+}
diff --git a/src/modules/backend/utils.ts b/src/modules/backend/utils.ts
new file mode 100644
index 0000000..47d2bd1
--- /dev/null
+++ b/src/modules/backend/utils.ts
@@ -0,0 +1,64 @@
+import { fetchConfigs } from '~/api/configs';
+import type { ClashAPIConfig } from '~/types';
+
+export const DEFAULT_API_BASE_URL = 'http://127.0.0.1:9090';
+
+const Ok = 0;
+
+export function normalizeAPIBaseURL(baseURL: string, currentProtocol: string) {
+ let normalizedBaseURL = baseURL || DEFAULT_API_BASE_URL;
+
+ if (normalizedBaseURL) {
+ const prefix = normalizedBaseURL.substring(0, 7);
+ if (prefix.includes(':/')) {
+ if (prefix !== 'http://' && prefix !== 'https:/') {
+ return { error: 'Must starts with http:// or https://' };
+ }
+ } else if (currentProtocol) {
+ normalizedBaseURL = `${currentProtocol}//${normalizedBaseURL}`;
+ }
+ }
+
+ return { baseURL: normalizedBaseURL };
+}
+
+export async function verifyAPIConfig(apiConfig: ClashAPIConfig): Promise<[number, string?]> {
+ try {
+ new URL(apiConfig.baseURL);
+ } catch (e) {
+ if (apiConfig.baseURL) {
+ const prefix = apiConfig.baseURL.substring(0, 7);
+ if (prefix !== 'http://' && prefix !== 'https:/') {
+ return [1, 'Must starts with http:// or https://'];
+ }
+ }
+
+ return [1, 'Invalid URL'];
+ }
+
+ try {
+ const res = await fetchConfigs(apiConfig);
+ if (res.status > 399) {
+ return [1, res.statusText];
+ }
+ return [Ok];
+ } catch (e) {
+ return [1, 'Failed to connect'];
+ }
+}
+
+export async function detectEmbeddedAPIBaseURL() {
+ try {
+ const res = await fetch('/');
+ if (res.headers.get('content-type')?.includes('application/json')) {
+ const data = await res.json();
+ if (data.hello === 'clash') {
+ return window.location.origin;
+ }
+ }
+ } catch (e) {
+ // ignore auto detection failures
+ }
+
+ return null;
+}
diff --git a/src/modules/config/hooks.ts b/src/modules/config/hooks.ts
new file mode 100644
index 0000000..b55e6e0
--- /dev/null
+++ b/src/modules/config/hooks.ts
@@ -0,0 +1,184 @@
+import * as React from 'react';
+import { useQuery } from 'react-query';
+
+import * as logsApi from '~/api/logs';
+import { fetchVersion } from '~/api/version';
+import {
+ fetchConfigs,
+ flushFakeIPPool,
+ reloadConfigFile,
+ restartCore,
+ updateConfigs,
+ upgradeCore,
+ upgradeGeo,
+ upgradeUI,
+} from '~/store/configs';
+import { openModal } from '~/store/modals';
+import { ClashGeneralConfig, DispatchFn } from '~/store/types';
+import { ClashAPIConfig } from '~/types';
+
+const { useCallback, useEffect, useRef, useState } = React;
+
+type UpdateAppConfigFn = (name: string, value: unknown) => void;
+
+function useConfigVersionQuery(apiConfig: ClashAPIConfig) {
+ return useQuery(['/version', apiConfig], () => fetchVersion('/version', apiConfig));
+}
+
+export function useConfigState(configs: ClashGeneralConfig) {
+ const [configState, setConfigStateInternal] = useState(configs);
+ const refConfigs = useRef(configs);
+
+ useEffect(() => {
+ if (refConfigs.current !== configs) {
+ setConfigStateInternal(configs);
+ }
+ refConfigs.current = configs;
+ }, [configs]);
+
+ const setConfigState = useCallback((name: string, value: any) => {
+ setConfigStateInternal((prev) => ({ ...prev, [name]: value }));
+ }, []);
+
+ const setTunConfigState = useCallback((name: string, value: any) => {
+ setConfigStateInternal((prev) => ({
+ ...prev,
+ tun: { ...prev.tun, [name]: value },
+ }));
+ }, []);
+
+ return {
+ configState,
+ setConfigState,
+ setTunConfigState,
+ };
+}
+
+export function useConfigPage({
+ apiConfig,
+ configs,
+ dispatch,
+ updateAppConfig,
+}: {
+ apiConfig: ClashAPIConfig;
+ configs: ClashGeneralConfig;
+ dispatch: DispatchFn;
+ updateAppConfig: UpdateAppConfigFn;
+}) {
+ useEffect(() => {
+ dispatch(fetchConfigs(apiConfig));
+ }, [apiConfig, dispatch]);
+
+ const { configState, setConfigState, setTunConfigState } = useConfigState(configs);
+ const versionQuery = useConfigVersionQuery(apiConfig);
+
+ const openAPIConfigModal = useCallback(() => {
+ dispatch(openModal('apiConfig'));
+ }, [dispatch]);
+
+ const handleInputOnChange = useCallback(
+ ({ name, value }: { name: string; value: any }) => {
+ switch (name) {
+ case 'mode':
+ case 'log-level':
+ case 'allow-lan':
+ case 'sniffing':
+ setConfigState(name, value);
+ dispatch(updateConfigs(apiConfig, { [name]: value }));
+ if (name === 'log-level') {
+ logsApi.reconnect({ ...apiConfig, logLevel: value });
+ }
+ break;
+ case 'mitm-port':
+ case 'redir-port':
+ case 'socks-port':
+ case 'mixed-port':
+ case 'port':
+ if (value !== '') {
+ const num = parseInt(value, 10);
+ if (num < 0 || num > 65535) return;
+ }
+ setConfigState(name, value);
+ break;
+ case 'enable':
+ case 'stack':
+ setTunConfigState(name, value);
+ dispatch(updateConfigs(apiConfig, { tun: { [name]: value } }));
+ break;
+ default:
+ return;
+ }
+ },
+ [apiConfig, dispatch, setConfigState, setTunConfigState]
+ );
+
+ const handleInputOnBlur = useCallback(
+ (
+ e:
+ | React.FocusEvent<HTMLSelectElement | HTMLInputElement>
+ | React.ChangeEvent<HTMLSelectElement | HTMLInputElement>
+ ) => {
+ const { name, value } = e.target;
+
+ switch (name) {
+ case 'port':
+ case 'socks-port':
+ case 'mixed-port':
+ case 'redir-port':
+ case 'mitm-port': {
+ const num = parseInt(value, 10);
+ if (num < 0 || num > 65535) return;
+ dispatch(updateConfigs(apiConfig, { [name]: num }));
+ break;
+ }
+ case 'latencyTestUrl':
+ updateAppConfig(name, value);
+ break;
+ case 'device name':
+ case 'interface name':
+ break;
+ default:
+ throw new Error(`unknown input name ${name}`);
+ }
+ },
+ [apiConfig, dispatch, updateAppConfig]
+ );
+
+ const handleReloadConfigFile = useCallback(() => {
+ dispatch(reloadConfigFile(apiConfig));
+ }, [apiConfig, dispatch]);
+
+ const handleRestartCore = useCallback(() => {
+ dispatch(restartCore(apiConfig));
+ }, [apiConfig, dispatch]);
+
+ const handleUpgradeCore = useCallback(() => {
+ dispatch(upgradeCore(apiConfig));
+ }, [apiConfig, dispatch]);
+
+ const handleUpgradeGeo = useCallback(() => {
+ dispatch(upgradeGeo(apiConfig));
+ }, [apiConfig, dispatch]);
+
+ const handleUpgradeUI = useCallback(() => {
+ dispatch(upgradeUI(apiConfig));
+ }, [apiConfig, dispatch]);
+
+ const handleFlushFakeIPPool = useCallback(() => {
+ dispatch(flushFakeIPPool(apiConfig));
+ }, [apiConfig, dispatch]);
+
+ return {
+ configState,
+ openAPIConfigModal,
+ handleInputOnChange,
+ handleInputOnBlur,
+ handleReloadConfigFile,
+ handleRestartCore,
+ handleUpgradeCore,
+ handleUpgradeGeo,
+ handleUpgradeUI,
+ handleFlushFakeIPPool,
+ versionQuery,
+ };
+}
diff --git a/src/modules/config/utils.ts b/src/modules/config/utils.ts
new file mode 100644
index 0000000..8300311
--- /dev/null
+++ b/src/modules/config/utils.ts
@@ -0,0 +1,54 @@
+export type SelectOption = [string, string];
+export type PortField = {
+ key: string;
+ label: string;
+};
+
+export const CONFIG_CHART_STYLE_PROPS = [{ id: 0 }, { id: 1 }, { id: 2 }, { id: 3 }];
+
+export const LOG_LEVEL_OPTIONS: SelectOption[] = [
+ ['debug', 'Debug'],
+ ['info', 'Info'],
+ ['warning', 'Warning'],
+ ['error', 'Error'],
+ ['silent', 'Silent'],
+];
+
+export const PORT_FIELDS: PortField[] = [
+ { key: 'port', label: 'Http Port' },
+ { key: 'socks-port', label: 'Socks5 Port' },
+ { key: 'mixed-port', label: 'Mixed Port' },
+ { key: 'redir-port', label: 'Redir Port' },
+ { key: 'mitm-port', label: 'MITM Port' },
+];
+
+export const LANGUAGE_OPTIONS: SelectOption[] = [
+ ['zh-cn', '简体中文'],
+ ['zh-tw', '繁體中文'],
+ ['en', 'English'],
+ ['vi', 'Vietnamese'],
+ ['ru', 'Русский'],
+];
+
+export const MODE_OPTIONS: SelectOption[] = [
+ ['direct', 'Direct'],
+ ['rule', 'Rule'],
+ ['script', 'Script'],
+ ['global', 'Global'],
+];
+
+export const TUN_STACK_OPTIONS: SelectOption[] = [
+ ['gvisor', 'gVisor'],
+ ['mixed', 'Mixed'],
+ ['system', 'System'],
+];
+
+export function getBackendContent(version: { meta?: boolean; premium?: boolean } | undefined) {
+ if (version?.meta && !version?.premium) {
+ return 'Clash.Meta ';
+ }
+ if (version?.meta && version?.premium) {
+ return 'sing-box ';
+ }
+ return 'Clash Premium';
+}
diff --git a/src/modules/connections/hooks.ts b/src/modules/connections/hooks.ts
new file mode 100644
index 0000000..1f1b415
--- /dev/null
+++ b/src/modules/connections/hooks.ts
@@ -0,0 +1,200 @@
+import * as React from 'react';
+import { useRecoilState } from 'recoil';
+
+import { ConnectionItem } from '~/api/connections';
+import * as connAPI from '~/api/connections';
+import {
+ closedConnectionsState,
+ connectionsState,
+ FormattedConn,
+ isRefreshPausedState,
+ MAX_CLOSED_CONNECTIONS,
+} from '~/store/connections';
+import { ClashAPIConfig } from '~/types';
+
+import {
+ ALL_SOURCE_IP,
+ arrayToIdKv,
+ CONNECTION_COLUMNS_DEFAULT,
+ ConnectionColumn,
+ filterConns,
+ formatConnectionDataItem,
+ getInitialColumns,
+ getInitialHiddenColumns,
+ getInitialSourceMap,
+ getNameFromSource,
+ HIDDEN_COLUMNS_DEFAULT,
+ saveColumns,
+ saveHiddenColumns,
+ saveSourceMap,
+ SourceMapItem,
+} from './utils';
+
+const { useCallback, useEffect, useMemo, useRef, useState } = React;
+
+export function useSourceMapState() {
+ const [sourceMapModal, setSourceMapModal] = useState(false);
+ const [sourceMap, setSourceMap] = useState<SourceMapItem[]>(() => getInitialSourceMap());
+
+ const openModalSource = useCallback(() => {
+ setSourceMap((prev) => (prev.length === 0 ? [{ reg: '', name: '' }] : prev));
+ setSourceMapModal(true);
+ }, []);
+
+ const closeModalSource = useCallback(() => {
+ setSourceMap((prev) => {
+ const nextSourceMap = prev.filter((item) => item.reg || item.name);
+ saveSourceMap(nextSourceMap);
+ return nextSourceMap;
+ });
+ setSourceMapModal(false);
+ }, []);
+
+ return {
+ sourceMap,
+ setSourceMap,
+ sourceMapModal,
+ openModalSource,
+ closeModalSource,
+ };
+}
+
+export function useConnectionsStream(apiConfig: ClashAPIConfig, sourceMap: SourceMapItem[]) {
+ const [conns, setConns] = useRecoilState(connectionsState);
+ const [closedConns, setClosedConns] = useRecoilState(closedConnectionsState);
+ const [isRefreshPaused, setIsRefreshPaused] = useRecoilState(isRefreshPausedState);
+ const [reConnectCount, setReConnectCount] = useState(0);
+ const prevConnsRef = useRef<FormattedConn[]>(conns);
+
+ const toggleIsRefreshPaused = useCallback(() => {
+ setIsRefreshPaused((value) => !value);
+ }, [setIsRefreshPaused]);
+
+ const closeAllConnections = useCallback(() => {
+ connAPI.closeAllConnections(apiConfig);
+ }, [apiConfig]);
+
+ const read = useCallback(
+ ({ connections }: { connections: ConnectionItem[] }) => {
+ const prevConnsKv = arrayToIdKv(prevConnsRef.current);
+ const now = Date.now();
+ const nextConnections =
+ connections?.map((item: ConnectionItem) =>
+ formatConnectionDataItem(item, prevConnsKv, now, sourceMap)
+ ) ?? [];
+ const closed: FormattedConn[] = [];
+
+ for (const connection of prevConnsRef.current) {
+ const idx = nextConnections.findIndex((conn) => conn.id === connection.id);
+ if (idx < 0) closed.push(connection);
+ }
+
+ if (closed.length > 0) {
+ setClosedConns((prev) => [...closed, ...prev].slice(0, MAX_CLOSED_CONNECTIONS + 1));
+ }
+
+ if (
+ nextConnections &&
+ (nextConnections.length !== 0 || prevConnsRef.current.length !== 0) &&
+ !isRefreshPaused
+ ) {
+ prevConnsRef.current = nextConnections;
+ setConns(nextConnections);
+ } else {
+ prevConnsRef.current = nextConnections;
+ }
+ },
+ [isRefreshPaused, setClosedConns, setConns, sourceMap]
+ );
+
+ useEffect(() => {
+ return connAPI.fetchData(apiConfig, read, () => {
+ setTimeout(() => {
+ setReConnectCount((prev) => prev + 1);
+ }, 1000);
+ });
+ }, [apiConfig, read, reConnectCount]);
+
+ return {
+ conns,
+ closedConns,
+ isRefreshPaused,
+ toggleIsRefreshPaused,
+ closeAllConnections,
+ };
+}
+
+export function useConnectionColumns() {
+ const [hiddenColumns, setHiddenColumnsState] = useState<string[]>(() =>
+ getInitialHiddenColumns()
+ );
+ const [columns, setColumnsState] = useState<ConnectionColumn[]>(() => getInitialColumns());
+
+ const setHiddenColumns = useCallback((nextHiddenColumns: string[]) => {
+ setHiddenColumnsState(nextHiddenColumns);
+ saveHiddenColumns(nextHiddenColumns);
+ }, []);
+
+ const setColumns = useCallback((nextColumns: ConnectionColumn[]) => {
+ setColumnsState(nextColumns);
+ saveColumns(nextColumns);
+ }, []);
+
+ const resetColumns = useCallback(() => {
+ setHiddenColumnsState([...HIDDEN_COLUMNS_DEFAULT]);
+ setColumnsState([...CONNECTION_COLUMNS_DEFAULT]);
+ saveHiddenColumns([...HIDDEN_COLUMNS_DEFAULT]);
+ saveColumns([...CONNECTION_COLUMNS_DEFAULT]);
+ }, []);
+
+ return {
+ hiddenColumns,
+ columns,
+ setHiddenColumns,
+ setColumns,
+ resetColumns,
+ };
+}
+
+export function useConnectionFilters({
+ conns,
+ closedConns,
+ sourceMap,
+ t,
+}: {
+ conns: FormattedConn[];
+ closedConns: FormattedConn[];
+ sourceMap: SourceMapItem[];
+ t: (key: string) => string;
+}) {
+ const [filterKeyword, setFilterKeyword] = useState('');
+ const [filterSourceIpStr, setFilterSourceIpStr] = useState(ALL_SOURCE_IP);
+
+ const filteredConns = useMemo(
+ () => filterConns(conns, filterKeyword, filterSourceIpStr),
+ [conns, filterKeyword, filterSourceIpStr]
+ );
+ const filteredClosedConns = useMemo(
+ () => filterConns(closedConns, filterKeyword, filterSourceIpStr),
+ [closedConns, filterKeyword, filterSourceIpStr]
+ );
+
+ const connIpSet = useMemo(() => {
+ return [
+ [ALL_SOURCE_IP, t('All')],
+ ...Array.from(new Set(conns.map((x) => x.sourceIP)))
+ .sort()
+ .map((value) => [value, getNameFromSource(value, sourceMap).trim() || t('internel')]),
+ ];
+ }, [conns, sourceMap, t]);
+
+ return {
+ filterKeyword,
+ setFilterKeyword,
+ filterSourceIpStr,
+ setFilterSourceIpStr,
+ filteredConns,
+ filteredClosedConns,
+ connIpSet,
+ };
+}
diff --git a/src/modules/connections/utils.ts b/src/modules/connections/utils.ts
new file mode 100644
index 0000000..47110c8
--- /dev/null
+++ b/src/modules/connections/utils.ts
@@ -0,0 +1,220 @@
+import { ConnectionItem } from '~/api/connections';
+import { FormattedConn } from '~/store/connections';
+
+export type SourceMapItem = {
+ reg: string;
+ name: string;
+};
+
+export type ConnectionColumn = {
+ accessor: string;
+ Header?: string;
+ show?: boolean;
+ sortDescFirst?: boolean;
+};
+
+export const ALL_SOURCE_IP = 'ALL_SOURCE_IP';
+export const SOURCE_MAP_STORAGE_KEY = 'sourceMap';
+export const CONNECTIONS_PADDING_BOTTOM = 30;
+export const HIDDEN_COLUMNS_STORAGE_KEY = 'hiddenColumns';
+export const COLUMNS_STORAGE_KEY = 'columns';
+
+const sortDescFirst = true;
+
+export const HIDDEN_COLUMNS_DEFAULT = ['id'];
+export const CONNECTION_COLUMNS_DEFAULT: ConnectionColumn[] = [
+ { accessor: 'id', show: false },
+ { Header: 'c_type', accessor: 'type' },
+ { Header: 'c_process', accessor: 'process' },
+ { Header: 'c_host', accessor: 'host' },
+ { Header: 'c_rule', accessor: 'rule' },
+ { Header: 'c_chains', accessor: 'chains' },
+ { Header: 'c_time', accessor: 'start' },
+ { Header: 'c_dl_speed', accessor: 'downloadSpeedCurr', sortDescFirst },
+ { Header: 'c_ul_speed', accessor: 'uploadSpeedCurr', sortDescFirst },
+ { Header: 'c_dl', accessor: 'download', sortDescFirst },
+ { Header: 'c_ul', accessor: 'upload', sortDescFirst },
+ { Header: 'c_source', accessor: 'source' },
+ { Header: 'c_destination_ip', accessor: 'destinationIP' },
+ { Header: 'c_sni', accessor: 'sniffHost' },
+ { Header: 'c_ctrl', accessor: 'ctrl' },
+];
+
+export function getInitialSourceMap(): SourceMapItem[] {
+ const sourceMap = localStorage.getItem(SOURCE_MAP_STORAGE_KEY);
+ return sourceMap ? JSON.parse(sourceMap) : [];
+}
+
+export function saveSourceMap(sourceMap: SourceMapItem[]) {
+ localStorage.setItem(SOURCE_MAP_STORAGE_KEY, JSON.stringify(sourceMap));
+}
+
+export function getInitialHiddenColumns(): string[] {
+ const hiddenColumns = localStorage.getItem(HIDDEN_COLUMNS_STORAGE_KEY);
+ return hiddenColumns ? JSON.parse(hiddenColumns) : [...HIDDEN_COLUMNS_DEFAULT];
+}
+
+export function saveHiddenColumns(hiddenColumns: string[]) {
+ localStorage.setItem(HIDDEN_COLUMNS_STORAGE_KEY, JSON.stringify(hiddenColumns));
+}
+
+export function getInitialColumns(): ConnectionColumn[] {
+ const savedColumns = localStorage.getItem(COLUMNS_STORAGE_KEY);
+ const columnOrder: ConnectionColumn[] | null = savedColumns ? JSON.parse(savedColumns) : null;
+
+ if (!columnOrder) {
+ return [...CONNECTION_COLUMNS_DEFAULT];
+ }
+
+ return [...CONNECTION_COLUMNS_DEFAULT].sort((prev, next) => {
+ const prevIdx = columnOrder.findIndex((column) => column.accessor === prev.accessor);
+ const nextIdx = columnOrder.findIndex((column) => column.accessor === next.accessor);
+
+ if (prevIdx === -1) {
+ return 1;
+ }
+
+ if (nextIdx === -1) {
+ return -1;
+ }
+
+ return prevIdx - nextIdx;
+ });
+}
+
+export function saveColumns(columns: ConnectionColumn[]) {
+ localStorage.setItem(COLUMNS_STORAGE_KEY, JSON.stringify(columns));
+}
+
+export function arrayToIdKv<T extends { id: string }>(items: T[]) {
+ const result: Record<string, T> = {};
+ for (let i = 0; i < items.length; i++) {
+ const item = items[i];
+ result[item.id] = item;
+ }
+ return result;
+}
+
+function hasSubstring(value: string, pattern: string) {
+ return value.toLowerCase().includes(pattern.toLowerCase());
+}
+
+function filterConnIps(conns: FormattedConn[], ipStr: string) {
+ return conns.filter((each) => each.sourceIP === ipStr);
+}
+
+export function filterConns(conns: FormattedConn[], keyword: string, sourceIp: string) {
+ let result = conns;
+ if (keyword !== '') {
+ result = conns.filter((conn) =>
+ [
+ conn.host,
+ conn.sourceIP,
+ conn.sourcePort,
+ conn.destinationIP,
+ conn.chains,
+ conn.rule,
+ conn.type,
+ conn.network,
+ conn.process,
+ ].some((field) => hasSubstring(field, keyword))
+ );
+ }
+ if (sourceIp !== ALL_SOURCE_IP) {
+ result = filterConnIps(result, sourceIp);
+ }
+
+ return result;
+}
+
+export function getNameFromSource(
+ source: string,
+ sourceMap: SourceMapItem[],
+ defaultVal?: string
+): string {
+ let sourceName = defaultVal ?? source;
+
+ sourceMap.forEach(({ reg, name }) => {
+ if (!reg) return;
+
+ if (reg.startsWith('/')) {
+ const regExp = new RegExp(reg.replace('/', ''), 'g');
+
+ if (regExp.test(source) && name) {
+ sourceName = `${name}(${source})`;
+ }
+ } else if (source === reg && name) {
+ sourceName = `${name}(${source})`;
+ }
+ });
+
+ return sourceName;
+}
+
+export function modifyChains(chains: string[]): string {
+ if (!Array.isArray(chains) || chains.length === 0) {
+ return '';
+ }
+
+ if (chains.length === 1) {
+ return chains[0];
+ }
+
+ return `${chains[chains.length - 1]} -> ${chains[0]}`;
+}
+
+export function formatConnectionDataItem(
+ item: ConnectionItem,
+ prevKv: Record<string, FormattedConn>,
+ now: number,
+ sourceMap: SourceMapItem[]
+): FormattedConn {
+ const { id, upload, download, start, chains, rule, rulePayload, metadata } = item;
+ const prev = prevKv[id];
+
+ if (prev) {
+ return {
+ ...prev,
+ upload,
+ download,
+ start: now - prev.startTime,
+ downloadSpeedCurr: download - prev.download,
+ uploadSpeedCurr: upload - prev.upload,
+ };
+ }
+
+ const {
+ host,
+ destinationPort,
+ destinationIP,
+ remoteDestination,
+ network,
+ type,
+ sourceIP,
+ sourcePort,
+ process,
+ sniffHost,
+ } = metadata;
+ const host2 = host || destinationIP;
+ const source = `${sourceIP}:${sourcePort}`;
+ const startTime = new Date(start).valueOf();
+
+ return {
+ id,
+ upload,
+ download,
+ start: now - startTime,
+ startTime,
+ chains: modifyChains(chains),
+ rule: !rulePayload ? rule : `${rule} :: ${rulePayload}`,
+ ...metadata,
+ host: `${host2}:${destinationPort}`,
+ sniffHost: sniffHost || '-',
+ type: `${type}(${network})`,
+ source: getNameFromSource(sourceIP, sourceMap, source),
+ downloadSpeedCurr: 0,
+ uploadSpeedCurr: 0,
+ process: process || '-',
+ destinationIP: remoteDestination || destinationIP || host,
+ };
+} \ No newline at end of file
diff --git a/src/modules/home/hooks.ts b/src/modules/home/hooks.ts
new file mode 100644
index 0000000..5c22bc6
--- /dev/null
+++ b/src/modules/home/hooks.ts
@@ -0,0 +1,36 @@
+import * as React from 'react';
+
+import * as connAPI from '~/api/connections';
+import prettyBytes from '~/misc/pretty-bytes';
+import { ClashAPIConfig } from '~/types';
+
+const { useCallback, useEffect, useState } = React;
+
+export function useConnectionSummary(apiConfig: ClashAPIConfig) {
+ const [state, setState] = useState({
+ upTotal: '0 B',
+ dlTotal: '0 B',
+ connNumber: 0,
+ mUsage: '0 B',
+ });
+
+ const read = useCallback(
+ ({ downloadTotal, uploadTotal, connections, memory }) => {
+ setState({
+ upTotal: prettyBytes(uploadTotal),
+ dlTotal: prettyBytes(downloadTotal),
+ connNumber: connections ? connections.length : 0,
+ mUsage: prettyBytes(memory),
+ });
+ },
+ [setState]
+ );
+
+ useEffect(() => {
+ return connAPI.fetchData(apiConfig, read, () => {
+ /* noop */
+ });
+ }, [apiConfig, read]);
+
+ return state;
+}
diff --git a/src/modules/home/utils.ts b/src/modules/home/utils.ts
new file mode 100644
index 0000000..177cfc9
--- /dev/null
+++ b/src/modules/home/utils.ts
@@ -0,0 +1,5 @@
+import prettyBytes from '~/misc/pretty-bytes';
+
+export function formatTrafficRate(value: number) {
+ return `${prettyBytes(value || 0)}/s`;
+}
diff --git a/src/modules/logs/hooks.ts b/src/modules/logs/hooks.ts
new file mode 100644
index 0000000..45e09e4
--- /dev/null
+++ b/src/modules/logs/hooks.ts
@@ -0,0 +1,68 @@
+import * as React from 'react';
+
+import { fetchLogs, reconnect as reconnectLogs, stop as stopLogs } from '~/api/logs';
+import { appendLog } from '~/store/logs';
+import { DispatchFn, Log } from '~/store/types';
+import { ClashAPIConfig } from '~/types';
+
+import { LOGS_SCROLL_BOTTOM_THRESHOLD } from './utils';
+
+const { useCallback, useEffect, useRef, useState } = React;
+
+type UpdateAppConfigFn = (name: string, value: unknown) => void;
+
+export function useLogsPage({
+ dispatch,
+ logLevel,
+ apiConfig,
+ logs,
+ logStreamingPaused,
+ updateAppConfig,
+}: {
+ dispatch: DispatchFn;
+ logLevel: string;
+ apiConfig: ClashAPIConfig;
+ logs: Log[];
+ logStreamingPaused: boolean;
+ updateAppConfig: UpdateAppConfigFn;
+}) {
+ const toggleIsRefreshPaused = useCallback(() => {
+ logStreamingPaused ? reconnectLogs({ ...apiConfig, logLevel }) : stopLogs();
+ updateAppConfig('logStreamingPaused', !logStreamingPaused);
+ }, [apiConfig, logLevel, logStreamingPaused, updateAppConfig]);
+
+ const appendLogInternal = useCallback((log) => dispatch(appendLog(log)), [dispatch]);
+
+ useEffect(() => {
+ fetchLogs({ ...apiConfig, logLevel }, appendLogInternal);
+ }, [apiConfig, logLevel, appendLogInternal]);
+
+ const scrollRef = useRef<HTMLDivElement>(null);
+ const [isAtBottom, setIsAtBottom] = useState(true);
+
+ const scrollToBottom = useCallback(() => {
+ if (scrollRef.current) {
+ scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
+ }
+ }, []);
+
+ useEffect(() => {
+ if (isAtBottom) {
+ scrollToBottom();
+ }
+ }, [logs, isAtBottom, scrollToBottom]);
+
+ const onScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
+ const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
+ const atBottom = scrollHeight - scrollTop - clientHeight < LOGS_SCROLL_BOTTOM_THRESHOLD;
+ setIsAtBottom(atBottom);
+ }, []);
+
+ return {
+ toggleIsRefreshPaused,
+ scrollRef,
+ isAtBottom,
+ scrollToBottom,
+ onScroll,
+ };
+} \ No newline at end of file
diff --git a/src/modules/logs/utils.ts b/src/modules/logs/utils.ts
new file mode 100644
index 0000000..cb0d7c6
--- /dev/null
+++ b/src/modules/logs/utils.ts
@@ -0,0 +1,9 @@
+export const LOG_TYPES: Record<string, string> = {
+ debug: 'debug',
+ info: 'info',
+ warning: 'warn',
+ error: 'error',
+};
+
+export const LOGS_HEIGHT_RATIO = 0.8;
+export const LOGS_SCROLL_BOTTOM_THRESHOLD = 50; \ No newline at end of file
diff --git a/src/modules/proxies/hooks.ts b/src/modules/proxies/hooks.ts
new file mode 100644
index 0000000..330f64e
--- /dev/null
+++ b/src/modules/proxies/hooks.ts
@@ -0,0 +1,282 @@
+import * as React from 'react';
+import { useRecoilState } from 'recoil';
+
+import {
+ fetchProxies,
+ NonProxyTypes,
+ proxyFilterText,
+ requestDelayAll,
+ updateProviderByName,
+ updateProviders,
+} from '~/store/proxies';
+import {
+ DelayMapping,
+ DispatchFn,
+ FormattedProxyProvider,
+ ProxiesMapping,
+ ProxyItem,
+} from '~/store/types';
+import { ClashAPIConfig } from '~/types';
+
+import { splitItemsByLayout } from './utils';
+
+const { useCallback, useEffect, useMemo, useRef, useState } = React;
+
+function filterAvailableProxies(list: string[], delay: DelayMapping) {
+ return list.filter((name) => {
+ const d = delay[name];
+ if (d === undefined) {
+ return true;
+ }
+ if (d.number === 0) {
+ return false;
+ }
+ return true;
+ });
+}
+
+const getSortDelay = (
+ d:
+ | undefined
+ | {
+ number?: number;
+ },
+ proxyInfo: ProxyItem
+) => {
+ if (d && typeof d.number === 'number' && d.number > 0) {
+ return d.number;
+ }
+
+ const type = proxyInfo && proxyInfo.type;
+ if (type && NonProxyTypes.indexOf(type) > -1) return -1;
+
+ return 999999;
+};
+
+const ProxySortingFns = {
+ Natural: (proxies: string[]) => proxies,
+ LatencyAsc: (proxies: string[], delay: DelayMapping, proxyMapping?: ProxiesMapping) => {
+ return proxies.sort((a, b) => {
+ const d1 = getSortDelay(delay[a], proxyMapping && proxyMapping[a]);
+ const d2 = getSortDelay(delay[b], proxyMapping && proxyMapping[b]);
+ return d1 - d2;
+ });
+ },
+ LatencyDesc: (proxies: string[], delay: DelayMapping, proxyMapping?: ProxiesMapping) => {
+ return proxies.sort((a, b) => {
+ const d1 = getSortDelay(delay[a], proxyMapping && proxyMapping[a]);
+ const d2 = getSortDelay(delay[b], proxyMapping && proxyMapping[b]);
+ return d2 - d1;
+ });
+ },
+ NameAsc: (proxies: string[]) => {
+ return proxies.sort();
+ },
+ NameDesc: (proxies: string[]) => {
+ return proxies.sort((a, b) => {
+ if (a > b) return -1;
+ if (a < b) return 1;
+ return 0;
+ });
+ },
+};
+
+function filterStrArr(all: string[], searchText: string) {
+ const segments = searchText
+ .toLowerCase()
+ .split(' ')
+ .map((x) => x.trim())
+ .filter((x) => !!x);
+
+ if (segments.length === 0) return all;
+
+ return all.filter((name) => {
+ let i = 0;
+ for (; i < segments.length; i++) {
+ const seg = segments[i];
+ if (name.toLowerCase().indexOf(seg) > -1) return true;
+ }
+ return false;
+ });
+}
+
+function filterAvailableProxiesAndSort(
+ all: string[],
+ delay: DelayMapping,
+ hideUnavailableProxies: boolean,
+ filterText: string,
+ proxySortBy: string,
+ proxies?: ProxiesMapping
+) {
+ let filtered = [...all];
+ if (hideUnavailableProxies) {
+ filtered = filterAvailableProxies(all, delay);
+ }
+
+ if (typeof filterText === 'string' && filterText !== '') {
+ filtered = filterStrArr(filtered, filterText);
+ }
+ return ProxySortingFns[proxySortBy](filtered, delay, proxies);
+}
+
+export function useFilteredAndSorted(
+ all: string[],
+ delay: DelayMapping,
+ hideUnavailableProxies: boolean,
+ proxySortBy: string,
+ proxies?: ProxiesMapping
+) {
+ const [filterText] = useRecoilState(proxyFilterText);
+ return useMemo(
+ () =>
+ filterAvailableProxiesAndSort(
+ all,
+ delay,
+ hideUnavailableProxies,
+ filterText,
+ proxySortBy,
+ proxies
+ ),
+ [all, delay, hideUnavailableProxies, filterText, proxySortBy, proxies]
+ );
+}
+
+export function useUpdateProviderItem({
+ dispatch,
+ apiConfig,
+ name,
+}: {
+ dispatch: DispatchFn;
+ apiConfig: ClashAPIConfig;
+ name: string;
+}) {
+ return useCallback(
+ () => dispatch(updateProviderByName(apiConfig, name)),
+ [apiConfig, dispatch, name]
+ );
+}
+
+export function useUpdateProviderItems({
+ dispatch,
+ apiConfig,
+ names,
+}: {
+ dispatch: DispatchFn;
+ apiConfig: ClashAPIConfig;
+ names: string[];
+}): [() => unknown, boolean] {
+ const [isLoading, setIsLoading] = useState(false);
+
+ const action = useCallback(async () => {
+ if (isLoading) {
+ return;
+ }
+
+ setIsLoading(true);
+ try {
+ await dispatch(updateProviders(apiConfig, names));
+ } catch (e) {
+ // ignore
+ }
+ setIsLoading(false);
+ }, [apiConfig, dispatch, names, isLoading]);
+
+ return [action, isLoading];
+}
+
+export function useTestLatencyAction({
+ dispatch,
+ apiConfig,
+}: {
+ dispatch: DispatchFn;
+ apiConfig: ClashAPIConfig;
+}): [() => unknown, boolean] {
+ const [isTestingLatency, setIsTestingLatency] = useState(false);
+ const requestDelayAllFn = useCallback(() => {
+ if (isTestingLatency) return;
+
+ setIsTestingLatency(true);
+ dispatch(requestDelayAll(apiConfig)).then(
+ () => setIsTestingLatency(false),
+ () => setIsTestingLatency(false)
+ );
+ }, [apiConfig, dispatch, isTestingLatency]);
+ return [requestDelayAllFn, isTestingLatency];
+}
+
+export function useProxiesPage({
+ dispatch,
+ apiConfig,
+ groupNames,
+ proxyProviders,
+ proxiesLayout,
+}: {
+ dispatch: DispatchFn;
+ apiConfig: ClashAPIConfig;
+ groupNames: string[];
+ proxyProviders: FormattedProxyProvider[];
+ proxiesLayout: string;
+}) {
+ const refFetchedTimestamp = useRef<{ startAt?: number; completeAt?: number }>({});
+
+ const fetchProxiesHooked = useCallback(() => {
+ refFetchedTimestamp.current.startAt = Date.now();
+ dispatch(fetchProxies(apiConfig)).then(() => {
+ refFetchedTimestamp.current.completeAt = Date.now();
+ });
+ }, [apiConfig, dispatch]);
+
+ useEffect(() => {
+ fetchProxiesHooked();
+
+ const fn = () => {
+ if (
+ refFetchedTimestamp.current.startAt &&
+ Date.now() - refFetchedTimestamp.current.startAt > 3e4
+ ) {
+ fetchProxiesHooked();
+ }
+ };
+ window.addEventListener('focus', fn, false);
+ return () => window.removeEventListener('focus', fn, false);
+ }, [fetchProxiesHooked]);
+
+ const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false);
+ const closeSettingsModal = useCallback(() => {
+ setIsSettingsModalOpen(false);
+ }, []);
+ const openSettingsModal = useCallback(() => {
+ setIsSettingsModalOpen(true);
+ }, []);
+
+ const [activeTab, setActiveTab] = useState('proxies');
+ const handleTabKeyDown = useCallback(
+ (tab: string) => (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ setActiveTab(tab);
+ }
+ },
+ []
+ );
+
+ const proxyGroups = useMemo(() => {
+ const formatted = groupNames.map((name, i) => ({ name, i }));
+ return splitItemsByLayout(formatted, proxiesLayout);
+ }, [groupNames, proxiesLayout]);
+
+ const providers = useMemo(() => {
+ const formatted = proxyProviders.map((item, i) => ({ item, i }));
+ return splitItemsByLayout(formatted, proxiesLayout);
+ }, [proxyProviders, proxiesLayout]);
+
+ return {
+ isSettingsModalOpen,
+ openSettingsModal,
+ closeSettingsModal,
+ activeTab,
+ setActiveTab,
+ handleTabKeyDown,
+ proxyGroups,
+ providers,
+ };
+}
diff --git a/src/modules/proxies/utils.ts b/src/modules/proxies/utils.ts
new file mode 100644
index 0000000..59e24a0
--- /dev/null
+++ b/src/modules/proxies/utils.ts
@@ -0,0 +1,58 @@
+import { DelayMapping, ProxiesMapping } from '~/store/types';
+
+export const PROXY_SORT_OPTIONS = [
+ ['Natural', 'order_natural'],
+ ['LatencyAsc', 'order_latency_asc'],
+ ['LatencyDesc', 'order_latency_desc'],
+ ['NameAsc', 'order_name_asc'],
+ ['NameDesc', 'order_name_desc'],
+] as const;
+
+export function formatQty(qty: number) {
+ return qty < 100 ? String(qty) : '99+';
+}
+
+export function splitItemsByLayout<T>(items: T[], layout: string) {
+ if (layout !== 'double') {
+ return [items];
+ }
+
+ const left: T[] = [];
+ const right: T[] = [];
+ items.forEach((item, index) => {
+ if (index % 2 === 0) {
+ left.push(item);
+ } else {
+ right.push(item);
+ }
+ });
+
+ return [left, right];
+}
+
+export function getProxyLatency(
+ proxies: ProxiesMapping,
+ delay: DelayMapping,
+ name: string,
+ visited = new Set<string>()
+) {
+ if (visited.has(name)) return undefined;
+ visited.add(name);
+
+ const latency = delay[name];
+ if (latency && (latency.testing || typeof latency.number === 'number' || latency.error)) {
+ return latency;
+ }
+
+ const proxy = proxies[name];
+ if (proxy && proxy.now && proxies[proxy.now]) {
+ return getProxyLatency(proxies, delay, proxy.now, visited);
+ }
+
+ const delayFromHistory = proxy?.history?.[proxy.history.length - 1]?.delay;
+ if (typeof delayFromHistory === 'number' && delayFromHistory > 0) {
+ return { number: delayFromHistory };
+ }
+
+ return latency;
+}
diff --git a/src/modules/rules/hooks.ts b/src/modules/rules/hooks.ts
new file mode 100644
index 0000000..e99b978
--- /dev/null
+++ b/src/modules/rules/hooks.ts
@@ -0,0 +1,108 @@
+import * as React from 'react';
+import { useMutation, useQuery, useQueryClient } from 'react-query';
+import { useRecoilState } from 'recoil';
+
+import {
+ fetchRuleProviders,
+ refreshRuleProviderByName,
+ updateRuleProviders,
+} from '~/api/rule-provider';
+import { fetchRules } from '~/api/rules';
+import { ruleFilterText } from '~/store/rules';
+import type { ClashAPIConfig } from '~/types';
+
+const { useCallback, useState } = React;
+
+export function useUpdateRuleProviderItem(
+ name: string,
+ apiConfig: ClashAPIConfig
+): [(ev: React.MouseEvent<HTMLButtonElement>) => unknown, boolean] {
+ const queryClient = useQueryClient();
+ const { mutate, isLoading } = useMutation(refreshRuleProviderByName, {
+ onSuccess: () => {
+ queryClient.invalidateQueries('/providers/rules');
+ },
+ });
+ const onClickRefreshButton = (ev: React.MouseEvent<HTMLButtonElement>) => {
+ ev.preventDefault();
+ mutate({ name, apiConfig });
+ };
+ return [onClickRefreshButton, isLoading];
+}
+
+export function useUpdateAllRuleProviderItems(
+ apiConfig: ClashAPIConfig
+): [(ev: React.MouseEvent<HTMLButtonElement>) => unknown, boolean] {
+ const queryClient = useQueryClient();
+ const { data: provider } = useRuleProviderQuery(apiConfig);
+ const { mutate, isLoading } = useMutation(updateRuleProviders, {
+ onSuccess: () => {
+ queryClient.invalidateQueries('/providers/rules');
+ },
+ });
+ const onClickRefreshButton = (ev: React.MouseEvent<HTMLButtonElement>) => {
+ ev.preventDefault();
+ mutate({ names: provider.names, apiConfig });
+ };
+ return [onClickRefreshButton, isLoading];
+}
+
+export function useInvalidateQueries() {
+ const queryClient = useQueryClient();
+ return useCallback(() => {
+ queryClient.invalidateQueries('/rules');
+ queryClient.invalidateQueries('/providers/rules');
+ }, [queryClient]);
+}
+
+export function useRuleProviderQuery(apiConfig: ClashAPIConfig) {
+ return useQuery(['/providers/rules', apiConfig], () =>
+ fetchRuleProviders('/providers/rules', apiConfig)
+ );
+}
+
+export function useRuleAndProvider(apiConfig: ClashAPIConfig) {
+ const { data: rules, isFetching } = useQuery(['/rules', apiConfig], () =>
+ fetchRules('/rules', apiConfig)
+ );
+ const { data: provider } = useRuleProviderQuery(apiConfig);
+
+ const [filterText] = useRecoilState(ruleFilterText);
+ if (filterText === '') {
+ return { rules, provider, isFetching };
+ }
+
+ const f = filterText.toLowerCase();
+ return {
+ rules: rules.filter((r) => r.payload.toLowerCase().indexOf(f) >= 0),
+ isFetching,
+ provider: {
+ byName: provider.byName,
+ names: provider.names.filter((t) => t.toLowerCase().indexOf(f) >= 0),
+ },
+ };
+}
+
+export function useRulesPage(apiConfig: ClashAPIConfig) {
+ const { rules, provider } = useRuleAndProvider(apiConfig);
+ const [activeTab, setActiveTab] = useState('rules');
+ const isRulesTab = activeTab === 'rules';
+
+ const handleTabKeyDown = useCallback(
+ (tab: string) => (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ setActiveTab(tab);
+ }
+ },
+ []
+ );
+
+ return {
+ rules,
+ provider,
+ activeTab,
+ setActiveTab,
+ isRulesTab,
+ handleTabKeyDown,
+ };
+} \ No newline at end of file
diff --git a/src/modules/rules/utils.ts b/src/modules/rules/utils.ts
new file mode 100644
index 0000000..c1d1464
--- /dev/null
+++ b/src/modules/rules/utils.ts
@@ -0,0 +1,24 @@
+import { ClashAPIConfig } from '~/types';
+
+export type RulesListItemData = {
+ rules: any[] | null;
+ provider: any;
+ apiConfig: ClashAPIConfig;
+};
+
+export function itemKey(index: number, { rules, provider }: RulesListItemData) {
+ if (!rules) {
+ return provider.names[index];
+ }
+ return rules[index].id;
+}
+
+export function getItemSizeFactory({ isRulesTab }: { isRulesTab: boolean }) {
+ return function getItemSize() {
+ return isRulesTab ? 70 : 100;
+ };
+}
+
+export function formatQty(qty: number) {
+ return qty < 100 ? String(qty) : '99+';
+}