diff --git a/package.json b/package.json index 54a08bb..44df52e 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,9 @@ "classnames": "^2.2.6", "core-js": "^3.4.5", "date-fns": "^2.8.1", + "framer-motion": "^1.7.0", "history": "^4.7.2", + "immer": "^5.0.1", "invariant": "^2.2.4", "lodash-es": "^4.17.14", "memoize-one": "^5.1.1", @@ -97,6 +99,7 @@ "postcss-nested": "^4.2.0", "prettier": "^1.17.1", "react-hot-loader": "^4.12.18", + "resize-observer-polyfill": "^1.5.1", "style-loader": "^1.0.1", "svg-sprite-loader": "^4.1.2", "terser-webpack-plugin": "^2.2.1", diff --git a/src/api/proxies.js b/src/api/proxies.js index caa6da4..3ffb275 100644 --- a/src/api/proxies.js +++ b/src/api/proxies.js @@ -18,13 +18,13 @@ Vary: Origin Date: Tue, 16 Oct 2018 16:38:33 GMT */ -async function fetchProxies(config) { +export async function fetchProxies(config) { const { url, init } = getURLAndInit(config); const res = await fetch(url + endpoint, init); return await res.json(); } -async function requestToSwitchProxy(apiConfig, name1, name2) { +export async function requestToSwitchProxy(apiConfig, name1, name2) { const body = { name: name2 }; const { url, init } = getURLAndInit(apiConfig); const fullURL = `${url}${endpoint}/${name1}`; @@ -35,11 +35,27 @@ async function requestToSwitchProxy(apiConfig, name1, name2) { }); } -async function requestDelayForProxy(apiConfig, name) { +export async function requestDelayForProxy(apiConfig, name) { const { url, init } = getURLAndInit(apiConfig); const qs = 'timeout=5000&url=http://www.google.com/generate_204'; const fullURL = `${url}${endpoint}/${name}/delay?${qs}`; return await fetch(fullURL, init); } -export { fetchProxies, requestToSwitchProxy, requestDelayForProxy }; +export async function fetchProviderProxies(config) { + const { url, init } = getURLAndInit(config); + const res = await fetch(url + '/providers/proxies', init); + if (res.status === 404) { + return { providers: {} }; + } + return await res.json(); +} + +export async function updateProviderByName(config, name) { + const { url, init } = getURLAndInit(config); + const options = { + ...init, + method: 'PUT' + }; + return await fetch(url + '/providers/proxies/' + name, options); +} diff --git a/src/components/Button.js b/src/components/Button.js index f56049e..5b0365b 100644 --- a/src/components/Button.js +++ b/src/components/Button.js @@ -1,4 +1,5 @@ import React from 'react'; +import cx from 'classnames'; import s0 from 'c/Button.module.css'; const noop = () => {}; @@ -13,6 +14,14 @@ function Button({ children, label, onClick = noop }, ref) { ); } +export function ButtonPlain({ children, label, onClick = noop }) { + return ( + + ); +} + function WithIcon({ text, icon, onClick = noop }, ref) { return ( */} {groupNames.map(groupName => { return (
- +
); })} + +
); } + +const mapState = s => ({ + groupNames: getProxyGroupNames(s), + proxies: getProxies(s), + proxyProviders: getProxyProviders(s), + delay: getDelay(s) +}); + +export default connect(mapState)(Proxies); diff --git a/src/components/Proxies.module.css b/src/components/Proxies.module.css index 72b70fb..5520a2e 100644 --- a/src/components/Proxies.module.css +++ b/src/components/Proxies.module.css @@ -1,7 +1,3 @@ -.body { - padding-bottom: 50px; -} - .group { padding: 10px 15px; @media (--breakpoint-not-small) { diff --git a/src/components/Proxy.js b/src/components/Proxy.js index b7efc84..117ff76 100644 --- a/src/components/Proxy.js +++ b/src/components/Proxy.js @@ -1,13 +1,36 @@ import React from 'react'; -import PropTypes from 'prop-types'; import cx from 'classnames'; -import { useStoreState } from 'm/store'; -import ProxyLatency from 'c/ProxyLatency'; +import { connect } from './StateProvider'; +import ProxyLatency from './ProxyLatency'; + +import { getProxies, getDelay } from '../store/proxies'; import s0 from './Proxy.module.css'; -import { getDelay, getProxies } from 'd/proxies'; +const { useMemo } = React; + +const colorMap = { + // green + good: '#67c23a', + // yellow + normal: '#d4b75c', + // orange + bad: '#e67f3c', + // bad: '#F56C6C', + na: '#909399' +}; + +function getLabelColor({ number, error } = {}) { + if (number < 200) { + return colorMap.good; + } else if (number < 400) { + return colorMap.normal; + } else if (typeof number === 'number') { + return colorMap.bad; + } + return colorMap.na; +} /* const colors = { @@ -22,18 +45,28 @@ const colors = { }; */ -const mapStateToProps = s => { - return { - proxies: getProxies(s), - delay: getDelay(s) - }; +type ProxyProps = { + name: string, + now?: boolean, + + // connect injected + // TODO refine type + proxy: any, + latency: any }; -function Proxy({ now, name }) { - const { proxies, delay } = useStoreState(mapStateToProps); - const latency = delay[name]; - const proxy = proxies[name]; +function ProxySmallImpl({ now, name, proxy, latency }: ProxyProps) { + const color = useMemo(() => getLabelColor(latency), [latency]); + return ( +
+ ); +} +function Proxy({ now, name, proxy, latency }: ProxyProps) { + const color = useMemo(() => getLabelColor(latency), [latency]); return (
- {latency && latency.number ? : null} + {latency && latency.number ? ( + + ) : null}
); } -Proxy.propTypes = { - now: PropTypes.bool, - name: PropTypes.string + +const mapState = (s, { name }) => { + const proxies = getProxies(s); + const delay = getDelay(s); + return { + proxy: proxies[name], + latency: delay[name] + }; }; -export default Proxy; +export default connect(mapState)(Proxy); +export const ProxySmall = connect(mapState)(ProxySmallImpl); diff --git a/src/components/Proxy.module.css b/src/components/Proxy.module.css index 6f42ccf..2af1ce8 100644 --- a/src/components/Proxy.module.css +++ b/src/components/Proxy.module.css @@ -2,10 +2,15 @@ position: relative; padding: 5px; border-radius: 8px; + overflow: hidden; + + max-width: 280px; @media (--breakpoint-not-small) { + min-width: 150px; border-radius: 10px; padding: 10px; } + background-color: var(--color-bg-proxy-selected); &.now { background-color: var(--color-focus-blue); @@ -40,3 +45,12 @@ display: flex; align-items: flex-end; } + +.proxySmall { + .now { + outline: pink solid 1px; + } + width: 12px; + height: 12px; + border-radius: 8px; +} diff --git a/src/components/ProxyGroup.js b/src/components/ProxyGroup.js index 337b09b..d824920 100644 --- a/src/components/ProxyGroup.js +++ b/src/components/ProxyGroup.js @@ -1,60 +1,108 @@ import React from 'react'; -import PropTypes from 'prop-types'; import cx from 'classnames'; -import { useActions, useStoreState } from 'm/store'; -import Proxy from 'c/Proxy'; +import Proxy, { ProxySmall } from './Proxy'; +import { SectionNameType } from './shared/Basic'; import s0 from './ProxyGroup.module.css'; -import { getProxies, switchProxy } from 'd/proxies'; +import { switchProxy } from '../store/proxies'; -const mapStateToProps = s => ({ - proxies: getProxies(s) -}); +const { memo, useCallback, useMemo } = React; -export default function ProxyGroup({ name }) { - const { proxies } = useStoreState(mapStateToProps); - const actions = useActions({ switchProxy }); +function ProxyGroup({ name, proxies, apiConfig, dispatch }) { const group = proxies[name]; - const { all } = group; + const { all, type, now } = group; + + const isSelectable = useMemo(() => type === 'Selector', [type]); + + const itemOnTapCallback = useCallback( + proxyName => { + if (!isSelectable) return; + + dispatch(switchProxy(apiConfig, name, proxyName)); + // switchProxyFn(name, proxyName); + }, + [apiConfig, dispatch, name, isSelectable] + ); return (
-

- {name} - {group.type} -

-
-
- {all.map(proxyName => { - const isSelectable = group.type === 'Selector'; - const proxyClassName = cx(s0.proxy, { - [s0.proxySelectable]: isSelectable - }); - return ( -
{ - if (!isSelectable) return; - actions.switchProxy(name, proxyName); - }} - > - -
- ); - })} +
+
); } -ProxyGroup.propTypes = { - name: PropTypes.string +type ProxyListProps = { + all: string[], + now?: string, + isSelectable?: boolean, + itemOnTapCallback?: string => void }; +export function ProxyList({ + all, + now, + isSelectable, + itemOnTapCallback +}: ProxyListProps) { + return ( +
+ {all.map(proxyName => { + const proxyClassName = cx(s0.proxy, { + [s0.proxySelectable]: isSelectable + }); + return ( +
{ + if (!isSelectable || !itemOnTapCallback) return; + itemOnTapCallback(proxyName); + }} + > + +
+ ); + })} +
+ ); +} + +export function ProxyListSummaryView({ + all, + now, + isSelectable, + itemOnTapCallback +}: ProxyListProps) { + return ( +
+ {all.map(proxyName => { + const proxyClassName = cx(s0.proxy, { + [s0.proxySelectable]: isSelectable + }); + return ( +
{ + if (!isSelectable || !itemOnTapCallback) return; + itemOnTapCallback(proxyName); + }} + > + +
+ ); + })} +
+ ); +} + +export default memo(ProxyGroup); diff --git a/src/components/ProxyGroup.module.css b/src/components/ProxyGroup.module.css index 08c4b42..748aa67 100644 --- a/src/components/ProxyGroup.module.css +++ b/src/components/ProxyGroup.module.css @@ -1,32 +1,19 @@ .header { - > h2 { - margin-top: 0; - - font-size: 1.3em; - @media (--breakpoint-not-small) { - font-size: 1.5em; - } - - span:nth-child(2) { - font-size: 12px; - color: #777; - font-weight: normal; - margin: 0 0.3em; - } - } + margin-bottom: 12px; } .list { display: flex; flex-wrap: wrap; + margin-top: 8px; } .proxy { - max-width: 280px; - margin: 2px; + margin-right: 5px; + margin-bottom: 5px; @media (--breakpoint-not-small) { - min-width: 150px; - margin: 10px; + margin-right: 10px; + margin-bottom: 10px; } transition: transform 0.2s ease-in-out; diff --git a/src/components/ProxyLatency.js b/src/components/ProxyLatency.js index 33a94f5..5cde880 100644 --- a/src/components/ProxyLatency.js +++ b/src/components/ProxyLatency.js @@ -1,39 +1,16 @@ -import React, { useMemo } from 'react'; -import PropTypes from 'prop-types'; +import React from 'react'; import s0 from './ProxyLatency.module.css'; -const colorMap = { - good: '#67C23A', - normal: '#E6A23C', - bad: '#F56C6C', - na: '#909399' +type ProxyLatencyProps = { + number: number, + color: string }; -function getLabelColor(number, error) { - if (error !== '') { - return colorMap.na; - } else if (number < 200) { - return colorMap.good; - } else if (number < 400) { - return colorMap.normal; - } - return colorMap.bad; -} - -export default function ProxyLatency({ latency }) { - const { number, error } = latency; - const color = useMemo(() => getLabelColor(number, error), [number, error]); +export default function ProxyLatency({ number, color }: ProxyLatencyProps) { return ( - {error !== '' ? {error} : {number} ms} + {number} ms ); } - -ProxyLatency.propTypes = { - latency: PropTypes.shape({ - number: PropTypes.number, - error: PropTypes.string - }) -}; diff --git a/src/components/ProxyProvider.js b/src/components/ProxyProvider.js new file mode 100644 index 0000000..e18fe17 --- /dev/null +++ b/src/components/ProxyProvider.js @@ -0,0 +1,211 @@ +import React from 'react'; +import { ChevronDown, RotateCw } from 'react-feather'; +import { formatDistance } from 'date-fns'; +import ResizeObserver from 'resize-observer-polyfill'; +import { motion } from 'framer-motion'; +import cx from 'classnames'; + +import { useStoreState } from '../misc/store'; +import { getClashAPIConfig } from '../ducks/app'; +import { connect } from './StateProvider'; +import { SectionNameType } from './shared/Basic'; +import { ProxyList, ProxyListSummaryView } from './ProxyGroup'; +import { ButtonWithIcon, ButtonPlain } from './Button'; + +import { updateProviderByName } from '../store/proxies'; + +import s from './ProxyProvider.module.css'; + +const { memo, useState, useRef, useEffect, useCallback } = React; + +type Props = { + item: Array<{ + name: string, + proxies: Array, + type: 'Proxy' | 'Rule', + vehicleType: 'HTTP' | 'File' | 'Compatible', + updatedAt?: string + }>, + proxies: { + [string]: any + }, + dispatch: any => void +}; + +const mapStateToProps = s => ({ + apiConfig: getClashAPIConfig(s) +}); + +function ProxyProvider({ item, dispatch }: Props) { + const { apiConfig } = useStoreState(mapStateToProps); + const updateProvider = useCallback( + () => dispatch(updateProviderByName(apiConfig, item.name)), + [apiConfig, dispatch, item.name] + ); + + const [isCollapsibleOpen, setCollapsibleOpen] = useState(false); + const toggle = useCallback(() => setCollapsibleOpen(x => !x), []); + const timeAgo = formatDistance(new Date(item.updatedAt), new Date()); + return ( +
+
+ + + + + + +
+
+ Updated {timeAgo} ago +
+ + +
+ } + onClick={updateProvider} + /> +
+
+ + + +
+ ); +} + +const button = { + rest: { scale: 1 }, + // hover: { scale: 1.1 }, + pressed: { scale: 0.95 } +}; +const arrow = { + rest: { rotate: 0 }, + hover: { rotate: 360, transition: { duration: 0.3 } } +}; +function Refresh() { + return ( + + + + + + ); +} + +function usePrevious(value) { + const ref = useRef(); + useEffect(() => void (ref.current = value), [value]); + return ref.current; +} + +function useMeasure() { + const ref = useRef(); + const [bounds, set] = useState({ height: 0 }); + useEffect(() => { + const ro = new ResizeObserver(([entry]) => set(entry.contentRect)); + if (ref.current) ro.observe(ref.current); + return () => ro.disconnect(); + }, []); + return [ref, bounds]; +} + +// import { useSpring, a } from 'react-spring'; +// const Collapsible = memo(({ children, isOpen }) => { +// const previous = usePrevious(isOpen); +// const [refToMeature, { height: viewHeight }] = useMeasure(); +// const { height, opacity, visibility, transform } = useSpring({ +// from: { +// height: 0, +// opacity: 0, +// transform: 'translate3d(20px,0,0)', +// visibility: 'hidden' +// }, +// to: { +// height: isOpen ? viewHeight : 0, +// opacity: isOpen ? 1 : 0, +// visibility: isOpen ? 'visible' : 'hidden', +// transform: `translate3d(${isOpen ? 0 : 20}px,0,0)` +// } +// }); +// return ( +//
+// +// +// +//
+// ); +// }); + +const variantsCollpapsibleWrap = { + initialOpen: { + height: 'auto', + transition: { duration: 0 } + }, + open: height => ({ + height, + opacity: 1, + visibility: 'visible', + transition: { duration: 0.3 } + }), + closed: { + height: 0, + opacity: 0, + visibility: 'hidden', + transition: { duration: 0.3 } + } +}; +const variantsCollpapsibleChildContainer = { + open: { + x: 0 + }, + closed: { + x: 20 + } +}; + +const Collapsible2 = memo(({ children, isOpen }) => { + const previous = usePrevious(isOpen); + const [refToMeature, { height }] = useMeasure(); + return ( +
+ + + {children} + + +
+ ); +}); + +const mapState = s => ({ + // proxies: getProxies(s) +}); +export default connect(mapState)(ProxyProvider); diff --git a/src/components/ProxyProvider.module.css b/src/components/ProxyProvider.module.css new file mode 100644 index 0000000..6668f67 --- /dev/null +++ b/src/components/ProxyProvider.module.css @@ -0,0 +1,43 @@ +.header { + display: flex; + align-items: center; + cursor: pointer; + + .arrow { + display: inline-flex; + transform: rotate(0deg); + transition: transform 0.3s; + &.isOpen { + transform: rotate(180deg); + } + + &:focus { + outline: var(--color-focus-blue) solid 1px; + } + } +} + +.updatedAt { + margin-bottom: 12px; + small { + color: #777; + } +} + +.body { + padding: 10px 15px; + @media (--breakpoint-not-small) { + padding: 10px 40px; + } +} + +.actionFooter { + display: flex; +} + +.refresh { + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; +} diff --git a/src/components/ProxyProviderList.js b/src/components/ProxyProviderList.js new file mode 100644 index 0000000..2ae0fce --- /dev/null +++ b/src/components/ProxyProviderList.js @@ -0,0 +1,19 @@ +import React from 'react'; + +import ContentHeader from './ContentHeader'; +import ProxyProvider from './ProxyProvider'; + +function ProxyProviderList({ items }) { + return ( + <> + +
+ {items.map(item => ( + + ))} +
+ + ); +} + +export default ProxyProviderList; diff --git a/src/components/Root.css b/src/components/Root.css index d611a02..3622d57 100644 --- a/src/components/Root.css +++ b/src/components/Root.css @@ -10,6 +10,13 @@ U+FEFF, U+FFFD; } +.relative { + position: relative; +} +/* .absolute { */ +/* position: absolute; */ +/* } */ + .border-left, .border-top, .border-bottom { @@ -113,6 +120,12 @@ body.light { --bg-modal: #fbfbfb; } +.flexCenter { + display: flex; + align-items: center; + justify-content: center; +} + /* TODO remove fabgrp in component css files */ .fabgrp { position: fixed; diff --git a/src/components/Root.js b/src/components/Root.js index 56c0be0..4147fd2 100644 --- a/src/components/Root.js +++ b/src/components/Root.js @@ -1,5 +1,6 @@ import React, { Suspense } from 'react'; -import { Provider } from 'm/store'; +import { Provider } from '../misc/store'; +import StateProvider from './StateProvider'; import { HashRouter as Router, Route } from 'react-router-dom'; import { hot } from 'react-hot-loader/root'; import Loading2 from 'c/Loading2'; @@ -36,30 +37,38 @@ const Rules = React.lazy(() => window.store = store; +const initialState = { + proxies: { + proxies: {}, + delay: {}, + groupNames: [] + } +}; + const Root = () => ( - - -
- - } /> -
- }> - } /> - - } /> - - - } /> - } /> - + + + +
+ + } /> +
+ }> + } /> + + } /> + + + } /> + } /> + +
-
- - + + + ); -// } /> -// export default hot(Root); diff --git a/src/components/StateProvider.js b/src/components/StateProvider.js new file mode 100644 index 0000000..adb1b24 --- /dev/null +++ b/src/components/StateProvider.js @@ -0,0 +1,79 @@ +import React from 'react'; +import produce, * as immer from 'immer'; + +const { + createContext, + memo, + useRef, + useEffect, + useCallback, + useContext, + useState +} = React; + +const StateContext = createContext(null); +const DispatchContext = createContext(null); + +export { immer }; + +export function useStoreState() { + return useContext(StateContext); +} + +export function useStoreDispatch() { + return useContext(DispatchContext); +} + +export default function Provider({ initialState, children }) { + const stateRef = useRef(initialState); + const [state, setState] = useState(initialState); + const getState = useCallback(() => stateRef.current, []); + useEffect(() => { + if (process.env.NODE_ENV === 'development') { + window.getState2 = getState; + } + }, [getState]); + const dispatch = useCallback( + (actionId, fn, thunk) => { + // if (thunk) return thunk(dispatch, getState); + if (typeof actionId === 'function') return actionId(dispatch, getState); + + const stateNext = produce(getState(), fn); + if (stateNext !== stateRef.current) { + if (process.env.NODE_ENV === 'development') { + // eslint-disable-next-line no-console + console.log(actionId, stateNext); + } + stateRef.current = stateNext; + setState(stateNext); + } + }, + [getState] + ); + + return ( + + + {children} + + + ); +} + +export function connect(mapStateToProps) { + return Component => { + const MemoComponent = memo(Component); + function Connected(props) { + const state = useContext(StateContext); + const dispatch = useContext(DispatchContext); + const mapped = mapStateToProps(state, props); + const nextProps = { + ...props, + ...mapped, + dispatch + }; + return ; + } + return Connected; + }; +} diff --git a/src/components/shared/Basic.js b/src/components/shared/Basic.js new file mode 100644 index 0000000..9d07a39 --- /dev/null +++ b/src/components/shared/Basic.js @@ -0,0 +1,12 @@ +import React from 'react'; + +import s from './Basic.module.css'; + +export function SectionNameType({ name, type }) { + return ( +

+ {name} + {type} +

+ ); +} diff --git a/src/components/shared/Basic.module.css b/src/components/shared/Basic.module.css new file mode 100644 index 0000000..8e3e65c --- /dev/null +++ b/src/components/shared/Basic.module.css @@ -0,0 +1,14 @@ +h2.sectionNameType { + margin: 0; + font-size: 1.3em; + @media (--breakpoint-not-small) { + font-size: 1.5em; + } + + span:nth-child(2) { + font-size: 12px; + color: #777; + font-weight: normal; + margin: 0 0.3em; + } +} diff --git a/src/ducks/index.js b/src/ducks/index.js index 35ccc10..33e5067 100644 --- a/src/ducks/index.js +++ b/src/ducks/index.js @@ -1,7 +1,6 @@ import { combineReducers } from 'redux'; import app from './app'; import modals from './modals'; -import proxies from './proxies'; import rules from './rules'; import logs from './logs'; import configs from './configs'; @@ -9,7 +8,6 @@ import configs from './configs'; export default combineReducers({ app, modals, - proxies, rules, logs, configs diff --git a/src/ducks/proxies.js b/src/store/proxies.js similarity index 56% rename from src/ducks/proxies.js rename to src/store/proxies.js index 3bc29d3..fe51368 100644 --- a/src/ducks/proxies.js +++ b/src/store/proxies.js @@ -1,5 +1,4 @@ -import * as proxiesAPI from 'a/proxies'; -import { getClashAPIConfig } from 'd/app'; +import * as proxiesAPI from '../api/proxies'; // see all types: // https://github.com/Dreamacro/clash/blob/master/constant/adapters.go @@ -12,10 +11,136 @@ const ProxyTypes = ['Shadowsocks', 'Snell', 'Socks5', 'Http', 'Vmess']; export const getProxies = s => s.proxies.proxies; export const getDelay = s => s.proxies.delay; export const getProxyGroupNames = s => s.proxies.groupNames; +export const getProxyProviders = s => s.proxies.proxyProviders || []; -const CompletedFetchProxies = 'proxies/CompletedFetchProxies'; -const OptimisticSwitchProxy = 'proxies/OptimisticSwitchProxy'; -const CompletedRequestDelayForProxy = 'proxies/CompletedRequestDelayForProxy'; +export function fetchProxies(apiConfig) { + return async (dispatch, getState) => { + const [proxiesData, providersData] = await Promise.all([ + proxiesAPI.fetchProxies(apiConfig), + proxiesAPI.fetchProviderProxies(apiConfig) + ]); + + const [proxyProviders, providerProxies] = formatProxyProviders( + providersData.providers + ); + const proxies = { ...providerProxies, ...proxiesData.proxies }; + const [groupNames, proxyNames] = retrieveGroupNamesFrom(proxies); + + const delayPrev = getDelay(getState()); + const delayNext = { ...delayPrev }; + + for (let i = 0; i < proxyNames.length; i++) { + const name = proxyNames[i]; + const { history } = proxies[name] || { history: [] }; + const h = history[history.length - 1]; + if (h) { + const ret = { error: '' }; + if (h.delay === 0) { + ret.error = 'LikelyTimeout'; + } else { + ret.number = h.delay; + } + delayNext[name] = ret; + } + } + + dispatch('store/proxies#fetchProxies', s => { + s.proxies.proxies = proxies; + s.proxies.groupNames = groupNames; + s.proxies.delay = delayNext; + s.proxies.proxyProviders = proxyProviders; + }); + }; +} + +export function updateProviderByName(apiConfig, name) { + return async dispatch => { + try { + await proxiesAPI.updateProviderByName(apiConfig, name); + } catch (x) { + // ignore + } + // should be optimized + // but ¯\_(ツ)_/¯ + dispatch(fetchProxies(apiConfig)); + }; +} + +export function switchProxy(apiConfig, name1, name2) { + return async dispatch => { + proxiesAPI + .requestToSwitchProxy(apiConfig, name1, name2) + .then( + res => { + if (res.ok === false) { + // eslint-disable-next-line no-console + console.log('failed to swith proxy', res.statusText); + } + }, + err => { + // eslint-disable-next-line no-console + console.log(err, 'failed to swith proxy'); + } + ) + .then(() => { + dispatch(fetchProxies(apiConfig)); + }); + // optimistic UI update + dispatch('store/proxies#switchProxy', s => { + const proxies = s.proxies.proxies; + if (proxies[name1] && proxies[name1].now) { + proxies[name1].now = name2; + } + }); + }; +} + +function requestDelayForProxyOnce(apiConfig, name) { + return async (dispatch, getState) => { + const res = await proxiesAPI.requestDelayForProxy(apiConfig, name); + let error = ''; + if (res.ok === false) { + error = res.statusText; + } + const { delay } = await res.json(); + + const delayPrev = getDelay(getState()); + const delayNext = { + ...delayPrev, + [name]: { + error, + number: delay + } + }; + + dispatch('requestDelayForProxyOnce', s => { + s.proxies.delay = delayNext; + }); + }; +} + +export function requestDelayForProxy(apiConfig, name) { + return async dispatch => { + await dispatch(requestDelayForProxyOnce(apiConfig, name)); + }; +} + +export function requestDelayAll(apiConfig) { + return async (dispatch, getState) => { + const state = getState(); + const proxies = getProxies(state); + const keys = Object.keys(proxies); + const proxyNames = []; + keys.forEach(k => { + if (proxies[k].type === 'Vmess' || proxies[k].type === 'Shadowsocks') { + proxyNames.push(k); + } + }); + await Promise.all( + proxyNames.map(p => dispatch(requestDelayForProxy(apiConfig, p))) + ); + }; +} function retrieveGroupNamesFrom(proxies) { let groupNames = []; @@ -44,141 +169,26 @@ function retrieveGroupNamesFrom(proxies) { return [groupNames, proxyNames]; } -export function fetchProxies() { - return async (dispatch, getState) => { - // TODO handle errors - - const state = getState(); - - const apiConfig = getClashAPIConfig(state); - // TODO show loading animation? - const json = await proxiesAPI.fetchProxies(apiConfig); - let { proxies = {} } = json; - - const [groupNames, proxyNames] = retrieveGroupNamesFrom(proxies); - const delayPrev = getDelay(getState()); - - const delayNext = { ...delayPrev }; - - for (let i = 0; i < proxyNames.length; i++) { - const name = proxyNames[i]; - const { history } = proxies[name] || { history: [] }; - const h = history[history.length - 1]; - if (h) { - const ret = { error: '' }; - if (h.delay === 0) { - ret.error = 'LikelyTimeout'; - } else { - ret.number = h.delay; - } - delayNext[name] = ret; - } +function formatProxyProviders(providersInput) { + const keys = Object.keys(providersInput); + const providers = []; + const proxies = {}; + for (let i = 0; i < keys.length; i++) { + const provider = providersInput[keys[i]]; + if (provider.name === 'default' || provider.vehicleType === 'Compatible') + continue; + const proxiesArr = provider.proxies; + const names = []; + for (let j = 0; j < proxiesArr.length; j++) { + const proxy = proxiesArr[j]; + proxies[proxy.name] = proxy; + names.push(proxy.name); } - dispatch({ - type: CompletedFetchProxies, - payload: { proxies, groupNames, delay: delayNext } - }); - }; -} - -export function switchProxy(name1, name2) { - return async (dispatch, getState) => { - const apiConfig = getClashAPIConfig(getState()); - // TODO display error message - proxiesAPI - .requestToSwitchProxy(apiConfig, name1, name2) - .then( - res => { - if (res.ok === false) { - // eslint-disable-next-line no-console - console.log('failed to swith proxy', res.statusText); - } - }, - err => { - // eslint-disable-next-line no-console - console.log(err, 'failed to swith proxy'); - } - ) - .then(() => { - // fetchProxies again - dispatch(fetchProxies()); - }); - // optimistic UI update - const proxiesCurr = getProxies(getState()); - const proxiesNext = { ...proxiesCurr }; - if (proxiesNext[name1] && proxiesNext[name1].now) { - proxiesNext[name1].now = name2; - } - dispatch({ - type: OptimisticSwitchProxy, - payload: { proxies: proxiesNext } - }); - }; -} - -function requestDelayForProxyOnce(name) { - return async (dispatch, getState) => { - const apiConfig = getClashAPIConfig(getState()); - const res = await proxiesAPI.requestDelayForProxy(apiConfig, name); - let error = ''; - if (res.ok === false) { - error = res.statusText; - } - const { delay } = await res.json(); - - const delayPrev = getDelay(getState()); - const delayNext = { - ...delayPrev, - [name]: { - error, - number: delay - } - }; - - dispatch({ - type: CompletedRequestDelayForProxy, - payload: { delay: delayNext } - }); - }; -} - -export function requestDelayForProxy(name) { - return async dispatch => { - await dispatch(requestDelayForProxyOnce(name)); - }; -} - -export function requestDelayAll() { - return async (dispatch, getState) => { - const state = getState(); - const proxies = getProxies(state); - const keys = Object.keys(proxies); - const proxyNames = []; - keys.forEach(k => { - if (proxies[k].type === 'Vmess' || proxies[k].type === 'Shadowsocks') { - proxyNames.push(k); - } - }); - await Promise.all(proxyNames.map(p => dispatch(requestDelayForProxy(p)))); - }; -} - -const initialState = { - proxies: {}, - delay: {}, - groupNames: [] -}; - -export default function reducer(state = initialState, { type, payload }) { - switch (type) { - case CompletedRequestDelayForProxy: - case OptimisticSwitchProxy: - case CompletedFetchProxies: { - return { ...state, ...payload }; - } - - default: - return state; + // mutate directly + provider.proxies = names; + providers.push(provider); } + + return [providers, proxies]; } diff --git a/yarn.lock b/yarn.lock index b3b0da0..3c773fd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -875,6 +875,18 @@ lodash "^4.17.13" to-fast-properties "^2.0.0" +"@emotion/is-prop-valid@^0.8.2": + version "0.8.5" + resolved "https://registry.yarnpkg.com/@emotion/is-prop-valid/-/is-prop-valid-0.8.5.tgz#2dda0791f0eafa12b7a0a5b39858405cc7bde983" + integrity sha512-6ZODuZSFofbxSbcxwsFz+6ioPjb0ISJRRPLZ+WIbjcU2IMU0Io+RGQjjaTgOvNQl007KICBm7zXQaYQEC1r6Bg== + dependencies: + "@emotion/memoize" "0.7.3" + +"@emotion/memoize@0.7.3": + version "0.7.3" + resolved "https://registry.yarnpkg.com/@emotion/memoize/-/memoize-0.7.3.tgz#5b6b1c11d6a6dddf1f2fc996f74cf3b219644d78" + integrity sha512-2Md9mH6mvo+ygq1trTeVp2uzAKwE2P7In0cRpD/M9Q70aH8L+rxMLbb3JCN2JoSWsV2O+DdFjfbbXoMoLBczow== + "@hot-loader/react-dom@16.10.2": version "16.10.2" resolved "https://registry.yarnpkg.com/@hot-loader/react-dom/-/react-dom-16.10.2.tgz#91920442252acac6f343eef5df41aca333f7dcea" @@ -911,6 +923,22 @@ "@nodelib/fs.scandir" "2.1.3" fastq "^1.6.0" +"@popmotion/easing@^1.0.1", "@popmotion/easing@^1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@popmotion/easing/-/easing-1.0.2.tgz#17d925c45b4bf44189e5a38038d149df42d8c0b4" + integrity sha512-IkdW0TNmRnWTeWI7aGQIVDbKXPWHVEYdGgd5ZR4SH/Ty/61p63jCjrPxX1XrR7IGkl08bjhJROStD7j+RKgoIw== + +"@popmotion/popcorn@^0.4.2", "@popmotion/popcorn@^0.4.4": + version "0.4.4" + resolved "https://registry.yarnpkg.com/@popmotion/popcorn/-/popcorn-0.4.4.tgz#a5f906fccdff84526e3fcb892712d7d8a98d6adc" + integrity sha512-jYO/8319fKoNLMlY4ZJPiPu8Ea8occYwRZhxpaNn/kZsK4QG2E7XFlXZMJBsTWDw7I1i0uaqyC4zn1nwEezLzg== + dependencies: + "@popmotion/easing" "^1.0.1" + framesync "^4.0.1" + hey-listen "^1.0.8" + style-value-types "^3.1.7" + tslib "^1.10.0" + "@samverschueren/stream-to-observable@^0.3.0": version "0.3.0" resolved "https://registry.yarnpkg.com/@samverschueren/stream-to-observable/-/stream-to-observable-0.3.0.tgz#ecdf48d532c58ea477acfcab80348424f8d0662f" @@ -3522,6 +3550,30 @@ fragment-cache@^0.2.1: dependencies: map-cache "^0.2.2" +framer-motion@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/framer-motion/-/framer-motion-1.7.0.tgz#36d038874c8e2c45ad6e4824df8a61508bacbda0" + integrity sha512-bE1aWkX0AKgmxMjFnAcXZ1d47TcuKugLn3D5cHvZcQwBiDPJuKD1WzwdTaDb7EHc+vbiWfBgMbECWxQFb9di5g== + dependencies: + "@popmotion/easing" "^1.0.2" + "@popmotion/popcorn" "^0.4.2" + framesync "^4.0.4" + hey-listen "^1.0.8" + popmotion "9.0.0-beta-8" + style-value-types "^3.1.6" + stylefire "^7.0.0" + tslib "^1.10.0" + optionalDependencies: + "@emotion/is-prop-valid" "^0.8.2" + +framesync@^4.0.0, framesync@^4.0.1, framesync@^4.0.4: + version "4.0.4" + resolved "https://registry.yarnpkg.com/framesync/-/framesync-4.0.4.tgz#79c42c0118f26821c078570db0ff81fb863516a2" + integrity sha512-mdP0WvVHe0/qA62KG2LFUAOiWLng5GLpscRlwzBxu2VXOp6B8hNs5C5XlFigsMgrfDrr2YbqTsgdWZTc4RXRMQ== + dependencies: + hey-listen "^1.0.8" + tslib "^1.10.0" + fresh@0.5.2: version "0.5.2" resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" @@ -3871,6 +3923,11 @@ hex-color-regex@^1.1.0: resolved "https://registry.yarnpkg.com/hex-color-regex/-/hex-color-regex-1.1.0.tgz#4c06fccb4602fe2602b3c93df82d7e7dbf1a8a8e" integrity sha512-l9sfDFsuqtOqKDsQdqrMRk0U85RZc0RtOR9yPI7mRVOa4FsR/BVnZ0shmQRM96Ji99kYZP/7hn1cedc1+ApsTQ== +hey-listen@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/hey-listen/-/hey-listen-1.0.8.tgz#8e59561ff724908de1aa924ed6ecc84a56a9aa68" + integrity sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q== + history@^4.7.2, history@^4.9.0: version "4.10.1" resolved "https://registry.yarnpkg.com/history/-/history-4.10.1.tgz#33371a65e3a83b267434e2b3f3b1b4c58aad4cf3" @@ -4069,6 +4126,11 @@ image-size@^0.5.1: resolved "https://registry.yarnpkg.com/image-size/-/image-size-0.5.5.tgz#09dfd4ab9d20e29eb1c3e80b8990378df9e3cb9c" integrity sha1-Cd/Uq50g4p6xw+gLiZA3jfnjy5w= +immer@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/immer/-/immer-5.0.1.tgz#1a1184fa758f68f1b5573db840825fb5164cceca" + integrity sha512-KFHV1ivrBmPCVRhjy9oBooypnPfJ876NTrWXMNoUhXFAaWWAViVqZ4l6HxPST52qcN82qqsR38/pCGYRWP5W7w== + import-cwd@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/import-cwd/-/import-cwd-2.1.0.tgz#aa6cf36e722761285cb371ec6519f53e2435b0a9" @@ -5873,6 +5935,18 @@ please-upgrade-node@^3.1.1, please-upgrade-node@^3.2.0: dependencies: semver-compare "^1.0.0" +popmotion@9.0.0-beta-8: + version "9.0.0-beta-8" + resolved "https://registry.yarnpkg.com/popmotion/-/popmotion-9.0.0-beta-8.tgz#f5a709f11737734e84f2a6b73f9bcf25ee30c388" + integrity sha512-6eQzqursPvnP7ePvdfPeY4wFHmS3OLzNP8rJRvmfFfEIfpFqrQgLsM50Gd9AOvGKJtYJOFknNG+dsnzCpgIdAA== + dependencies: + "@popmotion/easing" "^1.0.1" + "@popmotion/popcorn" "^0.4.2" + framesync "^4.0.4" + hey-listen "^1.0.8" + style-value-types "^3.1.6" + tslib "^1.10.0" + posix-character-classes@^0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab" @@ -6808,6 +6882,11 @@ reselect@^4.0.0: resolved "https://registry.yarnpkg.com/reselect/-/reselect-4.0.0.tgz#f2529830e5d3d0e021408b246a206ef4ea4437f7" integrity sha512-qUgANli03jjAyGlnbYVAV5vvnOmJnODyABz51RdBN7M4WaVu8mecZWgyQNkG8Yqe3KRGRt0l4K4B3XVEULC4CA== +resize-observer-polyfill@^1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz#0e9020dd3d21024458d4ebd27e23e40269810464" + integrity sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg== + resolve-cwd@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-2.0.0.tgz#00a9f7387556e27038eae232caa372a6a59b665a" @@ -7488,6 +7567,25 @@ style-loader@^1.0.1: loader-utils "^1.2.3" schema-utils "^2.0.1" +style-value-types@^3.1.6, style-value-types@^3.1.7: + version "3.1.7" + resolved "https://registry.yarnpkg.com/style-value-types/-/style-value-types-3.1.7.tgz#3d7d3cf9cb9f9ee86c00e19ba65d6a181a0db33a" + integrity sha512-jPaG5HcAPs3vetSwOJozrBXxuHo9tjZVnbRyBjxqb00c2saIoeuBJc1/2MtvB8eRZy41u/BBDH0CpfzWixftKg== + dependencies: + hey-listen "^1.0.8" + tslib "^1.10.0" + +stylefire@^7.0.0: + version "7.0.1" + resolved "https://registry.yarnpkg.com/stylefire/-/stylefire-7.0.1.tgz#69777dc9ffc48248284ea5c0a3fe03ba5c468339" + integrity sha512-xp7hGiK5xyX7xINQI7qWagcLMyS6aLLbP2kMA3DTS4X31uPI3M7IyOm9TPmXwaMKG9fJ2Cgo4F2okcNB6nzxKQ== + dependencies: + "@popmotion/popcorn" "^0.4.4" + framesync "^4.0.0" + hey-listen "^1.0.8" + style-value-types "^3.1.7" + tslib "^1.10.0" + stylehacks@^4.0.0: version "4.0.3" resolved "https://registry.yarnpkg.com/stylehacks/-/stylehacks-4.0.3.tgz#6718fcaf4d1e07d8a1318690881e8d96726a71d5" @@ -7779,7 +7877,7 @@ tryer@^1.0.1: resolved "https://registry.yarnpkg.com/tryer/-/tryer-1.0.1.tgz#f2c85406800b9b0f74c9f7465b81eaad241252f8" integrity sha512-c3zayb8/kWWpycWYg87P71E1S1ZL6b6IJxfb5fvsUgsf0S2MVGaDhDXXjDMpdCpfWXqptc+4mXwmiy1ypXqRAA== -tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3: +tslib@^1.10.0, tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3: version "1.10.0" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.10.0.tgz#c3c19f95973fb0a62973fb09d90d961ee43e5c8a" integrity sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==