diff --git a/app/package.json b/app/package.json index 65aa6c0d2..011cef76a 100644 --- a/app/package.json +++ b/app/package.json @@ -37,6 +37,8 @@ "mobx": "6.3.2", "mobx-react-lite": "3.2.0", "mobx-utils": "6.0.4", + "qrcode.react": "^3.1.0", + "rc-dialog": "^8.9.0", "rc-select": "11.5.0", "rc-tooltip": "4.2.1", "react": "17.0.2", diff --git a/app/src/App.scss b/app/src/App.scss index 738a63b55..96c23bccb 100644 --- a/app/src/App.scss +++ b/app/src/App.scss @@ -16,6 +16,7 @@ // react-component component styles @import '../node_modules/rc-tooltip/assets/bootstrap_white.css'; +@import '../node_modules/rc-dialog/assets/index.css'; @import './assets/styles/rc-select.scss'; // react-toastify styles diff --git a/app/src/AppRoutes.tsx b/app/src/AppRoutes.tsx index acd537972..bb0a02a98 100644 --- a/app/src/AppRoutes.tsx +++ b/app/src/AppRoutes.tsx @@ -4,6 +4,7 @@ import { Layout } from 'components/layout'; const LazyAuthPage = React.lazy(() => import('components/auth/AuthPage')); const LazyLoopPage = React.lazy(() => import('components/loop/LoopPage')); +const LazyHomePage = React.lazy(() => import('components/home/HomePage')); const LazyHistoryPage = React.lazy(() => import('components/history/HistoryPage')); const LazyPoolPage = React.lazy(() => import('components/pool/PoolPage')); const LazySettingsPage = React.lazy(() => import('components/settings/SettingsPage')); @@ -14,6 +15,14 @@ const AppRoutes: React.FC = () => { } /> + + + + } + /> { it('should navigate to the History page', () => { const { getByText, store } = render(); expect(store.router.location.pathname).toBe('/loop'); - fireEvent.click(getByText('History')); + fireEvent.click(getByText('Loop History')); expect(store.router.location.pathname).toBe('/history'); - expect(getByText('History').parentElement).toHaveClass('active'); + expect(getByText('Loop History').parentElement).toHaveClass('active'); }); it('should navigate back to the Loop page', () => { const { getByText, store } = render(); expect(store.router.location.pathname).toBe('/loop'); - fireEvent.click(getByText('History')); + fireEvent.click(getByText('Loop History')); expect(store.router.location.pathname).toBe('/history'); - expect(getByText('History').parentElement).toHaveClass('active'); - fireEvent.click(getByText('Lightning Loop')); + expect(getByText('Loop History').parentElement).toHaveClass('active'); + fireEvent.click(getByText('Loop')); expect(store.router.location.pathname).toBe('/loop'); - expect(getByText('Lightning Loop').parentElement).toHaveClass('active'); + expect(getByText('Loop').parentElement).toHaveClass('active'); }); it('should navigate to the Pool page', () => { const { getByText, store } = render(); expect(store.router.location.pathname).toBe('/loop'); - fireEvent.click(getByText('Lightning Pool')); + fireEvent.click(getByText('Pool')); expect(store.router.location.pathname).toBe('/pool'); - expect(getByText('Lightning Pool').parentElement).toHaveClass('active'); - fireEvent.click(getByText('Lightning Loop')); + expect(getByText('Pool').parentElement).toHaveClass('active'); + fireEvent.click(getByText('Loop')); expect(store.router.location.pathname).toBe('/loop'); - expect(getByText('Lightning Loop').parentElement).toHaveClass('active'); + expect(getByText('Loop').parentElement).toHaveClass('active'); }); it('should navigate to the Settings page', () => { @@ -61,8 +61,8 @@ describe('Layout component', () => { fireEvent.click(getByText('Settings')); expect(store.router.location.pathname).toBe('/settings'); expect(getByText('Settings').parentElement).toHaveClass('active'); - fireEvent.click(getByText('Lightning Loop')); + fireEvent.click(getByText('Loop')); expect(store.router.location.pathname).toBe('/loop'); - expect(getByText('Lightning Loop').parentElement).toHaveClass('active'); + expect(getByText('Loop').parentElement).toHaveClass('active'); }); }); diff --git a/app/src/assets/icons/bolt-outlined.svg b/app/src/assets/icons/bolt-outlined.svg new file mode 100644 index 000000000..9851a893a --- /dev/null +++ b/app/src/assets/icons/bolt-outlined.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/app/src/assets/icons/qr.svg b/app/src/assets/icons/qr.svg new file mode 100644 index 000000000..2efb7a01b --- /dev/null +++ b/app/src/assets/icons/qr.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/assets/images/home_dash_ss.png b/app/src/assets/images/home_dash_ss.png new file mode 100644 index 000000000..b910e05ca Binary files /dev/null and b/app/src/assets/images/home_dash_ss.png differ diff --git a/app/src/assets/images/home_loop_ss.png b/app/src/assets/images/home_loop_ss.png new file mode 100644 index 000000000..9d04793d3 Binary files /dev/null and b/app/src/assets/images/home_loop_ss.png differ diff --git a/app/src/assets/images/youtube.svg b/app/src/assets/images/youtube.svg new file mode 100644 index 000000000..80b9faddf --- /dev/null +++ b/app/src/assets/images/youtube.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/components/base/icons.tsx b/app/src/components/base/icons.tsx index ee37d033f..f3bd9db74 100644 --- a/app/src/components/base/icons.tsx +++ b/app/src/components/base/icons.tsx @@ -30,6 +30,8 @@ import { ReactComponent as RefreshIcon } from 'assets/icons/refresh-cw.svg'; import { ReactComponent as SettingsIcon } from 'assets/icons/settings.svg'; import { ReactComponent as CancelIcon } from 'assets/icons/slash.svg'; import { ReactComponent as UserPlusIcon } from 'assets/icons/user-plus.svg'; +import { ReactComponent as QRCodeIcon } from 'assets/icons/qr.svg'; +import { ReactComponent as BoltOutlinedIcon } from 'assets/icons/bolt-outlined.svg'; interface IconProps { size?: 'x-small' | 'small' | 'medium' | 'large'; @@ -119,3 +121,5 @@ export const Settings = Icon.withComponent(SettingsIcon); export const UserPlus = Icon.withComponent(UserPlusIcon); export const BarChart = Icon.withComponent(BarChartIcon); export const List = Icon.withComponent(ListIcon); +export const QRCode = Icon.withComponent(QRCodeIcon); +export const BoltOutlined = Icon.withComponent(BoltOutlinedIcon); diff --git a/app/src/components/base/text.tsx b/app/src/components/base/text.tsx index 96097178a..48f9553d2 100644 --- a/app/src/components/base/text.tsx +++ b/app/src/components/base/text.tsx @@ -48,3 +48,49 @@ export const Jumbo = styled.span` font-size: ${props => props.theme.sizes.xl}; line-height: 38px; `; + +// +// v2 Text Styles +// + +interface TextProps { + bold?: boolean; + semiBold?: boolean; + center?: boolean; + block?: boolean; + muted?: boolean; + space?: 8 | 12 | 16 | 20 | 24 | 32 | 40 | 48 | 56 | 64 | 96 | 120 | 160 | 200; + desktopSpace?: 8 | 12 | 16 | 20 | 24 | 32 | 40 | 48 | 56 | 64 | 96 | 120 | 160 | 200; +} + +const BaseText = styled.span` + // On larger devices, make bold elements bold instead of semi-bold + font-family: ${props => + props.bold + ? props.theme.fonts.open.bold + : props.semiBold + ? props.theme.fonts.open.semiBold + : props.theme.fonts.open.regular}; + + // The text-align property is ignored on mobile + ${props => props.muted && `color: ${props.theme.colors.gray};`} + ${props => props.space && `margin-bottom: ${props.space}px;`} + text-align: ${props => (props.center ? 'center' : 'left')}; +`; + +const BaseBlock = BaseText.withComponent('div'); + +export const DisplayLarge = styled(BaseBlock)` + font-size: 40px; + line-height: 48px; +`; + +export const Display = styled(BaseBlock)` + font-size: 32px; + line-height: 40px; +`; + +export const Paragraph = styled(BaseBlock)` + font-size: 16px; + line-height: 24px; +`; diff --git a/app/src/components/common/AlertContainer.tsx b/app/src/components/common/AlertContainer.tsx index 7418b66da..b6b29bbba 100644 --- a/app/src/components/common/AlertContainer.tsx +++ b/app/src/components/common/AlertContainer.tsx @@ -48,6 +48,10 @@ const Styled = { color: ${props => props.theme.colors.offWhite}; background-color: ${props => props.theme.colors.pink}; } + .Toastify__toast--warning { + color: ${props => props.theme.colors.offWhite}; + background-color: ${props => props.theme.colors.gold}; + } `, }; diff --git a/app/src/components/common/Modal.tsx b/app/src/components/common/Modal.tsx new file mode 100644 index 000000000..2cd8ee515 --- /dev/null +++ b/app/src/components/common/Modal.tsx @@ -0,0 +1,84 @@ +import React from 'react'; +import { Global, Theme } from '@emotion/react'; +import CloseIcon from 'assets/icons/close.svg'; +import Dialog from 'rc-dialog'; + +const GlobalStyles = (theme: Theme) => ` + div.rc-dialog { + font-family: ${theme.fonts.open.regular}; + font-size: ${theme.sizes.m}; + } + div.rc-dialog-content { + color: ${theme.colors.offWhite}; + background-color: ${theme.colors.blue}; + } + div.rc-dialog-header { + color: ${theme.colors.offWhite}; + background-color: ${theme.colors.blue}; + border-width: 0px; + padding: 32px 40px; + } + div.rc-dialog-title { + font-size: 32px; + line-height: 40px; + overflow: hidden; + text-overflow: ellipsis; + } + button.rc-dialog-close { + color: ${theme.colors.offWhite}; + font-size: ${theme.sizes.xxl}; + opacity: 1; + top: 34px; + right: 34px; + width: 24px; + height: 24px; + padding: 0; + background-color: ${theme.colors.offWhite}; + mask-image: url(${CloseIcon}); + padding: 0; + + &:hover { + opacity: 0.6; + } + } + span.rc-dialog-close-x:after { + content: ""; + } + div.rc-dialog-body { + padding: 0 40px 40px; + } + div.rc-dialog-mask { + background-color: rgba(0, 0, 0, 0.8); + } + div.rc-dialog-footer { + border-width: 0px; + padding: 0 40px 40px; + text-align: left; + } +`; + +interface Props { + title: string; + visible: boolean; + onClose: () => void; + className?: string; +} + +const Modal: React.FC = ({ title, visible, onClose, className, children }) => { + return ( + + {children} + + + ); +}; + +export default Modal; diff --git a/app/src/components/connect/AddSession.tsx b/app/src/components/connect/AddSession.tsx index 0e54258a5..f6e62b22a 100644 --- a/app/src/components/connect/AddSession.tsx +++ b/app/src/components/connect/AddSession.tsx @@ -1,8 +1,8 @@ import React, { useCallback, useState } from 'react'; import { observer } from 'mobx-react-lite'; +import * as LIT from 'types/generated/lit-sessions_pb'; import styled from '@emotion/styled'; import { usePrefixedTranslation } from 'hooks'; -import * as LIT from 'types/generated/lit-sessions_pb'; import { MAX_DATE } from 'util/constants'; import { useStore } from 'store'; import { Button, Column, HeaderFour, Row } from 'components/base'; @@ -55,7 +55,7 @@ const AddSession: React.FC = ({ primary }) => { ? LIT.SessionType.TYPE_MACAROON_ADMIN : LIT.SessionType.TYPE_MACAROON_READONLY; - const session = await sessionStore.addSession(label, sessionType, MAX_DATE); + const session = await sessionStore.addSession(label, sessionType, MAX_DATE, true); if (session) { setLabel(''); diff --git a/app/src/components/connect/ConnectPage.tsx b/app/src/components/connect/ConnectPage.tsx index bcf05837e..9c2ab0a7f 100644 --- a/app/src/components/connect/ConnectPage.tsx +++ b/app/src/components/connect/ConnectPage.tsx @@ -1,61 +1,34 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import { observer } from 'mobx-react-lite'; import styled from '@emotion/styled'; -import nodeConnectSvg from 'assets/images/lightning-node-connect.svg'; import { usePrefixedTranslation } from 'hooks'; import { useStore } from 'store'; -import { Copy } from 'components/base'; +import { DisplayLarge } from 'components/base'; import AddSession from './AddSession'; -import PurpleButton from './PurpleButton'; import SessionList from './SessionList'; const Styled = { Wrapper: styled.section` padding-top: 80px; `, - DisplayLarge: styled.div` - font-family: ${props => props.theme.fonts.open.semiBold}; - font-size: 32px; - line-height: 40px; - margin-top: 32px; - margin-bottom: 16px; - `, Description: styled.div` margin-bottom: 32px; `, - Divider: styled.div` - max-width: 640px; - border: 1px solid #384770; - margin: 32px 0; - `, }; const ConnectPage: React.FC = () => { const { l } = usePrefixedTranslation('cmps.connect.ConnectPage'); const { sessionStore } = useStore(); - const { Wrapper, DisplayLarge, Description, Divider } = Styled; - return !sessionStore.hasMultiple ? ( - - {l('pageTitle')} - {l('pageTitle')} - - {l('description1')} -
- {l('description2')} -
- - - {l('copyPhraseLabel')} - - - {l('addlDesc')} - -
- ) : ( + useEffect(() => { + sessionStore.fetchSessions(); + }, []); + + const { Wrapper, Description } = Styled; + return ( - {l('pageTitle')} - {l('description1')} + {l('pageTitle')} + {l('description')} diff --git a/app/src/components/connect/PurpleButton.tsx b/app/src/components/connect/PurpleButton.tsx index 6925e5352..ecdc4e602 100644 --- a/app/src/components/connect/PurpleButton.tsx +++ b/app/src/components/connect/PurpleButton.tsx @@ -1,6 +1,7 @@ import styled from '@emotion/styled'; interface Props { + secondary?: boolean; tertiary?: boolean; } @@ -23,6 +24,18 @@ const PurpleButton = styled.button` outline: none; } + ${props => + props.secondary && + ` + color: #252F4A; + background-color: ${props.theme.colors.white}; + + &:hover { + opacity: 0.8; + background-color: ${props.theme.colors.white}; + } + `} + ${props => props.tertiary && ` diff --git a/app/src/components/connect/QRCodeModal.tsx b/app/src/components/connect/QRCodeModal.tsx new file mode 100644 index 000000000..51df4df83 --- /dev/null +++ b/app/src/components/connect/QRCodeModal.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import styled from '@emotion/styled'; +import { usePrefixedTranslation } from 'hooks'; +import QRCodeImg from 'qrcode.react'; +import { Paragraph } from 'components/base'; +import Modal from 'components/common/Modal'; + +const Styled = { + QRWrap: styled.div` + display: inline-block; + padding: 8px 8px 0; + background-color: ${props => props.theme.colors.white}; + `, +}; + +interface Props { + url: string; + visible: boolean; + onClose: () => void; +} + +const QRCodeModal: React.FC = ({ url, visible, onClose }) => { + const { l } = usePrefixedTranslation('cmps.connect.QRCodeModal'); + const { QRWrap } = Styled; + return ( + + {l('desc')} + + + + + ); +}; + +export default QRCodeModal; diff --git a/app/src/components/connect/SessionRow.tsx b/app/src/components/connect/SessionRow.tsx index be2deca6f..51987fdd2 100644 --- a/app/src/components/connect/SessionRow.tsx +++ b/app/src/components/connect/SessionRow.tsx @@ -1,12 +1,13 @@ -import React, { CSSProperties, useCallback } from 'react'; +import React, { CSSProperties, useCallback, useState } from 'react'; import { observer } from 'mobx-react-lite'; import styled from '@emotion/styled'; import { usePrefixedTranslation } from 'hooks'; import { useStore } from 'store'; import { Session } from 'store/models'; -import { Close, Column, Copy, Row } from 'components/base'; +import { BoltOutlined, Close, Column, Copy, QRCode, Row } from 'components/base'; import SortableHeader from 'components/common/SortableHeader'; import Tip from 'components/common/Tip'; +import QRCodeModal from './QRCodeModal'; /** * the virtualized list requires each row to have a specified @@ -38,12 +39,24 @@ const Styled = { padding: 0 5px; `, ActionColumn: styled(Column)` - max-width: 70px; - padding: 0 20px; line-height: ${ROW_HEIGHT}px; - & > svg { - border-radius: 10px; + svg { + border-radius: 0; + margin-left: 10%; + + &:hover { + border-radius: 10px; + } + } + + > a { + color: ${props => props.theme.colors.offWhite}; + + &:hover svg { + color: ${props => props.theme.colors.blue}; + background-color: ${props => props.theme.colors.offWhite}; + } } `, CloseIcon: styled(Close)` @@ -95,7 +108,7 @@ const RowHeader: React.FC = () => { {l('expiry')} - + ); }; @@ -109,6 +122,7 @@ interface Props { const SessionRow: React.FC = ({ session, style }) => { const { l } = usePrefixedTranslation('cmps.connect.SessionRow'); + const [showQR, setShowQR] = useState(false); const { sessionStore } = useStore(); const handleCopy = useCallback(() => { @@ -119,29 +133,54 @@ const SessionRow: React.FC = ({ session, style }) => { sessionStore.revokeSession(session); }, [session]); + const toggleQRModal = useCallback(() => setShowQR(v => !v), []); + const { Row, Column, ActionColumn, CloseIcon } = Styled; return ( {session.isPaired ? ( - - - + <> + + + + + + + + + + ) : ( - - - + <> + + + + + + + + + + + + )} + {session.label} {session.typeLabel} {session.pairedLabel} {session.expiryLabel} - + - + ); }; diff --git a/app/src/components/home/HomePage.tsx b/app/src/components/home/HomePage.tsx new file mode 100644 index 000000000..c1e6b225d --- /dev/null +++ b/app/src/components/home/HomePage.tsx @@ -0,0 +1,116 @@ +import React, { useCallback, useState } from 'react'; +import { observer } from 'mobx-react-lite'; +import styled from '@emotion/styled'; +import DashUX from 'assets/images/home_dash_ss.png'; +import LoopUX from 'assets/images/home_loop_ss.png'; +import { ReactComponent as Youtube } from 'assets/images/youtube.svg'; +import { usePrefixedTranslation } from 'hooks'; +import { useStore } from 'store'; +import { + BoltOutlined, + Button, + Column, + Display, + Paragraph, + QRCode, + Row, +} from 'components/base'; +import PurpleButton from 'components/connect/PurpleButton'; +import QRCodeModal from 'components/connect/QRCodeModal'; +import YoutubeModal from './YoutubeModal'; + +const Styled = { + Wrapper: styled.div` + padding: 72px 0; + `, + PurpleButton: styled(PurpleButton)` + font-size: ${props => props.theme.sizes.s}; + line-height: 24px; + padding: 8px 16px; + margin-right: 24px; + `, + YoutubeButton: styled(Button)` + font-family: ${props => props.theme.fonts.open.semiBold}; + padding-left: 0; + + svg { + margin-right: 16px; + } + `, + Column: styled(Column)` + max-width: 480px; + `, + Image: styled.img` + width: 100%; + margin-bottom: 24px; + `, +}; + +const HomePage: React.FC = () => { + const { l } = usePrefixedTranslation('cmps.home.HomePage'); + const [qrUrl, setQrUrl] = useState(''); + const [showVideo, setShowVideo] = useState(false); + const { sessionStore } = useStore(); + + const openQRModal = useCallback( + async () => setQrUrl(await sessionStore.getNewSessionUrl()), + [], + ); + const closeQRModal = useCallback(() => setQrUrl(''), []); + const toggleVideoModal = useCallback(() => setShowVideo(v => !v), []); + + const { Wrapper, PurpleButton, YoutubeButton, Column, Image } = Styled; + return ( + + + {l('pageTitle')} + + {l('connectDesc')} + + + + {l('connectTerminalBtn')} + + + + {l('connectQrBtn')} + + + {l('learnDesc')} + + + + Learn More + + + + {l('whatsDiff')} + + {l('diffDesc')} + + + {l('loopTitle')} + + {l('loopTitle')} + + {l('loopDesc')} + + + {l('dashTitle')} + + {l('dashTitle')} + + {l('dashDesc')} + + + + + + ); +}; + +export default observer(HomePage); diff --git a/app/src/components/home/YoutubeModal.tsx b/app/src/components/home/YoutubeModal.tsx new file mode 100644 index 000000000..a079c8d1a --- /dev/null +++ b/app/src/components/home/YoutubeModal.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import styled from '@emotion/styled'; +import { usePrefixedTranslation } from 'hooks'; +import Modal from 'components/common/Modal'; + +const Styled = { + VideoModal: styled(Modal)` + width: 800px; + max-width: 90%; + overflow: hidden; + `, + VideoWrap: styled.div` + position: relative; + padding-bottom: 56.25%; /* 16:9 */ + height: 0; + + > iframe { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + } + `, +}; + +interface Props { + videoId: string; + visible: boolean; + onClose: () => void; +} + +const YoutubeModal: React.FC = ({ videoId, visible, onClose }) => { + const { l } = usePrefixedTranslation('cmps.home.YoutubeModal'); + const { VideoModal, VideoWrap } = Styled; + return ( + + + + + + ); +}; + +export default YoutubeModal; diff --git a/app/src/components/layout/NavMenu.tsx b/app/src/components/layout/NavMenu.tsx index 93bebdeb8..9b8125046 100644 --- a/app/src/components/layout/NavMenu.tsx +++ b/app/src/components/layout/NavMenu.tsx @@ -8,7 +8,9 @@ import { PUBLIC_URL } from '../../config'; const Styled = { NavHeader: styled(HeaderFour)` - padding: 8px 14px; + padding: 44px 14px 8px; + font-size: 10px; + line-height: 16px; `, Nav: styled.ul` padding-left: 0; @@ -78,12 +80,18 @@ const NavMenu: React.FC = () => { const { NavHeader, Nav } = Styled; return ( <> - {l('menu')} + + {l('liquidityHeader')} + {l('connectHeader')} + diff --git a/app/src/config.ts b/app/src/config.ts index 93381a4f7..79745a76d 100644 --- a/app/src/config.ts +++ b/app/src/config.ts @@ -9,6 +9,11 @@ export const IS_TEST = process.env.NODE_ENV === 'test'; export const PUBLIC_URL = process.env.PUBLIC_URL; +// the Terminal on the web url to link to with the session pairing phrase +export const LNC_APP_BASE_URL = IS_DEV + ? 'http://localhost:4000' + : 'https://terminal.lightning.engineering'; + // detect the host currently serving the app files const { protocol, hostname, port } = window.location; const host = `${protocol}//${hostname}:${port}`; diff --git a/app/src/i18n/locales/en-US.json b/app/src/i18n/locales/en-US.json index 5377b434b..f4d960d53 100644 --- a/app/src/i18n/locales/en-US.json +++ b/app/src/i18n/locales/en-US.json @@ -38,18 +38,32 @@ "cmps.connect.AddSession.expiration": "Expiration", "cmps.connect.AddSession.expirationSuffix": "", "cmps.connect.ConnectPage.pageTitle": "Lightning Node Connect", - "cmps.connect.ConnectPage.description1": "Lightning Node Connect enables you to connect to this node from the web.", - "cmps.connect.ConnectPage.description2": "Copy the default pairing phrase below to paste it into your app to make your first connection.", - "cmps.connect.ConnectPage.info": "You will need a unique session for each app you wish to connect.", - "cmps.connect.ConnectPage.copyPhraseLabel": "Copy Pairing Phrase", - "cmps.connect.ConnectPage.addlDesc": "If you’ve utilized your default pairing phrase, and want to create additional sessions.", + "cmps.connect.ConnectPage.description": "Lightning Node Connect enables you to connect to this node from the web.", "cmps.connect.SessionRowHeader.label": "Label", "cmps.connect.SessionRowHeader.type": "Type", "cmps.connect.SessionRowHeader.state": "State", "cmps.connect.SessionRowHeader.expiry": "Expiry", "cmps.connect.SessionRow.copy": "Copy Pairing Phrase", "cmps.connect.SessionRow.paired": "This Pairing Phrase has already been used by a client", + "cmps.connect.SessionRow.pairTerminal": "Pair with Lightning Terminal", + "cmps.connect.SessionRow.generateQR": "Generate QR Code", "cmps.connect.SessionRow.revoke": "Revoke Session", + "cmps.connect.QRCodeModal.title": "LNC QR", + "cmps.connect.QRCodeModal.desc": "Scan to connect to Terminal from your mobile phone.", + "cmps.home.HomePage.pageTitle": "Home", + "cmps.home.HomePage.connectDesc": "Securely and privately connect your node to Terminal on the web via the link below. For mobile, generate a QR code to connect.", + "cmps.home.HomePage.connectTerminalBtn": "Connect to Terminal", + "cmps.home.HomePage.connectQrBtn": "Connect with QR", + "cmps.home.HomePage.learnDesc": "The connection to your node occurs through the Lightning Node Connect protocol.", + "cmps.home.HomePage.learnMore": "Learn More", + "cmps.home.HomePage.whatsDiff": "What's different?", + "cmps.home.HomePage.diffDesc": "In the web based Terminal experience, you can expect new features like:", + "cmps.home.HomePage.loopTitle": "Improved Lightning Loop UX", + "cmps.home.HomePage.loopDesc": "Visually focused loop experience with Autoloop built-in.", + "cmps.home.HomePage.dashTitle": "Lightning Terminal Dashboard", + "cmps.home.HomePage.dashDesc": "Easily monitor routing activity and manage your channels through the dashboard.", + "cmps.home.YoutubeModal.title": "Get Connected", + "cmps.home.YoutubeModal.desc": "Get Connected with Lightning Node Connect", "cmps.history.HistoryPage.backText": "Lightning Loop", "cmps.history.HistoryPage.pageTitle": "History", "cmps.history.HistoryRowHeader.status": "Status", @@ -122,11 +136,13 @@ "cmps.loop.swap.SwapReviewStep.fees": "Fees", "cmps.loop.swap.SwapReviewStep.total": "Total", "cmps.loop.swap.SwapWizard.backTip": "Back to Previous", - "cmps.layout.NavMenu.menu": "Menu", - "cmps.layout.NavMenu.loop": "Lightning Loop", - "cmps.layout.NavMenu.history": "History", - "cmps.layout.NavMenu.pool": "Lightning Pool", + "cmps.layout.NavMenu.home": "Home", "cmps.layout.NavMenu.settings": "Settings", + "cmps.layout.NavMenu.liquidityHeader": "Liquidity", + "cmps.layout.NavMenu.loop": "Loop", + "cmps.layout.NavMenu.history": "Loop History", + "cmps.layout.NavMenu.pool": "Pool", + "cmps.layout.NavMenu.connectHeader": "Connect", "cmps.layout.NavMenu.connect": "Lightning Node Connect", "cmps.NodeStatus.title": "Node Status", "cmps.NodeStatus.offchainTip": "Off-chain Funds", diff --git a/app/src/store/models/session.ts b/app/src/store/models/session.ts index 3acb9a5f1..2013a2b38 100644 --- a/app/src/store/models/session.ts +++ b/app/src/store/models/session.ts @@ -1,6 +1,8 @@ import { makeAutoObservable } from 'mobx'; import * as LIT from 'types/generated/lit-sessions_pb'; import { SortParams } from 'types/state'; +import { Buffer } from 'buffer'; +import { LNC_APP_BASE_URL } from 'config'; import formatDate from 'date-fns/format'; import { MAX_DATE } from 'util/constants'; import { hex } from 'util/strings'; @@ -92,6 +94,17 @@ export default class Session { return this.isPaired ? 'In Use' : 'Created'; } + /** The HEX encoded pairing secret mnemonic and mailbox server address */ + get encodedPairingData() { + const data = `${this.pairingSecretMnemonic}||${this.mailboxServerAddr}`; + return Buffer.from(data, 'ascii').toString('base64'); + } + + /** The URL to use to pre-fill the pairing phrase in Terminal on the web */ + get terminalConnectUrl() { + return `${LNC_APP_BASE_URL}#/connect/pair/${this.encodedPairingData}`; + } + /** * Updates this session model using data provided from the LIT GRPC api */ diff --git a/app/src/store/store.ts b/app/src/store/store.ts index cb3b9f398..29297f0cf 100644 --- a/app/src/store/store.ts +++ b/app/src/store/store.ts @@ -5,6 +5,7 @@ import AppStorage from 'util/appStorage'; import CsvExporter from 'util/csv'; import { actionLog, Logger } from 'util/log'; import { GrpcClient, LitApi, LndApi, LoopApi, PoolApi } from 'api'; +import { PUBLIC_URL } from '../config'; import { AccountStore, AuthStore, @@ -30,7 +31,6 @@ import { RegisterSidecarView, RenewAccountView, } from './views'; -import { PUBLIC_URL } from '../config'; /** * The store used to manage global app state @@ -132,7 +132,7 @@ export class Store { // stay on the current page (ex: history, settings) if (document.location.pathname === `${PUBLIC_URL}/`) { runInAction(() => { - this.appView.goToLoop(); + this.appView.goToHome(); }); } // also fetch all the data we need diff --git a/app/src/store/stores/sessionStore.ts b/app/src/store/stores/sessionStore.ts index 989916f5d..d7adbfd8d 100644 --- a/app/src/store/stores/sessionStore.ts +++ b/app/src/store/stores/sessionStore.ts @@ -39,9 +39,18 @@ export default class SessionStore { return descending ? sessions.reverse() : sessions; } - /** indicates if there are more than one sessions active */ - get hasMultiple() { - return this.sortedSessions.length > 1; + /** The list of sessions that have not been paired */ + get unpairedSessions() { + return this.sortedSessions.filter(s => !s.isPaired); + } + + /** The label to use for a new generated session */ + get nextSessionLabel() { + const count = values(this.sessions).filter(s => + s.label.startsWith('Generated Session'), + ).length; + const countText = count === 0 ? '' : ` (${count})`; + return `Generated Session${countText}`; } /** @@ -76,19 +85,6 @@ export default class SessionStore { this._store.log.info('updated sessionStore.sessions', toJS(this.sessions)); }); - - // Ensures that there is at least one session created - if (this.sortedSessions.length === 0) { - const count = values(this.sessions).filter(s => - s.label.startsWith('Default Session'), - ).length; - const countText = count === 0 ? '' : `(${count})`; - await this.addSession( - `Default Session ${countText}`, - LIT.SessionType.TYPE_MACAROON_ADMIN, - MAX_DATE, - ); - } } catch (error: any) { this._store.appView.handleError(error, 'Unable to fetch sessions'); } @@ -99,11 +95,13 @@ export default class SessionStore { * @param label the user defined label for this session * @param type the type of session being created (admin, read-only, etc) * @param expiry how long the session should be valid for + * @param copy copy the session's phrase to the clipboard */ async addSession( label: string, type: LIT.SessionTypeMap[keyof LIT.SessionTypeMap], expiry: Date, + copy = false, ) { try { this._store.log.info(`submitting session with label ${label}`, { @@ -125,7 +123,10 @@ export default class SessionStore { await this.fetchSessions(); if (session) { - this.copyPhrase(session.label, session.pairingSecretMnemonic); + if (copy) this.copyPhrase(session.label, session.pairingSecretMnemonic); + const msg = + 'Please connect immediately. The pairing phrase will expire in 10 minutes.'; + this._store.appView.notify(msg, 'Session Created', 'warning'); return this.sessions.get(hex(session.localPublicKey)); } } catch (error: any) { @@ -133,6 +134,34 @@ export default class SessionStore { } } + /** + * Creates a new session if necessary and returns the Terminal Web url + */ + async getNewSessionUrl() { + let session = this.unpairedSessions.length > 0 ? this.unpairedSessions[0] : undefined; + if (!session) { + session = await this.addSession( + this.nextSessionLabel, + LIT.SessionType.TYPE_MACAROON_ADMIN, + MAX_DATE, + ); + } + return session ? session.terminalConnectUrl : ''; + } + + /** + * Opens the Terminal Web connect page in a new tab + */ + async connectToTerminalWeb() { + // open the window first, then set the url when the async function returns. This is done + // in this order because popup blockers will prevent the page from opening if the open() + // function is not in the immediate callstack of a user initiated action, such as a + // button click. If open() was called after the `await`, it would be blocked + const tab = window.open(); + const url = await this.getNewSessionUrl(); + if (tab) tab.location.replace(url); + } + /** * Revokes a session * @param session the Terminal Connect session object diff --git a/app/src/store/views/appView.ts b/app/src/store/views/appView.ts index 20521e73d..152ae10f3 100644 --- a/app/src/store/views/appView.ts +++ b/app/src/store/views/appView.ts @@ -45,6 +45,13 @@ export default class AppView { this._store.log.info('Go to the Auth page'); } + /** Change to the Home page */ + goToHome() { + this.goTo(`${PUBLIC_URL}/home`); + this._store.settingsStore.autoCollapseSidebar(); + this._store.log.info('Go to the Home page'); + } + /** Change to the Loop page */ goToLoop() { this.goTo(`${PUBLIC_URL}/loop`); diff --git a/app/yarn.lock b/app/yarn.lock index 5b456b577..32a10010c 100644 --- a/app/yarn.lock +++ b/app/yarn.lock @@ -1726,6 +1726,13 @@ dependencies: regenerator-runtime "^0.13.4" +"@babel/runtime@^7.18.3": + version "7.18.9" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.18.9.tgz#b4fcfce55db3d2e5e080d2490f608a3b9f407f4a" + integrity sha512-lkqXDcvlFT5rvEjiu6+QYO+1GXrEHRo2LOtS7E4GtX5ESIZOgepqsZBVIj6Pv+a6zqsya9VCgiK1KAK4BvJDAw== + dependencies: + regenerator-runtime "^0.13.4" + "@babel/template@^7.12.7", "@babel/template@^7.16.7", "@babel/template@^7.3.3": version "7.16.7" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.16.7.tgz#8d126c8701fde4d66b264b3eba3d96f07666d155" @@ -12828,6 +12835,11 @@ q@^1.1.2: resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7" integrity sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc= +qrcode.react@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/qrcode.react/-/qrcode.react-3.1.0.tgz#5c91ddc0340f768316fbdb8fff2765134c2aecd8" + integrity sha512-oyF+Urr3oAMUG/OiOuONL3HXM+53wvuH3mtIWQrYmsXoAq0DkvZp2RYUWFSMFtbdOpuS++9v+WAkzNVkMlNW6Q== + qs@6.9.7: version "6.9.7" resolved "https://registry.yarnpkg.com/qs/-/qs-6.9.7.tgz#4610846871485e1e048f44ae3b94033f0e675afe" @@ -12927,6 +12939,16 @@ rc-align@^4.0.0: rc-util "^5.3.0" resize-observer-polyfill "^1.5.1" +rc-dialog@^8.9.0: + version "8.9.0" + resolved "https://registry.yarnpkg.com/rc-dialog/-/rc-dialog-8.9.0.tgz#04dc39522f0321ed2e06018d4a7e02a4c32bd3ea" + integrity sha512-Cp0tbJnrvPchJfnwIvOMWmJ4yjX3HWFatO6oBFD1jx8QkgsQCR0p8nUWAKdd3seLJhEC39/v56kZaEjwp9muoQ== + dependencies: + "@babel/runtime" "^7.10.1" + classnames "^2.2.6" + rc-motion "^2.3.0" + rc-util "^5.21.0" + rc-motion@^1.0.0: version "1.1.2" resolved "https://registry.yarnpkg.com/rc-motion/-/rc-motion-1.1.2.tgz#07212f1b96c715b8245ec121339146c4a9b0968c" @@ -12946,6 +12968,15 @@ rc-motion@^2.0.0, rc-motion@^2.0.1: classnames "^2.2.1" rc-util "^5.19.2" +rc-motion@^2.3.0: + version "2.6.2" + resolved "https://registry.yarnpkg.com/rc-motion/-/rc-motion-2.6.2.tgz#3d31f97e41fb8e4f91a4a4189b6a98ac63342869" + integrity sha512-4w1FaX3dtV749P8GwfS4fYnFG4Rb9pxvCYPc/b2fw1cmlHJWNNgOFIz7ysiD+eOrzJSvnLJWlNQQncpNMXwwpg== + dependencies: + "@babel/runtime" "^7.11.1" + classnames "^2.2.1" + rc-util "^5.21.0" + rc-resize-observer@^1.0.0: version "1.2.0" resolved "https://registry.yarnpkg.com/rc-resize-observer/-/rc-resize-observer-1.2.0.tgz#9f46052f81cdf03498be35144cb7c53fd282c4c7" @@ -13008,6 +13039,15 @@ rc-util@^5.0.1, rc-util@^5.0.6, rc-util@^5.15.0, rc-util@^5.19.2, rc-util@^5.3.0 react-is "^16.12.0" shallowequal "^1.1.0" +rc-util@^5.21.0: + version "5.23.0" + resolved "https://registry.yarnpkg.com/rc-util/-/rc-util-5.23.0.tgz#a583b1ec3e1832a80eced7a700a494af0b590743" + integrity sha512-lgm6diJ/pLgyfoZY59Vz7sW4mSoQCgozqbBye9IJ7/mb5w5h4T7h+i2JpXAx/UBQxscBZe68q0sP7EW+qfkKUg== + dependencies: + "@babel/runtime" "^7.18.3" + react-is "^16.12.0" + shallowequal "^1.1.0" + rc-virtual-list@^3.2.0: version "3.4.6" resolved "https://registry.yarnpkg.com/rc-virtual-list/-/rc-virtual-list-3.4.6.tgz#af34235915221173dd42d9f25b32e95d4c0f5698"