diff --git a/app/src/__mocks__/debug.ts b/app/src/__mocks__/debug.ts new file mode 100644 index 000000000..45d59bbde --- /dev/null +++ b/app/src/__mocks__/debug.ts @@ -0,0 +1,9 @@ +// export this mock function so the executions can be checked in tests +export const debugMock = jest.fn(); + +const debug = jest.fn(() => { + // return the exported mock above each time `debug()` is called + return debugMock; +}); + +export default debug; diff --git a/app/src/__stories__/ChannelBalance.stories.tsx b/app/src/__stories__/ChannelBalance.stories.tsx index 751692a45..0965fa98b 100644 --- a/app/src/__stories__/ChannelBalance.stories.tsx +++ b/app/src/__stories__/ChannelBalance.stories.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { lndListChannelsOne } from 'util/tests/sampleData'; +import { Store, useStore } from 'store'; import { Channel } from 'store/models'; import ChannelBalance from 'components/loop/ChannelBalance'; @@ -9,27 +10,31 @@ export default { parameters: { centered: true }, }; -const getChannel = (ratio: number) => { - const channel = new Channel(lndListChannelsOne.channelsList[0]); +const getChannel = (store: Store, ratio: number) => { + const channel = new Channel(store, lndListChannelsOne.channelsList[0]); channel.localBalance = channel.capacity * ratio; channel.remoteBalance = channel.capacity * (1 - ratio); return channel; }; export const Good = () => { - return ; + const store = useStore(); + return ; }; export const Warn = () => { - return ; + const store = useStore(); + return ; }; export const Bad = () => { - return ; + const store = useStore(); + return ; }; export const Inactive = () => { - const channel = getChannel(0.45); + const store = useStore(); + const channel = getChannel(store, 0.45); channel.active = false; return ; }; diff --git a/app/src/__stories__/ChannelList.stories.tsx b/app/src/__stories__/ChannelList.stories.tsx index a90a17a5c..2b85133c5 100644 --- a/app/src/__stories__/ChannelList.stories.tsx +++ b/app/src/__stories__/ChannelList.stories.tsx @@ -1,5 +1,6 @@ import React from 'react'; -import { observable } from 'mobx'; +import { observable, ObservableMap, values } from 'mobx'; +import { BalanceMode } from 'util/constants'; import { useStore } from 'store'; import { Channel } from 'store/models'; import ChannelList from 'components/loop/ChannelList'; @@ -10,22 +11,45 @@ export default { parameters: { contained: true }, }; +const channelSubset = (channels: ObservableMap) => { + const few = values(channels) + .slice(0, 20) + .reduce((result, c) => { + result[c.chanId] = c; + return result; + }, {} as Record); + return observable.map(few); +}; + export const NoChannels = () => { const store = useStore(); store.channelStore.channels = observable.map(); return ; }; -export const FewChannels = () => { - const store = useStore(); - const channels = store.channelStore.sortedChannels.slice(0, 10).reduce((result, c) => { - result[c.chanId] = c; - return result; - }, {} as Record); - store.channelStore.channels = observable.map(channels); +export const ReceiveMode = () => { + const { channelStore, settingsStore } = useStore(); + settingsStore.balanceMode = BalanceMode.receive; + channelStore.channels = channelSubset(channelStore.channels); + return ; +}; + +export const SendMode = () => { + const { channelStore, settingsStore } = useStore(); + settingsStore.balanceMode = BalanceMode.send; + channelStore.channels = channelSubset(channelStore.channels); + return ; +}; + +export const RoutingMode = () => { + const { channelStore, settingsStore } = useStore(); + settingsStore.balanceMode = BalanceMode.routing; + channelStore.channels = channelSubset(channelStore.channels); return ; }; export const ManyChannels = () => { + const { settingsStore } = useStore(); + settingsStore.balanceMode = BalanceMode.routing; return ; }; diff --git a/app/src/__stories__/ChannelRow.stories.tsx b/app/src/__stories__/ChannelRow.stories.tsx index 2685c755b..1bada109c 100644 --- a/app/src/__stories__/ChannelRow.stories.tsx +++ b/app/src/__stories__/ChannelRow.stories.tsx @@ -32,44 +32,48 @@ const renderStory = ( }; export const Good = () => { - const channel = new Channel(lndListChannels.channelsList[0]); - return renderStory(channel, { ratio: 0.59 }); + const store = useStore(); + const channel = new Channel(store, lndListChannels.channelsList[0]); + return renderStory(channel, { ratio: 0.3 }); }; export const Warn = () => { - const channel = new Channel(lndListChannels.channelsList[1]); - return renderStory(channel, { ratio: 0.28 }); + const store = useStore(); + const channel = new Channel(store, lndListChannels.channelsList[1]); + return renderStory(channel, { ratio: 0.5 }); }; export const Bad = () => { - const channel = new Channel(lndListChannels.channelsList[2]); + const store = useStore(); + const channel = new Channel(store, lndListChannels.channelsList[2]); return renderStory(channel, { ratio: 0.91 }); }; export const Inactive = () => { - const channel = new Channel(lndListChannels.channelsList[3]); + const store = useStore(); + const channel = new Channel(store, lndListChannels.channelsList[3]); channel.active = false; return renderStory(channel); }; export const Editable = () => { - const channel = new Channel(lndListChannels.channelsList[4]); const store = useStore(); + const channel = new Channel(store, lndListChannels.channelsList[4]); store.buildSwapStore.startSwap(); return renderStory(channel); }; export const Selected = () => { - const channel = new Channel(lndListChannels.channelsList[5]); const store = useStore(); + const channel = new Channel(store, lndListChannels.channelsList[5]); store.buildSwapStore.startSwap(); store.buildSwapStore.toggleSelectedChannel(channel.chanId); return renderStory(channel); }; export const Disabled = () => { - const channel = new Channel(lndListChannels.channelsList[6]); const store = useStore(); + const channel = new Channel(store, lndListChannels.channelsList[6]); store.buildSwapStore.startSwap(); store.buildSwapStore.toggleSelectedChannel(channel.chanId); store.buildSwapStore.setDirection(SwapDirection.OUT); @@ -77,8 +81,8 @@ export const Disabled = () => { }; export const Dimmed = () => { - const channel = new Channel(lndListChannels.channelsList[6]); const store = useStore(); + const channel = new Channel(store, lndListChannels.channelsList[6]); store.buildSwapStore.startSwap(); store.buildSwapStore.setDirection(SwapDirection.OUT); return renderStory(channel); diff --git a/app/src/__stories__/SettingsPage.stories.tsx b/app/src/__stories__/SettingsPage.stories.tsx new file mode 100644 index 000000000..0239a1a3f --- /dev/null +++ b/app/src/__stories__/SettingsPage.stories.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { Layout } from 'components/layout'; +import SettingsPage from 'components/settings/SettingsPage'; + +export default { + title: 'Pages/Settings', + component: SettingsPage, + parameters: { contained: true }, +}; + +export const Default = () => { + return ; +}; + +export const InsideLayout = () => { + return ( + + + + ); +}; diff --git a/app/src/__stories__/StoryWrapper.tsx b/app/src/__stories__/StoryWrapper.tsx index 436734f3d..cdeca1782 100644 --- a/app/src/__stories__/StoryWrapper.tsx +++ b/app/src/__stories__/StoryWrapper.tsx @@ -1,7 +1,9 @@ import React, { CSSProperties, useMemo } from 'react'; import { observer } from 'mobx-react-lite'; +import { BalanceMode, Unit } from 'util/constants'; import { sampleApiResponses } from 'util/tests/sampleData'; import { createStore, StoreProvider } from 'store'; +import { PersistentSettings } from 'store/stores/settingsStore'; import { Background } from 'components/common/base'; import { ThemeProvider } from 'components/theme'; @@ -16,8 +18,19 @@ const grpc = { }, }; -// Create a store that pulls data from the mock GRPC for stories -const createStoryStore = () => createStore(grpc); +// fake the AppStorage dependency so that settings aren't shared across stories +class StoryAppStorage { + set = () => undefined; + get = (): PersistentSettings => ({ + sidebarVisible: true, + unit: Unit.sats, + balanceMode: BalanceMode.receive, + }); +} + +// Create a store that pulls data from the mock GRPC and doesn't use +// the real localStorage to save settings +const createStoryStore = () => createStore(grpc, new StoryAppStorage()); /** * This component is used to wrap every story. It provides the app theme diff --git a/app/src/__stories__/Tile.stories.tsx b/app/src/__stories__/Tile.stories.tsx index 90972515d..f47676e90 100644 --- a/app/src/__stories__/Tile.stories.tsx +++ b/app/src/__stories__/Tile.stories.tsx @@ -23,5 +23,5 @@ export const WithArrowIcon = () => ( ); export const InboundLiquidity = () => ( - + ); diff --git a/app/src/__tests__/Pages.spec.tsx b/app/src/__tests__/Pages.spec.tsx index 999cb8df0..37b586c2b 100644 --- a/app/src/__tests__/Pages.spec.tsx +++ b/app/src/__tests__/Pages.spec.tsx @@ -19,4 +19,11 @@ describe('Pages Component', () => { expect(getByText('Loop History')).toBeInTheDocument(); expect(store.uiStore.page).toBe('history'); }); + + it('should display the Settings page', () => { + const { getByText, store } = render(); + store.uiStore.goToSettings(); + expect(getByText('Settings')).toBeInTheDocument(); + expect(store.uiStore.page).toBe('settings'); + }); }); diff --git a/app/src/__tests__/components/NodeStatus.spec.tsx b/app/src/__tests__/components/NodeStatus.spec.tsx index 4716e2f74..52463938c 100644 --- a/app/src/__tests__/components/NodeStatus.spec.tsx +++ b/app/src/__tests__/components/NodeStatus.spec.tsx @@ -15,7 +15,7 @@ describe('NodeStatus component', () => { it('should display the lightning balance', () => { const { getByText, store } = render(); store.nodeStore.wallet = { channelBalance: 123, walletBalance: 0 }; - expect(getByText('123 SAT')).toBeInTheDocument(); + expect(getByText('123 sats')).toBeInTheDocument(); }); it('should display the bitcoin balance', () => { diff --git a/app/src/__tests__/components/common/Range.spec.tsx b/app/src/__tests__/components/common/Range.spec.tsx index 1a37fd5ce..f8451642a 100644 --- a/app/src/__tests__/components/common/Range.spec.tsx +++ b/app/src/__tests__/components/common/Range.spec.tsx @@ -29,7 +29,7 @@ describe('Range component', () => { it('should display slider and value by default', () => { const { getByText, getByLabelText } = render(); expect(getByLabelText('range-slider')).toBeInTheDocument(); - expect(getByText('50 SAT')).toBeInTheDocument(); + expect(getByText('50 sats')).toBeInTheDocument(); }); it('should accept custom props', () => { @@ -41,9 +41,9 @@ describe('Range component', () => { showRadios: true, }); expect(getByLabelText('range-slider')).toBeInTheDocument(); - expect(getByText('5,000 SAT')).toBeInTheDocument(); - expect(getByText('2,500 SAT')).toBeInTheDocument(); - expect(getByText('7,500 SAT')).toBeInTheDocument(); + expect(getByText('5,000 sats')).toBeInTheDocument(); + expect(getByText('2,500 sats')).toBeInTheDocument(); + expect(getByText('7,500 sats')).toBeInTheDocument(); expect(getByText('Min')).toHaveAttribute('aria-checked', 'false'); expect(getByText('Max')).toHaveAttribute('aria-checked', 'false'); }); diff --git a/app/src/__tests__/components/layout/Layout.spec.tsx b/app/src/__tests__/components/layout/Layout.spec.tsx index a0020577c..3df99bc20 100644 --- a/app/src/__tests__/components/layout/Layout.spec.tsx +++ b/app/src/__tests__/components/layout/Layout.spec.tsx @@ -40,4 +40,15 @@ describe('Layout component', () => { expect(store.uiStore.page).toBe('loop'); expect(getByText('Lightning Loop').parentElement).toHaveClass('active'); }); + + it('should navigate back to the Settings page', () => { + const { getByText, store } = render(); + expect(store.uiStore.page).toBe('loop'); + fireEvent.click(getByText('Settings')); + expect(store.uiStore.page).toBe('settings'); + expect(getByText('Settings').parentElement).toHaveClass('active'); + fireEvent.click(getByText('Lightning Loop')); + expect(store.uiStore.page).toBe('loop'); + expect(getByText('Lightning Loop').parentElement).toHaveClass('active'); + }); }); diff --git a/app/src/__tests__/components/loop/ChannelBalance.spec.tsx b/app/src/__tests__/components/loop/ChannelBalance.spec.tsx index 0f84d7f2a..24cc2d95f 100644 --- a/app/src/__tests__/components/loop/ChannelBalance.spec.tsx +++ b/app/src/__tests__/components/loop/ChannelBalance.spec.tsx @@ -1,22 +1,24 @@ import React from 'react'; import { renderWithProviders } from 'util/tests'; import { lndListChannelsOne } from 'util/tests/sampleData'; +import { createStore } from 'store'; import { Channel } from 'store/models'; import ChannelBalance from 'components/loop/ChannelBalance'; describe('ChannelBalance component', () => { - const channel: Channel = new Channel(lndListChannelsOne.channelsList[0]); + let channel: Channel; const bgColor = (el: any) => window.getComputedStyle(el).backgroundColor; const width = (el: any) => window.getComputedStyle(el).width; - const shiftBalance = (channel: Channel, ratio: number) => { + const render = (ratio: number, active = true) => { + const store = createStore(); + channel = new Channel(store, lndListChannelsOne.channelsList[0]); channel.localBalance = channel.capacity * ratio; channel.remoteBalance = channel.capacity * (1 - ratio); - }; + channel.active = active; - const render = () => { - const result = renderWithProviders(); + const result = renderWithProviders(, store); const el = result.container.children[0]; return { ...result, @@ -27,27 +29,23 @@ describe('ChannelBalance component', () => { }; it('should display a good balance', () => { - shiftBalance(channel, 0.55); - channel.localBalance = channel.capacity * 0.55; - const { el, remote, local } = render(); + const { el, remote, local } = render(0.25); expect(el.children.length).toBe(3); - expect(width(local)).toBe('55%'); + expect(width(local)).toBe('25%'); expect(bgColor(local)).toBe('rgb(70, 232, 14)'); expect(bgColor(remote)).toBe('rgb(70, 232, 14)'); }); it('should display a warning balance', () => { - shiftBalance(channel, 0.72); - const { el, remote, local } = render(); + const { el, remote, local } = render(0.52); expect(el.children.length).toBe(3); - expect(width(local)).toBe('72%'); + expect(width(local)).toBe('52%'); expect(bgColor(local)).toBe('rgb(246, 107, 28)'); expect(bgColor(remote)).toBe('rgb(246, 107, 28)'); }); it('should display a bad balance', () => { - shiftBalance(channel, 0.93); - const { el, remote, local } = render(); + const { el, remote, local } = render(0.93); expect(el.children.length).toBe(3); expect(width(local)).toBe('93%'); expect(bgColor(local)).toBe('rgb(245, 64, 110)'); @@ -55,9 +53,7 @@ describe('ChannelBalance component', () => { }); it('should display an inactive channel', () => { - channel.active = false; - shiftBalance(channel, 0.55); - const { el, remote, local } = render(); + const { el, remote, local } = render(0.55, false); expect(el.children.length).toBe(3); expect(width(local)).toBe('55%'); expect(bgColor(local)).toBe('rgb(132, 138, 153)'); diff --git a/app/src/__tests__/components/loop/ChannelRow.spec.tsx b/app/src/__tests__/components/loop/ChannelRow.spec.tsx index 9960e20fa..ebbc0b29f 100644 --- a/app/src/__tests__/components/loop/ChannelRow.spec.tsx +++ b/app/src/__tests__/components/loop/ChannelRow.spec.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { SwapDirection } from 'types/state'; import { fireEvent } from '@testing-library/react'; +import { formatSats } from 'util/formatters'; import { ellipseInside } from 'util/strings'; import { renderWithProviders } from 'util/tests'; import { createStore, Store } from 'store'; @@ -13,14 +14,7 @@ describe('ChannelRow component', () => { beforeEach(async () => { store = createStore(); - }); - - const render = () => { - return renderWithProviders(, store); - }; - - beforeEach(() => { - channel = new Channel({ + channel = new Channel(store, { chanId: '150633093070848', remotePubkey: '02ac59099da6d4bd818e6a81098f5d54580b7c3aa8255c707fa0f95ca89b02cb8c', capacity: 15000000, @@ -32,14 +26,22 @@ describe('ChannelRow component', () => { } as any); }); + const render = () => { + return renderWithProviders(, store); + }; + it('should display the remote balance', () => { const { getByText } = render(); - expect(getByText(channel.remoteBalance.toLocaleString())).toBeInTheDocument(); + expect( + getByText(formatSats(channel.remoteBalance, { withSuffix: false })), + ).toBeInTheDocument(); }); it('should display the local balance', () => { const { getByText } = render(); - expect(getByText(channel.localBalance.toLocaleString())).toBeInTheDocument(); + expect( + getByText(formatSats(channel.localBalance, { withSuffix: false })), + ).toBeInTheDocument(); }); it('should display the uptime', () => { @@ -54,7 +56,9 @@ describe('ChannelRow component', () => { it('should display the capacity', () => { const { getByText } = render(); - expect(getByText(channel.capacity.toLocaleString())).toBeInTheDocument(); + expect( + getByText(formatSats(channel.capacity, { withSuffix: false })), + ).toBeInTheDocument(); }); it('should display correct dot icon for an inactive channel', () => { @@ -65,8 +69,8 @@ describe('ChannelRow component', () => { }); it.each<[number, string]>([ - [55, 'success'], - [75, 'warn'], + [20, 'success'], + [50, 'warn'], [90, 'error'], ])('should display correct dot icon for a "%s" balance', (localPct, label) => { channel.localBalance = channel.capacity * (localPct / 100); diff --git a/app/src/__tests__/components/loop/LoopPage.spec.tsx b/app/src/__tests__/components/loop/LoopPage.spec.tsx index 3d894bbf5..953184051 100644 --- a/app/src/__tests__/components/loop/LoopPage.spec.tsx +++ b/app/src/__tests__/components/loop/LoopPage.spec.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { SwapStatus } from 'types/generated/loop_pb'; import { grpc } from '@improbable-eng/grpc-web'; import { fireEvent, waitFor } from '@testing-library/react'; +import { formatSats } from 'util/formatters'; import { renderWithProviders } from 'util/tests'; import { loopListSwaps } from 'util/tests/sampleData'; import { createStore, Store } from 'store'; @@ -37,12 +38,8 @@ describe('LoopPage component', () => { const { getByText, store } = render(); // wait for the channels to be fetched async before checking the UI labels await waitFor(() => expect(store.channelStore.totalInbound).toBeGreaterThan(0)); - expect( - getByText(`${store.channelStore.totalInbound.toLocaleString()} SAT`), - ).toBeInTheDocument(); - expect( - getByText(`${store.channelStore.totalOutbound.toLocaleString()} SAT`), - ).toBeInTheDocument(); + expect(getByText(formatSats(store.channelStore.totalInbound))).toBeInTheDocument(); + expect(getByText(formatSats(store.channelStore.totalOutbound))).toBeInTheDocument(); }); it('should display the loop history records', async () => { @@ -55,9 +52,9 @@ describe('LoopPage component', () => { ); expect(await findByText(formatDate(swap1))).toBeInTheDocument(); - expect(await findByText('530,000 SAT')).toBeInTheDocument(); + expect(await findByText('530,000 sats')).toBeInTheDocument(); expect(await findByText(formatDate(swap2))).toBeInTheDocument(); - expect(await findByText('525,000 SAT')).toBeInTheDocument(); + expect(await findByText('525,000 sats')).toBeInTheDocument(); }); describe('Swap Process', () => { diff --git a/app/src/__tests__/components/loop/SwapWizard.spec.tsx b/app/src/__tests__/components/loop/SwapWizard.spec.tsx index fe99dbdce..6a0ecc34e 100644 --- a/app/src/__tests__/components/loop/SwapWizard.spec.tsx +++ b/app/src/__tests__/components/loop/SwapWizard.spec.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { SwapDirection } from 'types/state'; import { fireEvent } from '@testing-library/react'; +import { formatSats } from 'util/formatters'; import { renderWithProviders } from 'util/tests'; import { createStore, Store } from 'store'; import SwapWizard from 'components/loop/swap/SwapWizard'; @@ -48,8 +49,8 @@ describe('SwapWizard component', () => { it('should display the correct min an max values', () => { const { getByText } = render(); const { min, max } = store.buildSwapStore.termsForDirection; - expect(getByText(`${min.toLocaleString()} SAT`)).toBeInTheDocument(); - expect(getByText(`${max.toLocaleString()} SAT`)).toBeInTheDocument(); + expect(getByText(formatSats(min))).toBeInTheDocument(); + expect(getByText(formatSats(max))).toBeInTheDocument(); }); it('should display the correct number of channels', () => { @@ -62,10 +63,10 @@ describe('SwapWizard component', () => { const { getByText, getByLabelText } = render(); const build = store.buildSwapStore; expect(build.amount).toEqual(625000); - expect(getByText(`625,000 SAT`)).toBeInTheDocument(); + expect(getByText(`625,000 sats`)).toBeInTheDocument(); fireEvent.change(getByLabelText('range-slider'), { target: { value: '575000' } }); expect(build.amount).toEqual(575000); - expect(getByText(`575,000 SAT`)).toBeInTheDocument(); + expect(getByText(`575,000 sats`)).toBeInTheDocument(); }); }); @@ -88,11 +89,9 @@ describe('SwapWizard component', () => { it('should display the correct values', () => { const { getByText } = render(); const build = store.buildSwapStore; - const toLabel = (x: number) => `${x.toLocaleString()} SAT`; - expect(getByText(toLabel(build.amount))).toBeInTheDocument(); - const pct = ((100 * build.fee) / build.amount).toFixed(2); - expect(getByText(toLabel(build.fee) + ` (${pct}%)`)).toBeInTheDocument(); - expect(getByText(toLabel(build.amount + build.fee))).toBeInTheDocument(); + expect(getByText(formatSats(build.amount))).toBeInTheDocument(); + expect(getByText(build.feesLabel)).toBeInTheDocument(); + expect(getByText(formatSats(build.invoiceTotal))).toBeInTheDocument(); }); }); diff --git a/app/src/__tests__/components/settings/BalanceSettings.spec.tsx b/app/src/__tests__/components/settings/BalanceSettings.spec.tsx new file mode 100644 index 000000000..91981d9b0 --- /dev/null +++ b/app/src/__tests__/components/settings/BalanceSettings.spec.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { fireEvent } from '@testing-library/react'; +import { BalanceMode } from 'util/constants'; +import { renderWithProviders } from 'util/tests'; +import { createStore, Store } from 'store'; +import BalanceSettings from 'components/settings/BalanceSettings'; + +describe('BalanceSettings', () => { + let store: Store; + + beforeEach(() => { + store = createStore(); + }); + + const render = () => { + return renderWithProviders(, store); + }; + + it('should display the title', () => { + const { getByText } = render(); + expect(getByText('Channel Balance Mode')).toBeInTheDocument(); + }); + + it('should display the balance mode list', () => { + const { getByText } = render(); + expect(getByText('Receiving')).toBeInTheDocument(); + expect(getByText('Sending')).toBeInTheDocument(); + expect(getByText('Routing')).toBeInTheDocument(); + }); + + it('should display the back link', () => { + const { getByText } = render(); + expect(getByText('Settings')).toBeInTheDocument(); + }); + + it('should display the back icon', () => { + const { getByText } = render(); + expect(getByText('arrow-left.svg')).toBeInTheDocument(); + }); + + it('should navigate back to the Settings screen', () => { + const { getByText } = render(); + fireEvent.click(getByText('Settings')); + expect(store.uiStore.selectedSetting).toEqual('general'); + }); + + it('should update the Balance Mode to receive', () => { + const { getByText, getAllByRole } = render(); + fireEvent.click(getByText('Receiving')); + expect(getAllByRole('switch')[0]).toHaveAttribute('aria-checked', 'true'); + expect(store.settingsStore.balanceMode).toEqual(BalanceMode.receive); + }); + + it('should update the Balance Mode to send', () => { + const { getByText, getAllByRole } = render(); + fireEvent.click(getByText('Sending')); + expect(getAllByRole('switch')[1]).toHaveAttribute('aria-checked', 'true'); + expect(store.settingsStore.balanceMode).toEqual(BalanceMode.send); + }); + + it('should update the Balance Mode to routing', () => { + const { getByText, getAllByRole } = render(); + fireEvent.click(getByText('Routing')); + expect(getAllByRole('switch')[2]).toHaveAttribute('aria-checked', 'true'); + expect(store.settingsStore.balanceMode).toEqual(BalanceMode.routing); + }); +}); diff --git a/app/src/__tests__/components/settings/SettingsPage.spec.tsx b/app/src/__tests__/components/settings/SettingsPage.spec.tsx new file mode 100644 index 000000000..0c5d83e15 --- /dev/null +++ b/app/src/__tests__/components/settings/SettingsPage.spec.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { fireEvent } from '@testing-library/react'; +import { renderWithProviders } from 'util/tests'; +import { createStore, Store } from 'store'; +import SettingsPage from 'components/settings/SettingsPage'; + +describe('SettingsPage', () => { + let store: Store; + + beforeEach(() => { + store = createStore(); + }); + + const render = () => { + return renderWithProviders(, store); + }; + + it('should display the title', () => { + const { getByText } = render(); + expect(getByText('Settings')).toBeInTheDocument(); + }); + + it('should display the general section', () => { + const { getByText } = render(); + expect(getByText('General')).toBeInTheDocument(); + }); + + it('should display the settings list', () => { + const { getByText } = render(); + expect(getByText('Bitcoin Unit')).toBeInTheDocument(); + expect(getByText('Satoshis (0.00000001 BTC)')).toBeInTheDocument(); + expect(getByText('Channel Balance Mode')).toBeInTheDocument(); + expect(getByText('Optimize for Receiving')).toBeInTheDocument(); + }); + + it('should navigate to the Bitcoin Unit screen', () => { + const { getByText } = render(); + fireEvent.click(getByText('Bitcoin Unit')); + expect(store.uiStore.selectedSetting).toEqual('unit'); + }); + + it('should navigate to the Channel Balance Mode screen', () => { + const { getByText } = render(); + fireEvent.click(getByText('Channel Balance Mode')); + expect(store.uiStore.selectedSetting).toEqual('balance'); + }); +}); diff --git a/app/src/__tests__/components/settings/UnitSettings.spec.tsx b/app/src/__tests__/components/settings/UnitSettings.spec.tsx new file mode 100644 index 000000000..9349d6bd7 --- /dev/null +++ b/app/src/__tests__/components/settings/UnitSettings.spec.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { fireEvent } from '@testing-library/react'; +import { Unit } from 'util/constants'; +import { formatUnit } from 'util/formatters'; +import { renderWithProviders } from 'util/tests'; +import { createStore, Store } from 'store'; +import UnitSettings from 'components/settings/UnitSettings'; + +describe('UnitSettings', () => { + let store: Store; + + beforeEach(() => { + store = createStore(); + }); + + const render = () => { + return renderWithProviders(, store); + }; + + it('should display the title', () => { + const { getByText } = render(); + expect(getByText('Bitcoin Unit')).toBeInTheDocument(); + }); + + it('should display the unit list', () => { + const { getByText } = render(); + expect(getByText(formatUnit(Unit.sats))).toBeInTheDocument(); + expect(getByText(formatUnit(Unit.bits))).toBeInTheDocument(); + expect(getByText(formatUnit(Unit.btc))).toBeInTheDocument(); + }); + + it('should display the back link', () => { + const { getByText } = render(); + expect(getByText('Settings')).toBeInTheDocument(); + }); + + it('should display the back icon', () => { + const { getByText } = render(); + expect(getByText('arrow-left.svg')).toBeInTheDocument(); + }); + + it('should navigate back to the Settings screen', () => { + const { getByText } = render(); + fireEvent.click(getByText('Settings')); + expect(store.uiStore.selectedSetting).toEqual('general'); + }); + + it('should update the Bitcoin Unit to sats', () => { + const { getByText, getAllByRole } = render(); + fireEvent.click(getByText(formatUnit(Unit.sats))); + expect(getAllByRole('switch')[0]).toHaveAttribute('aria-checked', 'true'); + expect(store.settingsStore.unit).toEqual(Unit.sats); + }); + + it('should update the Bitcoin Unit to bits', () => { + const { getByText, getAllByRole } = render(); + fireEvent.click(getByText(formatUnit(Unit.bits))); + expect(getAllByRole('switch')[1]).toHaveAttribute('aria-checked', 'true'); + expect(store.settingsStore.unit).toEqual(Unit.bits); + }); + + it('should update the Bitcoin Unit to BTC', () => { + const { getByText, getAllByRole } = render(); + fireEvent.click(getByText(formatUnit(Unit.btc))); + expect(getAllByRole('switch')[2]).toHaveAttribute('aria-checked', 'true'); + expect(store.settingsStore.unit).toEqual(Unit.btc); + }); +}); diff --git a/app/src/__tests__/store/buildSwapStore.spec.ts b/app/src/__tests__/store/buildSwapStore.spec.ts index 565c1d0fd..b14d88fa8 100644 --- a/app/src/__tests__/store/buildSwapStore.spec.ts +++ b/app/src/__tests__/store/buildSwapStore.spec.ts @@ -8,7 +8,7 @@ import { SWAP_ABORT_DELAY } from 'store/stores/buildSwapStore'; const grpcMock = grpc as jest.Mocked; -describe('SwapStore', () => { +describe('BuildSwapStore', () => { let store: BuildSwapStore; beforeEach(async () => { diff --git a/app/src/__tests__/store/channelStore.spec.ts b/app/src/__tests__/store/channelStore.spec.ts index bf935ae97..665dafb67 100644 --- a/app/src/__tests__/store/channelStore.spec.ts +++ b/app/src/__tests__/store/channelStore.spec.ts @@ -1,12 +1,28 @@ +import { observable, ObservableMap, values } from 'mobx'; +import { BalanceMode } from 'util/constants'; import { lndListChannels } from 'util/tests/sampleData'; -import { createStore } from 'store'; +import { createStore, SettingsStore } from 'store'; +import Channel from 'store/models/channel'; import ChannelStore from 'store/stores/channelStore'; -describe('Store', () => { +describe('ChannelStore', () => { + let settingsStore: SettingsStore; let store: ChannelStore; + const channelSubset = (channels: ObservableMap) => { + const few = values(channels) + .slice(0, 20) + .reduce((result, c) => { + result[c.chanId] = c; + return result; + }, {} as Record); + return observable.map(few); + }; + beforeEach(() => { - store = createStore().channelStore; + const rootStore = createStore(); + store = rootStore.channelStore; + settingsStore = rootStore.settingsStore; }); it('should fetch list of channels', async () => { @@ -29,6 +45,43 @@ describe('Store', () => { expect(updatedChan.uptime).toBe(prevUptime); }); + it('should sort channels correctly when using receive mode', async () => { + await store.fetchChannels(); + settingsStore.setBalanceMode(BalanceMode.receive); + store.channels = channelSubset(store.channels); + store.sortedChannels.forEach((c, i) => { + if (i === 0) return; + expect(c.localPercent).toBeLessThanOrEqual( + store.sortedChannels[i - 1].localPercent, + ); + }); + }); + + it('should sort channels correctly when using send mode', async () => { + await store.fetchChannels(); + settingsStore.setBalanceMode(BalanceMode.send); + store.channels = channelSubset(store.channels); + store.sortedChannels.forEach((c, i) => { + if (i === 0) return; + expect(c.localPercent).toBeGreaterThanOrEqual( + store.sortedChannels[i - 1].localPercent, + ); + }); + }); + + it('should sort channels correctly when using routing mode', async () => { + await store.fetchChannels(); + settingsStore.setBalanceMode(BalanceMode.routing); + store.channels = channelSubset(store.channels); + store.sortedChannels.forEach((c, i) => { + if (i === 0) return; + const currPct = Math.max(c.localPercent, 99 - c.localPercent); + const prev = store.sortedChannels[i - 1]; + const prevPct = Math.max(prev.localPercent, 99 - prev.localPercent); + expect(currPct).toBeLessThanOrEqual(prevPct); + }); + }); + it('should compute inbound liquidity', async () => { await store.fetchChannels(); const inbound = lndListChannels.channelsList.reduce( diff --git a/app/src/__tests__/store/nodeStore.spec.ts b/app/src/__tests__/store/nodeStore.spec.ts index cbbb439b7..b4b4dedc6 100644 --- a/app/src/__tests__/store/nodeStore.spec.ts +++ b/app/src/__tests__/store/nodeStore.spec.ts @@ -1,7 +1,7 @@ import { lndChannelBalance, lndWalletBalance } from 'util/tests/sampleData'; import { createStore, NodeStore } from 'store'; -describe('NodeAction', () => { +describe('NodeStore', () => { let store: NodeStore; beforeEach(() => { diff --git a/app/src/__tests__/store/settingsStore.spec.ts b/app/src/__tests__/store/settingsStore.spec.ts new file mode 100644 index 000000000..d14af53ce --- /dev/null +++ b/app/src/__tests__/store/settingsStore.spec.ts @@ -0,0 +1,40 @@ +import AppStorage from 'util/appStorage'; +import { BalanceMode, Unit } from 'util/constants'; +import { createStore, SettingsStore } from 'store'; +import { PersistentSettings } from 'store/stores/settingsStore'; + +const appStorageMock = AppStorage as jest.Mock>; + +describe('SettingsStore', () => { + let store: SettingsStore; + + beforeEach(() => { + store = createStore().settingsStore; + }); + + it('should load settings', async () => { + const getMock = appStorageMock.mock.instances[0].get as jest.Mock; + getMock.mockImplementation(() => ({ + sidebarVisible: false, + unit: Unit.bits, + balanceMode: BalanceMode.routing, + })); + + store.load(); + + expect(store.sidebarVisible).toEqual(false); + expect(store.unit).toEqual(Unit.bits); + expect(store.balanceMode).toEqual(BalanceMode.routing); + }); + + it('should do nothing if nothing is saved in storage', () => { + const getMock = appStorageMock.mock.instances[0].get as jest.Mock; + getMock.mockReturnValue(undefined as any); + + store.load(); + + expect(store.sidebarVisible).toEqual(true); + expect(store.unit).toEqual(Unit.sats); + expect(store.balanceMode).toEqual(BalanceMode.receive); + }); +}); diff --git a/app/src/__tests__/util/appStorage.spec.ts b/app/src/__tests__/util/appStorage.spec.ts new file mode 100644 index 000000000..adc571177 --- /dev/null +++ b/app/src/__tests__/util/appStorage.spec.ts @@ -0,0 +1,42 @@ +import AppStorage from 'util/appStorage'; + +jest.unmock('util/appStorage'); + +describe('appStorage util', () => { + const key = 'test-data'; + const settings = { + someNumber: 123, + someString: 'abc', + someBool: false, + }; + const appStorage = new AppStorage(); + + it('should save an object to localStorage', () => { + appStorage.set(key, settings); + + const json = localStorage.getItem(key); + expect(json).toBeDefined(); + expect(typeof json).toBe('string'); + const data = JSON.parse(json as string) as typeof settings; + expect(data).toBeDefined(); + expect(data.someNumber).toEqual(settings.someNumber); + expect(data.someString).toEqual(settings.someString); + expect(data.someBool).toEqual(settings.someBool); + }); + + it('should load an object from localStorage', () => { + const json = '{"someNumber":123,"someString":"abc","someBool":false}'; + localStorage.setItem(key, json); + + const data = appStorage.get(key) as typeof settings; + expect(data).toBeDefined(); + expect(data.someNumber).toEqual(settings.someNumber); + expect(data.someString).toEqual(settings.someString); + expect(data.someBool).toEqual(settings.someBool); + }); + + it('should return undefined for a missing key', () => { + const data = appStorage.get('invalid-key'); + expect(data).toBeUndefined(); + }); +}); diff --git a/app/src/__tests__/util/balances.spec.ts b/app/src/__tests__/util/balances.spec.ts new file mode 100644 index 000000000..69522a682 --- /dev/null +++ b/app/src/__tests__/util/balances.spec.ts @@ -0,0 +1,108 @@ +import { getBalanceStatus } from 'util/balances'; +import { BalanceConfig, BalanceModes, BalanceStatus } from 'util/constants'; + +describe('balances Util', () => { + let config: BalanceConfig; + + describe('Receive Optimized', () => { + beforeEach(() => { + config = BalanceModes.receive; + }); + + it('should return ok status', () => { + expect(getBalanceStatus(1, 1000, config)).toBe(BalanceStatus.ok); + expect(getBalanceStatus(100, 1000, config)).toBe(BalanceStatus.ok); + expect(getBalanceStatus(350, 1000, config)).not.toBe(BalanceStatus.ok); + expect(getBalanceStatus(600, 1000, config)).not.toBe(BalanceStatus.ok); + expect(getBalanceStatus(900, 1000, config)).not.toBe(BalanceStatus.ok); + expect(getBalanceStatus(999, 1000, config)).not.toBe(BalanceStatus.ok); + }); + + it('should return warn status', () => { + expect(getBalanceStatus(1, 1000, config)).not.toBe(BalanceStatus.warn); + expect(getBalanceStatus(100, 1000, config)).not.toBe(BalanceStatus.warn); + expect(getBalanceStatus(350, 1000, config)).toBe(BalanceStatus.warn); + expect(getBalanceStatus(600, 1000, config)).toBe(BalanceStatus.warn); + expect(getBalanceStatus(900, 1000, config)).not.toBe(BalanceStatus.warn); + expect(getBalanceStatus(999, 1000, config)).not.toBe(BalanceStatus.warn); + }); + + it('should return danger status', () => { + expect(getBalanceStatus(1, 1000, config)).not.toBe(BalanceStatus.danger); + expect(getBalanceStatus(100, 1000, config)).not.toBe(BalanceStatus.danger); + expect(getBalanceStatus(350, 1000, config)).not.toBe(BalanceStatus.danger); + expect(getBalanceStatus(600, 1000, config)).not.toBe(BalanceStatus.danger); + expect(getBalanceStatus(900, 1000, config)).toBe(BalanceStatus.danger); + expect(getBalanceStatus(999, 1000, config)).toBe(BalanceStatus.danger); + }); + }); + + describe('Send Optimized', () => { + beforeEach(() => { + config = BalanceModes.send; + }); + + it('should return ok status', () => { + expect(getBalanceStatus(1, 1000, config)).not.toBe(BalanceStatus.ok); + expect(getBalanceStatus(100, 1000, config)).not.toBe(BalanceStatus.ok); + expect(getBalanceStatus(350, 1000, config)).not.toBe(BalanceStatus.ok); + expect(getBalanceStatus(600, 1000, config)).not.toBe(BalanceStatus.ok); + expect(getBalanceStatus(900, 1000, config)).toBe(BalanceStatus.ok); + expect(getBalanceStatus(999, 1000, config)).toBe(BalanceStatus.ok); + }); + + it('should return warn status', () => { + expect(getBalanceStatus(1, 1000, config)).not.toBe(BalanceStatus.warn); + expect(getBalanceStatus(100, 1000, config)).not.toBe(BalanceStatus.warn); + expect(getBalanceStatus(350, 1000, config)).toBe(BalanceStatus.warn); + expect(getBalanceStatus(600, 1000, config)).toBe(BalanceStatus.warn); + expect(getBalanceStatus(900, 1000, config)).not.toBe(BalanceStatus.warn); + expect(getBalanceStatus(999, 1000, config)).not.toBe(BalanceStatus.warn); + }); + + it('should return danger status', () => { + expect(getBalanceStatus(1, 1000, config)).toBe(BalanceStatus.danger); + expect(getBalanceStatus(100, 1000, config)).toBe(BalanceStatus.danger); + expect(getBalanceStatus(350, 1000, config)).not.toBe(BalanceStatus.danger); + expect(getBalanceStatus(600, 1000, config)).not.toBe(BalanceStatus.danger); + expect(getBalanceStatus(900, 1000, config)).not.toBe(BalanceStatus.danger); + expect(getBalanceStatus(999, 1000, config)).not.toBe(BalanceStatus.danger); + }); + }); + + describe('Routing Optimized', () => { + beforeEach(() => { + config = BalanceModes.routing; + }); + + it('should return ok status', () => { + expect(getBalanceStatus(1, 1000, config)).not.toBe(BalanceStatus.ok); + expect(getBalanceStatus(100, 1000, config)).not.toBe(BalanceStatus.ok); + expect(getBalanceStatus(350, 1000, config)).toBe(BalanceStatus.ok); + expect(getBalanceStatus(600, 1000, config)).toBe(BalanceStatus.ok); + expect(getBalanceStatus(800, 1000, config)).not.toBe(BalanceStatus.ok); + expect(getBalanceStatus(900, 1000, config)).not.toBe(BalanceStatus.ok); + expect(getBalanceStatus(999, 1000, config)).not.toBe(BalanceStatus.ok); + }); + + it('should return warn status', () => { + expect(getBalanceStatus(1, 1000, config)).not.toBe(BalanceStatus.warn); + expect(getBalanceStatus(100, 1000, config)).not.toBe(BalanceStatus.warn); + expect(getBalanceStatus(350, 1000, config)).not.toBe(BalanceStatus.warn); + expect(getBalanceStatus(600, 1000, config)).not.toBe(BalanceStatus.warn); + expect(getBalanceStatus(800, 1000, config)).toBe(BalanceStatus.warn); + expect(getBalanceStatus(900, 1000, config)).not.toBe(BalanceStatus.warn); + expect(getBalanceStatus(999, 1000, config)).not.toBe(BalanceStatus.warn); + }); + + it('should return danger status', () => { + expect(getBalanceStatus(1, 1000, config)).toBe(BalanceStatus.danger); + expect(getBalanceStatus(100, 1000, config)).toBe(BalanceStatus.danger); + expect(getBalanceStatus(350, 1000, config)).not.toBe(BalanceStatus.danger); + expect(getBalanceStatus(600, 1000, config)).not.toBe(BalanceStatus.danger); + expect(getBalanceStatus(800, 1000, config)).not.toBe(BalanceStatus.danger); + expect(getBalanceStatus(900, 1000, config)).toBe(BalanceStatus.danger); + expect(getBalanceStatus(999, 1000, config)).toBe(BalanceStatus.danger); + }); + }); +}); diff --git a/app/src/__tests__/util/formatters.spec.ts b/app/src/__tests__/util/formatters.spec.ts new file mode 100644 index 000000000..be686319c --- /dev/null +++ b/app/src/__tests__/util/formatters.spec.ts @@ -0,0 +1,73 @@ +import { Unit } from 'util/constants'; +import { formatSats, formatUnit } from 'util/formatters'; + +describe('formatters Util', () => { + describe('formatSats', () => { + it('should format to sats', () => { + expect(formatSats(0)).toEqual('0 sats'); + expect(formatSats(123)).toEqual('123 sats'); + expect(formatSats(123456)).toEqual('123,456 sats'); + expect(formatSats(123456789)).toEqual('123,456,789 sats'); + expect(formatSats(12345678901)).toEqual('12,345,678,901 sats'); + }); + + it('should format to sats without', () => { + const opts = { withSuffix: false }; + expect(formatSats(0, opts)).toEqual('0'); + expect(formatSats(123, opts)).toEqual('123'); + expect(formatSats(123456, opts)).toEqual('123,456'); + expect(formatSats(123456789, opts)).toEqual('123,456,789'); + expect(formatSats(12345678901, opts)).toEqual('12,345,678,901'); + }); + + it('should format to bits', () => { + const opts = { unit: Unit.bits }; + expect(formatSats(0, opts)).toEqual('0.00 bits'); + expect(formatSats(123, opts)).toEqual('1.23 bits'); + expect(formatSats(123456, opts)).toEqual('1,234.56 bits'); + expect(formatSats(123456789, opts)).toEqual('1,234,567.89 bits'); + expect(formatSats(12345678901, opts)).toEqual('123,456,789.01 bits'); + }); + + it('should format to bits without', () => { + const opts = { unit: Unit.bits, withSuffix: false }; + expect(formatSats(0, opts)).toEqual('0.00'); + expect(formatSats(123, opts)).toEqual('1.23'); + expect(formatSats(123456, opts)).toEqual('1,234.56'); + expect(formatSats(123456789, opts)).toEqual('1,234,567.89'); + expect(formatSats(12345678901, opts)).toEqual('123,456,789.01'); + }); + + it('should format to BTC', () => { + const opts = { unit: Unit.btc }; + expect(formatSats(0, opts)).toEqual('0.00000000 BTC'); + expect(formatSats(123, opts)).toEqual('0.00000123 BTC'); + expect(formatSats(123456, opts)).toEqual('0.00123456 BTC'); + expect(formatSats(123456789, opts)).toEqual('1.23456789 BTC'); + expect(formatSats(12345678901, opts)).toEqual('123.45678901 BTC'); + }); + + it('should format to BTC without suffix', () => { + const opts = { unit: Unit.btc, withSuffix: false }; + expect(formatSats(0, opts)).toEqual('0.00000000'); + expect(formatSats(123, opts)).toEqual('0.00000123'); + expect(formatSats(123456, opts)).toEqual('0.00123456'); + expect(formatSats(123456789, opts)).toEqual('1.23456789'); + expect(formatSats(12345678901, opts)).toEqual('123.45678901'); + }); + }); + + describe('formatUnit', () => { + it('should format sats', () => { + expect(formatUnit(Unit.sats)).toEqual('Satoshis (0.00000001 BTC)'); + }); + + it('should format bits', () => { + expect(formatUnit(Unit.bits)).toEqual('Bits (0.00000100 BTC)'); + }); + + it('should format BTC', () => { + expect(formatUnit(Unit.btc)).toEqual('Bitcoin (1.00000000 BTC)'); + }); + }); +}); diff --git a/app/src/__tests__/util/log.spec.ts b/app/src/__tests__/util/log.spec.ts new file mode 100644 index 000000000..ffd5018ef --- /dev/null +++ b/app/src/__tests__/util/log.spec.ts @@ -0,0 +1,140 @@ +import { debugMock } from '__mocks__/debug'; +import * as config from 'config'; +import { Logger, LogLevel } from 'util/log'; + +describe('log Util', () => { + describe('fromEnv', () => { + afterEach(() => { + localStorage.removeItem('debug'); + localStorage.removeItem('debug-level'); + }); + + it('should enable debug logging in dev env', () => { + Object.defineProperty(config, 'IS_DEV', { get: () => true }); + + const log = Logger.fromEnv('test'); + log.debug('sample message'); + expect(debugMock).toBeCalledWith('[debug] sample message'); + + // revert IS_DEV + Object.defineProperty(config, 'IS_DEV', { get: () => false }); + }); + + it('should enable debug logging when storage key exists', () => { + localStorage.setItem('debug', '*'); + const log = Logger.fromEnv('test'); + log.debug('sample message'); + expect(debugMock).toBeCalledWith('[debug] sample message'); + }); + + it('should enable logging based on the debug-level storage key', () => { + localStorage.setItem('debug', '*'); + localStorage.setItem('debug-level', 'warn'); + const log = Logger.fromEnv('test'); + log.debug('sample message'); + expect(debugMock).not.toBeCalledWith('[debug] sample message'); + log.info('sample message'); + expect(debugMock).not.toBeCalledWith('[info] sample message'); + log.warn('sample message'); + expect(debugMock).toBeCalledWith('[warn] sample message'); + log.error('sample message'); + expect(debugMock).toBeCalledWith('[error] sample message'); + }); + }); + + describe('LogLevel debug', () => { + const log = new Logger(LogLevel.debug, 'test'); + + it('should output a debug log message', () => { + log.debug('sample message'); + expect(debugMock).toBeCalledWith('[debug] sample message'); + }); + + it('should output a info log message', () => { + log.info('sample message'); + expect(debugMock).toBeCalledWith('[info] sample message'); + }); + + it('should output a warn log message', () => { + log.warn('sample message'); + expect(debugMock).toBeCalledWith('[warn] sample message'); + }); + + it('should output a error log message', () => { + log.error('sample message'); + expect(debugMock).toBeCalledWith('[error] sample message'); + }); + }); + + describe('LogLevel info', () => { + const log = new Logger(LogLevel.info, 'test'); + + it('should not output a debug log message', () => { + log.debug('sample message'); + expect(debugMock).not.toBeCalledWith('[debug] sample message'); + }); + + it('should output a info log message', () => { + log.info('sample message'); + expect(debugMock).toBeCalledWith('[info] sample message'); + }); + + it('should output a warn log message', () => { + log.warn('sample message'); + expect(debugMock).toBeCalledWith('[warn] sample message'); + }); + + it('should output a error log message', () => { + log.error('sample message'); + expect(debugMock).toBeCalledWith('[error] sample message'); + }); + }); + + describe('LogLevel warn', () => { + const log = new Logger(LogLevel.warn, 'test'); + + it('should not output a debug log message', () => { + log.debug('sample message'); + expect(debugMock).not.toBeCalledWith('[debug] sample message'); + }); + + it('should not output a info log message', () => { + log.info('sample message'); + expect(debugMock).not.toBeCalledWith('[info] sample message'); + }); + + it('should output a warn log message', () => { + log.warn('sample message'); + expect(debugMock).toBeCalledWith('[warn] sample message'); + }); + + it('should output a error log message', () => { + log.error('sample message'); + expect(debugMock).toBeCalledWith('[error] sample message'); + }); + }); + + describe('LogLevel error', () => { + const log = new Logger(LogLevel.error, 'test'); + + it('should not output a debug log message', () => { + log.debug('sample message'); + expect(debugMock).not.toBeCalledWith('[debug] sample message'); + }); + + it('should not output a info log message', () => { + log.info('sample message'); + expect(debugMock).not.toBeCalledWith('[info] sample message'); + }); + + it('should not output a warn log message', () => { + log.warn('sample message'); + expect(debugMock).not.toBeCalledWith('[warn] sample message'); + }); + + it('should output a error log message', () => { + log.error('sample message'); + expect(debugMock).toBeCalledWith('[error] sample message'); + }); + }); +}); diff --git a/app/src/__tests__/util/strings.spec.ts b/app/src/__tests__/util/strings.spec.ts new file mode 100644 index 000000000..fa110890a --- /dev/null +++ b/app/src/__tests__/util/strings.spec.ts @@ -0,0 +1,32 @@ +import { ellipseInside } from 'util/strings'; + +describe('strings util', () => { + describe('ellipseInside', () => { + it('should ellipse valid text', () => { + expect(ellipseInside('xxxxxxyyyyzzzzzz')).toEqual('xxxxxx...zzzzzz'); + expect(ellipseInside('xxxxyyyyzzzz', 4)).toEqual('xxxx...zzzz'); + expect(ellipseInside('xxyyyyzz', 2)).toEqual('xx...zz'); + expect(ellipseInside('xxyyyyzz', 2, 4)).toEqual('xx...yyzz'); + }); + + it('should do nothing with short text', () => { + expect(ellipseInside('abcdef')).toEqual('abcdef'); + expect(ellipseInside('abcdef', 3)).toEqual('abcdef'); + }); + + it('should keep 6 chars with invalid value provided', () => { + expect(ellipseInside('xxxxxxyyyyzzzzzz', 0)).toEqual('xxxxxx...zzzzzz'); + expect(ellipseInside('xxxxxxyyyyzzzzzz', -1)).toEqual('xxxxxx...zzzzzz'); + expect(ellipseInside('xxxxxxyyyyzzzzzz', NaN)).toEqual('xxxxxx...zzzzzz'); + expect(ellipseInside('xxxxxxyyyyzzzzzz', (undefined as unknown) as number)).toEqual( + 'xxxxxx...zzzzzz', + ); + }); + + it('should handle empty text', () => { + expect(ellipseInside((undefined as unknown) as string)).toBeUndefined(); + expect(ellipseInside((null as unknown) as string)).toBeNull(); + expect(ellipseInside('')).toEqual(''); + }); + }); +}); diff --git a/app/src/components/NodeStatus.tsx b/app/src/components/NodeStatus.tsx index 93127f6cd..983b23969 100644 --- a/app/src/components/NodeStatus.tsx +++ b/app/src/components/NodeStatus.tsx @@ -4,6 +4,7 @@ import { usePrefixedTranslation } from 'hooks'; import { useStore } from 'store'; import { SmallText, Title, XLargeText } from 'components/common/text'; import { Bitcoin, Bolt } from './common/icons'; +import Unit from './common/Unit'; import { styled } from './theme'; const Styled = { @@ -32,23 +33,20 @@ const Styled = { }; const NodeStatus: React.FC = () => { - const { Wrapper, StatusTitle, BoltIcon, BitcoinIcon, Divider } = Styled; - const { l } = usePrefixedTranslation('cmps.NodeStatus'); + const { nodeStore } = useStore(); - const store = useStore(); - const { channelBalance, walletBalance } = store.nodeStore.wallet; - + const { Wrapper, StatusTitle, BoltIcon, BitcoinIcon, Divider } = Styled; return ( {l('title')} - {channelBalance.toLocaleString()} SAT + - {walletBalance.toLocaleString()} + diff --git a/app/src/components/Pages.tsx b/app/src/components/Pages.tsx index b9092b084..820a10e73 100644 --- a/app/src/components/Pages.tsx +++ b/app/src/components/Pages.tsx @@ -3,6 +3,7 @@ import { observer } from 'mobx-react-lite'; import { useStore } from 'store'; import HistoryPage from './history/HistoryPage'; import LoopPage from './loop/LoopPage'; +import SettingsPage from './settings/SettingsPage'; const Pages: React.FC = () => { const { uiStore } = useStore(); @@ -10,6 +11,8 @@ const Pages: React.FC = () => { switch (uiStore.page) { case 'history': return ; + case 'settings': + return ; default: return ; } diff --git a/app/src/components/common/Radio.tsx b/app/src/components/common/Radio.tsx index c79b1aebe..f0241d837 100644 --- a/app/src/components/common/Radio.tsx +++ b/app/src/components/common/Radio.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { ReactNode } from 'react'; import { styled } from 'components/theme'; import { SmallText } from './text'; @@ -30,7 +30,7 @@ const Styled = { interface Props { text: string; - description?: string; + description?: ReactNode; active?: boolean; right?: boolean; onClick?: () => void; diff --git a/app/src/components/common/Range.tsx b/app/src/components/common/Range.tsx index 13ab87c7f..3ae61157f 100644 --- a/app/src/components/common/Range.tsx +++ b/app/src/components/common/Range.tsx @@ -2,6 +2,7 @@ import React, { ChangeEvent, useCallback } from 'react'; import { styled } from 'components/theme'; import { RangeInput } from './base'; import Radio from './Radio'; +import Unit from './Unit'; const Styled = { RadioGroup: styled.div` @@ -47,13 +48,13 @@ const Range: React.FC = ({ } onClick={handleMinClicked} active={min === value} /> } onClick={handleMaxClicked} active={max === value} right @@ -72,7 +73,9 @@ const Range: React.FC = ({ onChange={handleInputClicked} /> - {value.toLocaleString()} SAT + + + ); }; diff --git a/app/src/components/common/Tile.tsx b/app/src/components/common/Tile.tsx index 306e9f0e1..b5f09297d 100644 --- a/app/src/components/common/Tile.tsx +++ b/app/src/components/common/Tile.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { ReactNode } from 'react'; import { styled } from 'components/theme'; import { Maximize } from './icons'; import { Title } from './text'; @@ -43,7 +43,7 @@ interface Props { * optional text to display in the tile. if this is not * provided, then the `children` will be displayed instead */ - text?: string; + text?: ReactNode; /** * optional click handler for the icon which will not be * visible if this prop is not defined diff --git a/app/src/components/common/Unit.tsx b/app/src/components/common/Unit.tsx new file mode 100644 index 000000000..4001507e1 --- /dev/null +++ b/app/src/components/common/Unit.tsx @@ -0,0 +1,26 @@ +import React, { useMemo } from 'react'; +import { observer } from 'mobx-react-lite'; +import { formatSats } from 'util/formatters'; +import { useStore } from 'store'; + +interface Props { + sats: number; + suffix?: boolean; +} + +const Sats: React.FC = ({ sats, suffix = true }) => { + const { settingsStore } = useStore(); + + // memoize so that formatSats isn't called too often unnecessarily + const text = useMemo(() => { + return formatSats(sats, { + unit: settingsStore.unit, + withSuffix: suffix, + lang: settingsStore.lang, + }); + }, [sats, suffix, settingsStore.unit, settingsStore.lang]); + + return <>{text}>; +}; + +export default observer(Sats); diff --git a/app/src/components/common/base.tsx b/app/src/components/common/base.tsx index 6ff593806..52cc76a80 100644 --- a/app/src/components/common/base.tsx +++ b/app/src/components/common/base.tsx @@ -37,7 +37,7 @@ export const Button = styled.button` color: ${props => props.theme.colors.white}; background-color: ${props => props.ghost ? 'transparent' : props.theme.colors.tileBack}; - border-width: ${props => (props.borderless ? '0' : '1px')}; + border-width: ${props => (props.borderless && !props.primary ? '0' : '1px')}; border-color: ${props => props.primary ? props.theme.colors.green : props.theme.colors.white}; border-style: solid; @@ -53,6 +53,23 @@ export const Button = styled.button` } `; +interface RadioButtonProps { + checked?: boolean; +} + +export const RadioButton = styled.span` + display: inline-block; + width: 14px; + height: 14px; + border-radius: 14px; + border: 1px solid ${props => props.theme.colors.lightPurple}; + background-color: ${props => (props.checked ? props.theme.colors.lightPurple : 'none')}; + + &:hover { + opacity: 0.8; + } +`; + /** * the react-virtualized list doesn't play nice with the bootstrap row -15px * margin. We need to manually offset the container and remove the diff --git a/app/src/components/common/icons.tsx b/app/src/components/common/icons.tsx index 11c25dc20..8c26ace1b 100644 --- a/app/src/components/common/icons.tsx +++ b/app/src/components/common/icons.tsx @@ -1,9 +1,9 @@ import React, { ReactNode } from 'react'; +import { ReactComponent as ArrowRight } from 'assets/icons/arrow-right.svg'; import { ReactComponent as Clock } from 'assets/icons/clock.svg'; import { ReactComponent as Download } from 'assets/icons/download.svg'; import { styled } from 'components/theme'; -export { ReactComponent as ArrowRight } from 'assets/icons/arrow-right.svg'; export { ReactComponent as ArrowLeft } from 'assets/icons/arrow-left.svg'; export { ReactComponent as Bolt } from 'assets/icons/bolt.svg'; export { ReactComponent as Bitcoin } from 'assets/icons/bitcoin.svg'; @@ -17,11 +17,13 @@ export { ReactComponent as Maximize } from 'assets/icons/maximize.svg'; export { ReactComponent as Refresh } from 'assets/icons/refresh-cw.svg'; interface IconComponents { + 'arrow-right': ReactNode; clock: ReactNode; download: ReactNode; } const components: IconComponents = { + 'arrow-right': , clock: , download: , }; diff --git a/app/src/components/history/HistoryRow.tsx b/app/src/components/history/HistoryRow.tsx index f442fccbc..b3767a65b 100644 --- a/app/src/components/history/HistoryRow.tsx +++ b/app/src/components/history/HistoryRow.tsx @@ -4,6 +4,7 @@ import { usePrefixedTranslation } from 'hooks'; import { Swap } from 'store/models'; import { Column, Row } from 'components/common/grid'; import { Title } from 'components/common/text'; +import Unit from 'components/common/Unit'; import SwapDot from 'components/loop/SwapDot'; import { styled } from 'components/theme'; @@ -75,7 +76,9 @@ const HistoryRow: React.FC = ({ swap, style }) => { {swap.stateLabel} {swap.typeName} - {swap.amount.toLocaleString()} + + + {swap.createdOnLabel} {swap.updatedOnLabel} diff --git a/app/src/components/layout/NavMenu.tsx b/app/src/components/layout/NavMenu.tsx index b9d6d8006..a2082fc89 100644 --- a/app/src/components/layout/NavMenu.tsx +++ b/app/src/components/layout/NavMenu.tsx @@ -44,24 +44,30 @@ const Styled = { `, }; +const NavItem: React.FC<{ page: string; onClick: () => void }> = observer( + ({ page, onClick }) => { + const { l } = usePrefixedTranslation('cmps.layout.NavMenu'); + const { uiStore } = useStore(); + return ( + + {l(page)} + + ); + }, +); + const NavMenu: React.FC = () => { const { l } = usePrefixedTranslation('cmps.layout.NavMenu'); const { uiStore } = useStore(); - const { NavTitle, Nav, NavItem } = Styled; + const { NavTitle, Nav } = Styled; return ( <> {l('menu')} - - {l('loop')} - - - {l('history')} - - - {l('settings')} - + + + > ); diff --git a/app/src/components/loop/ChannelBalance.tsx b/app/src/components/loop/ChannelBalance.tsx index 0611af25e..3541b7613 100644 --- a/app/src/components/loop/ChannelBalance.tsx +++ b/app/src/components/loop/ChannelBalance.tsx @@ -1,27 +1,28 @@ import React from 'react'; import { observer } from 'mobx-react-lite'; -import { BalanceLevel } from 'types/state'; +import { statusToColor } from 'util/balances'; +import { BalanceStatus } from 'util/constants'; import { Channel } from 'store/models'; -import { levelToColor, styled } from 'components/theme'; +import { styled } from 'components/theme'; const Styled = { - Wrapper: styled.div<{ pct: number; level: BalanceLevel; active: boolean }>` + Wrapper: styled.div<{ pct: number; status: BalanceStatus; active: boolean }>` display: flex; width: 100%; > div { - min-width: 10px; + min-width: 6px; &:first-of-type { flex-grow: 1; background-color: ${props => - levelToColor(props.level, props.active, props.theme)}; + statusToColor(props.status, props.active, props.theme)}; } &:last-of-type { width: ${props => props.pct}%; background-color: ${props => - levelToColor(props.level, props.active, props.theme)}; + statusToColor(props.status, props.active, props.theme)}; } } `, @@ -45,7 +46,7 @@ const ChannelBalance: React.FC = ({ channel, className }) => { return ( diff --git a/app/src/components/loop/ChannelRow.tsx b/app/src/components/loop/ChannelRow.tsx index 4e5ba46f7..01c599f4c 100644 --- a/app/src/components/loop/ChannelRow.tsx +++ b/app/src/components/loop/ChannelRow.tsx @@ -1,7 +1,7 @@ import React, { CSSProperties } from 'react'; import { observer } from 'mobx-react-lite'; -import { BalanceLevel } from 'types/state'; import { usePrefixedTranslation } from 'hooks'; +import { BalanceStatus } from 'util/constants'; import { ellipseInside } from 'util/strings'; import { useStore } from 'store'; import { Channel } from 'store/models'; @@ -9,6 +9,7 @@ import Checkbox from 'components/common/Checkbox'; import { Column, Row } from 'components/common/grid'; import StatusDot from 'components/common/StatusDot'; import { Title } from 'components/common/text'; +import Unit from 'components/common/Unit'; import { styled } from 'components/theme'; import ChannelBalance from './ChannelBalance'; @@ -41,7 +42,7 @@ const Styled = { Check: styled(Checkbox)` float: left; margin-top: 18px; - margin-left: 15px; + margin-left: 10px; `, Balance: styled(ChannelBalance)` margin-top: ${ROW_HEIGHT / 2 - 2}px; @@ -75,12 +76,12 @@ export const ChannelRowHeader: React.FC = () => { const ChannelDot: React.FC<{ channel: Channel }> = observer(({ channel }) => { if (!channel.active) return ; - switch (channel.balanceLevel) { - case BalanceLevel.good: + switch (channel.balanceStatus) { + case BalanceStatus.ok: return ; - case BalanceLevel.warn: + case BalanceStatus.warn: return ; - case BalanceLevel.bad: + case BalanceStatus.danger: return ; } }); @@ -113,15 +114,19 @@ const ChannelRow: React.FC = ({ channel, style }) => { )} - {channel.remoteBalance.toLocaleString()} + - {channel.localBalance.toLocaleString()} + + + {channel.uptimePercent} {ellipseInside(channel.remotePubkey)} - {channel.capacity.toLocaleString()} + + + ); }; diff --git a/app/src/components/loop/LoopActions.tsx b/app/src/components/loop/LoopActions.tsx index 8a95aff1b..ed115b1f1 100644 --- a/app/src/components/loop/LoopActions.tsx +++ b/app/src/components/loop/LoopActions.tsx @@ -34,13 +34,11 @@ const Styled = { const LoopActions: React.FC = () => { const { l } = usePrefixedTranslation('cmps.loop.LoopActions'); const { buildSwapStore } = useStore(); - const handleLoopOut = useCallback( - () => buildSwapStore.setDirection(SwapDirection.OUT), - [buildSwapStore], - ); - const handleLoopIn = useCallback(() => buildSwapStore.setDirection(SwapDirection.IN), [ - buildSwapStore, + const { setDirection, inferredDirection } = buildSwapStore; + const handleLoopOut = useCallback(() => setDirection(SwapDirection.OUT), [ + setDirection, ]); + const handleLoopIn = useCallback(() => setDirection(SwapDirection.IN), [setDirection]); const selectedCount = buildSwapStore.selectedChanIds.length; const { Wrapper, Actions, CloseIcon, Selected } = Styled; @@ -52,16 +50,16 @@ const LoopActions: React.FC = () => { {selectedCount} {l('channelsSelected')} Loop out diff --git a/app/src/components/loop/LoopHistory.tsx b/app/src/components/loop/LoopHistory.tsx index b1cb79d0e..fd29996e5 100644 --- a/app/src/components/loop/LoopHistory.tsx +++ b/app/src/components/loop/LoopHistory.tsx @@ -3,6 +3,7 @@ import { observer } from 'mobx-react-lite'; import { useStore } from 'store'; import { Column, Row } from 'components/common/grid'; import { SmallText } from 'components/common/text'; +import Unit from 'components/common/Unit'; import { styled } from 'components/theme'; import SwapDot from './SwapDot'; @@ -29,7 +30,9 @@ const LoopHistory: React.FC = () => { {swap.createdOn.toLocaleDateString()} - {`${swap.amount.toLocaleString()} SAT`} + + + ))} diff --git a/app/src/components/loop/LoopTiles.tsx b/app/src/components/loop/LoopTiles.tsx index 243401bb9..41ea4a4ec 100644 --- a/app/src/components/loop/LoopTiles.tsx +++ b/app/src/components/loop/LoopTiles.tsx @@ -4,6 +4,7 @@ import { usePrefixedTranslation } from 'hooks'; import { useStore } from 'store'; import { Column, Row } from 'components/common/grid'; import Tile from 'components/common/Tile'; +import Unit from 'components/common/Unit'; import { styled } from 'components/theme'; import LoopHistory from './LoopHistory'; @@ -27,16 +28,10 @@ const LoopTiles: React.FC = () => { - + } /> - + } /> diff --git a/app/src/components/loop/processing/SwapInfo.tsx b/app/src/components/loop/processing/SwapInfo.tsx index 28b2bb1fc..bf6826c86 100644 --- a/app/src/components/loop/processing/SwapInfo.tsx +++ b/app/src/components/loop/processing/SwapInfo.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { observer } from 'mobx-react-lite'; import { Swap } from 'store/models'; import { Title } from 'components/common/text'; +import Unit from 'components/common/Unit'; import { styled } from 'components/theme'; import SwapDot from '../SwapDot'; @@ -36,7 +37,9 @@ const SwapInfo: React.FC = ({ swap }) => { {swap.idEllipsed} - {swap.amount.toLocaleString()} SAT + + + ); diff --git a/app/src/components/loop/swap/SwapReviewStep.tsx b/app/src/components/loop/swap/SwapReviewStep.tsx index 35525408c..51ed3df6e 100644 --- a/app/src/components/loop/swap/SwapReviewStep.tsx +++ b/app/src/components/loop/swap/SwapReviewStep.tsx @@ -3,6 +3,7 @@ import { observer } from 'mobx-react-lite'; import { usePrefixedTranslation } from 'hooks'; import { useStore } from 'store'; import { Title, XLargeText } from 'components/common/text'; +import Unit from 'components/common/Unit'; import { styled } from 'components/theme'; import StepButtons from './StepButtons'; import StepSummary from './StepSummary'; @@ -55,7 +56,9 @@ const SwapReviewStep: React.FC = () => { {l('amount', { type: buildSwapStore.direction })} - {buildSwapStore.amount.toLocaleString()} SAT + + + {l('fees')} @@ -64,7 +67,9 @@ const SwapReviewStep: React.FC = () => { {l('total')} - {buildSwapStore.invoiceTotal.toLocaleString()} SAT + + + = observer(({ mode }) => { + const { l } = usePrefixedTranslation('enums.BalanceMode'); + const { settingsStore } = useStore(); + + const handleClick = useCallback(() => { + settingsStore.setBalanceMode(mode); + }, [mode, settingsStore]); + + return ( + + ); +}); + +const BalanceSettings: React.FC = () => { + const { l } = usePrefixedTranslation('cmps.settings.BalanceSettings'); + const { uiStore } = useStore(); + + const handleBack = useCallback(() => uiStore.showSettings('general'), [uiStore]); + + const { Wrapper, Content } = Styled; + return ( + + + + {l('title')} + + + + + + ); +}; + +export default observer(BalanceSettings); diff --git a/app/src/components/settings/GeneralSettings.tsx b/app/src/components/settings/GeneralSettings.tsx new file mode 100644 index 000000000..a7101821d --- /dev/null +++ b/app/src/components/settings/GeneralSettings.tsx @@ -0,0 +1,49 @@ +import React, { useCallback } from 'react'; +import { observer } from 'mobx-react-lite'; +import { usePrefixedTranslation } from 'hooks'; +import { formatUnit } from 'util/formatters'; +import { useStore } from 'store'; +import PageHeader from 'components/common/PageHeader'; +import { Title } from 'components/common/text'; +import { styled } from 'components/theme'; +import SettingItem from './SettingItem'; + +const Styled = { + Wrapper: styled.section``, + Content: styled.div` + margin: 100px 50px; + `, +}; + +const GeneralSettings: React.FC = () => { + const { l } = usePrefixedTranslation('cmps.settings.GeneralSettings'); + const { l: lbm } = usePrefixedTranslation('enums.BalanceMode'); + const { uiStore, settingsStore } = useStore(); + + const handleUnit = useCallback(() => uiStore.showSettings('unit'), [uiStore]); + const handleBalance = useCallback(() => uiStore.showSettings('balance'), [uiStore]); + + const { Wrapper, Content } = Styled; + return ( + + + + {l('title')} + + + + + ); +}; + +export default observer(GeneralSettings); diff --git a/app/src/components/settings/SettingItem.tsx b/app/src/components/settings/SettingItem.tsx new file mode 100644 index 000000000..aa4499651 --- /dev/null +++ b/app/src/components/settings/SettingItem.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { observer } from 'mobx-react-lite'; +import { RadioButton } from 'components/common/base'; +import { Icon } from 'components/common/icons'; +import { styled } from 'components/theme'; + +const Styled = { + Wrapper: styled.div` + display: flex; + line-height: 80px; + cursor: pointer; + border-bottom: 0.5px solid ${props => props.theme.colors.darkGray}; + + &:last-child { + border-bottom-width: 0; + } + + &:hover { + opacity: 0.8; + } + `, + Name: styled.span` + flex: 1; + font-size: ${props => props.theme.sizes.l}; + `, + Value: styled.span` + color: ${props => props.theme.colors.gray}; + margin-right: 20px; + `, + Icon: styled(Icon)` + line-height: 80px; + `, + Radio: styled(RadioButton)` + margin-top: 32px; + `, +}; + +interface Props { + name: string; + value?: string; + icon: 'arrow' | 'radio'; + checked?: boolean; + onClick: () => void; +} + +const SettingItem: React.FC = ({ name, value, icon, checked, onClick }) => { + const { Wrapper, Name, Value, Radio, Icon } = Styled; + return ( + + {name} + {value && {value}} + {icon === 'radio' && ( + + )} + {icon === 'arrow' && } + + ); +}; + +export default observer(SettingItem); diff --git a/app/src/components/settings/SettingsPage.tsx b/app/src/components/settings/SettingsPage.tsx new file mode 100644 index 000000000..ccfb0c00c --- /dev/null +++ b/app/src/components/settings/SettingsPage.tsx @@ -0,0 +1,35 @@ +import React, { ReactNode } from 'react'; +import { observer } from 'mobx-react-lite'; +import { useStore } from 'store'; +import { styled } from 'components/theme'; +import BalanceSettings from './BalanceSettings'; +import GeneralSettings from './GeneralSettings'; +import UnitSettings from './UnitSettings'; + +const Styled = { + Wrapper: styled.div` + padding: 40px 0; + `, +}; + +const SettingsPage: React.FC = () => { + const { uiStore } = useStore(); + + let cmp: ReactNode; + switch (uiStore.selectedSetting) { + case 'unit': + cmp = ; + break; + case 'balance': + cmp = ; + break; + case 'general': + default: + cmp = ; + } + + const { Wrapper } = Styled; + return {cmp}; +}; + +export default observer(SettingsPage); diff --git a/app/src/components/settings/UnitSettings.tsx b/app/src/components/settings/UnitSettings.tsx new file mode 100644 index 000000000..6bf3a5301 --- /dev/null +++ b/app/src/components/settings/UnitSettings.tsx @@ -0,0 +1,59 @@ +import React, { useCallback } from 'react'; +import { observer } from 'mobx-react-lite'; +import { usePrefixedTranslation } from 'hooks'; +import { Unit } from 'util/constants'; +import { formatUnit } from 'util/formatters'; +import { useStore } from 'store'; +import PageHeader from 'components/common/PageHeader'; +import { styled } from 'components/theme'; +import SettingItem from './SettingItem'; + +const Styled = { + Wrapper: styled.section``, + Content: styled.div` + margin: 100px auto; + max-width: 500px; + `, +}; + +const UnitItem: React.FC<{ unit: Unit }> = observer(({ unit }) => { + const { settingsStore } = useStore(); + + const handleClick = useCallback(() => { + settingsStore.setUnit(unit); + }, [unit, settingsStore]); + + return ( + + ); +}); + +const UnitSettings: React.FC = () => { + const { l } = usePrefixedTranslation('cmps.settings.UnitSettings'); + const { uiStore } = useStore(); + + const handleBack = useCallback(() => uiStore.showSettings('general'), [uiStore]); + + const { Wrapper, Content } = Styled; + return ( + + + + + + + + + ); +}; + +export default observer(UnitSettings); diff --git a/app/src/components/theme.tsx b/app/src/components/theme.tsx index 390c53dcb..3beea2954 100644 --- a/app/src/components/theme.tsx +++ b/app/src/components/theme.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { BalanceLevel } from 'types/state'; + import emotionStyled, { CreateStyled } from '@emotion/styled/macro'; import { ThemeProvider as EmotionThemeProvider } from 'emotion-theming'; @@ -29,6 +29,7 @@ export interface Theme { orange: string; tileBack: string; purple: string; + lightPurple: string; }; } @@ -58,17 +59,10 @@ const theme: Theme = { orange: '#f66b1c', tileBack: 'rgba(245,245,245,0.04)', purple: '#57038d', + lightPurple: '#a540cd', }, }; -export const levelToColor = (level: BalanceLevel, active: boolean, theme: Theme) => { - if (!active) return theme.colors.gray; - - if (level === BalanceLevel.bad) return theme.colors.pink; - if (level === BalanceLevel.warn) return theme.colors.orange; - return theme.colors.green; -}; - export const styled = emotionStyled as CreateStyled; export const ThemeProvider: React.FC = ({ children }) => { diff --git a/app/src/i18n/locales/en-US.json b/app/src/i18n/locales/en-US.json index fe84bb37b..0852734c1 100644 --- a/app/src/i18n/locales/en-US.json +++ b/app/src/i18n/locales/en-US.json @@ -1,4 +1,7 @@ { + "enums.BalanceMode.receive": "Receiving", + "enums.BalanceMode.send": "Sending", + "enums.BalanceMode.routing": "Routing", "cmps.history.HistoryPage.backText": "Lightning Loop", "cmps.history.HistoryPage.pageTitle": "Loop History", "cmps.history.HistoryRowHeader.status": "Status", @@ -35,5 +38,15 @@ "cmps.layout.NavMenu.loop": "Lightning Loop", "cmps.layout.NavMenu.history": "History", "cmps.layout.NavMenu.settings": "Settings", - "cmps.NodeStatus.title": "Node Status" + "cmps.NodeStatus.title": "Node Status", + "cmps.settings.BalanceSettings.pageTitle": "Channel Balance Mode", + "cmps.settings.BalanceSettings.backText": "Settings", + "cmps.settings.BalanceSettings.title": "Optimize For", + "cmps.settings.GeneralSettings.title": "General", + "cmps.settings.GeneralSettings.bitcoinUnit": "Bitcoin Unit", + "cmps.settings.GeneralSettings.balances": "Channel Balance Mode", + "cmps.settings.GeneralSettings.balancesValue": "Optimize for {{mode}}", + "cmps.settings.GeneralSettings.pageTitle": "Settings", + "cmps.settings.UnitSettings.pageTitle": "Bitcoin Unit", + "cmps.settings.UnitSettings.backText": "Settings" } diff --git a/app/src/setupTests.ts b/app/src/setupTests.ts index d5f188840..297e49518 100644 --- a/app/src/setupTests.ts +++ b/app/src/setupTests.ts @@ -10,6 +10,9 @@ import './i18n'; // adds support for lottie-web animations in unit test env import 'jest-canvas-mock'; +// don't use the real localStorage in unit tests +jest.mock('util/appStorage'); + beforeEach(() => { jest.clearAllMocks(); }); diff --git a/app/src/store/models/channel.ts b/app/src/store/models/channel.ts index a08283a69..232eea596 100644 --- a/app/src/store/models/channel.ts +++ b/app/src/store/models/channel.ts @@ -1,8 +1,12 @@ import { action, computed, observable } from 'mobx'; import * as LND from 'types/generated/lnd_pb'; -import { BalanceLevel } from 'types/state'; +import { getBalanceStatus } from 'util/balances'; +import { BalanceMode, BalanceModes } from 'util/constants'; +import { Store } from 'store/store'; export default class Channel { + private _store: Store; + @observable chanId = ''; @observable remotePubkey = ''; @observable capacity = 0; @@ -12,7 +16,8 @@ export default class Channel { @observable uptime = 0; @observable lifetime = 0; - constructor(lndChannel: LND.Channel.AsObject) { + constructor(store: Store, lndChannel: LND.Channel.AsObject) { + this._store = store; this.update(lndChannel); } @@ -28,28 +33,36 @@ export default class Channel { * remote balances */ @computed get localPercent() { - return Math.round( - (this.localBalance * 100) / (this.localBalance + this.remoteBalance), - ); + return Math.floor((this.localBalance * 100) / this.capacity); } /** - * The imbalance percentage irregardless of direction + * The order to sort this channel based on the current mode */ - @computed get balancePercent() { - const pct = this.localPercent; - return pct >= 50 ? pct : 100 - pct; + @computed get sortOrder() { + const mode = this._store.settingsStore.balanceMode; + switch (mode) { + case BalanceMode.routing: + const pct = this.localPercent; + // disregard direction. the highest local percentage first + // 99 is the highest since we use Math.floor() + return Math.max(pct, 99 - pct); + case BalanceMode.send: + // the lowest local percentage first + return 100 - this.localPercent; + case BalanceMode.receive: + default: + // the highest local percentage first + return this.localPercent; + } } /** - * Determines the balance level of a channel based on the percentage on each side + * The balance status of this channel (ok, warn, or danger) */ - @computed get balanceLevel() { - const pct = this.balancePercent; - - if (pct > 85) return BalanceLevel.bad; - if (pct > 65) return BalanceLevel.warn; - return BalanceLevel.good; + @computed get balanceStatus() { + const mode = this._store.settingsStore.balanceMode; + return getBalanceStatus(this.localBalance, this.capacity, BalanceModes[mode]); } /** diff --git a/app/src/store/store.ts b/app/src/store/store.ts index b6d64be5e..990776238 100644 --- a/app/src/store/store.ts +++ b/app/src/store/store.ts @@ -1,5 +1,6 @@ import { observable } from 'mobx'; import { IS_DEV, IS_TEST } from 'config'; +import AppStorage from 'util/appStorage'; import { actionLog, Logger } from 'util/log'; import { GrpcClient, LndApi, LoopApi } from 'api'; import { @@ -10,6 +11,7 @@ import { SwapStore, UiStore, } from './stores'; +import { PersistentSettings } from './stores/settingsStore'; /** * The store used to manage global app state @@ -34,19 +36,29 @@ export class Store { /** the logger for actions to use when modifying state */ log: Logger; + /** the wrapper class around persistent storage */ + storage: AppStorage; + // 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) { + constructor( + lnd: LndApi, + loop: LoopApi, + storage: AppStorage, + log: Logger, + ) { this.api = { lnd, loop }; this.log = log; + this.storage = storage; } /** * load initial data to populate the store */ async init() { + this.settingsStore.init(); await this.channelStore.fetchChannels(); await this.swapStore.fetchSwaps(); await this.nodeStore.fetchBalances(); @@ -58,12 +70,16 @@ export class Store { * Creates an initialized Store instance with the dependencies injected * @param grpcClient an alternate GrpcClient to use instead of the default */ -export const createStore = (grpcClient?: GrpcClient) => { +export const createStore = ( + grpcClient?: GrpcClient, + appStorage?: AppStorage, +) => { const grpc = grpcClient || new GrpcClient(); + const storage = appStorage || new AppStorage(); const lndApi = new LndApi(grpc); const loopApi = new LoopApi(grpc); - const store = new Store(lndApi, loopApi, actionLog); + const store = new Store(lndApi, loopApi, storage, actionLog); // initialize the store immediately to fetch API data, except when running unit tests if (!IS_TEST) store.init(); diff --git a/app/src/store/stores/buildSwapStore.ts b/app/src/store/stores/buildSwapStore.ts index 4bc362e74..cdd6fe8fc 100644 --- a/app/src/store/stores/buildSwapStore.ts +++ b/app/src/store/stores/buildSwapStore.ts @@ -1,5 +1,6 @@ -import { action, computed, observable, runInAction, toJS } from 'mobx'; +import { action, computed, observable, runInAction, toJS, values } from 'mobx'; import { BuildSwapSteps, Quote, SwapDirection, SwapTerms } from 'types/state'; +import { formatSats } from 'util/formatters'; import { Store } from 'store'; // an artificial delay to allow the user to abort a swap before it executed @@ -98,7 +99,8 @@ class BuildSwapStore { @computed get feesLabel() { const feesPct = ((100 * this.fee) / this.amount).toFixed(2); - return `${this.fee.toLocaleString()} SAT (${feesPct}%)`; + const amount = formatSats(this.fee, { unit: this._store.settingsStore.unit }); + return `${amount} (${feesPct}%)`; } /** the invoice total including the swap amount and fee */ @@ -107,6 +109,22 @@ class BuildSwapStore { return this.amount + this.fee; } + /** infer a swap direction based on the selected channels */ + @computed + get inferredDirection(): SwapDirection | undefined { + if (this.selectedChanIds.length === 0) return undefined; + + // calculate the average local balance percent + const percents = values(this._store.channelStore.channels) + .filter(c => this.selectedChanIds.includes(c.chanId)) + .map(c => c.localPercent); + const sum = percents.reduce((s, p) => s + p, 0); + const avgPct = sum / percents.length; + + // if the average is low, suggest Loop In. Otherwise, suggest Loop Out + return avgPct < 50 ? SwapDirection.IN : SwapDirection.OUT; + } + // // Actions // diff --git a/app/src/store/stores/channelStore.ts b/app/src/store/stores/channelStore.ts index 2e72e38fa..c5d947585 100644 --- a/app/src/store/stores/channelStore.ts +++ b/app/src/store/stores/channelStore.ts @@ -26,7 +26,7 @@ export default class ChannelStore { @computed get sortedChannels() { return values(this.channels) .slice() - .sort((a, b) => b.balancePercent - a.balancePercent); + .sort((a, b) => b.sortOrder - a.sortOrder); } /** @@ -61,7 +61,7 @@ export default class ChannelStore { if (existing) { existing.update(lndChan); } else { - this.channels.set(lndChan.chanId, new Channel(lndChan)); + this.channels.set(lndChan.chanId, new Channel(this._store, lndChan)); } }); // remove any channels in state that are not in the API response diff --git a/app/src/store/stores/settingsStore.ts b/app/src/store/stores/settingsStore.ts index c16e7d28a..3c0fd4005 100644 --- a/app/src/store/stores/settingsStore.ts +++ b/app/src/store/stores/settingsStore.ts @@ -1,11 +1,28 @@ -import { action, observable, toJS } from 'mobx'; +import { action, autorun, observable, toJS } from 'mobx'; +import { BalanceMode, Unit } from 'util/constants'; import { Store } from 'store'; +export interface PersistentSettings { + sidebarVisible: boolean; + unit: Unit; + balanceMode: BalanceMode; +} + export default class SettingsStore { private _store: Store; + /** determines if the sidebar nav is visible */ @observable sidebarVisible = true; + /** specifies which denomination to show units in */ + @observable unit: Unit = Unit.sats; + + /** specifies the mode to use to determine channel balance status */ + @observable balanceMode: BalanceMode = BalanceMode.receive; + + /** the chosen language */ + @observable lang = 'en-US'; + constructor(store: Store) { this._store = store; } @@ -18,4 +35,50 @@ export default class SettingsStore { this.sidebarVisible = !this.sidebarVisible; this._store.log.info('updated SettingsStore.showSidebar', toJS(this.sidebarVisible)); } + + /** + * sets the unit to display throughout the app + */ + @action.bound setUnit(unit: Unit) { + this.unit = unit; + } + + /** + * sets the balance mode + */ + @action.bound setBalanceMode(mode: BalanceMode) { + this.balanceMode = mode; + } + + /** + * initialized the settings and auto-save when a setting is changed + */ + @action.bound + init() { + this.load(); + autorun(() => { + const settings: PersistentSettings = { + sidebarVisible: this.sidebarVisible, + unit: this.unit, + balanceMode: this.balanceMode, + }; + this._store.storage.set('settings', settings); + this._store.log.info('saved settings to localStorage', settings); + }); + } + + /** + * load settings from the browser's local storage + */ + @action.bound + load() { + this._store.log.info('loading settings from localStorage'); + const settings = this._store.storage.get('settings'); + if (settings) { + this.sidebarVisible = settings.sidebarVisible; + this.unit = settings.unit; + this.balanceMode = settings.balanceMode; + this._store.log.info('loaded settings', settings); + } + } } diff --git a/app/src/store/stores/uiStore.ts b/app/src/store/stores/uiStore.ts index 3d9be1d91..7a31d1dbe 100644 --- a/app/src/store/stores/uiStore.ts +++ b/app/src/store/stores/uiStore.ts @@ -1,7 +1,9 @@ import { action, observable } from 'mobx'; import { Store } from 'store'; -type PageName = 'loop' | 'history'; +type PageName = 'loop' | 'history' | 'settings'; + +type SettingName = 'general' | 'unit' | 'balance'; export default class UiStore { private _store: Store; @@ -10,33 +12,44 @@ export default class UiStore { @observable page: PageName = 'loop'; /** indicates if the Processing Loops section is displayed on the Loop page */ @observable processingSwapsVisible = false; + /** the selected setting on the Settings page */ + @observable selectedSetting: SettingName = 'general'; constructor(store: Store) { this._store = store; } - /** - * Change to the Loop page - */ + /** Change to the Loop page */ @action.bound goToLoop() { this.page = 'loop'; this._store.log.info('Go to the Loop page'); } - /** - * Change to the History page - */ + /** Change to the History page */ @action.bound goToHistory() { this.page = 'history'; + this._store.log.info('Go to the History page'); + } + + /** Change to the History page */ + @action.bound + goToSettings() { + this.page = 'settings'; + this.selectedSetting = 'general'; + this._store.log.info('Go to the Settings page'); } - /** - * Toggle displaying of the Processing Loops section - */ + /** Toggle displaying of the Processing Loops section */ @action.bound toggleProcessingSwaps() { this.processingSwapsVisible = !this.processingSwapsVisible; } + + /** sets the selected setting to display */ + @action.bound + showSettings(name: SettingName) { + this.selectedSetting = name; + } } diff --git a/app/src/types/state.ts b/app/src/types/state.ts index 8b586b06a..b7d16ff78 100644 --- a/app/src/types/state.ts +++ b/app/src/types/state.ts @@ -1,9 +1,3 @@ -export enum BalanceLevel { - good = 'good', - warn = 'warn', - bad = 'bad', -} - export enum SwapDirection { IN = 'Loop In', OUT = 'Loop Out', diff --git a/app/src/util/appStorage.ts b/app/src/util/appStorage.ts new file mode 100644 index 000000000..ec61e4427 --- /dev/null +++ b/app/src/util/appStorage.ts @@ -0,0 +1,18 @@ +export default class AppStorage { + /** + * stores data in the browser local storage + */ + set(key: string, data: T) { + localStorage.setItem(key, JSON.stringify(data)); + } + + /** + * retrieves data from the browser local storage + */ + get(key: string): T | undefined { + const json = localStorage.getItem(key); + if (json) { + return JSON.parse(json) as T; + } + } +} diff --git a/app/src/util/balances.ts b/app/src/util/balances.ts new file mode 100644 index 000000000..49641778a --- /dev/null +++ b/app/src/util/balances.ts @@ -0,0 +1,51 @@ +import { Theme } from 'components/theme'; +import { BalanceConfig, BalanceConstraint, BalanceStatus } from './constants'; + +/** + * Returns true if the local balance percentage satisfies the constraint + * @param pct the percentage of the local balance + * @param constraint the constraint to check the pct against + */ +const satisfies = (pct: number, constraint: BalanceConstraint) => { + const { min, max, bidirectional } = constraint; + + if (bidirectional && pct < 50) { + // 99 is the highest since we use Math.floor() + pct = 99 - pct; + } + + return min <= pct && pct < max; +}; + +/** + * Returns the current status of the local balance of a channel + * @param local the local balance of the channel + * @param capacity the total capacity of the channel + * @param config the balance configuration (receive, send, routing) + */ +export const getBalanceStatus = ( + local: number, + capacity: number, + config: BalanceConfig, +): BalanceStatus => { + const pct = Math.floor((local * 100) / capacity); + + if (satisfies(pct, config.danger)) return BalanceStatus.danger; + if (satisfies(pct, config.warn)) return BalanceStatus.warn; + + return BalanceStatus.ok; +}; + +/** + * Converts a channel balance status to a theme color + * @param level the status of the channel + * @param active whether the channel is active or not + * @param theme the app theme containing colors + */ +export const statusToColor = (level: BalanceStatus, active: boolean, theme: Theme) => { + if (!active) return theme.colors.gray; + + if (level === BalanceStatus.danger) return theme.colors.pink; + if (level === BalanceStatus.warn) return theme.colors.orange; + return theme.colors.green; +}; diff --git a/app/src/util/constants.ts b/app/src/util/constants.ts new file mode 100644 index 000000000..a4371c43c --- /dev/null +++ b/app/src/util/constants.ts @@ -0,0 +1,68 @@ +/** the enumeration of unit supported in the app */ +export enum Unit { + sats = 'sats', + bits = 'bits', + btc = 'btc', +} + +interface UnitFormat { + suffix: string; + name: string; + denominator: number; + decimals: number; +} + +/** a mapping of units to parameters that define how it should be formatted */ +export const Units: { [key in Unit]: UnitFormat } = { + sats: { suffix: 'sats', name: 'Satoshis', denominator: 1, decimals: 0 }, + bits: { suffix: 'bits', name: 'Bits', denominator: 100, decimals: 2 }, + btc: { suffix: 'BTC', name: 'Bitcoin', denominator: 100000000, decimals: 8 }, +}; + +/** the different operating modes of a LN node, used to decide what is considered a good/bad channel balance */ +export enum BalanceMode { + receive = 'receive', + send = 'send', + routing = 'routing', +} + +/** the different balance statuses that a channel may have */ +export enum BalanceStatus { + ok = 'ok', + warn = 'warn', + danger = 'danger', +} + +/** the constraints that need to be satisfied to meet obtain balance status */ +export interface BalanceConstraint { + /** the minimum local balance percentage to satisfy the status */ + min: number; + /** the maximum local balance percentage to satisfy the status */ + max: number; + /** if true, either local or remote balance can satisfy the status */ + bidirectional?: boolean; +} + +/** the balance config params for each status */ +export type BalanceConfig = { + [key in BalanceStatus]: BalanceConstraint; +}; + +/** hard-coded configs for all modes and statuses for channel balances */ +export const BalanceModes: { [key in BalanceMode]: BalanceConfig } = { + receive: { + ok: { min: 0, max: 33 }, + warn: { min: 33, max: 66 }, + danger: { min: 66, max: 100 }, + }, + send: { + ok: { min: 66, max: 100 }, + warn: { min: 33, max: 66 }, + danger: { min: 0, max: 33 }, + }, + routing: { + ok: { min: 50, max: 70, bidirectional: true }, + warn: { min: 70, max: 85, bidirectional: true }, + danger: { min: 85, max: 100, bidirectional: true }, + }, +}; diff --git a/app/src/util/formatters.ts b/app/src/util/formatters.ts new file mode 100644 index 000000000..9d3f8680f --- /dev/null +++ b/app/src/util/formatters.ts @@ -0,0 +1,45 @@ +import { Unit, Units } from './constants'; + +interface FormatSatsOptions { + /** the units to convert the sats to (defaults to `sats`) */ + unit?: Unit; + /** if true, return the units abbreviation as a suffix after the amount */ + withSuffix?: boolean; + /** the language to use to determine the format of the number */ + lang?: string; +} + +/** the default values to use for the formatSats function */ +const defaultFormatSatsOptions = { + unit: Unit.sats, + withSuffix: true, + lang: 'en-US', +}; + +/** + * Converts a number representing an amount of satoshis to a string + * @param sats the numeric value in satoshis + * @param options the format options + */ +export const formatSats = (sats: number, options?: FormatSatsOptions) => { + const { unit, withSuffix, lang } = Object.assign({}, defaultFormatSatsOptions, options); + const { suffix, denominator, decimals } = Units[unit]; + const formatter = Intl.NumberFormat(lang, { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }); + let text = formatter.format(sats / denominator); + if (withSuffix) text = `${text} ${suffix}`; + return text; +}; + +/** + * Formats a specific unit to display the name and amount in BTC + * ex: Satoshis (0.00000001 BTC) + * @param unit the unit to describe + */ +export const formatUnit = (unit: Unit) => { + const { name, denominator } = Units[unit]; + const btcValue = formatSats(denominator, { unit: Unit.btc }); + return `${name} (${btcValue})`; +}; diff --git a/app/src/util/log.ts b/app/src/util/log.ts index 131c3c8ab..0ca1537fd 100644 --- a/app/src/util/log.ts +++ b/app/src/util/log.ts @@ -1,7 +1,7 @@ import { IS_DEV } from 'config'; import debug, { Debugger } from 'debug'; -enum LogLevel { +export enum LogLevel { debug = 1, info = 2, warn = 3,