From 830586b6bd40b58b33d077b058bae5ce0ecde357 Mon Sep 17 00:00:00 2001 From: AP Date: Sat, 27 Jun 2020 19:15:49 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20=E2=9C=A8=20balance=20from=20channel=20?= =?UTF-8?q?view?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pages/_app.tsx | 7 +- server/schema/bos/resolvers.ts | 4 +- src/components/generic/CardGeneric.tsx | 14 +++- src/components/notification/Beta.tsx | 7 +- .../toastContainer/ToastContainer.ts | 22 +++++ src/context/ConfigContext.tsx | 6 +- src/context/ContextProvider.tsx | 5 +- src/context/RebalanceContext.tsx | 75 +++++++++++++++++ src/views/balance/AdvancedBalance.tsx | 80 +++++++++++++------ src/views/balance/Balance.styled.tsx | 7 ++ src/views/balance/SimpleBalance.tsx | 24 +++--- src/views/channels/channels/Channel.style.ts | 70 +++++++++++++++- src/views/channels/channels/ChannelCard.tsx | 58 +++++++++++++- src/views/channels/channels/ChannelManage.tsx | 6 ++ src/views/channels/channels/Channels.tsx | 31 ++++++- 15 files changed, 361 insertions(+), 55 deletions(-) create mode 100644 src/components/toastContainer/ToastContainer.ts create mode 100644 src/context/RebalanceContext.tsx diff --git a/pages/_app.tsx b/pages/_app.tsx index 717769ab..1b524b50 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -2,20 +2,18 @@ import * as React from 'react'; import { ThemeProvider } from 'styled-components'; import { ModalProvider, BaseModalBackground } from 'styled-react-modal'; import { useRouter } from 'next/router'; -import { toast } from 'react-toastify'; import Head from 'next/head'; +import { StyledToastContainer } from 'src/components/toastContainer/ToastContainer'; import { ContextProvider } from '../src/context/ContextProvider'; import { useConfigState, ConfigProvider } from '../src/context/ConfigContext'; import { GlobalStyles } from '../src/styles/GlobalStyle'; import { Header } from '../src/layouts/header/Header'; import { Footer } from '../src/layouts/footer/Footer'; -import 'react-toastify/dist/ReactToastify.css'; +import 'react-toastify/dist/ReactToastify.min.css'; import { PageWrapper, HeaderBodyWrapper } from '../src/layouts/Layout.styled'; import { parseCookies } from '../src/utils/cookies'; import 'react-circular-progressbar/dist/styles.css'; -toast.configure({ draggable: false, pauseOnFocusLoss: false }); - const Wrapper: React.FC = ({ children }) => { const { theme } = useConfigState(); const { pathname } = useRouter(); @@ -50,6 +48,7 @@ const App = ({ Component, pageProps, initialConfig }) => ( + ); diff --git a/server/schema/bos/resolvers.ts b/server/schema/bos/resolvers.ts index 67d41bba..2a9ce82e 100644 --- a/server/schema/bos/resolvers.ts +++ b/server/schema/bos/resolvers.ts @@ -76,14 +76,14 @@ export const bosResolvers = { const lnd = getLnd(auth, context); const filteredParams = { - ...(avoid.length > 0 && { avoid }), + avoid, + out_channels, ...(in_through && { in_through }), ...(is_avoiding_high_inbound && { is_avoiding_high_inbound }), ...(max_fee > 0 && { max_fee }), ...(max_fee_rate > 0 && { max_fee_rate }), ...(max_rebalance > 0 && { max_rebalance }), ...(node && { node }), - ...(out_channels.length > 0 && { out_channels }), ...(out_through && { out_through }), ...(target && { target }), }; diff --git a/src/components/generic/CardGeneric.tsx b/src/components/generic/CardGeneric.tsx index f6c8b1aa..e59dbfa4 100644 --- a/src/components/generic/CardGeneric.tsx +++ b/src/components/generic/CardGeneric.tsx @@ -1,4 +1,4 @@ -import styled from 'styled-components'; +import styled, { css } from 'styled-components'; import { progressBackground, mediaWidths, @@ -65,8 +65,16 @@ export const StatusLine = styled.div` margin: 0 0 -8px 0; `; -export const MainInfo = styled.div` - cursor: pointer; +type MainProps = { + disabled?: boolean; +}; + +export const MainInfo = styled.div` + ${({ disabled }) => + !disabled && + css` + cursor: pointer; + `} `; export const StatusDot = styled.div` diff --git a/src/components/notification/Beta.tsx b/src/components/notification/Beta.tsx index d30a4bf7..debe0241 100644 --- a/src/components/notification/Beta.tsx +++ b/src/components/notification/Beta.tsx @@ -1,5 +1,5 @@ import styled from 'styled-components'; -import { chartColors } from 'src/styles/Themes'; +import { chartColors, mediaWidths } from 'src/styles/Themes'; export const BetaNotification = styled.div` width: 100%; @@ -9,4 +9,9 @@ export const BetaNotification = styled.div` color: black; margin-bottom: 16px; padding: 4px 0; + + @media (${mediaWidths.mobile}) { + margin-top: 8px; + margin-bottom: 8px; + } `; diff --git a/src/components/toastContainer/ToastContainer.ts b/src/components/toastContainer/ToastContainer.ts new file mode 100644 index 00000000..69c50a59 --- /dev/null +++ b/src/components/toastContainer/ToastContainer.ts @@ -0,0 +1,22 @@ +import styled from 'styled-components'; +import { ToastContainer } from 'react-toastify'; +import { themeColors, chartColors } from 'src/styles/Themes'; + +export const StyledToastContainer = styled(ToastContainer)` + .Toastify__toast { + border-radius: 4px; + background-color: ${themeColors.blue2}; + } + .Toastify__toast--error { + border-radius: 4px; + background-color: ${chartColors.red}; + } + .Toastify__toast--warning { + border-radius: 4px; + background-color: ${chartColors.orange}; + } + .Toastify__toast--success { + border-radius: 4px; + background-color: ${chartColors.green}; + } +`; diff --git a/src/context/ConfigContext.tsx b/src/context/ConfigContext.tsx index 1c332719..4e1014f3 100644 --- a/src/context/ConfigContext.tsx +++ b/src/context/ConfigContext.tsx @@ -6,7 +6,11 @@ import omit from 'lodash.omit'; const themeTypes = ['dark', 'light']; const currencyTypes = ['sat', 'btc', 'fiat']; -export type channelBarStyleTypes = 'normal' | 'compact' | 'ultracompact'; +export type channelBarStyleTypes = + | 'normal' + | 'compact' + | 'ultracompact' + | 'balancing'; export type channelBarTypeTypes = 'balance' | 'fees' | 'size' | 'proportional'; export type channelSortTypes = | 'none' diff --git a/src/context/ContextProvider.tsx b/src/context/ContextProvider.tsx index 17dca536..984ceb9e 100644 --- a/src/context/ContextProvider.tsx +++ b/src/context/ContextProvider.tsx @@ -4,13 +4,16 @@ import { BitcoinInfoProvider } from './BitcoinContext'; import { StatusProvider } from './StatusContext'; import { PriceProvider } from './PriceContext'; import { ChatProvider } from './ChatContext'; +import { RebalanceProvider } from './RebalanceContext'; export const ContextProvider: React.FC = ({ children }) => ( - {children} + + {children} + diff --git a/src/context/RebalanceContext.tsx b/src/context/RebalanceContext.tsx new file mode 100644 index 00000000..c0c7a254 --- /dev/null +++ b/src/context/RebalanceContext.tsx @@ -0,0 +1,75 @@ +import React, { createContext, useContext, useReducer } from 'react'; +import { ChannelType } from 'src/graphql/types'; + +type State = { + inChannel: ChannelType | null; + outChannel: ChannelType | null; +}; + +type ActionType = + | { + type: 'setIn'; + channel: ChannelType | null; + } + | { + type: 'setOut'; + channel: ChannelType | null; + } + | { + type: 'clear'; + }; + +type Dispatch = (action: ActionType) => void; + +export const StateContext = createContext(undefined); +export const DispatchContext = createContext(undefined); + +const initialState = { + inChannel: null, + outChannel: null, +}; + +const stateReducer = (state: State, action: ActionType): State => { + switch (action.type) { + case 'setIn': + return { ...state, inChannel: action.channel }; + case 'setOut': + return { ...state, outChannel: action.channel }; + case 'clear': + return initialState; + default: + return state; + } +}; + +const RebalanceProvider = ({ children }) => { + const [state, dispatch] = useReducer(stateReducer, initialState); + + return ( + + {children} + + ); +}; + +const useRebalanceState = () => { + const context = useContext(StateContext); + if (context === undefined) { + throw new Error( + 'useRebalanceState must be used within a RebalanceProvider' + ); + } + return context; +}; + +const useRebalanceDispatch = () => { + const context = useContext(DispatchContext); + if (context === undefined) { + throw new Error( + 'useRebalanceDispatch must be used within a RebalanceProvider' + ); + } + return context; +}; + +export { RebalanceProvider, useRebalanceState, useRebalanceDispatch }; diff --git a/src/views/balance/AdvancedBalance.tsx b/src/views/balance/AdvancedBalance.tsx index 5b440933..3c5afc71 100644 --- a/src/views/balance/AdvancedBalance.tsx +++ b/src/views/balance/AdvancedBalance.tsx @@ -16,6 +16,10 @@ import { chartColors } from 'src/styles/Themes'; import { ViewSwitch } from 'src/components/viewSwitch/ViewSwitch'; import { useMutationResultWithReset } from 'src/hooks/UseMutationWithReset'; import { BetaNotification } from 'src/components/notification/Beta'; +import { + useRebalanceState, + useRebalanceDispatch, +} from 'src/context/RebalanceContext'; import { AdvancedResult } from './AdvancedResult'; import { ModalNodes } from './Modals/ModalNodes'; import { ModalChannels } from './Modals/ModalChannels'; @@ -165,7 +169,29 @@ const SettingLine: React.FC<{ title: string }> = ({ children, title }) => ( export const AdvancedBalance = () => { const [openType, openTypeSet] = React.useState('none'); const [isDetailed, isDetailedSet] = React.useState(false); - const [state, dispatch] = React.useReducer(reducer, initialState); + + const rebalanceDispatch = useRebalanceDispatch(); + const { inChannel, outChannel } = useRebalanceState(); + + const in_through = inChannel + ? { + alias: inChannel.partner_node_info?.node?.alias, + id: inChannel.partner_public_key, + } + : defaultRebalanceId; + + const out_through = outChannel + ? { + alias: outChannel.partner_node_info?.node?.alias, + id: outChannel.partner_public_key, + } + : defaultRebalanceId; + + const [state, dispatch] = React.useReducer(reducer, { + ...initialState, + in_through, + out_through, + }); const [rebalance, { data: _data, loading }] = useBosRebalanceMutation({ onError: error => toast.error(getErrorContent(error)), @@ -231,21 +257,6 @@ export const AdvancedBalance = () => { {hasAvoid ? : } - - {hasInChannel ? ( - {state.in_through.alias} - ) : null} - - hasInChannel - ? dispatch({ type: 'inChannel', channel: defaultRebalanceId }) - : openTypeSet('inChannel') - } - > - {hasInChannel ? : } - - {!hasOutChannels && ( {hasOutChannel ? ( @@ -253,19 +264,40 @@ export const AdvancedBalance = () => { ) : null} - hasOutChannel - ? dispatch({ - type: 'outChannel', - channel: defaultRebalanceId, - }) - : openTypeSet('outChannel') - } + onClick={() => { + if (hasOutChannel) { + rebalanceDispatch({ type: 'setOut', channel: null }); + dispatch({ + type: 'outChannel', + channel: defaultRebalanceId, + }); + } else { + openTypeSet('outChannel'); + } + }} > {hasOutChannel ? : } )} + + {hasInChannel ? ( + {state.in_through.alias} + ) : null} + { + if (hasInChannel) { + rebalanceDispatch({ type: 'setIn', channel: null }); + dispatch({ type: 'inChannel', channel: defaultRebalanceId }); + } else { + openTypeSet('inChannel'); + } + }} + > + {hasInChannel ? : } + + {!hasOutChannel && ( {hasOutChannels && ( diff --git a/src/views/balance/Balance.styled.tsx b/src/views/balance/Balance.styled.tsx index ffd407db..93df4d65 100644 --- a/src/views/balance/Balance.styled.tsx +++ b/src/views/balance/Balance.styled.tsx @@ -74,6 +74,13 @@ export const RebalanceTag = styled.div` border-radius: 4px; margin-right: 8px; font-size: 14px; + + @media (${mediaWidths.mobile}) { + max-width: 80px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } `; export const RebalanceLine = styled(SingleLine)` diff --git a/src/views/balance/SimpleBalance.tsx b/src/views/balance/SimpleBalance.tsx index fa5bb6da..0fbc38e9 100644 --- a/src/views/balance/SimpleBalance.tsx +++ b/src/views/balance/SimpleBalance.tsx @@ -18,16 +18,19 @@ import { Input } from 'src/components/input/Input'; import { BalanceCard } from 'src/views/balance/BalanceCard'; import { BalanceRoute } from 'src/views/balance/BalanceRoute'; import { Price } from 'src/components/price/Price'; +import { + useRebalanceState, + useRebalanceDispatch, +} from 'src/context/RebalanceContext'; export const SimpleBalance = () => { const { auth } = useAccountState(); - const [outgoing, setOutgoing] = useState(); - const [incoming, setIncoming] = useState(); + const dispatch = useRebalanceDispatch(); + const { inChannel: incoming, outChannel: outgoing } = useRebalanceState(); + const [amount, setAmount] = useState(); - const [maxFee, setMaxFee] = useState(); - const [blocked, setBlocked] = useState(false); const { loading, data } = useGetChannelsQuery({ @@ -43,17 +46,15 @@ export const SimpleBalance = () => { const handleReset = (type: string) => { switch (type) { case 'outgoing': - setOutgoing(undefined); - setIncoming(undefined); + dispatch({ type: 'clear' }); break; case 'incoming': - setIncoming(undefined); + dispatch({ type: 'setIn', channel: null }); break; case 'all': + dispatch({ type: 'clear' }); setMaxFee(undefined); setAmount(undefined); - setOutgoing(undefined); - setIncoming(undefined); setBlocked(false); break; default: @@ -91,8 +92,9 @@ export const SimpleBalance = () => { } const callback = isOutgoing - ? !outgoing && { callback: () => setOutgoing(channel) } - : outgoing && !incoming && { callback: () => setIncoming(channel) }; + ? !outgoing && { callback: () => dispatch({ type: 'setOut', channel }) } + : outgoing && + !incoming && { callback: () => dispatch({ type: 'setIn', channel }) }; return ( ` + display: flex; + justify-content: center; + align-items: center; + outline: none; + padding: 6px 8px; + border: none; + text-decoration: none; + border-radius: 4px; + white-space: nowrap; + font-size: 14px; + box-sizing: border-box; + margin-left: 8px; + color: ${({ disabled }) => (disabled ? disabledTextColor : textColor)}; + ${({ disabled }) => + !disabled && + css` + cursor: pointer; + `} + background-color: ${({ selected }) => + selected ? chartColors.orange : colorButtonBackground}; + + @media(${mediaWidths.mobile}) { + margin: 8px 8px 16px; + width: 100%; + } + + :hover { + background-color: ${({ selected, disabled }) => + disabled + ? colorButtonBackground + : selected + ? chartColors.orange2 + : colorButtonBorder}; + } +`; + +export const ChannelGoToToast = styled.div` + width: 100%; + text-align: center; +`; diff --git a/src/views/channels/channels/ChannelCard.tsx b/src/views/channels/channels/ChannelCard.tsx index 36a43e6a..a1b83c5e 100644 --- a/src/views/channels/channels/ChannelCard.tsx +++ b/src/views/channels/channels/ChannelCard.tsx @@ -1,8 +1,19 @@ import React, { useState } from 'react'; import ReactTooltip from 'react-tooltip'; -import { ArrowDown, ArrowUp, EyeOff } from 'react-feather'; +import { + ArrowDown, + ArrowUp, + EyeOff, + ChevronsUp, + ChevronsDown, + X, +} from 'react-feather'; import { ChannelType } from 'src/graphql/types'; import { BalanceBars } from 'src/components/balance'; +import { + useRebalanceState, + useRebalanceDispatch, +} from 'src/context/RebalanceContext'; import { getPercent, formatSeconds } from '../../../utils/helpers'; import { ProgressBar, @@ -40,6 +51,8 @@ import { ChannelStatsColumn, ChannelSingleLine, ChannelStatsLine, + ChannelBalanceRow, + ChannelBalanceButton, } from './Channel.style'; const getSymbol = (status: boolean) => { @@ -78,6 +91,9 @@ export const ChannelCard: React.FC = ({ biggestBaseFee, biggestRateFee, }) => { + const dispatch = useRebalanceDispatch(); + const { inChannel, outChannel } = useRebalanceState(); + const { channelBarType, channelBarStyle } = useConfigState(); const [modalOpen, setModalOpen] = useState(false); @@ -114,6 +130,9 @@ export const ChannelCard: React.FC = ({ partner_fee_info, } = channelInfo; + const isIn = inChannel?.id === id; + const isOut = outChannel?.id === id; + const { alias, capacity: partnerNodeCapacity = 0, @@ -340,6 +359,7 @@ export const ChannelCard: React.FC = ({ const getSubCardProps = () => { switch (channelBarStyle) { case 'ultracompact': + case 'balancing': return { withMargin: '0 0 4px 0', padding: index === indexOpen ? '0 0 16px' : '2px 0', @@ -357,7 +377,10 @@ export const ChannelCard: React.FC = ({ return ( - handleClick()}> + channelBarStyle !== 'balancing' && handleClick()} + > {channelBarStyle === 'normal' && ( {getStatusDot(is_active, 'active')} @@ -368,7 +391,10 @@ export const ChannelCard: React.FC = ({ {alias || partner_public_key?.substring(0, 6)} - {channelBarStyle !== 'ultracompact' && ( + {!( + channelBarStyle === 'ultracompact' || + channelBarStyle === 'balancing' + ) && ( {formatBalance} @@ -380,6 +406,32 @@ export const ChannelCard: React.FC = ({ {renderBars()} + {channelBarStyle === 'balancing' && ( + + + isOut + ? dispatch({ type: 'setOut', channel: null }) + : dispatch({ type: 'setOut', channel: channelInfo }) + } + > + {isOut ? : } + + + isIn + ? dispatch({ type: 'setIn', channel: null }) + : dispatch({ type: 'setIn', channel: channelInfo }) + } + > + {isIn ? : } + + + )} diff --git a/src/views/channels/channels/ChannelManage.tsx b/src/views/channels/channels/ChannelManage.tsx index def2e9bc..44a88f95 100644 --- a/src/views/channels/channels/ChannelManage.tsx +++ b/src/views/channels/channels/ChannelManage.tsx @@ -59,6 +59,12 @@ export const ChannelManage = () => { > Ultra-Compact + changeStyle('balancing')} + > + Balancing + diff --git a/src/views/channels/channels/Channels.tsx b/src/views/channels/channels/Channels.tsx index 6a28ba6f..6549e0fd 100644 --- a/src/views/channels/channels/Channels.tsx +++ b/src/views/channels/channels/Channels.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { toast } from 'react-toastify'; import { useAccountState } from 'src/context/AccountContext'; import { useGetChannelsQuery } from 'src/graphql/queries/__generated__/getChannels.generated'; @@ -6,17 +6,46 @@ import { useConfigState } from 'src/context/ConfigContext'; import { sortBy } from 'underscore'; import { getPercent } from 'src/utils/helpers'; import { ChannelType } from 'src/graphql/types'; +import { useRebalanceState } from 'src/context/RebalanceContext'; +import { useRouter } from 'next/router'; +import { appendBasePath } from 'src/utils/basePath'; import { Card } from '../../../components/generic/Styled'; import { getErrorContent } from '../../../utils/error'; import { LoadingCard } from '../../../components/loading/LoadingCard'; import { ChannelCard } from './ChannelCard'; +import { ChannelGoToToast } from './Channel.style'; export const Channels: React.FC = () => { + const toastId = useRef(null); + const { push } = useRouter(); + const { sortDirection, channelSort } = useConfigState(); const [indexOpen, setIndexOpen] = useState(0); const { auth } = useAccountState(); + const { inChannel, outChannel } = useRebalanceState(); + const hasIn = !!inChannel; + const hasOut = !!outChannel; + + useEffect(() => { + if (hasIn && hasOut) { + toastId.current = toast.info( + Click to go to rebalancing, + { + position: 'bottom-right', + autoClose: false, + closeButton: false, + onClick: () => push(appendBasePath('/rebalance')), + } + ); + } + if (!hasIn || !hasOut) { + toast.dismiss(toastId.current); + } + return () => toast.dismiss(); + }, [hasIn, hasOut, push]); + const { loading, data } = useGetChannelsQuery({ skip: !auth, variables: { auth },