Feature: replace unstated-next with recoil

This commit is contained in:
Dreamacro 2020-05-30 16:12:17 +08:00
parent 45d1473adc
commit e4da18d01a
26 changed files with 1374 additions and 947 deletions

1373
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -29,71 +29,72 @@
},
"devDependencies": {
"@babel/cli": "^7.8.4",
"@babel/core": "^7.9.0",
"@babel/preset-env": "^7.9.5",
"@babel/core": "^7.9.6",
"@babel/preset-env": "^7.9.6",
"@babel/preset-react": "^7.9.4",
"@hot-loader/react-dom": "^16.13.0",
"@types/classnames": "^2.2.10",
"@types/lodash-es": "^4.17.3",
"@types/node": "^13.13.2",
"@types/react": "^16.9.34",
"@types/react-dom": "^16.9.6",
"@types/react-router-dom": "^5.1.4",
"@types/react-table": "^7.0.16",
"@types/node": "^14.0.6",
"@types/react": "^16.9.35",
"@types/react-dom": "^16.9.8",
"@types/react-router-dom": "^5.1.5",
"@types/react-table": "^7.0.18",
"@types/react-virtualized-auto-sizer": "^1.0.0",
"@types/react-window": "^1.8.2",
"@types/recoil": "0.0.0",
"@typescript-eslint/eslint-plugin": "^2.29.0",
"@typescript-eslint/parser": "^2.29.0",
"autoprefixer": "^9.7.6",
"autoprefixer": "^9.8.0",
"awesome-typescript-loader": "^5.2.1",
"babel-loader": "^8.1.0",
"babel-preset-minify": "^0.5.1",
"css-loader": "^3.5.3",
"eslint": "^6.8.0",
"eslint-config-standard": "^14.1.1",
"eslint-loader": "^4.0.0",
"eslint-loader": "^4.0.2",
"eslint-plugin-import": "^2.20.2",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^4.2.1",
"eslint-plugin-react": "^7.19.0",
"eslint-plugin-react": "^7.20.0",
"eslint-plugin-standard": "^4.0.1",
"file-loader": "^6.0.0",
"html-webpack-plugin": "^4.2.0",
"html-webpack-plugin": "^4.3.0",
"image-webpack-loader": "^6.0.0",
"mini-css-extract-plugin": "^0.9.0",
"offline-plugin": "^5.0.7",
"postcss-loader": "^3.0.0",
"react-hot-loader": "^4.12.20",
"sass": "^1.26.5",
"react-hot-loader": "^4.12.21",
"sass": "^1.26.7",
"sass-loader": "^8.0.2",
"style-loader": "^1.2.0",
"stylelint": "^13.3.3",
"style-loader": "^1.2.1",
"stylelint": "^13.5.0",
"stylelint-config-standard": "^20.0.0",
"stylelint-webpack-plugin": "^1.2.3",
"terser-webpack-plugin": "^2.3.5",
"typescript": "^3.8.3",
"typescript": "^3.9.3",
"webpack": "^4.43.0",
"webpack-cli": "^3.3.11",
"webpack-dev-middleware": "^3.7.2",
"webpack-dev-server": "^3.10.3",
"webpack-dev-server": "^3.11.0",
"webpack-merge": "^4.2.2",
"webpack-pwa-manifest": "^4.2.0"
},
"dependencies": {
"axios": "^0.19.2",
"classnames": "^2.2.6",
"dayjs": "^1.8.25",
"eventemitter3": "^4.0.0",
"immer": "^6.0.3",
"dayjs": "^1.8.28",
"eventemitter3": "^4.0.4",
"immer": "^6.0.9",
"lodash-es": "^4.17.15",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router-dom": "^5.1.2",
"react-table": "^7.0.4",
"react-router-dom": "^5.2.0",
"react-table": "^7.1.0",
"react-virtualized-auto-sizer": "^1.0.2",
"react-window": "^1.8.5",
"swr": "^0.2.0",
"unstated-next": "^1.1.0",
"recoil": "0.0.7",
"swr": "^0.2.2",
"use-immer": "^0.4.0"
}
}

View File

@ -1,5 +1,5 @@
import React, { useState, useRef, useLayoutEffect } from 'react'
import { containers } from '@stores'
import { useI18n } from '@stores'
import { BaseComponentProps } from '@models'
import { noop } from '@lib/helper'
import classnames from 'classnames'
@ -8,16 +8,16 @@ import './style.scss'
interface TagsProps extends BaseComponentProps {
data: string[]
onClick: (name: string) => void
shouldError?: (name: string) => boolean
errSet?: Set<string>
select: string
rowHeight: number
canClick: boolean
}
export function Tags (props: TagsProps) {
const { className, data, onClick, select, canClick, shouldError, rowHeight: rawHeight } = props
const { className, data, onClick, select, canClick, errSet, rowHeight: rawHeight } = props
const { useTranslation } = containers.useI18n()
const { useTranslation } = useI18n()
const { t } = useTranslation('Proxies')
const [expand, setExpand] = useState(false)
const [showExtend, setShowExtend] = useState(false)
@ -36,7 +36,7 @@ export function Tags (props: TagsProps) {
const tags = data
.map(t => {
const tagClass = classnames({ 'tags-selected': select === t, 'can-click': canClick, error: shouldError && shouldError(t) })
const tagClass = classnames({ 'tags-selected': select === t, 'can-click': canClick, error: errSet?.has(t) })
return (
<li className={tagClass} key={t} onClick={() => handleClick(t)}>
{ t }

View File

@ -2,7 +2,7 @@ import React, { useMemo, useLayoutEffect } from 'react'
import { useBlockLayout, useResizeColumns, useTable } from 'react-table'
import classnames from 'classnames'
import { Header, Card, Checkbox, Modal, Icon } from '@components'
import { containers } from '@stores'
import { useI18n } from '@stores'
import * as API from '@lib/request'
import { StreamReader } from '@lib/streamer'
import { useObject, useVisible } from '@lib/hook'
@ -62,7 +62,7 @@ function formatSpeed (upload: number, download: number) {
}
export default function Connections () {
const { useTranslation, lang } = containers.useI18n()
const { useTranslation, lang } = useI18n()
const t = useMemo(() => useTranslation('Connections').t, [useTranslation])
// total

View File

@ -1,14 +1,14 @@
import React, { useEffect } from 'react'
import { useObject } from '@lib/hook'
import { Modal, Input, Row, Col, Alert } from '@components'
import { containers } from '@stores'
import { useI18n, useAPIInfo, useIdentity } from '@stores'
import './style.scss'
export default function ExternalController () {
const { useTranslation } = containers.useI18n()
const { useTranslation } = useI18n()
const { t } = useTranslation('Settings')
const { data: info, update, fetch } = containers.useAPIInfo()
const { unauthorized: { hide, visible } } = containers.useData()
const { data: info, update, fetch } = useAPIInfo()
const { identity, set: setIdentity } = useIdentity()
const [value, set] = useObject({
hostname: '',
port: '',
@ -30,10 +30,10 @@ export default function ExternalController () {
return (
<Modal
show={visible}
show={!identity}
title={t('externalControllerSetting.title')}
bodyClassName="external-controller"
onClose={hide}
onClose={() => setIdentity(true)}
onOk={handleOk}
>
<Alert type="info" inside={true}>

View File

@ -1,6 +1,6 @@
import React, { useLayoutEffect, useEffect, useRef, useState } from 'react'
import dayjs from 'dayjs'
import { containers } from '@stores'
import { useI18n } from '@stores'
import { Card, Header } from '@components'
import { getLogsStreamReader } from '@lib/request'
import { StreamReader } from '@lib/streamer'
@ -11,7 +11,7 @@ export default function Logs () {
const listRef = useRef<HTMLUListElement>()
const logsRef = useRef<Log[]>([])
const [logs, setLogs] = useState<Log[]>([])
const { useTranslation } = containers.useI18n()
const { useTranslation } = useI18n()
const { t } = useTranslation('Logs')
useLayoutEffect(() => {

View File

@ -1,5 +1,6 @@
import * as React from 'react'
import { containers } from '@stores'
import React, { useMemo } from 'react'
import { useRecoilValue } from 'recoil'
import { useProxy, useConfig, proxyMapping } from '@stores'
import { changeProxySelected, Group as IGroup, getConnections, closeConnection } from '@lib/request'
import { Tags, Tag } from '@components'
import './style.scss'
@ -9,13 +10,14 @@ interface GroupProps {
}
export function Group (props: GroupProps) {
const { fetch, data: Data } = containers.useData()
const { data: Config } = containers.useConfig()
const { update } = useProxy()
const proxyMap = useRecoilValue(proxyMapping)
const { data: Config } = useConfig()
const { config } = props
async function handleChangeProxySelected (name: string) {
await changeProxySelected(props.config.name, name)
await fetch()
await update()
if (Config.breakConnections) {
const list: string[] = []
const snapshot = await getConnections()
@ -31,14 +33,17 @@ export function Group (props: GroupProps) {
}
}
function shouldError (name: string) {
const history = Data.proxyMap.get(name)?.history
if (history?.length) {
return !history.slice(-1)[0].delay
const errSet = useMemo(() => {
const set = new Set<string>()
for (const proxy of config.all) {
const history = proxyMap.get(proxy)?.history
if (history?.length && history.slice(-1)[0].delay !== 0) {
set.add(proxy)
}
}
return false
}
return set
}, [proxyMap])
const canClick = config.type === 'Selector'
return (
@ -52,7 +57,7 @@ export function Group (props: GroupProps) {
className="proxy-group-tags"
data={config.all}
onClick={handleChangeProxySelected}
shouldError={shouldError}
errSet={errSet}
select={config.now}
canClick={canClick}
rowHeight={30} />

View File

@ -1,7 +1,7 @@
import * as React from 'react'
import { useMemo } from 'react'
import { Card, Tag, Icon, Loading } from '@components'
import { containers } from '@stores'
import { useI18n, useProxyProviders } from '@stores'
import { fromNow } from '@lib/date'
import { Provider as IProvider, Proxy as IProxy, updateProvider, healthCheckProvider } from '@lib/request'
import { useVisible } from '@lib/hook'
@ -14,22 +14,22 @@ interface ProvidersProps {
}
export function Provider (props: ProvidersProps) {
const { fetch } = containers.useData()
const { useTranslation, lang } = containers.useI18n()
const { provider } = props
const { update } = useProxyProviders()
const { useTranslation, lang } = useI18n()
const { provider } = props
const { t } = useTranslation('Proxies')
const { visible, hide, show } = useVisible()
function handleHealthChech () {
show()
healthCheckProvider(provider.name).then(() => fetch()).finally(() => hide())
healthCheckProvider(provider.name).then(() => update()).finally(() => hide())
}
function handleUpdate () {
show()
updateProvider(provider.name).then(() => fetch()).finally(() => hide())
updateProvider(provider.name).then(() => update()).finally(() => hide())
}
const proxies = useMemo(() => {

View File

@ -1,7 +1,7 @@
import React, { useState, useMemo, useLayoutEffect, useEffect } from 'react'
import React, { useMemo, useLayoutEffect } from 'react'
import classnames from 'classnames'
import { BaseComponentProps } from '@models'
import { containers } from '@stores'
import { useProxy } from '@stores'
import { getProxyDelay, Proxy as IProxy } from '@lib/request'
import EE, { Action } from '@lib/event'
import { isClashX, jsBridge } from '@lib/jsBridge'
@ -14,8 +14,8 @@ interface ProxyProps extends BaseComponentProps {
const TagColors = {
'#909399': 0,
'#00c520': 150,
'#ff9a28': 500,
'#00c520': 260,
'#ff9a28': 600,
'#ff3e5e': Infinity
}
@ -31,20 +31,24 @@ async function getDelay (name: string) {
export function Proxy (props: ProxyProps) {
const { config, className } = props
const [delay, setDelay] = useState(0)
const { updateDelay } = containers.useData()
const { set } = useProxy()
async function speedTest () {
const [delay, err] = await to(getDelay(config.name))
const validDelay = err ? 0 : delay
setDelay(validDelay)
updateDelay(config.name, validDelay)
set(draft => {
const proxy = draft.proxies.find(p => p.name === proxy)
if (proxy) {
proxy.history.push({ time: Date.now().toString(), delay: validDelay })
}
})
}
useEffect(() => {
setDelay(config.history && config.history.length ? config.history.slice(-1)[0].delay : 0)
}, [config])
const delay = useMemo(
() => config.history?.length ? config.history.slice(-1)[0].delay : 0,
[config]
)
useLayoutEffect(() => {
EE.subscribe(Action.SPEED_NOTIFY, speedTest)

View File

@ -3,7 +3,7 @@ import useSWR from 'swr'
import EE from '@lib/event'
import { useRound } from '@lib/hook'
import { Card, Header, Icon, Checkbox } from '@components'
import { containers } from '@stores'
import { useI18n, useConfig, useProxy, useProxyProviders } from '@stores'
import * as API from '@lib/request'
import { Proxy, Group, Provider } from './components'
@ -27,36 +27,15 @@ export function compareDesc (a: API.Proxy, b: API.Proxy) {
return (lastDelayB || Number.MAX_SAFE_INTEGER) - (lastDelayA || Number.MAX_SAFE_INTEGER)
}
export default function Proxies () {
const { data, fetch } = containers.useData()
const { useTranslation } = containers.useI18n()
const { data: config, set: setConfig } = containers.useConfig()
function ProxyGroups () {
const { groups } = useProxy()
const { data: config, set: setConfig } = useConfig()
const { useTranslation } = useI18n()
const { t } = useTranslation('Proxies')
useSWR('data', fetch)
function handleNotitySpeedTest () {
EE.notifySpeedTest()
}
const { current: sort, next } = useRound(
[sortType.Asc, sortType.Desc, sortType.None]
)
const proxies = useMemo(() => {
switch (sort) {
case sortType.Desc:
return data.proxy.slice().sort((a, b) => compareDesc(a, b))
case sortType.Asc:
return data.proxy.slice().sort((a, b) => -1 * compareDesc(a, b))
default:
return data.proxy.slice()
}
}, [sort, data])
const handleSort = next
return (
<div className="page">
return <>
{
data.proxyGroup.length !== 0 &&
groups.length !== 0 &&
<div className="proxies-container">
<Header title={t('groupTitle')}>
<Checkbox
@ -69,7 +48,7 @@ export default function Proxies () {
<Card className="proxies-group-card">
<ul className="proxies-group-list">
{
data.proxyGroup.map(p => (
groups.map(p => (
<li className="proxies-group-item" key={p.name}>
<Group config={p} />
</li>
@ -79,13 +58,22 @@ export default function Proxies () {
</Card>
</div>
}
</>
}
function ProxyProviders () {
const { providers } = useProxyProviders()
const { useTranslation } = useI18n()
const { t } = useTranslation('Proxies')
return <>
{
data.proxyProviders.length !== 0 &&
providers.length !== 0 &&
<div className="proxies-container">
<Header title={t('providerTitle')} />
<ul className="proxies-providers-list">
{
data.proxyProviders.map(p => (
providers.map(p => (
<li className="proxies-providers-item" key={p.name}>
<Provider provider={p} />
</li>
@ -94,8 +82,36 @@ export default function Proxies () {
</ul>
</div>
}
</>
}
function Proxies () {
const { proxies } = useProxy()
const { useTranslation } = useI18n()
const { t } = useTranslation('Proxies')
function handleNotitySpeedTest () {
EE.notifySpeedTest()
}
const { current: sort, next } = useRound(
[sortType.Asc, sortType.Desc, sortType.None]
)
const sortedProxies = useMemo(() => {
switch (sort) {
case sortType.Desc:
return proxies.slice().sort((a, b) => compareDesc(a, b))
case sortType.Asc:
return proxies.slice().sort((a, b) => -1 * compareDesc(a, b))
default:
return proxies.slice()
}
}, [sort, proxies])
const handleSort = next
return <>
{
proxies.length !== 0 &&
sortedProxies.length !== 0 &&
<div className="proxies-container">
<Header title={t('title')}>
<Icon className="proxies-action-icon" type={sortMap[sort]} onClick={handleSort} size={20} />
@ -104,7 +120,7 @@ export default function Proxies () {
</Header>
<ul className="proxies-list">
{
proxies.map(p => (
sortedProxies.map(p => (
<li key={p.name}>
<Proxy config={p} />
</li>
@ -113,6 +129,21 @@ export default function Proxies () {
</ul>
</div>
}
</>
}
export default function ProxyContainer () {
const { update: updateProxy } = useProxy()
const { update: updateProvider } = useProxyProviders()
useSWR('proxies', updateProxy)
useSWR('providers', updateProvider)
return (
<div className="page">
<ProxyGroups />
<ProxyProviders />
<Proxies />
</div>
)
}

View File

@ -1,19 +1,17 @@
import React, { useEffect } from 'react'
import React from 'react'
import { Header, Card, Row, Col } from '@components'
import { containers } from '@stores'
import { useI18n, useRule } from '@stores'
import { FixedSizeList as List } from 'react-window'
import AutoSizer from 'react-virtualized-auto-sizer'
import useSWR from 'swr'
import './style.scss'
export default function Rules () {
const { data, fetch } = containers.useData()
const { useTranslation } = containers.useI18n()
const { rules, update } = useRule()
const { useTranslation } = useI18n()
const { t } = useTranslation('Rules')
const { rules } = data
useEffect(() => {
fetch()
}, [])
useSWR('rules', update)
function renderRuleItem ({ index, style }: { index: number, style: React.CSSProperties }) {
const rule = rules[index]

View File

@ -1,9 +1,8 @@
import React, { useEffect } from 'react'
import { Header, Card, Row, Col, Switch, ButtonSelect, ButtonSelectOptions, Input, Icon } from '@components'
import { containers } from '@stores'
import { useI18n, useClashXData, useAPIInfo, useGeneral, useIdentity } from '@stores'
import { updateConfig } from '@lib/request'
import { useObject } from '@lib/hook'
import { to } from '@lib/helper'
import { isClashX, jsBridge } from '@lib/jsBridge'
import { Lang } from '@i18n'
import './style.scss'
@ -11,10 +10,11 @@ import './style.scss'
const languageOptions: ButtonSelectOptions[] = [{ label: '中文', value: 'zh_CN' }, { label: 'English', value: 'en_US' }]
export default function Settings () {
const { data: clashXData, fetch: fetchClashXData } = containers.useClashXData()
const { data, fetch, unauthorized: { show } } = containers.useData()
const { data: apiInfo } = containers.useAPIInfo()
const { useTranslation, setLang, lang } = containers.useI18n()
const { data: clashXData, update: fetchClashXData } = useClashXData()
const { general, update: fetchGeneral } = useGeneral()
const { set: setIdentity } = useIdentity()
const { data: apiInfo } = useAPIInfo()
const { useTranslation, setLang, lang } = useI18n()
const { t } = useTranslation('Settings')
const [info, set] = useObject({
socks5ProxyPort: 7891,
@ -23,22 +23,20 @@ export default function Settings () {
})
useEffect(() => {
fetch()
fetchGeneral()
if (isClashX()) {
fetchClashXData().then(() => set('isClashX', true))
}
}, [])
useEffect(() => {
set('socks5ProxyPort', data.general.socksPort)
set('httpProxyPort', data.general.port)
}, [data])
set('socks5ProxyPort', general.socksPort)
set('httpProxyPort', general.port)
}, [general])
async function handleProxyModeChange (mode: string) {
const [, err] = await to(updateConfig({ mode }))
if (!err) {
fetch()
}
await updateConfig({ mode })
await fetchGeneral()
}
async function handleStartAtLoginChange (state: boolean) {
@ -56,26 +54,18 @@ export default function Settings () {
}
async function handleHttpPortSave () {
const [, err] = await to(updateConfig({ port: info.httpProxyPort }))
if (!err) {
await fetch()
set('httpProxyPort', data.general.port)
}
await updateConfig({ port: info.httpProxyPort })
await fetchGeneral()
}
async function handleSocksPortSave () {
const [, err] = await to(updateConfig({ 'socks-port': info.socks5ProxyPort }))
if (!err) {
await fetch()
set('socks5ProxyPort', data.general.socksPort)
}
await updateConfig({ 'socks-port': info.socks5ProxyPort })
await fetchGeneral()
}
async function handleAllowLanChange (state: boolean) {
const [, err] = await to(updateConfig({ 'allow-lan': state }))
if (!err) {
await fetch()
}
await updateConfig({ 'allow-lan': state })
await fetchGeneral()
}
const {
@ -83,7 +73,7 @@ export default function Settings () {
port: externalControllerPort
} = apiInfo
const { allowLan, mode } = data.general
const { allowLan, mode } = general
const {
startAtLogin,
systemProxy
@ -189,7 +179,7 @@ export default function Settings () {
<span className="label">{t('labels.externalController')}</span>
</Col>
<Col className="external-controller" span={10}>
<span className="modify-btn" onClick={show}>
<span className="modify-btn" onClick={() => setIdentity(false)}>
{`${externalControllerHost}:${externalControllerPort}`}
</span>
</Col>

View File

@ -1,10 +1,11 @@
import * as React from 'react'
import { NavLink } from 'react-router-dom'
import classnames from 'classnames'
import { containers } from '@stores'
import { useI18n, useVersion } from '@stores'
import './style.scss'
import logo from '@assets/logo.png'
import useSWR from 'swr'
interface SidebarProps {
routes: {
@ -17,9 +18,12 @@ interface SidebarProps {
export default function Sidebar (props: SidebarProps) {
const { routes } = props
const { useTranslation } = containers.useI18n()
const { useTranslation } = useI18n()
const { version, premium, update } = useVersion()
const { t } = useTranslation('SideBar')
useSWR('version', update)
const navlinks = routes.map(
({ path, name, exact, noMobile }) => (
<li className={classnames('item', { 'no-mobile': noMobile })} key={name}>
@ -34,6 +38,11 @@ export default function Sidebar (props: SidebarProps) {
<ul className="sidebar-menu">
{ navlinks }
</ul>
<div className="sidebar-version">
<span className="sidebar-version-label">Clash { t('Version') }</span>
<span className="sidebar-version-text">{ version }</span>
{ premium && <span className="sidebar-version-label">Premium</span> }
</div>
</div>
)
}

View File

@ -3,6 +3,7 @@
.sidebar {
position: fixed;
top: 0;
bottom: 0;
left: 0;
display: flex;
flex-direction: column;
@ -21,6 +22,7 @@
.sidebar-menu {
display: flex;
flex-direction: column;
flex: 1;
margin-top: 12px;
.item {
@ -52,6 +54,26 @@
}
}
.sidebar-version {
display: flex;
flex-direction: column;
align-items: center;
padding-bottom: 20px;
}
.sidebar-version-label {
font-size: 14px;
color: $color-primary-dark;
text-shadow: 0 2px 6px rgba($color: $color-primary-dark, $alpha: 0.4);
}
.sidebar-version-text {
text-align: center;
font-size: 14px;
margin: 8px 0;
color: $color-primary-darken;
}
@media (max-width: 768px) {
.sidebar {
width: 100%;

View File

@ -5,7 +5,8 @@ export default {
Logs: 'Logs',
Rules: 'Rules',
Settings: 'Setting',
Connections: 'Connections'
Connections: 'Connections',
Version: 'Version'
},
Settings: {
title: 'Settings',

View File

@ -1,13 +1,11 @@
/* eslint-disable camelcase */
/* eslint-disable @typescript-eslint/camelcase */
import { useState, useCallback } from 'react'
import get from 'lodash/get'
import { getLocalStorageItem, setLocalStorageItem } from '@lib/helper'
import en_US from './en_US'
import zh_CN from './zh_CN'
const Language = {
export const Language = {
en_US,
zh_CN
}
@ -16,7 +14,7 @@ export type Lang = keyof typeof Language
const languageKey = 'language'
const locales = Object.keys(Language)
export const locales = Object.keys(Language)
function getNavigatorLanguage (): string[] {
const found: string[] = []
@ -34,7 +32,7 @@ function getNavigatorLanguage (): string[] {
return found
}
function getLanguage (): Lang {
export function getLanguage (): Lang {
const localLanguage = getLocalStorageItem(languageKey)
if (localLanguage && locales.includes(localLanguage)) {
return localLanguage as Lang
@ -52,23 +50,6 @@ function getLanguage (): Lang {
return 'en_US'
}
export function useI18n () {
const [lang, set] = useState(getLanguage())
function setLang (lang: Lang) {
set(lang)
export function setLanguage (lang: Lang) {
setLocalStorageItem(languageKey, lang)
}
const useTranslation = useCallback(
function (namespace: string) {
function t (path: string) {
return get(Language[lang][namespace], path) as string
}
return { t }
},
[lang]
)
return { lang, locales, setLang, useTranslation }
}

View File

@ -5,7 +5,8 @@ export default {
Logs: '日志',
Rules: '规则',
Settings: '设置',
Connections: '连接'
Connections: '连接',
Version: '版本'
},
Settings: {
title: '设置',

View File

@ -26,8 +26,6 @@ export async function to <T, E = Error> (promise: Promise<T>): Promise<[T, E]> {
}
}
export type Partial<T> = { [P in keyof T]?: T[P] }
export function partition<T> (arr: T[], fn: (T) => boolean): [T[], T[]] {
const left: T[] = []
const right: T[] = []

View File

@ -1,6 +1,5 @@
import { Draft } from 'immer'
import { useImmer } from 'use-immer'
import { createContainer } from 'unstated-next'
import { useRef, useEffect, useState, useMemo } from 'react'
import { noop } from '@lib/helper'
@ -54,26 +53,6 @@ export function useInterval (callback: () => void, delay: number) {
)
}
type containerFn<Value, State = void> = (initialState?: State) => Value
export function composeContainer<T, C extends containerFn<T>, U extends { [key: string]: C }, K extends keyof U> (mapping: U) {
function Global () {
return Object.keys(mapping).reduce((obj, key) => {
obj[key as K] = mapping[key]()
return obj
}, {} as { [K in keyof U]: T })
}
const allContainer = createContainer(Global)
return {
Provider: allContainer.Provider,
containers: Object.keys(mapping).reduce((obj, key) => {
obj[key as K] = (() => allContainer.useContainer()[key]) as U[K]
return obj
}, {} as { [K in keyof U]: U[K] })
}
}
export function useRound<T> (list: T[], defidx = 0) {
if (list.length < 2) {
throw new Error('List requires at least two elements')

33
src/lib/recoil.ts Normal file
View File

@ -0,0 +1,33 @@
import { useRecoilState, RecoilState } from 'recoil'
import produce, { Draft } from 'immer'
import { useCallback } from 'react'
export function useRecoilObjectWithImmer<T> (value: RecoilState<T>) {
const [copy, rawSet] = useRecoilState(value)
function set<K extends keyof Draft<T>> (key: K, value: Draft<T>[K]): void
function set<K extends keyof Draft<T>> (data: Partial<T>): void
function set<K extends keyof Draft<T>> (f: (draft: Draft<T>) => void | T): void
function set<K extends keyof Draft<T>> (data: any, value?: Draft<T>[K]): void {
if (typeof data === 'string') {
rawSet(produce(copy, (draft: Draft<T>) => {
const key = data as K
const v = value
draft[key] = v
}))
} else if (typeof data === 'function') {
const fn = data as (draft: Draft<T>) => void | T
rawSet(produce(copy, fn) as T)
} else if (typeof data === 'object') {
rawSet(produce(copy, (draft: Draft<T>) => {
const obj = data as Draft<T>
for (const key of Object.keys(obj)) {
const k = key as keyof Draft<T>
draft[k] = obj[k]
}
}))
}
}
return [copy, useCallback(set, [copy])] as [T, typeof set]
}

View File

@ -1,5 +1,5 @@
import axios from 'axios'
import { Partial, getLocalStorageItem, to } from '@lib/helper'
import { getLocalStorageItem, to } from '@lib/helper'
import { isClashX, jsBridge } from '@lib/jsBridge'
import { createAsyncSingleton } from '@lib/asyncSingleton'
import { Log } from '@models/Log'
@ -181,7 +181,7 @@ export async function getProxy (name: string) {
export async function getVersion () {
const req = await getInstance()
return req.get<{ version: string }>('version')
return req.get<{ version: string, premium?: boolean }>('version')
}
export async function getProxyDelay (name: string) {

View File

@ -64,17 +64,6 @@ export interface Config {
}
export interface ClashXData {
startAtLogin: boolean
systemProxy: boolean
}
export interface APIInfo {
hostname: string
port: string
secret?: string
}
export interface Data {
version?: string

View File

@ -1,17 +1,17 @@
import * as React from 'react'
import { render } from 'react-dom'
import { HashRouter } from 'react-router-dom'
import { Provider as Global } from '@stores'
import { RecoilRoot } from 'recoil'
import App from '@containers/App'
export default function renderApp () {
const rootEl = document.getElementById('root')
const AppInstance = (
<Global>
<RecoilRoot>
<HashRouter>
<App />
</HashRouter>
</Global>
</RecoilRoot>
)
render(AppInstance, rootEl)

View File

@ -1,149 +0,0 @@
import * as Models from '@models'
import * as API from '@lib/request'
import { useObject, composeContainer, useVisible } from '@lib/hook'
import { jsBridge } from '@lib/jsBridge'
import { setLocalStorageItem, partition, to } from '@lib/helper'
import { useI18n } from '@i18n'
import { AxiosError } from 'axios'
function useData () {
const [data, set] = useObject<Models.Data>({
version: '',
general: {},
proxy: [],
proxyGroup: [],
proxyProviders: [],
rules: [],
proxyMap: new Map<string, API.Proxy>()
})
const { visible, show, hide } = useVisible()
async function fetch () {
const [resp, err] = await to(Promise.all([API.getConfig(), API.getProxies(), API.getRules(), API.getProxyProviders()]))
const rErr = err as AxiosError
if (rErr && (!rErr.response || rErr.response.status === 401)) {
show()
}
const [{ data: general }, rawProxies, rules, proxyProviders] = resp
set('general', {
port: general.port,
socksPort: general['socks-port'],
redirPort: general['redir-port'],
mode: general.mode,
logLevel: general['log-level'],
allowLan: general['allow-lan']
})
const policyGroup = new Set(['Selector', 'URLTest', 'Fallback', 'LoadBalance'])
const unUsedProxy = new Set(['DIRECT', 'REJECT', 'GLOBAL'])
const proxyList = rawProxies.data.proxies.GLOBAL as API.Group
// fix missing name
proxyList.name = 'GLOBAL'
const proxies = proxyList.all
.filter(key => !unUsedProxy.has(key))
.map(key => ({ ...rawProxies.data.proxies[key], name: key }))
const [proxy, groups] = partition(proxies, proxy => !policyGroup.has(proxy.type))
const providers = Object.keys(proxyProviders.data.providers)
.map<API.Provider>(name => proxyProviders.data.providers[name])
.filter(pd => pd.name !== 'default')
.filter(pd => pd.vehicleType !== 'Compatible')
const proxyMap = new Map<string, API.Proxy>()
for (const p of proxy) {
proxyMap.set(p.name, p as API.Proxy)
}
for (const provider of providers) {
for (const p of provider.proxies) {
proxyMap.set(p.name, p as API.Proxy)
}
}
set({
proxy: proxy as API.Proxy[],
proxyGroup: general.mode === 'Global' ? [proxyList] : groups as API.Group[],
proxyProviders: providers,
rules: rules.data.rules,
proxyMap
})
const [version, vErr] = await to(API.getVersion())
if (vErr) {
return
}
set('version', version.data.version)
}
function updateDelay (proxy: string, delay: number) {
set(draft => {
const p = draft.proxy.find(p => p.name === proxy)
if (p) {
p.history.push({ time: Date.now().toString(), delay })
}
})
}
return { data, fetch, unauthorized: { visible, show, hide }, updateDelay }
}
function useAPIInfo () {
const [data, set] = useObject<Models.APIInfo>({
hostname: '127.0.0.1',
port: '9090',
secret: ''
})
async function fetch () {
const info = await API.getExternalControllerConfig()
set({ ...info })
}
async function update (info: Models.APIInfo) {
const { hostname, port, secret } = info
setLocalStorageItem('externalControllerAddr', hostname)
setLocalStorageItem('externalControllerPort', port)
setLocalStorageItem('secret', secret)
window.location.reload()
}
return { data, fetch, update }
}
function useConfig () {
const [data, set] = useObject({
breakConnections: false
})
return { data, set }
}
function useClashXData () {
const [data, set] = useObject<Models.ClashXData>({
startAtLogin: false,
systemProxy: false
})
async function fetch () {
const startAtLogin = await jsBridge.getStartAtLogin()
const systemProxy = await jsBridge.isSystemProxySet()
set({ startAtLogin, systemProxy })
}
return { data, fetch }
}
const { Provider, containers } = composeContainer({
useData,
useAPIInfo,
useClashXData,
useI18n,
useConfig
})
export { Provider, containers }

View File

@ -1 +1 @@
export * from './HookStore'
export * from './recoil'

271
src/stores/recoil.ts Normal file
View File

@ -0,0 +1,271 @@
import { atom, useRecoilState, selector } from 'recoil'
import get from 'lodash/get'
import { useCallback } from 'react'
import { AxiosError } from 'axios'
import { getLanguage, setLanguage, Lang, locales, Language } from '@i18n'
import { useRecoilObjectWithImmer } from '@lib/recoil'
import * as API from '@lib/request'
import { setLocalStorageItem, partition, to } from '@lib/helper'
import { jsBridge } from '@lib/jsBridge'
import * as Models from '@models'
const identity = atom({
key: 'identity',
default: true
})
type AsyncFunction<A, O> = (...args: A[]) => Promise<O>
export function useIdentity () {
const [id, set] = useRecoilState(identity)
function wrapFetcher<A, O> (fn: AsyncFunction<A, O>) {
return async function (...args: A[]) {
const [resp, err] = await to(fn(...args))
const rErr = err as AxiosError
if (rErr && (!rErr.response || rErr.response.status === 401)) {
set(false)
throw err
}
set(true)
return resp
}
}
return { identity: id, wrapFetcher, set }
}
const language = atom({
key: 'i18n',
default: getLanguage()
})
export function useI18n () {
const [lang, set] = useRecoilState(language)
function setLang (lang: Lang) {
set(lang)
setLanguage(lang)
}
const useTranslation = useCallback(
function (namespace: string) {
function t (path: string) {
return get(Language[lang][namespace], path) as string
}
return { t }
},
[lang]
)
return { lang, locales, setLang, useTranslation }
}
export const config = atom({
key: 'config',
default: {
breakConnections: false
}
})
export function useConfig () {
const [data, set] = useRecoilObjectWithImmer(config)
return { data, set }
}
export const proxyProvider = atom({
key: 'proxyProvider',
default: [] as API.Provider[]
})
export function useProxyProviders () {
const [data, set] = useRecoilState(proxyProvider)
async function update () {
const proxyProviders = await API.getProxyProviders()
const providers = Object.keys(proxyProviders.data.providers)
.map<API.Provider>(name => proxyProviders.data.providers[name])
.filter(pd => pd.name !== 'default')
.filter(pd => pd.vehicleType !== 'Compatible')
set(providers)
}
return { providers: data, update }
}
export const general = atom({
key: 'general',
default: {} as Models.Data['general']
})
export function useGeneral () {
const [data, set] = useRecoilState(general)
async function update () {
const resp = await API.getConfig()
const data = resp.data
set({
port: data.port,
socksPort: data['socks-port'],
redirPort: data['redir-port'],
mode: data.mode,
logLevel: data['log-level'],
allowLan: data['allow-lan']
})
}
return { general: data, update }
}
export const proxies = atom({
key: 'proxies',
default: {
proxies: [] as API.Proxy[],
groups: [] as API.Group[],
global: {} as API.Group
}
})
export function useProxy () {
const [data, set] = useRecoilObjectWithImmer(proxies)
async function update () {
const allProxies = await API.getProxies()
const global = allProxies.data.proxies.GLOBAL as API.Group
// fix missing name
global.name = 'GLOBAL'
const policyGroup = new Set(['Selector', 'URLTest', 'Fallback', 'LoadBalance'])
const unUsedProxy = new Set(['DIRECT', 'REJECT', 'GLOBAL'])
const proxies = global.all
.filter(key => !unUsedProxy.has(key))
.map(key => ({ ...allProxies.data.proxies[key], name: key }))
const [proxy, groups] = partition(proxies, proxy => !policyGroup.has(proxy.type))
set({ proxies: proxy as API.Proxy[], groups: groups as API.Group[], global: global })
}
return {
proxies: data.proxies,
groups: data.groups,
global: data.global,
update,
set
}
}
export const proxyMapping = selector({
key: 'proxyMapping',
get: ({ get }) => {
const ps = get(proxies)
const providers = get(proxyProvider)
const proxyMap = new Map<string, API.Proxy>()
for (const p of ps.proxies) {
proxyMap.set(p.name, p as API.Proxy)
}
for (const provider of providers) {
for (const p of provider.proxies) {
proxyMap.set(p.name, p as API.Proxy)
}
}
return proxyMap
}
})
export const version = atom({
key: 'version',
default: {
version: '',
premium: false
}
})
export function useVersion () {
const [data, set] = useRecoilState(version)
const { set: setIdentity } = useIdentity()
async function update () {
const [resp, err] = await to(API.getVersion())
setIdentity(!err)
set(
err
? { version: '', premium: false }
: { version: resp.data.version, premium: !!resp.data.premium }
)
}
return { version: data.version, premium: data.premium, update }
}
export const clashxData = atom({
key: 'clashxData',
default: {
startAtLogin: false,
systemProxy: false
}
})
export function useClashXData () {
const [data, set] = useRecoilState(clashxData)
async function update () {
const startAtLogin = await jsBridge.getStartAtLogin()
const systemProxy = await jsBridge.isSystemProxySet()
set({ startAtLogin, systemProxy })
}
return { data, update }
}
export const apiData = atom({
key: 'apiData',
default: {
hostname: '127.0.0.1',
port: '9090',
secret: ''
}
})
export function useAPIInfo () {
const [data, set] = useRecoilState(apiData)
async function fetch () {
const info = await API.getExternalControllerConfig()
set({ ...info })
}
async function update (info: typeof data) {
const { hostname, port, secret } = info
setLocalStorageItem('externalControllerAddr', hostname)
setLocalStorageItem('externalControllerPort', port)
setLocalStorageItem('secret', secret)
window.location.reload()
}
return { data, fetch, update }
}
export const rules = atom({
key: 'rules',
default: [] as API.Rule[]
})
export function useRule () {
const [data, set] = useRecoilObjectWithImmer(rules)
async function update () {
const resp = await API.getRules()
set(resp.data.rules)
}
return { rules: data, update }
}