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 (
+
+
+
+ {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"