diff --git a/app/.prettierrc b/.prettierrc similarity index 93% rename from app/.prettierrc rename to .prettierrc index 8d0738be6..b094fe314 100644 --- a/app/.prettierrc +++ b/.prettierrc @@ -8,6 +8,7 @@ } ], "printWidth": 90, + "proseWrap": "always", "singleQuote": true, "useTabs": false, "semi": true, @@ -16,4 +17,4 @@ "bracketSpacing": true, "jsxBracketSameLine": false, "arrowParens": "avoid" -} \ No newline at end of file +} diff --git a/README.md b/README.md index 883c0de74..6eec590e2 100644 --- a/README.md +++ b/README.md @@ -4,34 +4,69 @@ ![screenshot](./app/src/assets/images/screenshot.png) -Shushtar is a browser-based interface for managing the off-chain liquidity of your `lnd` Lightning Network node. It presents a visual representation of your channels and balances, while allowing you to perform submarine swaps via the [Lightning Loop](https://lightning.engineering/loop) service using a graphical interface. With a bird's eye view of all of your open channels, you can instantly see which ones need your immediate attention. +Shushtar is a browser-based interface for managing the off-chain liquidity of your `lnd` +Lightning Network node. It presents a visual representation of your channels and balances, +while allowing you to perform submarine swaps via the +[Lightning Loop](https://lightning.engineering/loop) service using a graphical interface. +With a bird's eye view of all of your open channels, you can instantly see which ones need +your immediate attention. You can configure the UI to classify channels according to your node's operating mode. -- **Optimize for Receiving**: For merchants who primarily receive inbound Lightning payments, the channels with high local balances will be shaded red. -- **Optimize for Routing**: For routing node operators, that want to keep their channels balanced close to 50%, the channels with a high balance in either direction will be flagged. -- **Optimize for Sending**: For exchanges, fiat gateways, and other operators who primarily send outgoing Lightning payments, the channels with high local balances will be shaded red. +- **Optimize for Receiving**: For merchants who primarily receive inbound Lightning + payments, the channels with high local balances will be shaded red. +- **Optimize for Routing**: For routing node operators, that want to keep their channels + balanced close to 50%, the channels with a high balance in either direction will be + flagged. +- **Optimize for Sending**: For exchanges, fiat gateways, and other operators who + primarily send outgoing Lightning payments, the channels with high local balances will + be shaded red. ## Architecture -Shushtar is packaged as a single binary which contains the [`lnd`](https://github.com/lightningnetwork/lnd), [`loopd`](https://github.com/lightninglabs/loop) and [`faraday`](https://github.com/lightninglabs/faraday) daemons all in one. It also contains an HTTP server to serve the web assets (html/js/css) and a GRPC proxy to forward web requests from the browser to the appropriate GRPC server. This deployment strategy was chosen as it greatly simplifies the operational overhead of installation, configuration and maintenance that would be necessary to run each of these servers independently. You only need to download one executable and run one command to get Shushtar up and running. We include the CLI binaries `lncli`, `loop` and `frcli` for convenience in the downloadable archives as well. +Shushtar is packaged as a single binary which contains the +[`lnd`](https://github.com/lightningnetwork/lnd), +[`loopd`](https://github.com/lightninglabs/loop) and +[`faraday`](https://github.com/lightninglabs/faraday) daemons all in one. It also contains +an HTTP server to serve the web assets (html/js/css) and a GRPC proxy to forward web +requests from the browser to the appropriate GRPC server. This deployment strategy was +chosen as it greatly simplifies the operational overhead of installation, configuration +and maintenance that would be necessary to run each of these servers independently. You +only need to download one executable and run one command to get Shushtar up and running. +We include the CLI binaries `lncli`, `loop` and `frcli` for convenience in the +downloadable archives as well. ## Installation -There are two options for installing Shushtar: download the published binaries for your platform, or compile from source code. +There are two options for installing Shushtar: download the published binaries for your +platform, or compile from source code. #### Download Binaries -Shushtar binaries for many platforms are made available on the GitHub [Releases](https://github.com/lightninglabs/shushtar/releases) page in this repo. There you can download the latest version and extract the archive into a directory on your computer. +Shushtar binaries for many platforms are made available on the GitHub +[Releases](https://github.com/lightninglabs/shushtar/releases) page in this repo. There +you can download the latest version and extract the archive into a directory on your +computer. #### Compile from Source Code -To compile from source code, you'll need to have some prerequisite developer tooling installed on your machine. +To compile from source code, you'll need to have some prerequisite developer tooling +installed on your machine. -- **Go**: Shushtar's backend web server is written in Go. Instructions for installing Go for your operating system can be found on the [golang install](https://golang.org/doc/install) page. The minimum version supported is Go v1.13. -- **NodeJS**: Shushtar's frontend is written in TypeScript and built on top of the React JS web framework. To bundle the assets into Javascript & CSS compatible with web browsers, NodeJS is required. It can be downloaded and installed by following the instructions on the [NodeJS download](https://nodejs.org/en/download/) page. +- **Go**: Shushtar's backend web server is written in Go. Instructions for installing Go + for your operating system can be found on the + [golang install](https://golang.org/doc/install) page. The minimum version supported is + Go v1.13. +- **NodeJS**: Shushtar's frontend is written in TypeScript and built on top of the React + JS web framework. To bundle the assets into Javascript & CSS compatible with web + browsers, NodeJS is required. It can be downloaded and installed by following the + instructions on the [NodeJS download](https://nodejs.org/en/download/) page. +- **Yarn**: a popular package manager for NodeJS application dependencies. Installation + information can be found on the + [Yarn Installation](https://classic.yarnpkg.com/en/docs/install) page. -Once you have the necessary prerequisites, Shushtar can be compiled by running the following commands: +Once you have the necessary prerequisites, Shushtar can be compiled by running the +following commands: ``` git clone https://github.com/lightninglabs/shushtar.git @@ -47,13 +82,31 @@ Shushtar only has a few configuration parameters itself. #### Required -You must set `httpslisten` to the host & port that the https server should listen on. Also set `uipassword` to a strong password to use to login to the website in your browser. A minimum of 8 characters is required. In a production environment, it's recommended that you store this password as an environment variable. +You must set `httpslisten` to the host & port that the https server should listen on. Also +set `uipassword` to a strong password to use to login to the website in your browser. A +minimum of 8 characters is required. In a production environment, it's recommended that +you store this password as an environment variable. #### Optional -You can also configure the HTTP server to automatically install a free SSL certificate provided by [LetsEncrypt](https://letsencrypt.org/). This is recommended if you plan to access the website from a remote computer and do not want to deal with the browser warning you about the self-signed certificate. You just need to specify the domain name you wish to use, and make sure port 80 is open in your in your firewall. LetsEncrypt requires this to verify that you own the domain name. Shushtar will listen on port 80 to handle the verification requests. On some linux-based platforms, you may need to run Shushtar with superuser privileges since port 80 is a system port. - -> Note: Shushtar only serves content over **HTTPS**. If you do not use `letsencrypt`, Shushtar will use the self-signed certificate that is auto-generated by `lnd` to encrypt the browser-to-server communication. Web browsers will display a warning when using the self-signed certificate. +You can also configure the HTTP server to automatically install a free SSL certificate +provided by [LetsEncrypt](https://letsencrypt.org/). This is recommended if you plan to +access the website from a remote computer and do not want to deal with the browser warning +you about the self-signed certificate. You just need to specify the domain name you wish +to use, and make sure port 80 is open in your in your firewall. LetsEncrypt requires this +to verify that you own the domain name. Shushtar will listen on port 80 to handle the +verification requests. + +On some linux-based platforms, you may need to run Shushtar with superuser privileges +since port 80 is a system port. You can permit the +[`CAP_NET_BIND_SERVICE`](https://www.man7.org/linux/man-pages/man7/capabilities.7.html) +capability using `setcap 'CAP_NET_BIND_SERVICE=+eip' /path/to/shushtar` to allow binding +on port 80 without needing to run the daemon as root. + +> Note: Shushtar only serves content over **HTTPS**. If you do not use `letsencrypt`, +> Shushtar will use the self-signed certificate that is auto-generated by `lnd` to encrypt +> the browser-to-server communication. Web browsers will display a warning when using the +> self-signed certificate. ``` Application Options: @@ -68,7 +121,14 @@ Application Options: certificate (default: /Users/jamal/Library/Application Support/Lnd/letsencrypt) ``` -In addition to the Shushtar specific parameters, you must also provide configuration to the `lnd`, `loop` and `faraday` daemons. For `lnd`, each flag must be prefixed with `lnd.` (ex: `lnd.lnddir=~/.lnd`). Please see the [sample-lnd.conf](https://github.com/lightningnetwork/lnd/blob/master/sample-lnd.conf) file for more details on the available parameters. Note that `loopd` and `faraday` will automatically connect to the in-process `lnd` node, so you do not need to provide them with any additional parameters unless you want to override them. If you do override them, be sure to add the `loop.` and `faraday.` prefixes. +In addition to the Shushtar specific parameters, you must also provide configuration to +the `lnd`, `loop` and `faraday` daemons. For `lnd`, each flag must be prefixed with `lnd.` +(ex: `lnd.lnddir=~/.lnd`). Please see the +[sample-lnd.conf](https://github.com/lightningnetwork/lnd/blob/master/sample-lnd.conf) +file for more details on the available parameters. Note that `loopd` and `faraday` will +automatically connect to the in-process `lnd` node, so you do not need to provide them +with any additional parameters unless you want to override them. If you do override them, +be sure to add the `loop.` and `faraday.` prefixes. Here is an example command to start `shushtar` on testnet with a local `bitcoind` node: @@ -96,14 +156,13 @@ $ ./shushtar \ --faraday.min_monitored=48h ``` -You can also store the configuration in a persistent `lnd.conf` file so you do -not need to type in the command line arguments every time you start the server. -Just remember to use the appropriate prefixes as necessary. +You can also store the configuration in a persistent `lnd.conf` file so you do not need to +type in the command line arguments every time you start the server. Just remember to use +the appropriate prefixes as necessary. -Also. make sure to include the `lnd` general options in the `[Application Options]` -section because the section name `[Lnd]` is not unique anymore because of how we -combine the configurations of all daemons. This will hopefully be fixed in a -future release. +Also make sure to include the `lnd` general options in the `[Application Options]` section +because the section name `[Lnd]` is not unique anymore because of how we combine the +configurations of all daemons. This will hopefully be fixed in a future release. Example `lnd.conf`: @@ -148,36 +207,48 @@ The default location for the `lnd.conf` file will depend on your operating syste ### Upgrade Existing Nodes -If you already have existing `lnd`, `loop`, or `faraday` nodes, you can easily upgrade them to the Shushtar single executable while keeping all of your past data. +If you already have existing `lnd`, `loop`, or `faraday` nodes, you can easily upgrade +them to the Shushtar single executable while keeping all of your past data. For `lnd`: -- if you use an `lnd.conf` file for configurations, add the `lnd.` prefix to each of the configuration parameters. - +- if you use an `lnd.conf` file for configurations, add the `lnd.` prefix to each of the + configuration parameters. + Before: + ``` [Application Options] alias=merchant ``` + After: + ``` [Application Options] lnd.alias=merchant ``` -- if you use command line arguments for configuration, add the `lnd.` prefix to each argument to `shushtar` - + +- if you use command line arguments for configuration, add the `lnd.` prefix to each + argument to `shushtar` + Before: + ``` $ lnd --lnddir=~/.lnd --alias=merchant ... ``` + After: + ``` $ shushtar lnd.lnddir=~/.lnd --lnd.alias=merchant ... ``` For `loop`: -- if you use an `loop.conf` file for configurations, copy the parameters into the `lnd.conf` file that `shushtar` uses, and add the `loop.` prefix to each of the configuration parameters. +- if you use an `loop.conf` file for configurations, copy the parameters into the + `lnd.conf` file that `shushtar` uses, and add the `loop.` prefix to each of the + configuration parameters. Before: (in `loop.conf`) @@ -193,64 +264,91 @@ For `loop`: loop.loopoutmaxparts=5 ``` -- if you use command line arguments for configuration, add the `loop.` prefix to each argument to `shushtar` - +- if you use command line arguments for configuration, add the `loop.` prefix to each + argument to `shushtar` + Before: + ``` $ loop --loopoutmaxparts=5 --debuglevel=debug ... ``` + After: + ``` $ shushtar --loop.loopoutmaxparts=5 --loop.debuglevel=debug ... ``` For `faraday`: -- the standalone `faraday` daemon does not load configuration from a file, but you can now store the parameters into the `lnd.conf` file that `shushtar` uses. Just add the `faraday.` prefix to each of the configuration parameters. +- the standalone `faraday` daemon does not load configuration from a file, but you can now + store the parameters into the `lnd.conf` file that `shushtar` uses. Just add the + `faraday.` prefix to each of the configuration parameters. Before: (from command line) + ``` $ faraday --min_monitored=48h ``` + After: (in `lnd.conf`) + ``` [Faraday] faraday.min_monitored=48h ``` -- if you use command line arguments for configuration, add the `faraday.` prefix to each argument to `shushtar` - +- if you use command line arguments for configuration, add the `faraday.` prefix to each + argument to `shushtar` + Before: + ``` $ faraday --min_monitored=48h --debuglevel=debug ... ``` + After: + ``` $ shushtar --faraday.min_monitored=48h --faraday.debuglevel=debug... ``` ### Troubleshooting -If you have trouble running your node, please first check the logs for warnings or errors. If there are errors relating to one of the embedded servers, then you should open an issue in their respective GitHub repos ([lnd](https://github.com/lightningnetwork/lnd/issues), [loop](https://github.com/lightninglabs/loop/issues), [faraday](https://github.com/lightninglabs/faraday/issues). If the issue is related to the web app, then you should open an [issue](https://github.com/lightninglabs/shushtar/issues) here in this repo. +If you have trouble running your node, please first check the logs for warnings or errors. +If there are errors relating to one of the embedded servers, then you should open an issue +in their respective GitHub repos ([lnd](https://github.com/lightningnetwork/lnd/issues), +[loop](https://github.com/lightninglabs/loop/issues), +[faraday](https://github.com/lightninglabs/faraday/issues). If the issue is related to the +web app, then you should open an [issue](https://github.com/lightninglabs/shushtar/issues) +here in this repo. #### Server -Server-side logs are stored in the directory specified by `lnd.lnddir` in your configuration. Inside, there is a `logs` dir containing the log files in subdirectories. Be sure to set `lnd.debuglevel=debug` in your configuration to see the most verbose logging information. +Server-side logs are stored in the directory specified by `lnd.lnddir` in your +configuration. Inside, there is a `logs` dir containing the log files in subdirectories. +Be sure to set `lnd.debuglevel=debug` in your configuration to see the most verbose +logging information. #### Browser -Client-side logs are disabled by default in production builds. Logging can be turned on by adding a couple keys to your browser's `localStorage`. Simply run these two JS statements in you browser's DevTools console then refresh the page: +Client-side logs are disabled by default in production builds. Logging can be turned on by +adding a couple keys to your browser's `localStorage`. Simply run these two JS statements +in you browser's DevTools console then refresh the page: ``` localStorage.setItem('debug', '*'); localStorage.setItem('debug-level', 'debug'); ``` -The value for `debug` is a namespace filter which determines which portions of the app to display logs for. The namespaces currently used by the app are as follows: +The value for `debug` is a namespace filter which determines which portions of the app to +display logs for. The namespaces currently used by the app are as follows: - `main`: logs general application messages - `action`: logs all actions that modify the internal application state - `grpc`: logs all GRPC API requests and responses -Example filters: `main,action` will only log main and action messages. `*,-action` will log everything except action messages. +Example filters: `main,action` will only log main and action messages. `*,-action` will +log everything except action messages. -The value for `debug-level` determines the verbosity of the logs. The value can be one of `debug`, `info`, `warn`, or `error`. +The value for `debug-level` determines the verbosity of the logs. The value can be one of +`debug`, `info`, `warn`, or `error`. diff --git a/app/README.md b/app/README.md deleted file mode 100644 index 64e343e18..000000000 --- a/app/README.md +++ /dev/null @@ -1,44 +0,0 @@ -This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). - -## Available Scripts - -In the project directory, you can run: - -### `yarn start` - -Runs the app in the development mode.
-Open [http://localhost:3000](http://localhost:3000) to view it in the browser. - -The page will reload if you make edits.
-You will also see any lint errors in the console. - -### `yarn test` - -Launches the test runner in the interactive watch mode.
-See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. - -### `yarn build` - -Builds the app for production to the `build` folder.
-It correctly bundles React in production mode and optimizes the build for the best performance. - -The build is minified and the filenames include the hashes.
-Your app is ready to be deployed! - -See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. - -### `yarn eject` - -**Note: this is a one-way operation. Once you `eject`, you can’t go back!** - -If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. - -Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. - -You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. - -## Learn More - -You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). - -To learn React, check out the [React documentation](https://reactjs.org/). diff --git a/app/public/index.html b/app/public/index.html index 017a815d8..5dfb05361 100644 --- a/app/public/index.html +++ b/app/public/index.html @@ -3,7 +3,6 @@ - diff --git a/app/src/App.scss b/app/src/App.scss index 30880b37d..0d7052f30 100644 --- a/app/src/App.scss +++ b/app/src/App.scss @@ -75,4 +75,5 @@ body, #root { min-height: 100vh; width: 100%; + min-width: 1024px; } diff --git a/app/src/__stories__/ChannelRow.stories.tsx b/app/src/__stories__/ChannelRow.stories.tsx index 44a21f47b..9351f770e 100644 --- a/app/src/__stories__/ChannelRow.stories.tsx +++ b/app/src/__stories__/ChannelRow.stories.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { useObserver } from 'mobx-react-lite'; +import { SwapState, SwapType } from 'types/generated/loop_pb'; import { SwapDirection } from 'types/state'; import { lndListChannels } from 'util/tests/sampleData'; import { useStore } from 'store'; @@ -87,3 +88,37 @@ export const Dimmed = () => { store.buildSwapStore.setDirection(SwapDirection.OUT); return renderStory(channel); }; + +export const LoopingIn = () => { + const store = useStore(); + const channel = new Channel(store, lndListChannels.channelsList[0]); + const swap = store.swapStore.sortedSwaps[0]; + swap.type = SwapType.LOOP_IN; + swap.state = SwapState.INITIATED; + store.swapStore.addSwappedChannels(swap.id, [channel.chanId]); + return renderStory(channel, { ratio: 0.3 }); +}; + +export const LoopingOut = () => { + const store = useStore(); + const channel = new Channel(store, lndListChannels.channelsList[0]); + const swap = store.swapStore.sortedSwaps[0]; + swap.type = SwapType.LOOP_OUT; + swap.state = SwapState.INITIATED; + store.swapStore.addSwappedChannels(swap.id, [channel.chanId]); + return renderStory(channel, { ratio: 0.3 }); +}; + +export const LoopingInAndOut = () => { + const store = useStore(); + const channel = new Channel(store, lndListChannels.channelsList[0]); + const swap1 = store.swapStore.sortedSwaps[0]; + swap1.type = SwapType.LOOP_IN; + swap1.state = SwapState.INITIATED; + store.swapStore.addSwappedChannels(swap1.id, [channel.chanId]); + const swap2 = store.swapStore.sortedSwaps[1]; + swap2.type = SwapType.LOOP_OUT; + swap2.state = SwapState.INITIATED; + store.swapStore.addSwappedChannels(swap2.id, [channel.chanId]); + return renderStory(channel, { ratio: 0.3 }); +}; diff --git a/app/src/__tests__/components/loop/ChannelBalance.spec.tsx b/app/src/__tests__/components/loop/ChannelBalance.spec.tsx index b8d85701b..fa284c3c0 100644 --- a/app/src/__tests__/components/loop/ChannelBalance.spec.tsx +++ b/app/src/__tests__/components/loop/ChannelBalance.spec.tsx @@ -40,8 +40,8 @@ describe('ChannelBalance component', () => { const { el, remote, local } = render(0.52); expect(el.children.length).toBe(3); expect(width(local)).toBe('52%'); - expect(bgColor(local)).toBe('rgb(246, 107, 28)'); - expect(bgColor(remote)).toBe('rgb(246, 107, 28)'); + expect(bgColor(local)).toBe('rgb(255, 249, 23)'); + expect(bgColor(remote)).toBe('rgb(255, 249, 23)'); }); it('should display a bad balance', () => { diff --git a/app/src/__tests__/components/loop/ChannelRow.spec.tsx b/app/src/__tests__/components/loop/ChannelRow.spec.tsx index 0781bb00e..ed386d638 100644 --- a/app/src/__tests__/components/loop/ChannelRow.spec.tsx +++ b/app/src/__tests__/components/loop/ChannelRow.spec.tsx @@ -1,10 +1,11 @@ import React from 'react'; +import { SwapState, SwapType } from 'types/generated/loop_pb'; 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 { Channel } from 'store/models'; +import { Channel, Swap } from 'store/models'; import ChannelRow from 'components/loop/ChannelRow'; describe('ChannelRow component', () => { @@ -54,6 +55,16 @@ describe('ChannelRow component', () => { expect(getByText(channel.aliasLabel)).toBeInTheDocument(); }); + it('should display the peer pubkey & alias tooltip', () => { + const { getByText, getAllByText } = render(); + channel.alias = 'test-alias'; + fireEvent.mouseEnter(getByText(channel.aliasLabel)); + expect(getByText(channel.remotePubkey)).toBeInTheDocument(); + expect(getAllByText(channel.alias)).toHaveLength(2); + channel.alias = channel.remotePubkey.substring(12); + expect(getByText(channel.remotePubkey)).toBeInTheDocument(); + }); + it('should display the capacity', () => { const { getByText } = render(); expect( @@ -121,4 +132,44 @@ describe('ChannelRow component', () => { fireEvent.click(getByRole('checkbox')); expect(store.buildSwapStore.selectedChanIds).toEqual([]); }); + + describe('pending swaps', () => { + let swap1: Swap; + let swap2: Swap; + + beforeEach(() => { + swap1 = store.swapStore.sortedSwaps[0]; + swap2 = store.swapStore.sortedSwaps[1]; + swap1.state = swap2.state = SwapState.INITIATED; + }); + + it('should display the pending Loop In icon', () => { + swap1.type = SwapType.LOOP_IN; + store.swapStore.addSwappedChannels(swap1.id, [channel.chanId]); + const { getByText } = render(); + expect(getByText('chevrons-right.svg')).toBeInTheDocument(); + fireEvent.mouseEnter(getByText('chevrons-right.svg')); + expect(getByText('Loop In currently in progress')).toBeInTheDocument(); + }); + + it('should display the pending Loop Out icon', () => { + swap1.type = SwapType.LOOP_OUT; + store.swapStore.addSwappedChannels(swap1.id, [channel.chanId]); + const { getByText } = render(); + expect(getByText('chevrons-left.svg')).toBeInTheDocument(); + fireEvent.mouseEnter(getByText('chevrons-left.svg')); + expect(getByText('Loop Out currently in progress')).toBeInTheDocument(); + }); + + it('should display the pending Loop In and Loop Out icon', () => { + swap1.type = SwapType.LOOP_IN; + swap2.type = SwapType.LOOP_OUT; + store.swapStore.addSwappedChannels(swap1.id, [channel.chanId]); + store.swapStore.addSwappedChannels(swap2.id, [channel.chanId]); + const { getByText } = render(); + expect(getByText('chevrons.svg')).toBeInTheDocument(); + fireEvent.mouseEnter(getByText('chevrons.svg')); + expect(getByText('Loop In and Loop Out currently in progress')).toBeInTheDocument(); + }); + }); }); diff --git a/app/src/__tests__/components/loop/LoopPage.spec.tsx b/app/src/__tests__/components/loop/LoopPage.spec.tsx index ab97ed236..3d88e59b2 100644 --- a/app/src/__tests__/components/loop/LoopPage.spec.tsx +++ b/app/src/__tests__/components/loop/LoopPage.spec.tsx @@ -17,6 +17,7 @@ describe('LoopPage component', () => { beforeEach(async () => { store = createStore(); await store.fetchAllData(); + await store.buildSwapStore.getTerms(); }); const render = () => { @@ -106,7 +107,7 @@ describe('LoopPage component', () => { store.channelStore.sortedChannels.slice(0, 1).forEach(c => { store.buildSwapStore.toggleSelectedChannel(c.chanId); }); - fireEvent.click(getByText('Loop In')); + fireEvent.click(getByText('Loop Out')); expect(getByText('Step 1 of 2')).toBeInTheDocument(); }); @@ -117,7 +118,7 @@ describe('LoopPage component', () => { store.channelStore.sortedChannels.slice(0, 1).forEach(c => { store.buildSwapStore.toggleSelectedChannel(c.chanId); }); - fireEvent.click(getByText('Loop In')); + fireEvent.click(getByText('Loop Out')); expect(getByText('Step 1 of 2')).toBeInTheDocument(); fireEvent.click(getByText('arrow-left.svg')); expect(getByText('Loop History')).toBeInTheDocument(); diff --git a/app/src/__tests__/components/loop/ProcessingSwaps.spec.tsx b/app/src/__tests__/components/loop/ProcessingSwaps.spec.tsx index 49526b444..5acb2195a 100644 --- a/app/src/__tests__/components/loop/ProcessingSwaps.spec.tsx +++ b/app/src/__tests__/components/loop/ProcessingSwaps.spec.tsx @@ -49,6 +49,7 @@ describe('ProcessingSwaps component', () => { const swap = addSwap(LOOP_IN, INITIATED); expect(getByText('dot.svg')).toHaveClass('warn'); expect(getByText(swap.ellipsedId)).toBeInTheDocument(); + expect(getByText(swap.typeName)).toBeInTheDocument(); expect(getByTitle(swap.stateLabel)).toBeInTheDocument(); expect(width(getByTitle(swap.stateLabel))).toBe('25%'); }); @@ -58,6 +59,7 @@ describe('ProcessingSwaps component', () => { const swap = addSwap(LOOP_IN, HTLC_PUBLISHED); expect(getByText('dot.svg')).toHaveClass('warn'); expect(getByText(swap.ellipsedId)).toBeInTheDocument(); + expect(getByText(swap.typeName)).toBeInTheDocument(); expect(getByTitle(swap.stateLabel)).toBeInTheDocument(); expect(width(getByTitle(swap.stateLabel))).toBe('50%'); }); @@ -67,6 +69,7 @@ describe('ProcessingSwaps component', () => { const swap = addSwap(LOOP_IN, INVOICE_SETTLED); expect(getByText('dot.svg')).toHaveClass('warn'); expect(getByText(swap.ellipsedId)).toBeInTheDocument(); + expect(getByText(swap.typeName)).toBeInTheDocument(); expect(getByTitle(swap.stateLabel)).toBeInTheDocument(); expect(width(getByTitle(swap.stateLabel))).toBe('75%'); }); @@ -75,6 +78,7 @@ describe('ProcessingSwaps component', () => { const { getByText, getByTitle } = render(); const swap = addSwap(LOOP_IN, SUCCESS); expect(getByText(swap.ellipsedId)).toBeInTheDocument(); + expect(getByText(swap.typeName)).toBeInTheDocument(); expect(getByTitle(swap.stateLabel)).toBeInTheDocument(); expect(width(getByTitle(swap.stateLabel))).toBe('100%'); }); @@ -92,6 +96,7 @@ describe('ProcessingSwaps component', () => { const swap = addSwap(LOOP_OUT, INITIATED); expect(getByText('dot.svg')).toHaveClass('warn'); expect(getByText(swap.ellipsedId)).toBeInTheDocument(); + expect(getByText(swap.typeName)).toBeInTheDocument(); expect(getByTitle(swap.stateLabel)).toBeInTheDocument(); expect(width(getByTitle(swap.stateLabel))).toBe('33%'); }); @@ -101,6 +106,7 @@ describe('ProcessingSwaps component', () => { const swap = addSwap(LOOP_OUT, PREIMAGE_REVEALED); expect(getByText('dot.svg')).toHaveClass('warn'); expect(getByText(swap.ellipsedId)).toBeInTheDocument(); + expect(getByText(swap.typeName)).toBeInTheDocument(); expect(getByTitle(swap.stateLabel)).toBeInTheDocument(); expect(width(getByTitle(swap.stateLabel))).toBe('66%'); }); @@ -110,6 +116,7 @@ describe('ProcessingSwaps component', () => { const swap = addSwap(LOOP_OUT, SUCCESS); expect(getByText('dot.svg')).toHaveClass('success'); expect(getByText(swap.ellipsedId)).toBeInTheDocument(); + expect(getByText(swap.typeName)).toBeInTheDocument(); expect(getByTitle(swap.stateLabel)).toBeInTheDocument(); expect(width(getByTitle(swap.stateLabel))).toBe('100%'); }); diff --git a/app/src/__tests__/components/loop/SwapWizard.spec.tsx b/app/src/__tests__/components/loop/SwapWizard.spec.tsx index 1b59da906..eec10bfde 100644 --- a/app/src/__tests__/components/loop/SwapWizard.spec.tsx +++ b/app/src/__tests__/components/loop/SwapWizard.spec.tsx @@ -29,7 +29,7 @@ describe('SwapWizard component', () => { it('should display the description labels', () => { const { getByText } = render(); expect(getByText('Step 1 of 2')).toBeInTheDocument(); - expect(getByText('Set Liquidity Parameters')).toBeInTheDocument(); + expect(getByText('Loop Out Amount')).toBeInTheDocument(); }); it('should navigate forward and back through each step', async () => { @@ -63,10 +63,10 @@ describe('SwapWizard component', () => { it('should update the amount when the slider changes', () => { const { getByText, getByLabelText } = render(); const build = store.buildSwapStore; - expect(+build.amount).toEqual(625000); + expect(+build.amountForSelected).toEqual(625000); expect(getByText(`625,000 sats`)).toBeInTheDocument(); fireEvent.change(getByLabelText('range-slider'), { target: { value: '575000' } }); - expect(+build.amount).toEqual(575000); + expect(+build.amountForSelected).toEqual(575000); expect(getByText(`575,000 sats`)).toBeInTheDocument(); }); }); @@ -84,7 +84,7 @@ describe('SwapWizard component', () => { expect(getByText('Review the quote')).toBeInTheDocument(); expect(getByText('Loop Out Amount')).toBeInTheDocument(); expect(getByText('Fees')).toBeInTheDocument(); - expect(getByText('Invoice Total')).toBeInTheDocument(); + expect(getByText('Total')).toBeInTheDocument(); }); it('should display the correct values', () => { diff --git a/app/src/__tests__/store/buildSwapStore.spec.ts b/app/src/__tests__/store/buildSwapStore.spec.ts index 9b47e8178..6c207566b 100644 --- a/app/src/__tests__/store/buildSwapStore.spec.ts +++ b/app/src/__tests__/store/buildSwapStore.spec.ts @@ -4,7 +4,9 @@ import { grpc } from '@improbable-eng/grpc-web'; import { waitFor } from '@testing-library/react'; import Big from 'big.js'; import { injectIntoGrpcUnary } from 'util/tests'; +import { lndChannel, loopTerms } from 'util/tests/sampleData'; import { BuildSwapStore, createStore, Store } from 'store'; +import { Channel } from 'store/models'; import { SWAP_ABORT_DELAY } from 'store/stores/buildSwapStore'; const grpcMock = grpc as jest.Mocked; @@ -65,16 +67,31 @@ describe('BuildSwapStore', () => { }); }); - it('should adjust the amount after fetching the loop terms', async () => { - store.setAmount(Big(100)); + it('should return the amount in between min/max by default', async () => { await store.getTerms(); - expect(+store.amount).toBe(625000); - store.setAmount(Big(5000000)); + expect(+store.amountForSelected).toBe(625000); + }); + + it('should ensure amount is greater than the min terms', async () => { + store.setAmount(Big(loopTerms.minSwapAmount - 100)); await store.getTerms(); - expect(+store.amount).toBe(625000); - store.setAmount(Big(500000)); + expect(+store.amountForSelected).toBe(loopTerms.minSwapAmount); + }); + + it('should ensure amount is less than the max terms', async () => { + store.setAmount(Big(loopTerms.maxSwapAmount + 100)); await store.getTerms(); - expect(+store.amount).toBe(500000); + expect(+store.amountForSelected).toBe(loopTerms.maxSwapAmount); + }); + + it('should select all channels with the same peer for loop in', () => { + const channels = rootStore.channelStore.sortedChannels; + channels[1].remotePubkey = channels[0].remotePubkey; + channels[2].remotePubkey = channels[0].remotePubkey; + expect(store.selectedChanIds).toHaveLength(0); + store.toggleSelectedChannel(channels[0].chanId); + store.setDirection(SwapDirection.IN); + expect(store.selectedChanIds).toHaveLength(3); }); it('should fetch a loop in quote', async () => { @@ -147,6 +164,31 @@ describe('BuildSwapStore', () => { }); }); + it('should store swapped channels after a loop in', async () => { + const channels = rootStore.channelStore.sortedChannels; + // the pubkey in the sampleData is not valid, so hard-code this valid one + channels[0].remotePubkey = + '035c82e14eb74d2324daa17eebea8c58b46a9eabac87191cc83ee26275b514e6a0'; + store.toggleSelectedChannel(channels[0].chanId); + store.setDirection(SwapDirection.IN); + store.setAmount(Big(600)); + expect(rootStore.swapStore.swappedChannels.size).toBe(0); + store.requestSwap(); + await waitFor(() => expect(store.currentStep).toBe(BuildSwapSteps.Closed)); + expect(rootStore.swapStore.swappedChannels.size).toBe(1); + }); + + it('should store swapped channels after a loop out', async () => { + const channels = rootStore.channelStore.sortedChannels; + store.toggleSelectedChannel(channels[0].chanId); + store.setDirection(SwapDirection.OUT); + store.setAmount(Big(600)); + expect(rootStore.swapStore.swappedChannels.size).toBe(0); + store.requestSwap(); + await waitFor(() => expect(store.currentStep).toBe(BuildSwapSteps.Closed)); + expect(rootStore.swapStore.swappedChannels.size).toBe(1); + }); + it('should set the correct swap deadline in production', async () => { store.setDirection(SwapDirection.OUT); store.setAmount(Big(600)); @@ -224,4 +266,60 @@ describe('BuildSwapStore', () => { expect(spy).not.toBeCalled(); spy.mockClear(); }); + + describe('min/max swap limits', () => { + const addChannel = (capacity: number, localBalance: number) => { + const remoteBalance = capacity - localBalance; + const lndChan = { ...lndChannel, capacity, localBalance, remoteBalance }; + const channel = new Channel(rootStore, lndChan); + channel.chanId = `${channel.chanId}${rootStore.channelStore.channels.size}`; + channel.remotePubkey = `${channel.remotePubkey}${rootStore.channelStore.channels.size}`; + rootStore.channelStore.channels.set(channel.chanId, channel); + }; + + const round = (amount: number) => { + return Math.floor(amount / store.AMOUNT_INCREMENT) * store.AMOUNT_INCREMENT; + }; + + beforeEach(() => { + rootStore.channelStore.channels.clear(); + [ + { capacity: 200000, local: 100000 }, + { capacity: 100000, local: 50000 }, + { capacity: 100000, local: 20000 }, + ].forEach(({ capacity, local }) => addChannel(capacity, local)); + }); + + it('should limit Loop In max based on all remote balances', async () => { + await store.getTerms(); + store.setDirection(SwapDirection.IN); + // should be the sum of all remote balances minus the reserve + expect(+store.termsForDirection.max).toBe(round(230000 * 0.99)); + }); + + it('should limit Loop In max based on selected remote balances', async () => { + store.toggleSelectedChannel(store.channels[0].chanId); + store.toggleSelectedChannel(store.channels[1].chanId); + await store.getTerms(); + store.setDirection(SwapDirection.IN); + // should be the sum of the first two remote balances minus the reserve + expect(+store.termsForDirection.max).toBe(round(150000 * 0.99)); + }); + + it('should limit Loop Out max based on all local balances', async () => { + await store.getTerms(); + store.setDirection(SwapDirection.OUT); + // should be the sum of all local balances minus the reserve + expect(+store.termsForDirection.max).toBe(round(170000 * 0.99)); + }); + + it('should limit Loop Out max based on selected local balances', async () => { + store.toggleSelectedChannel(store.channels[0].chanId); + store.toggleSelectedChannel(store.channels[1].chanId); + await store.getTerms(); + store.setDirection(SwapDirection.OUT); + // should be the sum of the first two local balances minus the reserve + expect(+store.termsForDirection.max).toBe(round(150000 * 0.99)); + }); + }); }); diff --git a/app/src/__tests__/store/channelStore.spec.ts b/app/src/__tests__/store/channelStore.spec.ts index 65bf13041..f09e4a0ad 100644 --- a/app/src/__tests__/store/channelStore.spec.ts +++ b/app/src/__tests__/store/channelStore.spec.ts @@ -133,6 +133,7 @@ describe('ChannelStore', () => { // the alias is fetched from the API and should be updated after a few ticks await waitFor(() => { expect(channel.alias).toBe(lndGetNodeInfo.node.alias); + expect(channel.aliasLabel).toBe(lndGetNodeInfo.node.alias); }); }); diff --git a/app/src/__tests__/store/settingsStore.spec.ts b/app/src/__tests__/store/settingsStore.spec.ts index 1016f2e34..14c428a43 100644 --- a/app/src/__tests__/store/settingsStore.spec.ts +++ b/app/src/__tests__/store/settingsStore.spec.ts @@ -4,6 +4,13 @@ import { createStore, SettingsStore } from 'store'; describe('SettingsStore', () => { let store: SettingsStore; + const runInWindowSize = (width: number, func: () => void) => { + const defaultWidth = window.innerWidth; + (window as any).innerWidth = width; + func(); + (window as any).innerWidth = defaultWidth; + }; + beforeEach(() => { store = createStore().settingsStore; }); @@ -24,11 +31,23 @@ describe('SettingsStore', () => { expect(store.balanceMode).toEqual(BalanceMode.routing); }); - it('should do nothing if nothing is saved in storage', () => { - store.load(); + it('should use defaults if nothing is saved in storage', () => { + runInWindowSize(1250, () => { + store.load(); + + expect(store.sidebarVisible).toEqual(true); + expect(store.unit).toEqual(Unit.sats); + expect(store.balanceMode).toEqual(BalanceMode.receive); + }); + }); + + it('should auto hide sidebar if width is less than 1200', () => { + runInWindowSize(1100, () => { + store.load(); - expect(store.sidebarVisible).toEqual(true); - expect(store.unit).toEqual(Unit.sats); - expect(store.balanceMode).toEqual(BalanceMode.receive); + expect(store.sidebarVisible).toEqual(false); + expect(store.unit).toEqual(Unit.sats); + expect(store.balanceMode).toEqual(BalanceMode.receive); + }); }); }); diff --git a/app/src/__tests__/store/swapStore.spec.ts b/app/src/__tests__/store/swapStore.spec.ts index f9fb28cd9..ac20cdb8f 100644 --- a/app/src/__tests__/store/swapStore.spec.ts +++ b/app/src/__tests__/store/swapStore.spec.ts @@ -17,6 +17,41 @@ describe('SwapStore', () => { store = rootStore.swapStore; }); + it('should add swapped channels', () => { + expect(store.swappedChannels.size).toBe(0); + store.addSwappedChannels('s1', ['c1', 'c2']); + expect(store.swappedChannels.size).toBe(2); + expect(store.swappedChannels.get('c1')).toEqual(['s1']); + expect(store.swappedChannels.get('c2')).toEqual(['s1']); + store.addSwappedChannels('s2', ['c2']); + expect(store.swappedChannels.size).toBe(2); + expect(store.swappedChannels.get('c2')).toEqual(['s1', 's2']); + }); + + it('should prune the swapped channels list', async () => { + await rootStore.channelStore.fetchChannels(); + await store.fetchSwaps(); + const swaps = store.sortedSwaps; + // make these swaps pending + swaps[0].state = LOOP.SwapState.HTLC_PUBLISHED; + swaps[1].state = LOOP.SwapState.INITIATED; + const channels = rootStore.channelStore.sortedChannels; + const [c1, c2, c3] = channels.map(c => c.chanId); + store.addSwappedChannels(swaps[0].id, [c1, c2]); + store.addSwappedChannels(swaps[1].id, [c2, c3]); + // confirm swapped channels are set + expect(store.swappedChannels.size).toBe(3); + expect(store.swappedChannels.get(c2)).toHaveLength(2); + // change one swap to complete + swaps[1].state = LOOP.SwapState.SUCCESS; + store.pruneSwappedChannels(); + // confirm swap1 removed + expect(store.swappedChannels.size).toBe(2); + expect(store.swappedChannels.get(c1)).toHaveLength(1); + expect(store.swappedChannels.get(c2)).toHaveLength(1); + expect(store.swappedChannels.get(c3)).toBeUndefined(); + }); + it('should fetch list of swaps', async () => { expect(store.sortedSwaps).toHaveLength(0); await store.fetchSwaps(); diff --git a/app/src/__tests__/util/appStorage.spec.ts b/app/src/__tests__/util/appStorage.spec.ts index 0cbde55d6..c564ac8f0 100644 --- a/app/src/__tests__/util/appStorage.spec.ts +++ b/app/src/__tests__/util/appStorage.spec.ts @@ -1,7 +1,5 @@ import AppStorage from 'util/appStorage'; -jest.unmock('util/appStorage'); - describe('appStorage util', () => { const key = 'test-data'; const settings = { diff --git a/app/src/assets/icons/chevrons-left.svg b/app/src/assets/icons/chevrons-left.svg new file mode 100644 index 000000000..3af2d1a8e --- /dev/null +++ b/app/src/assets/icons/chevrons-left.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/assets/icons/chevrons-right.svg b/app/src/assets/icons/chevrons-right.svg new file mode 100644 index 000000000..0f93f3230 --- /dev/null +++ b/app/src/assets/icons/chevrons-right.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/assets/images/screenshot.png b/app/src/assets/images/screenshot.png index 8cc6e3309..83e45fea8 100644 Binary files a/app/src/assets/images/screenshot.png and b/app/src/assets/images/screenshot.png differ diff --git a/app/src/components/base/grid.tsx b/app/src/components/base/grid.tsx index e31ac625b..5f4641e36 100644 --- a/app/src/components/base/grid.tsx +++ b/app/src/components/base/grid.tsx @@ -23,11 +23,13 @@ export const Row: React.FC<{ */ export const Column: React.FC<{ cols?: number; + colsXl?: number; right?: boolean; className?: string; -}> = ({ cols, right, children, className }) => { - const cn: string[] = []; - cn.push(cols ? `col-${cols}` : 'col'); +}> = ({ cols, colsXl, right, children, className }) => { + const cn: string[] = ['col']; + cols && cn.push(`col-${cols}`); + colsXl && cn.push(`col-xl-${colsXl}`); className && cn.push(className); right && cn.push('text-right'); return
{children}
; diff --git a/app/src/components/base/icons.tsx b/app/src/components/base/icons.tsx index b591b28c0..7ce1c7d64 100644 --- a/app/src/components/base/icons.tsx +++ b/app/src/components/base/icons.tsx @@ -3,6 +3,8 @@ import { ReactComponent as ArrowRightIcon } from 'assets/icons/arrow-right.svg'; import { ReactComponent as BitcoinIcon } from 'assets/icons/bitcoin.svg'; import { ReactComponent as BoltIcon } from 'assets/icons/bolt.svg'; import { ReactComponent as CheckIcon } from 'assets/icons/check.svg'; +import { ReactComponent as ChevronsLeftIcon } from 'assets/icons/chevrons-left.svg'; +import { ReactComponent as ChevronsRightIcon } from 'assets/icons/chevrons-right.svg'; import { ReactComponent as ChevronsIcon } from 'assets/icons/chevrons.svg'; import { ReactComponent as ClockIcon } from 'assets/icons/clock.svg'; import { ReactComponent as CloseIcon } from 'assets/icons/close.svg'; @@ -65,6 +67,8 @@ export const Bolt = Icon.withComponent(BoltIcon); export const Bitcoin = Icon.withComponent(BitcoinIcon); export const Check = Icon.withComponent(CheckIcon); export const Chevrons = Icon.withComponent(ChevronsIcon); +export const ChevronsLeft = Icon.withComponent(ChevronsLeftIcon); +export const ChevronsRight = Icon.withComponent(ChevronsRightIcon); export const Close = Icon.withComponent(CloseIcon); export const Dot = Icon.withComponent(DotIcon); export const Menu = Icon.withComponent(MenuIcon); diff --git a/app/src/components/base/shared.tsx b/app/src/components/base/shared.tsx index 3bbc998ae..63d6d3c3d 100644 --- a/app/src/components/base/shared.tsx +++ b/app/src/components/base/shared.tsx @@ -30,8 +30,8 @@ export const Badge = styled.span` margin-left: 10px; font-family: ${props => props.theme.fonts.open.light}; font-size: ${props => props.theme.sizes.xxs}; - color: ${props => props.theme.colors.orange}; - border: 1px solid ${props => props.theme.colors.orange}; + color: ${props => props.theme.colors.pink}; + border: 1px solid ${props => props.theme.colors.pink}; border-radius: 4px; padding: 3px 5px 5px; text-transform: lowercase; diff --git a/app/src/components/common/StatusArrow.tsx b/app/src/components/common/StatusArrow.tsx new file mode 100644 index 000000000..60e41b5d8 --- /dev/null +++ b/app/src/components/common/StatusArrow.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { styled } from 'components/theme'; +import { Chevrons, ChevronsLeft, ChevronsRight } from '../base'; + +const BaseIcon = styled.span` + padding: 0; + + &.success { + color: ${props => props.theme.colors.green}; + } + &.warn { + color: ${props => props.theme.colors.yellow}; + } + &.error { + color: ${props => props.theme.colors.pink}; + } + &.idle { + color: ${props => props.theme.colors.gray}; + } +`; + +interface Props { + status: 'success' | 'warn' | 'error' | 'idle'; + direction: 'in' | 'out' | 'both'; +} + +const DirectionIcons: Record = { + both: BaseIcon.withComponent(Chevrons), + in: BaseIcon.withComponent(ChevronsRight), + out: BaseIcon.withComponent(ChevronsLeft), +}; + +const StatusArrow: React.FC = ({ status, direction }) => { + const Icon = DirectionIcons[direction]; + return ; +}; + +export default StatusArrow; diff --git a/app/src/components/common/StatusDot.tsx b/app/src/components/common/StatusDot.tsx index 19b41eda2..8046992e3 100644 --- a/app/src/components/common/StatusDot.tsx +++ b/app/src/components/common/StatusDot.tsx @@ -8,7 +8,7 @@ const Styled = { color: ${props => props.theme.colors.green}; } &.warn { - color: ${props => props.theme.colors.orange}; + color: ${props => props.theme.colors.yellow}; } &.error { color: ${props => props.theme.colors.pink}; diff --git a/app/src/components/common/Tip.tsx b/app/src/components/common/Tip.tsx index 5022896cb..b5c117759 100644 --- a/app/src/components/common/Tip.tsx +++ b/app/src/components/common/Tip.tsx @@ -68,11 +68,11 @@ const TooltipWrapper: React.FC = ({ * prop. So we basically proxy the className using the TooltipWrapper * above, then export this styled component for the rest of the app to use */ -const Tip = styled(TooltipWrapper)` +const Tip = styled(TooltipWrapper)<{ capitalize?: boolean }>` color: ${props => props.theme.colors.blue}; font-family: ${props => props.theme.fonts.open.semiBold}; font-size: ${props => props.theme.sizes.xs}; - text-transform: uppercase; + text-transform: ${props => (props.capitalize === false ? 'none' : 'uppercase')}; opacity: 0.95; &.rc-tooltip-placement-bottom .rc-tooltip-arrow, diff --git a/app/src/components/history/HistoryRow.tsx b/app/src/components/history/HistoryRow.tsx index 3c40b3318..9a588a50b 100644 --- a/app/src/components/history/HistoryRow.tsx +++ b/app/src/components/history/HistoryRow.tsx @@ -48,10 +48,10 @@ export const HistoryRowHeader: React.FC = () => { {l('amount')} (sats) - + {l('created')} - + {l('updated')} @@ -77,8 +77,12 @@ const HistoryRow: React.FC = ({ swap, style }) => { - {swap.createdOnLabel} - {swap.updatedOnLabel} + + {swap.createdOnLabel} + + + {swap.updatedOnLabel} + ); }; diff --git a/app/src/components/layout/Layout.tsx b/app/src/components/layout/Layout.tsx index a5203c88a..ad811e025 100644 --- a/app/src/components/layout/Layout.tsx +++ b/app/src/components/layout/Layout.tsx @@ -17,12 +17,12 @@ const Styled = { width: 100%; margin: 0 auto; `, - Hamburger: styled.span` + Hamburger: styled.span` display: inline-block; - position: absolute; + position: ${props => (props.collapsed ? 'absolute' : 'fixed')}; top: 35px; - left: 10px; - z-index: 1; + margin-left: 10px; + z-index: 2; padding: 4px; &:hover { @@ -36,6 +36,7 @@ const Styled = { position: fixed; top: 0; height: 100vh; + z-index: 1; background-color: ${props => props.theme.colors.darkBlue}; overflow: hidden; @@ -53,6 +54,10 @@ const Styled = { margin-left: ${props => (props.collapsed ? '0' : '285px')}; padding: 0 15px; transition: all 0.2s; + + @media (max-width: 1200px) { + margin-left: 0; + } `, }; @@ -67,7 +72,10 @@ const StandardLayout: React.FC = observer(({ children }) => { return ( - +