diff --git a/app/package.json b/app/package.json index f1d8cfdce..f1aef493a 100644 --- a/app/package.json +++ b/app/package.json @@ -29,6 +29,7 @@ "lottie-web": "5.6.8", "mobx": "5.15.4", "mobx-react-lite": "2.0.6", + "mobx-utils": "5.5.7", "react": "^16.13.1", "react-dom": "^16.13.1", "react-i18next": "11.4.0", diff --git a/app/src/__stories__/LoopHistory.stories.tsx b/app/src/__stories__/LoopHistory.stories.tsx index 878c831c8..dfcaf904c 100644 --- a/app/src/__stories__/LoopHistory.stories.tsx +++ b/app/src/__stories__/LoopHistory.stories.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import { useStore } from 'store'; import Tile from 'components/common/Tile'; import LoopHistory from 'components/loop/LoopHistory'; @@ -10,15 +9,13 @@ export default { }; export const Default = () => { - const { swapStore } = useStore(); - return ; + return ; }; export const InsideTile = () => { - const { swapStore } = useStore(); return ( - + ); }; diff --git a/app/src/__stories__/ProcessingSwaps.stories.tsx b/app/src/__stories__/ProcessingSwaps.stories.tsx new file mode 100644 index 000000000..c52421493 --- /dev/null +++ b/app/src/__stories__/ProcessingSwaps.stories.tsx @@ -0,0 +1,120 @@ +import React, { useEffect } from 'react'; +import { observable } from 'mobx'; +import * as LOOP from 'types/generated/loop_pb'; +import { loopListSwaps } from 'util/tests/sampleData'; +import { useStore } from 'store'; +import { Swap } from 'store/models'; +import ProcessingSwaps from 'components/loop/processing/ProcessingSwaps'; + +const { LOOP_IN, LOOP_OUT } = LOOP.SwapType; +const { + INITIATED, + PREIMAGE_REVEALED, + HTLC_PUBLISHED, + SUCCESS, + INVOICE_SETTLED, + FAILED, +} = LOOP.SwapState; + +export default { + title: 'Components/Processing Swaps', + component: ProcessingSwaps, + parameters: { contained: true }, + decorators: [ + (StoryFn: any) => ( +
+ +
+ ), + ], +}; + +// the multiple variations of swap types and states +const swapProps = [ + [LOOP_IN, INITIATED], + [LOOP_IN, HTLC_PUBLISHED], + [LOOP_IN, INVOICE_SETTLED], + [LOOP_IN, SUCCESS], + [LOOP_IN, FAILED], + [LOOP_OUT, INITIATED], + [LOOP_OUT, PREIMAGE_REVEALED], + [LOOP_OUT, SUCCESS], + [LOOP_OUT, FAILED], +]; +// const mockSwap = loopListSwaps.swapsList[0]; +const mockSwap = (type: number, state: number, id?: string) => { + const swap = new Swap(loopListSwaps.swapsList[0]); + swap.id = `${id || ''}${swap.id}`; + swap.type = type; + swap.state = state; + swap.lastUpdateTime = Date.now() * 1000 * 1000; + return swap; +}; +// create a list of swaps to use for stories +const createSwaps = () => { + return [...Array(9)] + .map((_, i) => mockSwap(swapProps[i][0], swapProps[i][1], `${i}`)) + .reduce((map, swap) => { + map.set(swap.id, swap); + return map; + }, observable.map()); +}; + +let timer: NodeJS.Timeout; +const delay = (timeout: number) => + new Promise(resolve => (timer = setTimeout(resolve, timeout))); + +export const AllSwapStates = () => { + const store = useStore(); + store.swapStore.stopAutoPolling(); + store.swapStore.swaps = createSwaps(); + return ; +}; + +export const LoopInProgress = () => { + const store = useStore(); + store.swapStore.stopAutoPolling(); + const swap = mockSwap(LOOP_IN, INITIATED); + store.swapStore.swaps = observable.map({ [swap.id]: swap }); + + useEffect(() => { + const startTransitions = async () => { + await delay(2000); + swap.state = HTLC_PUBLISHED; + await delay(2000); + swap.state = INVOICE_SETTLED; + await delay(2000); + swap.state = SUCCESS; + await delay(2000); + swap.initiationTime = 0; + }; + + startTransitions(); + return () => clearTimeout(timer); + }, []); + + return ; +}; + +export const LoopOutProgress = () => { + const store = useStore(); + store.swapStore.stopAutoPolling(); + const swap = mockSwap(LOOP_OUT, INITIATED); + store.swapStore.swaps = observable.map({ [swap.id]: swap }); + + useEffect(() => { + const startTransitions = async () => { + await delay(2000); + swap.state = PREIMAGE_REVEALED; + await delay(2000); + swap.state = SUCCESS; + await delay(2000); + swap.initiationTime = 0; + }; + + startTransitions(); + return () => clearTimeout(timer); + }, []); + + return ; +}; diff --git a/app/src/__stories__/Tile.stories.tsx b/app/src/__stories__/Tile.stories.tsx index a3e48fbaa..90972515d 100644 --- a/app/src/__stories__/Tile.stories.tsx +++ b/app/src/__stories__/Tile.stories.tsx @@ -17,7 +17,7 @@ export const WithChildren = () => ( ); export const WithArrowIcon = () => ( - action('ArrowIcon')}> + action('ArrowIcon')}> Sample Text ); diff --git a/app/src/__tests__/components/common/Tile.spec.tsx b/app/src/__tests__/components/common/Tile.spec.tsx index 6b36dd7e4..6fcf876b4 100644 --- a/app/src/__tests__/components/common/Tile.spec.tsx +++ b/app/src/__tests__/components/common/Tile.spec.tsx @@ -8,7 +8,7 @@ describe('Tile component', () => { const render = (text?: string, children?: ReactNode) => { const cmp = ( - + {children} ); @@ -32,7 +32,7 @@ describe('Tile component', () => { it('should handle the arrow click event', () => { const { getByText } = render(); - fireEvent.click(getByText('arrow-right.svg')); + fireEvent.click(getByText('maximize.svg')); expect(handleArrowClick).toBeCalled(); }); }); diff --git a/app/src/__tests__/components/loop/LoopHistory.spec.tsx b/app/src/__tests__/components/loop/LoopHistory.spec.tsx index 2ceae2edc..b5c7644dc 100644 --- a/app/src/__tests__/components/loop/LoopHistory.spec.tsx +++ b/app/src/__tests__/components/loop/LoopHistory.spec.tsx @@ -10,11 +10,16 @@ describe('LoopHistory component', () => { beforeEach(async () => { store = createStore(); await store.init(); + + // remove all but one swap to prevent `getByText` from + // complaining about multiple elements in tests + const swap = store.swapStore.sortedSwaps[0]; + store.swapStore.swaps.clear(); + store.swapStore.swaps.set(swap.id, swap); }); const render = () => { - const swaps = store.swapStore.sortedSwaps.slice(0, 1); - return renderWithProviders(); + return renderWithProviders(, store); }; it('should display a successful swap', async () => { diff --git a/app/src/__tests__/components/loop/ProcessingSwaps.spec.tsx b/app/src/__tests__/components/loop/ProcessingSwaps.spec.tsx new file mode 100644 index 000000000..87f8b0534 --- /dev/null +++ b/app/src/__tests__/components/loop/ProcessingSwaps.spec.tsx @@ -0,0 +1,133 @@ +import React from 'react'; +import * as LOOP from 'types/generated/loop_pb'; +import { fireEvent } from '@testing-library/react'; +import { ellipseInside } from 'util/strings'; +import { renderWithProviders } from 'util/tests'; +import { loopListSwaps } from 'util/tests/sampleData'; +import { createStore, Store } from 'store'; +import { Swap } from 'store/models'; +import ProcessingSwaps from 'components/loop/processing/ProcessingSwaps'; + +const { LOOP_IN, LOOP_OUT } = LOOP.SwapType; +const { + INITIATED, + PREIMAGE_REVEALED, + HTLC_PUBLISHED, + SUCCESS, + INVOICE_SETTLED, + FAILED, +} = LOOP.SwapState; +const width = (el: any) => window.getComputedStyle(el).width; + +describe('ProcessingSwaps component', () => { + let store: Store; + + const addSwap = (type: number, state: number, id?: string) => { + const swap = new Swap(loopListSwaps.swapsList[0]); + swap.id = `${id || ''}${swap.id}`; + swap.type = type; + swap.state = state; + swap.lastUpdateTime = Date.now() * 1000 * 1000; + store.swapStore.swaps.set(swap.id, swap); + return swap; + }; + + beforeEach(async () => { + store = createStore(); + }); + + const render = () => { + return renderWithProviders(, store); + }; + + it('should display the title', async () => { + const { getByText } = render(); + expect(getByText('Processing Loops')).toBeInTheDocument(); + }); + + it('should display an INITIATED Loop In', () => { + const { getByText, getByTitle } = render(); + const swap = addSwap(LOOP_IN, INITIATED); + expect(getByText('dot.svg')).toHaveClass('warn'); + expect(getByText(ellipseInside(swap.id))).toBeInTheDocument(); + expect(getByTitle(swap.stateLabel)).toBeInTheDocument(); + expect(width(getByTitle(swap.stateLabel))).toBe('25%'); + }); + + it('should display an HTLC_PUBLISHED Loop In', () => { + const { getByText, getByTitle } = render(); + const swap = addSwap(LOOP_IN, HTLC_PUBLISHED); + expect(getByText('dot.svg')).toHaveClass('warn'); + expect(getByText(ellipseInside(swap.id))).toBeInTheDocument(); + expect(getByTitle(swap.stateLabel)).toBeInTheDocument(); + expect(width(getByTitle(swap.stateLabel))).toBe('50%'); + }); + + it('should display an INVOICE_SETTLED Loop In', () => { + const { getByText, getByTitle } = render(); + const swap = addSwap(LOOP_IN, INVOICE_SETTLED); + expect(getByText('dot.svg')).toHaveClass('warn'); + expect(getByText(ellipseInside(swap.id))).toBeInTheDocument(); + expect(getByTitle(swap.stateLabel)).toBeInTheDocument(); + expect(width(getByTitle(swap.stateLabel))).toBe('75%'); + }); + + it('should display an SUCCESS Loop In', () => { + const { getByText, getByTitle } = render(); + const swap = addSwap(LOOP_IN, SUCCESS); + expect(getByText(ellipseInside(swap.id))).toBeInTheDocument(); + expect(getByTitle(swap.stateLabel)).toBeInTheDocument(); + expect(width(getByTitle(swap.stateLabel))).toBe('100%'); + }); + + it('should display an FAILED Loop In', () => { + const { getByText } = render(); + const swap = addSwap(LOOP_IN, FAILED); + expect(getByText(ellipseInside(swap.id))).toBeInTheDocument(); + expect(getByText(swap.stateLabel)).toBeInTheDocument(); + expect(getByText('close.svg')).toBeInTheDocument(); + }); + + it('should display an INITIATED Loop Out', () => { + const { getByText, getByTitle } = render(); + const swap = addSwap(LOOP_OUT, INITIATED); + expect(getByText('dot.svg')).toHaveClass('warn'); + expect(getByText(ellipseInside(swap.id))).toBeInTheDocument(); + expect(getByTitle(swap.stateLabel)).toBeInTheDocument(); + expect(width(getByTitle(swap.stateLabel))).toBe('33%'); + }); + + it('should display an PREIMAGE_REVEALED Loop Out', () => { + const { getByText, getByTitle } = render(); + const swap = addSwap(LOOP_OUT, PREIMAGE_REVEALED); + expect(getByText('dot.svg')).toHaveClass('warn'); + expect(getByText(ellipseInside(swap.id))).toBeInTheDocument(); + expect(getByTitle(swap.stateLabel)).toBeInTheDocument(); + expect(width(getByTitle(swap.stateLabel))).toBe('66%'); + }); + + it('should display an SUCCESS Loop Out', () => { + const { getByText, getByTitle } = render(); + const swap = addSwap(LOOP_OUT, SUCCESS); + expect(getByText('dot.svg')).toHaveClass('success'); + expect(getByText(ellipseInside(swap.id))).toBeInTheDocument(); + expect(getByTitle(swap.stateLabel)).toBeInTheDocument(); + expect(width(getByTitle(swap.stateLabel))).toBe('100%'); + }); + + it('should display an FAILED Loop Out', () => { + const { getByText } = render(); + const swap = addSwap(LOOP_OUT, FAILED); + expect(getByText(ellipseInside(swap.id))).toBeInTheDocument(); + expect(getByText(swap.stateLabel)).toBeInTheDocument(); + expect(getByText('close.svg')).toBeInTheDocument(); + }); + + it('should dismiss a failed Loop', () => { + const { getByText } = render(); + addSwap(LOOP_OUT, FAILED); + expect(store.swapStore.dismissedSwapIds).toHaveLength(0); + fireEvent.click(getByText('close.svg')); + expect(store.swapStore.dismissedSwapIds).toHaveLength(1); + }); +}); diff --git a/app/src/__tests__/store/swapStore.spec.ts b/app/src/__tests__/store/swapStore.spec.ts index d9dbd195d..294604a71 100644 --- a/app/src/__tests__/store/swapStore.spec.ts +++ b/app/src/__tests__/store/swapStore.spec.ts @@ -1,4 +1,5 @@ import * as LOOP from 'types/generated/loop_pb'; +import { waitFor } from '@testing-library/react'; import { loopListSwaps } from 'util/tests/sampleData'; import { createStore, SwapStore } from 'store'; @@ -55,4 +56,37 @@ describe('SwapStore', () => { swap.type = type; expect(swap.typeName).toEqual(label); }); + + it('should poll for swap updates', async () => { + await store.fetchSwaps(); + const swap = store.sortedSwaps[0]; + // create a pending swap to trigger auto-polling + swap.state = LOOP.SwapState.INITIATED; + expect(store.pendingSwaps).toHaveLength(1); + // wait for polling to start + await waitFor(() => { + expect(store.pollingInterval).toBeDefined(); + }); + // change the swap to complete + swap.state = LOOP.SwapState.SUCCESS; + expect(store.pendingSwaps).toHaveLength(0); + // confirm polling has stopped + await waitFor(() => { + expect(store.pollingInterval).toBeUndefined(); + }); + }); + + it('should handle startPolling when polling is already running', () => { + expect(store.pollingInterval).toBeUndefined(); + store.startPolling(); + expect(store.pollingInterval).toBeDefined(); + store.startPolling(); + expect(store.pollingInterval).toBeDefined(); + }); + + it('should handle stopPolling when polling is already stopped', () => { + expect(store.pollingInterval).toBeUndefined(); + store.stopPolling(); + expect(store.pollingInterval).toBeUndefined(); + }); }); diff --git a/app/src/assets/icons/maximize.svg b/app/src/assets/icons/maximize.svg new file mode 100644 index 000000000..e41fc0b73 --- /dev/null +++ b/app/src/assets/icons/maximize.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/assets/icons/minimize.svg b/app/src/assets/icons/minimize.svg new file mode 100644 index 000000000..a720fa6c3 --- /dev/null +++ b/app/src/assets/icons/minimize.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/src/components/common/Tile.tsx b/app/src/components/common/Tile.tsx index f3ffcd3af..306e9f0e1 100644 --- a/app/src/components/common/Tile.tsx +++ b/app/src/components/common/Tile.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { styled } from 'components/theme'; -import { ArrowRight } from './icons'; +import { Maximize } from './icons'; import { Title } from './text'; const Styled = { @@ -14,10 +14,17 @@ const Styled = { display: flex; justify-content: space-between; `, - ArrowIcon: styled(ArrowRight)` - width: 16px; + MaximizeIcon: styled(Maximize)` + width: 20px; + height: 20px; + padding: 4px; margin-top: -5px; cursor: pointer; + + &:hover { + border-radius: 24px; + background-color: ${props => props.theme.colors.purple}; + } `, Text: styled.div` font-size: ${props => props.theme.sizes.xl}; @@ -38,20 +45,20 @@ interface Props { */ text?: string; /** - * optional click handler for the arrow which will not be + * optional click handler for the icon which will not be * visible if this prop is not defined */ - onArrowClick?: () => void; + onMaximizeClick?: () => void; } -const Tile: React.FC = ({ title, text, onArrowClick, children }) => { - const { TileWrap, Header, ArrowIcon, Text } = Styled; +const Tile: React.FC = ({ title, text, onMaximizeClick, children }) => { + const { TileWrap, Header, MaximizeIcon, Text } = Styled; return (
{title} - {onArrowClick && } + {onMaximizeClick && }
{text ? {text} : children}
diff --git a/app/src/components/common/icons.tsx b/app/src/components/common/icons.tsx index fc9c7a195..78489b00c 100644 --- a/app/src/components/common/icons.tsx +++ b/app/src/components/common/icons.tsx @@ -7,4 +7,6 @@ export { ReactComponent as Chevrons } from 'assets/icons/chevrons.svg'; export { ReactComponent as Close } from 'assets/icons/close.svg'; export { ReactComponent as Dot } from 'assets/icons/dot.svg'; export { ReactComponent as Menu } from 'assets/icons/menu.svg'; +export { ReactComponent as Minimize } from 'assets/icons/minimize.svg'; +export { ReactComponent as Maximize } from 'assets/icons/maximize.svg'; export { ReactComponent as Refresh } from 'assets/icons/refresh-cw.svg'; diff --git a/app/src/components/loop/LoopHistory.tsx b/app/src/components/loop/LoopHistory.tsx index d0091edec..b1cb79d0e 100644 --- a/app/src/components/loop/LoopHistory.tsx +++ b/app/src/components/loop/LoopHistory.tsx @@ -1,10 +1,10 @@ import React from 'react'; import { observer } from 'mobx-react-lite'; -import { Swap } from 'store/models'; +import { useStore } from 'store'; import { Column, Row } from 'components/common/grid'; -import StatusDot from 'components/common/StatusDot'; import { SmallText } from 'components/common/text'; import { styled } from 'components/theme'; +import SwapDot from './SwapDot'; const Styled = { RightColumn: styled(Column)` @@ -16,28 +16,13 @@ const Styled = { `, }; -const SwapDot: React.FC<{ swap: Swap }> = ({ swap }) => { - switch (swap.stateLabel) { - case 'Success': - return ; - case 'Failed': - return ; - default: - return ; - } -}; - -interface Props { - swaps: Swap[]; -} - -const LoopHistory: React.FC = ({ swaps }) => { - const recentSwaps = swaps.slice(0, 2); +const LoopHistory: React.FC = () => { + const store = useStore(); const { RightColumn, SmallText } = Styled; return ( <> - {recentSwaps.map(swap => ( + {store.swapStore.lastTwoSwaps.map(swap => ( diff --git a/app/src/components/loop/LoopPage.tsx b/app/src/components/loop/LoopPage.tsx index a0fea1f37..1dcfb526c 100644 --- a/app/src/components/loop/LoopPage.tsx +++ b/app/src/components/loop/LoopPage.tsx @@ -7,6 +7,7 @@ import { styled } from 'components/theme'; import ChannelList from './ChannelList'; import LoopActions from './LoopActions'; import LoopTiles from './LoopTiles'; +import ProcessingSwaps from './processing/ProcessingSwaps'; import SwapWizard from './swap/SwapWizard'; const Styled = { @@ -17,13 +18,14 @@ const Styled = { const LoopPage: React.FC = () => { const { l } = usePrefixedTranslation('cmps.loop.LoopPage'); - const store = useStore(); - const build = store.buildSwapStore; + const { uiStore, buildSwapStore } = useStore(); const { PageWrap } = Styled; return ( - {build.showWizard ? ( + {uiStore.processingSwapsVisible ? ( + + ) : buildSwapStore.showWizard ? ( ) : ( <> diff --git a/app/src/components/loop/LoopTiles.tsx b/app/src/components/loop/LoopTiles.tsx index ed1304230..243401bb9 100644 --- a/app/src/components/loop/LoopTiles.tsx +++ b/app/src/components/loop/LoopTiles.tsx @@ -15,27 +15,27 @@ const Styled = { const LoopTiles: React.FC = () => { const { l } = usePrefixedTranslation('cmps.loop.LoopTiles'); - const store = useStore(); + const { channelStore, uiStore } = useStore(); const { TileSection } = Styled; return ( - null}> - + + diff --git a/app/src/components/loop/SwapDot.tsx b/app/src/components/loop/SwapDot.tsx new file mode 100644 index 000000000..ffe23d622 --- /dev/null +++ b/app/src/components/loop/SwapDot.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { observer } from 'mobx-react-lite'; +import { Swap } from 'store/models'; +import StatusDot from 'components/common/StatusDot'; + +interface Props { + swap: Swap; +} + +const SwapDot: React.FC = ({ swap }) => { + switch (swap.stateLabel) { + case 'Success': + return ; + case 'Failed': + return ; + default: + return ; + } +}; + +export default observer(SwapDot); diff --git a/app/src/components/loop/processing/FailedSwap.tsx b/app/src/components/loop/processing/FailedSwap.tsx new file mode 100644 index 000000000..8663272a0 --- /dev/null +++ b/app/src/components/loop/processing/FailedSwap.tsx @@ -0,0 +1,56 @@ +import React, { useCallback } from 'react'; +import { observer } from 'mobx-react-lite'; +import { useStore } from 'store'; +import { Swap } from 'store/models'; +import { Close } from 'components/common/icons'; +import { styled } from 'components/theme'; + +const Styled = { + Wrapper: styled.div` + height: 100%; + display: flex; + align-items: center; + `, + Circle: styled.span` + display: inline-block; + width: 34px; + height: 34px; + text-align: center; + line-height: 30px; + background-color: ${props => props.theme.colors.darkGray}; + border-radius: 34px; + margin-right: 10px; + cursor: pointer; + + &:hover { + opacity: 0.8; + } + `, + ErrorMessage: styled.span` + color: ${props => props.theme.colors.pink}; + `, +}; + +interface Props { + swap: Swap; +} + +const FailedSwap: React.FC = ({ swap }) => { + const { swapStore } = useStore(); + const handleCloseClick = useCallback(() => swapStore.dismissSwap(swap.id), [ + swapStore, + swap, + ]); + + const { Wrapper, Circle, ErrorMessage } = Styled; + return ( + + + + + {swap.stateLabel} + + ); +}; + +export default observer(FailedSwap); diff --git a/app/src/components/loop/processing/ProcessingSwapRow.tsx b/app/src/components/loop/processing/ProcessingSwapRow.tsx new file mode 100644 index 000000000..626667bee --- /dev/null +++ b/app/src/components/loop/processing/ProcessingSwapRow.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { observer } from 'mobx-react-lite'; +import { Swap } from 'store/models'; +import { Column, Row } from 'components/common/grid'; +import { styled } from 'components/theme'; +import FailedSwap from './FailedSwap'; +import SwapInfo from './SwapInfo'; +import SwapProgress from './SwapProgress'; + +const Styled = { + Row: styled(Row)` + margin-bottom: 10px; + `, + InfoCol: styled(Column)` + min-width: 200px; + `, +}; + +interface Props { + swap: Swap; +} + +const ProcessingSwapRow: React.FC = ({ swap }) => { + const { Row, InfoCol } = Styled; + return ( + + + + + + {swap.isFailed ? : } + + + ); +}; + +export default observer(ProcessingSwapRow); diff --git a/app/src/components/loop/processing/ProcessingSwaps.tsx b/app/src/components/loop/processing/ProcessingSwaps.tsx new file mode 100644 index 000000000..e63d3352e --- /dev/null +++ b/app/src/components/loop/processing/ProcessingSwaps.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { observer } from 'mobx-react-lite'; +import { usePrefixedTranslation } from 'hooks'; +import { useStore } from 'store'; +import { Minimize } from 'components/common/icons'; +import { Title } from 'components/common/text'; +import { styled } from 'components/theme'; +import ProcessingSwapRow from './ProcessingSwapRow'; + +const Styled = { + Wrapper: styled.section` + display: flex; + flex-direction: column; + min-height: 360px; + padding: 40px; + background-color: ${props => props.theme.colors.darkBlue}; + border-radius: 35px; + box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.5); + `, + Header: styled.div` + display: flex; + justify-content: space-between; + margin-bottom: 20px; + `, + MinimizeIcon: styled(Minimize)` + display: inline-block; + padding: 4px; + cursor: pointer; + + &:hover { + border-radius: 24px; + background-color: ${props => props.theme.colors.purple}; + } + `, + Content: styled.div` + display: flex; + flex-direction: column; + `, +}; + +const ProcessingSwaps: React.FC = () => { + const { l } = usePrefixedTranslation('cmps.loop.processing.ProcessingSwaps'); + const { swapStore, uiStore } = useStore(); + + const { Wrapper, Header, MinimizeIcon, Content } = Styled; + return ( + +
+ {l('title')} + +
+ + {swapStore.processingSwaps.map(swap => ( + + ))} + +
+ ); +}; + +export default observer(ProcessingSwaps); diff --git a/app/src/components/loop/processing/SwapInfo.tsx b/app/src/components/loop/processing/SwapInfo.tsx new file mode 100644 index 000000000..28b2bb1fc --- /dev/null +++ b/app/src/components/loop/processing/SwapInfo.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { observer } from 'mobx-react-lite'; +import { Swap } from 'store/models'; +import { Title } from 'components/common/text'; +import { styled } from 'components/theme'; +import SwapDot from '../SwapDot'; + +const Styled = { + Wrapper: styled.div` + display: flex; + `, + Dot: styled.div` + display: flex; + justify-content: center; + align-items: center; + margin-right: 20px; + `, + Details: styled.div` + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + `, +}; + +interface Props { + swap: Swap; +} + +const SwapInfo: React.FC = ({ swap }) => { + const { Wrapper, Dot, Details } = Styled; + return ( + + + + +
+ {swap.idEllipsed} +
{swap.amount.toLocaleString()} SAT
+
+
+ ); +}; + +export default observer(SwapInfo); diff --git a/app/src/components/loop/processing/SwapProgress.tsx b/app/src/components/loop/processing/SwapProgress.tsx new file mode 100644 index 000000000..5519cd025 --- /dev/null +++ b/app/src/components/loop/processing/SwapProgress.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { observer } from 'mobx-react-lite'; +import { SwapState, SwapType } from 'types/generated/loop_pb'; +import { Swap } from 'store/models'; +import { styled } from 'components/theme'; + +const { LOOP_IN, LOOP_OUT } = SwapType; +const { + INITIATED, + PREIMAGE_REVEALED, + HTLC_PUBLISHED, + SUCCESS, + INVOICE_SETTLED, +} = SwapState; + +// the order of steps for each of the swap types. used to calculate +// the percentage of progress made based on the current swap state +const progressSteps: Record = { + [LOOP_IN]: [INITIATED, HTLC_PUBLISHED, INVOICE_SETTLED, SUCCESS], + [LOOP_OUT]: [INITIATED, PREIMAGE_REVEALED, SUCCESS], +}; + +const Styled = { + Wrapper: styled.div` + height: 100%; + display: flex; + flex-direction: column; + justify-content: center; + `, + Track: styled.div` + height: 3px; + background-color: #464d62; + border: 1px solid #5a6276; + border-radius: 2px; + `, + Fill: styled.div<{ state: number; pct: number }>` + height: 1px; + width: ${props => props.pct}%; + background-color: ${props => + props.state === SUCCESS ? props.theme.colors.green : props.theme.colors.orange}; + transition: all 1s; + `, +}; + +interface Props { + swap: Swap; +} + +const SwapProgress: React.FC = ({ swap }) => { + const steps = progressSteps[swap.type]; + const pct = Math.floor(((steps.indexOf(swap.state) + 1) / steps.length) * 100); + + const { Wrapper, Track, Fill } = Styled; + return ( + + + + + + ); +}; + +export default observer(SwapProgress); diff --git a/app/src/components/theme.tsx b/app/src/components/theme.tsx index 4ae7d404f..390c53dcb 100644 --- a/app/src/components/theme.tsx +++ b/app/src/components/theme.tsx @@ -28,6 +28,7 @@ export interface Theme { green: string; orange: string; tileBack: string; + purple: string; }; } @@ -56,6 +57,7 @@ const theme: Theme = { green: '#46E80E', orange: '#f66b1c', tileBack: 'rgba(245,245,245,0.04)', + purple: '#57038d', }, }; diff --git a/app/src/i18n/locales/en-US.json b/app/src/i18n/locales/en-US.json index 84bb1007e..161c7d849 100644 --- a/app/src/i18n/locales/en-US.json +++ b/app/src/i18n/locales/en-US.json @@ -9,6 +9,7 @@ "cmps.loop.LoopTiles.history": "Loop History", "cmps.loop.LoopTiles.inbound": "Total Inbound Liquidity", "cmps.loop.LoopTiles.outbound": "Total Outbound Liquidity", + "cmps.loop.processing.ProcessingSwaps.title": "Processing Loops", "cmps.loop.swap.StepButtons.cancel": "Cancel", "cmps.loop.swap.StepButtons.next": "Next", "cmps.loop.swap.StepButtons.confirm": "Confirm", diff --git a/app/src/store/models/swap.ts b/app/src/store/models/swap.ts index 3ab3b64f4..9cef42cfa 100644 --- a/app/src/store/models/swap.ts +++ b/app/src/store/models/swap.ts @@ -1,5 +1,7 @@ import { action, computed, observable } from 'mobx'; +import { now } from 'mobx-utils'; import * as LOOP from 'types/generated/loop_pb'; +import { ellipseInside } from 'util/strings'; export default class Swap { // native values from the Loop api @@ -7,12 +9,37 @@ export default class Swap { @observable type = 0; @observable amount = 0; @observable initiationTime = 0; + @observable lastUpdateTime = 0; @observable state = 0; constructor(loopSwap: LOOP.SwapStatus.AsObject) { this.update(loopSwap); } + /** the first and last 6 chars of the swap id */ + @computed get idEllipsed() { + return ellipseInside(this.id); + } + + /** True if the swap's state is Failed */ + @computed get isFailed() { + return this.state === LOOP.SwapState.FAILED; + } + + /** True if the swap */ + @computed get isRecent() { + const fiveMinutes = 5 * 60 * 1000; + return now() - this.updatedOn.getTime() < fiveMinutes; + } + + /** True when the state of this swap is not Success or Failed */ + @computed get isPending() { + const pending = + this.state !== LOOP.SwapState.SUCCESS && this.state !== LOOP.SwapState.FAILED; + + return pending; + } + /** * The numeric swap type as a user friendly string */ @@ -48,10 +75,16 @@ export default class Swap { return 'Unknown'; } + /** The date this swap was created as a JS Date object */ @computed get createdOn() { return new Date(this.initiationTime / 1000 / 1000); } + /** The date this swap was last updated as a JS Date object */ + @computed get updatedOn() { + return new Date(this.lastUpdateTime / 1000 / 1000); + } + /** * Updates this swap model using data provided from the Loop GRPC api * @param loopSwap the swap data @@ -62,6 +95,7 @@ export default class Swap { this.type = loopSwap.type; this.amount = loopSwap.amt; this.initiationTime = loopSwap.initiationTime; + this.lastUpdateTime = loopSwap.lastUpdateTime; this.state = loopSwap.state; } } diff --git a/app/src/store/store.ts b/app/src/store/store.ts index 97b499f79..b6d64be5e 100644 --- a/app/src/store/store.ts +++ b/app/src/store/store.ts @@ -8,6 +8,7 @@ import { NodeStore, SettingsStore, SwapStore, + UiStore, } from './stores'; /** @@ -17,14 +18,12 @@ export class Store { // // Child Stores // - @observable buildSwapStore = new BuildSwapStore(this); - @observable channelStore = new ChannelStore(this); - @observable swapStore = new SwapStore(this); - @observable nodeStore = new NodeStore(this); - @observable settingsStore = new SettingsStore(this); - // a flag to indicate when the store has completed all of its - // API requests requested during initialization - @observable initialized = false; + buildSwapStore = new BuildSwapStore(this); + channelStore = new ChannelStore(this); + swapStore = new SwapStore(this); + nodeStore = new NodeStore(this); + settingsStore = new SettingsStore(this); + uiStore = new UiStore(this); /** the backend api services to be used by child stores */ api: { @@ -35,6 +34,10 @@ export class Store { /** the logger for actions to use when modifying state */ log: Logger; + // a flag to indicate when the store has completed all of its + // API requests requested during initialization + @observable initialized = false; + constructor(lnd: LndApi, loop: LoopApi, log: Logger) { this.api = { lnd, loop }; this.log = log; diff --git a/app/src/store/stores/buildSwapStore.ts b/app/src/store/stores/buildSwapStore.ts index 327e40e6d..4bc362e74 100644 --- a/app/src/store/stores/buildSwapStore.ts +++ b/app/src/store/stores/buildSwapStore.ts @@ -276,6 +276,8 @@ class BuildSwapStore { this._store.log.info('completed loop', toJS(res)); // hide the swap UI after it is complete this.cancel(); + this._store.uiStore.toggleProcessingSwaps(); + this._store.swapStore.fetchSwaps(); } catch (error) { this.swapError = error; this._store.log.error(`failed to perform ${direction}`, error); diff --git a/app/src/store/stores/index.ts b/app/src/store/stores/index.ts index 0ed1ca1db..d68799356 100644 --- a/app/src/store/stores/index.ts +++ b/app/src/store/stores/index.ts @@ -3,3 +3,4 @@ export { default as ChannelStore } from './channelStore'; export { default as NodeStore } from './nodeStore'; export { default as SettingsStore } from './settingsStore'; export { default as SwapStore } from './swapStore'; +export { default as UiStore } from './uiStore'; diff --git a/app/src/store/stores/swapStore.ts b/app/src/store/stores/swapStore.ts index 57518bd7e..d0caaa6ef 100644 --- a/app/src/store/stores/swapStore.ts +++ b/app/src/store/stores/swapStore.ts @@ -1,34 +1,79 @@ import { action, computed, + IReactionDisposer, observable, ObservableMap, + reaction, runInAction, toJS, values, } from 'mobx'; +import { IS_PROD, IS_TEST } from 'config'; import { Store } from 'store'; import { Swap } from '../models'; export default class SwapStore { private _store: Store; + /** a reference to the polling timer, needed to stop polling */ + pollingInterval?: NodeJS.Timeout; + /** the mobx disposer func to cancel automatic polling */ + stopAutoPolling: IReactionDisposer; /** the collection of swaps */ @observable swaps: ObservableMap = observable.map(); + /** the ids of failed swaps that have been dismissed */ + @observable dismissedSwapIds: string[] = []; + constructor(store: Store) { this._store = store; + + // automatically start & stop polling for swaps if there are any pending + this.stopAutoPolling = reaction( + () => this.pendingSwaps.length, + (length: number) => { + if (length > 0) { + this.startPolling(); + } else { + this.stopPolling(); + // also update our channels and balances when the loop is complete + this._store.channelStore.fetchChannels(); + this._store.nodeStore.fetchBalances(); + } + }, + ); } - /** - * an array of swaps sorted by created date descending - */ + /** swaps sorted by created date descending */ @computed get sortedSwaps() { return values(this.swaps) .slice() .sort((a, b) => b.initiationTime - a.initiationTime); } + /** the last two swaps */ + @computed get lastTwoSwaps() { + return this.sortedSwaps.slice(0, 2); + } + + /** swaps that are currently processing or recently completed */ + @computed get processingSwaps() { + return this.sortedSwaps.filter( + s => s.isPending || (s.isRecent && !this.dismissedSwapIds.includes(s.id)), + ); + } + + /** swaps that are currently pending */ + @computed get pendingSwaps() { + return this.sortedSwaps.filter(s => s.isPending); + } + + @action.bound + dismissSwap(swapId: string) { + this.dismissedSwapIds.push(swapId); + } + /** * queries the Loop api to fetch the list of swaps and stores them * in the state @@ -58,4 +103,24 @@ export default class SwapStore { this._store.log.info('updated swapStore.swaps', toJS(this.swaps)); }); } + + @action.bound + startPolling() { + if (this.pollingInterval) this.stopPolling(); + this._store.log.info('start polling for swap updates'); + const ms = IS_PROD ? 60 * 1000 : IS_TEST ? 100 : 1000; + this.pollingInterval = setInterval(this.fetchSwaps, ms); + } + + @action.bound + stopPolling() { + this._store.log.info('stop polling for swap updates'); + if (this.pollingInterval) { + clearInterval(this.pollingInterval); + this.pollingInterval = undefined; + this._store.log.info('polling stopped'); + } else { + this._store.log.info('polling was already stopped'); + } + } } diff --git a/app/src/store/stores/uiStore.ts b/app/src/store/stores/uiStore.ts new file mode 100644 index 000000000..5699f36c9 --- /dev/null +++ b/app/src/store/stores/uiStore.ts @@ -0,0 +1,17 @@ +import { action, observable } from 'mobx'; +import { Store } from 'store'; + +export default class UiStore { + private _store: Store; + + @observable processingSwapsVisible = false; + + constructor(store: Store) { + this._store = store; + } + + @action.bound + toggleProcessingSwaps() { + this.processingSwapsVisible = !this.processingSwapsVisible; + } +} diff --git a/app/src/util/tests/sampleData.ts b/app/src/util/tests/sampleData.ts index 55d1a2f00..5ac0f1f89 100644 --- a/app/src/util/tests/sampleData.ts +++ b/app/src/util/tests/sampleData.ts @@ -115,7 +115,7 @@ export const loopListSwaps: LOOP.ListSwapsResponse.AsObject = { id: `f4eb118383c2b09d8c7289ce21c25900cfb4545d46c47ed23a31ad2aa57ce83${i}`, idBytes: '9OsRg4PCsJ2MconOIcJZAM+0VF1GxH7SOjGtKqV86DU=', type: (i % 3) as LOOP.SwapStatus.AsObject['type'], - state: (i % 7) as LOOP.SwapStatus.AsObject['state'], + state: i % 2 ? LOOP.SwapState.SUCCESS : LOOP.SwapState.FAILED, initiationTime: 1586390353623905000 + i * 100000000000000, lastUpdateTime: 1586398369729857000, htlcAddress: 'bcrt1qzu4077erkr78k52yuf2rwkk6ayr6m3wtazdfz2qqmd7taa5vvy9s5d75gd', diff --git a/app/yarn.lock b/app/yarn.lock index 9ed2e321e..d3e2bf110 100644 --- a/app/yarn.lock +++ b/app/yarn.lock @@ -9715,6 +9715,11 @@ mobx-react-lite@2.0.6: resolved "https://registry.yarnpkg.com/mobx-react-lite/-/mobx-react-lite-2.0.6.tgz#e1307a2b271c6a6016c8ad815a25014b7f95997d" integrity sha512-h/5GqxNIoSqnjt7SHxVtU7i1Kg0Xoxj853amzmzLgLRZKK9WwPc9tMuawW79ftmFSQhML0Zwt8kEuG1DIjQNBA== +mobx-utils@5.5.7: + version "5.5.7" + resolved "https://registry.yarnpkg.com/mobx-utils/-/mobx-utils-5.5.7.tgz#0ef58f2d5e05ca0e59ba2322f84f9c763de6ce14" + integrity sha512-jEtTe45gCXYtv3WTAyPiQUhQQRRDnx68WxgNn886i1B11ormsAey+gIJJkfh/cqSssBEWXcXwYTvODpGPN8Tgw== + mobx@5.15.4: version "5.15.4" resolved "https://registry.yarnpkg.com/mobx/-/mobx-5.15.4.tgz#9da1a84e97ba624622f4e55a0bf3300fb931c2ab"