From 024a514dd673cddec56a4309a807e3093d3e1a71 Mon Sep 17 00:00:00 2001 From: Reckless_Satoshi Date: Thu, 25 May 2023 03:37:35 -0700 Subject: [PATCH] Squash and rebase the-federation-layer over main v0.5.0 --- frontend/src/App.tsx | 6 +- frontend/src/basic/BookPage/index.tsx | 24 +- frontend/src/basic/Main.tsx | 10 +- frontend/src/basic/MainDialogs/index.tsx | 89 ++- frontend/src/basic/MakerPage/index.tsx | 9 +- frontend/src/basic/NavBar/MoreTooltip.tsx | 25 +- frontend/src/basic/NavBar/NavBar.tsx | 44 +- frontend/src/basic/OrderPage/index.tsx | 32 +- frontend/src/basic/RobotPage/Onboarding.tsx | 4 +- frontend/src/basic/RobotPage/RobotProfile.tsx | 8 +- frontend/src/basic/RobotPage/index.tsx | 9 +- frontend/src/basic/SettingsPage/index.tsx | 29 +- frontend/src/components/BookTable/index.tsx | 227 ++++-- .../components/Charts/DepthChart/index.tsx | 15 +- .../Dialogs/{Info.tsx => About.tsx} | 4 +- frontend/src/components/Dialogs/Client.tsx | 80 ++ .../src/components/Dialogs/Coordinator.tsx | 734 ++++++++++++++++++ .../components/Dialogs/CoordinatorSummary.tsx | 174 ----- frontend/src/components/Dialogs/Exchange.tsx | 188 +++++ frontend/src/components/Dialogs/Stats.tsx | 223 ------ .../Dialogs/{UpdateClient.tsx => Update.tsx} | 42 +- frontend/src/components/Dialogs/index.ts | 9 +- .../src/components/FederationTable/index.tsx | 237 ++++++ .../src/components/Icons/BadgeDevFund.tsx | 150 ++++ .../src/components/Icons/BadgeFounder.tsx | 77 ++ frontend/src/components/Icons/BadgeLimits.tsx | 184 +++++ frontend/src/components/Icons/BadgeLoved.tsx | 96 +++ .../src/components/Icons/BadgePrivacy.tsx | 276 +++++++ frontend/src/components/Icons/Nostr.tsx | 11 + frontend/src/components/Icons/index.ts | 8 + .../src/components/MakerForm/MakerForm.tsx | 18 +- frontend/src/components/RobotAvatar/index.tsx | 7 +- .../src/components/SettingsForm/index.tsx | 47 +- .../src/components/TorConnection/index.tsx | 97 +++ .../TradeBox/Forms/LightningPayout.tsx | 12 +- frontend/src/contexts/AppContext.ts | 399 +++++++--- frontend/src/models/Book.model.ts | 3 + frontend/src/models/Coordinator.model.ts | 115 ++- frontend/src/models/Exchange.model.ts | 75 ++ frontend/src/models/Info.model.ts | 58 -- frontend/src/models/Limit.model.ts | 19 + frontend/src/models/Settings.model.ts | 2 - frontend/src/models/index.ts | 15 +- frontend/src/pro/Main.tsx | 37 +- frontend/src/pro/ToolBar/index.tsx | 8 +- frontend/src/pro/Widgets/Book.tsx | 9 +- frontend/src/pro/Widgets/Depth.tsx | 7 +- frontend/src/pro/Widgets/Federation.tsx | 54 ++ frontend/src/pro/Widgets/Maker.tsx | 1 - frontend/src/pro/Widgets/Settings.tsx | 1 - frontend/src/pro/Widgets/index.ts | 1 + frontend/src/utils/aggregateInfo.ts | 115 +++ frontend/src/utils/checkVer.ts | 57 +- frontend/src/utils/index.ts | 3 +- frontend/src/utils/weightedMean.ts | 16 + frontend/static/federation.json | 113 ++- frontend/static/federation/exp.small.webp | Bin 0 -> 2652 bytes frontend/static/federation/exp.webp | Bin 0 -> 49962 bytes frontend/static/federation/local.small.webp | Bin 0 -> 1146 bytes frontend/static/federation/local.webp | Bin 0 -> 5424 bytes frontend/static/federation/temp.small.webp | Bin 0 -> 1070 bytes frontend/static/federation/temp.webp | Bin 0 -> 8450 bytes frontend/urls.py | 2 +- 63 files changed, 3422 insertions(+), 893 deletions(-) rename frontend/src/components/Dialogs/{Info.tsx => About.tsx} (99%) create mode 100644 frontend/src/components/Dialogs/Client.tsx create mode 100644 frontend/src/components/Dialogs/Coordinator.tsx delete mode 100644 frontend/src/components/Dialogs/CoordinatorSummary.tsx create mode 100644 frontend/src/components/Dialogs/Exchange.tsx delete mode 100644 frontend/src/components/Dialogs/Stats.tsx rename frontend/src/components/Dialogs/{UpdateClient.tsx => Update.tsx} (73%) create mode 100644 frontend/src/components/FederationTable/index.tsx create mode 100644 frontend/src/components/Icons/BadgeDevFund.tsx create mode 100644 frontend/src/components/Icons/BadgeFounder.tsx create mode 100644 frontend/src/components/Icons/BadgeLimits.tsx create mode 100644 frontend/src/components/Icons/BadgeLoved.tsx create mode 100644 frontend/src/components/Icons/BadgePrivacy.tsx create mode 100644 frontend/src/components/Icons/Nostr.tsx create mode 100644 frontend/src/components/TorConnection/index.tsx create mode 100644 frontend/src/models/Exchange.model.ts delete mode 100644 frontend/src/models/Info.model.ts create mode 100644 frontend/src/pro/Widgets/Federation.tsx create mode 100644 frontend/src/utils/aggregateInfo.ts create mode 100644 frontend/src/utils/weightedMean.ts create mode 100644 frontend/static/federation/exp.small.webp create mode 100644 frontend/static/federation/exp.webp create mode 100644 frontend/static/federation/local.small.webp create mode 100644 frontend/static/federation/local.webp create mode 100644 frontend/static/federation/temp.small.webp create mode 100644 frontend/static/federation/temp.webp diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e32cbac7e..ea18c7f60 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -39,7 +39,11 @@ const loadApp = () => { setTimeout(loadApp, 200); } else { const root = ReactDOM.createRoot(document.getElementById('app') ?? new HTMLElement()); - root.render(); + root.render( + + + , + ); } }; diff --git a/frontend/src/basic/BookPage/index.tsx b/frontend/src/basic/BookPage/index.tsx index b11c13b73..923c35dfd 100644 --- a/frontend/src/basic/BookPage/index.tsx +++ b/frontend/src/basic/BookPage/index.tsx @@ -13,7 +13,7 @@ import { BarChart, FormatListBulleted } from '@mui/icons-material'; import { AppContext, type UseAppStoreType } from '../../contexts/AppContext'; const BookPage = (): JSX.Element => { - const { robot, fetchBook, windowSize, setDelay, setOrder } = + const { robot, fetchFederationBook, windowSize, setDelay, clearOrder } = useContext(AppContext); const { t } = useTranslation(); const navigate = useNavigate(); @@ -27,18 +27,14 @@ const BookPage = (): JSX.Element => { const chartWidthEm = width - maxBookTableWidth; useEffect(() => { - fetchBook(); + fetchFederationBook(); }, []); - const onViewOrder = function () { - setOrder(undefined); - setDelay(10000); - }; - - const onOrderClicked = function (id: number) { + const onOrderClicked = function (id: number, shortAlias: string) { if (robot.avatarLoaded) { - navigate('/order/' + id); - onViewOrder(); + clearOrder(); + setDelay(10000); + navigate(`/order/${shortAlias}/${id}`); } else { setOpenNoRobot(true); } @@ -85,7 +81,9 @@ const BookPage = (): JSX.Element => { onClose={() => { setOpenNoRobot(false); }} - onClickGenerateRobot={() => navigate('/robot')} + onClickGenerateRobot={() => { + navigate('/robot'); + }} /> {openMaker ? ( { onOrderCreated={(id) => { navigate('/order/' + id); }} - onClickGenerateRobot={() => navigate('/robot')} + onClickGenerateRobot={() => { + navigate('/robot'); + }} /> diff --git a/frontend/src/basic/Main.tsx b/frontend/src/basic/Main.tsx index da2eeed5a..192b80173 100644 --- a/frontend/src/basic/Main.tsx +++ b/frontend/src/basic/Main.tsx @@ -1,6 +1,7 @@ import React, { useContext } from 'react'; import { MemoryRouter, BrowserRouter, Routes, Route } from 'react-router-dom'; import { Box, Slide, Typography, styled } from '@mui/material'; +import { type UseAppStoreType, AppContext, closeAll, hostUrl } from '../contexts/AppContext'; import RobotPage from './RobotPage'; import MakerPage from './MakerPage'; @@ -11,10 +12,8 @@ import NavBar from './NavBar'; import MainDialogs from './MainDialogs'; import RobotAvatar from '../components/RobotAvatar'; - import { useTranslation } from 'react-i18next'; import Notifications from '../components/Notifications'; -import { type UseAppStoreType, AppContext, closeAll } from '../contexts/AppContext'; const Router = window.NativeRobosats === undefined ? BrowserRouter : MemoryRouter; @@ -39,7 +38,6 @@ const Main: React.FC = () => { settings, robot, setRobot, - baseUrl, order, page, slideDirection, @@ -53,7 +51,7 @@ const Main: React.FC = () => { { setRobot((robot) => { return { ...robot, avatarLoaded: true }; @@ -130,7 +128,7 @@ const Main: React.FC = () => { /> { /> - + ); diff --git a/frontend/src/basic/MainDialogs/index.tsx b/frontend/src/basic/MainDialogs/index.tsx index b4ffcfa83..2ffc93280 100644 --- a/frontend/src/basic/MainDialogs/index.tsx +++ b/frontend/src/basic/MainDialogs/index.tsx @@ -1,15 +1,16 @@ import React, { useState, useContext, useEffect } from 'react'; import { CommunityDialog, - CoordinatorSummaryDialog, - InfoDialog, + ExchangeDialog, + CoordinatorDialog, + AboutDialog, LearnDialog, ProfileDialog, - StatsDialog, - UpdateClientDialog, + ClientDialog, + UpdateDialog, } from '../../components/Dialogs'; import { pn } from '../../utils'; -import { AppContext, type UseAppStoreType, closeAll } from '../../contexts/AppContext'; +import { AppContext, type UseAppStoreType, closeAll, hostUrl } from '../../contexts/AppContext'; export interface OpenDialogs { more: boolean; @@ -17,14 +18,26 @@ export interface OpenDialogs { community: boolean; info: boolean; coordinator: boolean; - stats: boolean; + exchange: boolean; + client: boolean; update: boolean; profile: boolean; } const MainDialogs = (): JSX.Element => { - const { open, setOpen, info, limits, robot, setRobot, setCurrentOrder, baseUrl } = - useContext(AppContext); + const { + open, + setOpen, + limits, + robot, + setRobot, + setCurrentOrder, + settings, + federation, + clientVersion, + focusedCoordinator, + exchange, + } = useContext(AppContext); const [maxAmount, setMaxAmount] = useState('...loading...'); @@ -34,64 +47,74 @@ const MainDialogs = (): JSX.Element => { } }, [limits.list]); - useEffect(() => { - if (info.openUpdateClient) { - setOpen({ ...closeAll, update: true }); - } - }, [info]); - return ( <> - { - setOpen({ ...open, update: false }); + setOpen((open) => { + return { ...open, update: false }; + }); }} /> - { - setOpen({ ...open, info: false }); + setOpen((open) => { + return { ...open, info: false }; + }); }} /> { - setOpen({ ...open, learn: false }); + setOpen((open) => { + return { ...open, learn: false }; + }); }} /> { - setOpen({ ...open, community: false }); + setOpen((open) => { + return { ...open, community: false }; + }); }} /> - { - setOpen({ ...open, coordinator: false }); + setOpen((open) => { + return { ...open, exchange: false }; + }); }} - info={info} /> - { - setOpen({ ...open, stats: false }); + setOpen((open) => { + return { ...open, client: false }; + }); }} - info={info} /> { setOpen({ ...open, profile: false }); }} robot={robot} setRobot={setRobot} - setCurrentOrder={setCurrentOrder} + /> + { + setOpen(closeAll); + }} + coordinator={federation[focusedCoordinator]} /> ); diff --git a/frontend/src/basic/MakerPage/index.tsx b/frontend/src/basic/MakerPage/index.tsx index 5f72fc3cd..e36c08b52 100644 --- a/frontend/src/basic/MakerPage/index.tsx +++ b/frontend/src/basic/MakerPage/index.tsx @@ -2,7 +2,6 @@ import React, { useContext, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { Grid, Paper, Collapse, Typography } from '@mui/material'; - import { filterOrders } from '../../utils'; import MakerForm from '../../components/MakerForm'; @@ -69,7 +68,9 @@ const MakerPage = (): JSX.Element => { onClose={() => { setOpenNoRobot(false); }} - onClickGenerateRobot={() => navigate('/robot')} + onClickGenerateRobot={() => { + navigate('/robot'); + }} /> 0 && showMatches}> @@ -115,7 +116,9 @@ const MakerPage = (): JSX.Element => { setShowMatches(false); }} submitButtonLabel={matches.length > 0 && !showMatches ? 'Submit' : 'Create order'} - onClickGenerateRobot={() => navigate('/robot')} + onClickGenerateRobot={() => { + navigate('/robot'); + }} /> diff --git a/frontend/src/basic/NavBar/MoreTooltip.tsx b/frontend/src/basic/NavBar/MoreTooltip.tsx index 42de40942..31d9ac1ae 100644 --- a/frontend/src/basic/NavBar/MoreTooltip.tsx +++ b/frontend/src/basic/NavBar/MoreTooltip.tsx @@ -1,9 +1,8 @@ -import React from 'react'; +import React, { useContext } from 'react'; import { useTranslation } from 'react-i18next'; import { useTheme, styled, Grid, IconButton } from '@mui/material'; import Tooltip, { type TooltipProps, tooltipClasses } from '@mui/material/Tooltip'; -import { closeAll } from '../../contexts/AppContext'; -import { type OpenDialogs } from '../MainDialogs'; +import { closeAll, type UseAppStoreType, AppContext } from '../../contexts/AppContext'; import { BubbleChart, Info, People, PriceChange, School } from '@mui/icons-material'; @@ -20,13 +19,13 @@ const StyledTooltip = styled(({ className, ...props }: TooltipProps) => ( })); interface MoreTooltipProps { - open: OpenDialogs; - setOpen: (state: OpenDialogs) => void; children: JSX.Element; } -const MoreTooltip = ({ open, setOpen, children }: MoreTooltipProps): JSX.Element => { +const MoreTooltip = ({ children }: MoreTooltipProps): JSX.Element => { const { t } = useTranslation(); + const { open, setOpen } = useContext(AppContext); + const theme = useTheme(); return ( - + { - setOpen({ ...closeAll, coordinator: !open.coordinator }); + setOpen({ ...closeAll, exchange: !open.exchange }); }} > @@ -106,13 +103,13 @@ const MoreTooltip = ({ open, setOpen, children }: MoreTooltipProps): JSX.Element - + { - setOpen({ ...closeAll, stats: !open.stats }); + setOpen({ ...closeAll, client: !open.client }); }} > diff --git a/frontend/src/basic/NavBar/NavBar.tsx b/frontend/src/basic/NavBar/NavBar.tsx index 9ba781d45..7e10ef5b7 100644 --- a/frontend/src/basic/NavBar/NavBar.tsx +++ b/frontend/src/basic/NavBar/NavBar.tsx @@ -15,31 +15,28 @@ import { MoreHoriz, } from '@mui/icons-material'; import RobotAvatar from '../../components/RobotAvatar'; -import { AppContext, type UseAppStoreType, closeAll } from '../../contexts/AppContext'; +import { AppContext, type UseAppStoreType, closeAll, hostUrl } from '../../contexts/AppContext'; -interface NavBarProps { - width: number; - height: number; -} - -const NavBar = ({ width, height }: NavBarProps): JSX.Element => { +const NavBar = (): JSX.Element => { + const theme = useTheme(); + const { t } = useTranslation(); const { - robot, page, - settings, setPage, + robot, + settings, setSlideDirection, open, setOpen, + windowSize, currentOrder, - baseUrl, + navbarHeight, } = useContext(AppContext); - const theme = useTheme(); - const { t } = useTranslation(); const navigate = useNavigate(); const location = useLocation(); - const smallBar = width < 50; + const smallBar = windowSize.width < 50; + const color = settings.network === 'mainnet' ? 'primary' : 'secondary'; const tabSx = smallBar ? { position: 'relative', bottom: robot.avatarLoaded ? '0.9em' : '0.13em', minWidth: '1em' } @@ -79,7 +76,8 @@ const NavBar = ({ width, height }: NavBarProps): JSX.Element => { } else { handleSlideDirection(page, newPage); setPage(newPage); - const param = newPage === 'order' ? currentOrder ?? '' : ''; + const param = + newPage === 'order' ? `${currentOrder.shortAlias}/${currentOrder.id}` ?? '' : ''; setTimeout(() => { navigate(`/${newPage}/${param}`); }, theme.transitions.duration.leavingScreen * 3); @@ -93,14 +91,20 @@ const NavBar = ({ width, height }: NavBarProps): JSX.Element => { return ( { style={{ width: '2.3em', height: '2.3em', position: 'relative', top: '0.2em' }} avatarClass={theme.palette.mode === 'dark' ? 'navBarAvatarDark' : 'navBarAvatar'} nickname={robot.nickname} - baseUrl={baseUrl} + baseUrl={hostUrl} /> ) : ( <> @@ -150,7 +154,7 @@ const NavBar = ({ width, height }: NavBarProps): JSX.Element => { sx={tabSx} label={smallBar ? undefined : t('Order')} value='order' - disabled={!robot.avatarLoaded || currentOrder == undefined} + disabled={!robot.avatarLoaded || currentOrder.id == null} icon={} iconPosition='start' /> @@ -170,7 +174,7 @@ const NavBar = ({ width, height }: NavBarProps): JSX.Element => { open.more ? null : setOpen({ ...open, more: true }); }} icon={ - + } diff --git a/frontend/src/basic/OrderPage/index.tsx b/frontend/src/basic/OrderPage/index.tsx index ae5f5b250..f419563fd 100644 --- a/frontend/src/basic/OrderPage/index.tsx +++ b/frontend/src/basic/OrderPage/index.tsx @@ -7,7 +7,7 @@ import TradeBox from '../../components/TradeBox'; import OrderDetails from '../../components/OrderDetails'; import { apiClient } from '../../services/api'; -import { AppContext, type UseAppStoreType } from '../../contexts/AppContext'; +import { AppContext, hostUrl, type UseAppStoreType } from '../../contexts/AppContext'; const OrderPage = (): JSX.Element => { const { @@ -22,7 +22,6 @@ const OrderPage = (): JSX.Element => { setCurrentOrder, badOrder, setBadOrder, - baseUrl, navbarHeight, } = useContext(AppContext); const { t } = useTranslation(); @@ -35,9 +34,10 @@ const OrderPage = (): JSX.Element => { const [tab, setTab] = useState<'order' | 'contract'>('contract'); useEffect(() => { - if (currentOrder != params.orderId) { + const newOrder = { shortAlias: params.shortAlias, id: Number(params.orderId) }; + if (currentOrder != newOrder) { clearOrder(); - setCurrentOrder(Number(params.orderId)); + setCurrentOrder(newOrder); } }, [params.orderId]); @@ -59,7 +59,7 @@ const OrderPage = (): JSX.Element => { bond_size: order.bond_size, }; apiClient - .post(baseUrl, '/api/make/', body, { tokenSHA256: robot.tokenSHA256 }) + .post(hostUrl, '/api/make/', body, { tokenSHA256: robot.tokenSHA256 }) .then((data: any) => { if (data.bad_request) { setBadOrder(data.bad_request); @@ -106,10 +106,12 @@ const OrderPage = (): JSX.Element => { navigate('/robot')} + onClickGenerateRobot={() => { + navigate('/robot'); + }} /> @@ -128,7 +130,7 @@ const OrderPage = (): JSX.Element => { settings={settings} setOrder={setOrder} setBadOrder={setBadOrder} - baseUrl={baseUrl} + baseUrl={hostUrl} onRenewOrder={renewOrder} onStartAgain={startAgain} /> @@ -162,10 +164,12 @@ const OrderPage = (): JSX.Element => { navigate('/robot')} + onClickGenerateRobot={() => { + navigate('/robot'); + }} />
@@ -175,7 +179,7 @@ const OrderPage = (): JSX.Element => { settings={settings} setOrder={setOrder} setBadOrder={setBadOrder} - baseUrl={baseUrl} + baseUrl={hostUrl} onRenewOrder={renewOrder} onStartAgain={startAgain} /> @@ -195,10 +199,12 @@ const OrderPage = (): JSX.Element => { navigate('/robot')} + onClickGenerateRobot={() => { + navigate('/robot'); + }} /> ) diff --git a/frontend/src/basic/RobotPage/Onboarding.tsx b/frontend/src/basic/RobotPage/Onboarding.tsx index 6f8ef428f..e467e53fd 100644 --- a/frontend/src/basic/RobotPage/Onboarding.tsx +++ b/frontend/src/basic/RobotPage/Onboarding.tsx @@ -21,6 +21,7 @@ import RobotAvatar from '../../components/RobotAvatar'; import TokenInput from './TokenInput'; import { genBase62Token } from '../../utils'; import { NewTabIcon } from '../../components/Icons'; +import { hostUrl } from '../../contexts/AppContext'; interface OnboardingProps { setView: (state: 'welcome' | 'onboarding' | 'recovery' | 'profile') => void; @@ -41,7 +42,6 @@ const Onboarding = ({ setRobot, badToken, getGenerateRobot, - baseUrl, }: OnboardingProps): JSX.Element => { const { t } = useTranslation(); const navigate = useNavigate(); @@ -174,7 +174,7 @@ const Onboarding = ({ width: '12.4em', }} tooltipPosition='top' - baseUrl={baseUrl} + baseUrl={hostUrl} /> diff --git a/frontend/src/basic/RobotPage/RobotProfile.tsx b/frontend/src/basic/RobotPage/RobotProfile.tsx index e6da0d816..da276529d 100644 --- a/frontend/src/basic/RobotPage/RobotProfile.tsx +++ b/frontend/src/basic/RobotPage/RobotProfile.tsx @@ -17,7 +17,7 @@ import { Bolt, Add, DeleteSweep, Logout, Download } from '@mui/icons-material'; import RobotAvatar from '../../components/RobotAvatar'; import TokenInput from './TokenInput'; import { type Slot, type Robot } from '../../models'; -import { AppContext, type UseAppStoreType } from '../../contexts/AppContext'; +import { AppContext, hostUrl, type UseAppStoreType } from '../../contexts/AppContext'; import { genBase62Token } from '../../utils'; import { LoadingButton } from '@mui/lab'; @@ -29,7 +29,6 @@ interface RobotProfileProps { inputToken: string; logoutRobot: () => void; setInputToken: (state: string) => void; - baseUrl: string; width: number; } @@ -41,7 +40,6 @@ const RobotProfile = ({ setInputToken, logoutRobot, setView, - baseUrl, width, }: RobotProfileProps): JSX.Element => { const { currentSlot, garage, setCurrentSlot, windowSize } = @@ -134,7 +132,7 @@ const RobotProfile = ({ }} tooltip={t('This is your trading avatar')} tooltipPosition='top' - baseUrl={baseUrl} + baseUrl={hostUrl} /> {robot.found && !robot.lastOrderId ? ( @@ -271,7 +269,7 @@ const RobotProfile = ({ smooth={true} style={{ width: '2.6em', height: '2.6em' }} placeholderType='loading' - baseUrl={baseUrl} + baseUrl={hostUrl} small={true} /> diff --git a/frontend/src/basic/RobotPage/index.tsx b/frontend/src/basic/RobotPage/index.tsx index d50c2f6a8..0d67f61eb 100644 --- a/frontend/src/basic/RobotPage/index.tsx +++ b/frontend/src/basic/RobotPage/index.tsx @@ -19,11 +19,11 @@ import RobotProfile from './RobotProfile'; import Recovery from './Recovery'; import { TorIcon } from '../../components/Icons'; import { genKey } from '../../pgp'; -import { AppContext, type UseAppStoreType } from '../../contexts/AppContext'; +import { AppContext, hostUrl, type UseAppStoreType } from '../../contexts/AppContext'; import { validateTokenEntropy } from '../../utils'; const RobotPage = (): JSX.Element => { - const { robot, setRobot, fetchRobot, torStatus, windowSize, baseUrl, settings } = + const { robot, setRobot, fetchRobot, torStatus, windowSize, settings } = useContext(AppContext); const { t } = useTranslation(); const params = useParams(); @@ -152,7 +152,7 @@ const RobotPage = (): JSX.Element => { inputToken={inputToken} setInputToken={setInputToken} getGenerateRobot={getGenerateRobot} - baseUrl={baseUrl} + baseUrl={hostUrl} /> ) : null} @@ -166,7 +166,7 @@ const RobotPage = (): JSX.Element => { inputToken={inputToken} setInputToken={setInputToken} getGenerateRobot={getGenerateRobot} - baseUrl={baseUrl} + baseUrl={hostUrl} /> ) : null} @@ -179,6 +179,7 @@ const RobotPage = (): JSX.Element => { inputToken={inputToken} setInputToken={setInputToken} getGenerateRobot={getGenerateRobot} + baseUrl={hostUrl} /> ) : null} diff --git a/frontend/src/basic/SettingsPage/index.tsx b/frontend/src/basic/SettingsPage/index.tsx index 8ae3bb674..47e2598c8 100644 --- a/frontend/src/basic/SettingsPage/index.tsx +++ b/frontend/src/basic/SettingsPage/index.tsx @@ -1,10 +1,20 @@ import React, { useContext } from 'react'; import { Grid, Paper } from '@mui/material'; import SettingsForm from '../../components/SettingsForm'; -import { type UseAppStoreType, AppContext } from '../../contexts/AppContext'; +import { AppContext, hostUrl, type UseAppStoreType } from '../../contexts/AppContext'; +import FederationTable from '../../components/FederationTable'; const SettingsPage = (): JSX.Element => { - const { windowSize, navbarHeight } = useContext(AppContext); + const { + windowSize, + navbarHeight, + federation, + dispatchFederation, + setFocusedCoordinator, + settings, + setOpen, + open, + } = useContext(AppContext); const maxHeight = (windowSize.height - navbarHeight) * 0.85 - 3; return ( @@ -12,7 +22,7 @@ const SettingsPage = (): JSX.Element => { elevation={12} sx={{ padding: '0.6em', - width: '21em', + width: '20.5em', maxHeight: `${maxHeight}em`, overflow: 'auto', overflowX: 'clip', @@ -22,6 +32,19 @@ const SettingsPage = (): JSX.Element => { + + { + setOpen({ ...open, coordinator: true }); + }} + baseUrl={hostUrl} + maxHeight={14} + network={settings.network} + /> + ); diff --git a/frontend/src/components/BookTable/index.tsx b/frontend/src/components/BookTable/index.tsx index 5ab9b74cb..ae0ad10ff 100644 --- a/frontend/src/components/BookTable/index.tsx +++ b/frontend/src/components/BookTable/index.tsx @@ -34,7 +34,7 @@ import RobotAvatar from '../RobotAvatar'; // Icons import { Fullscreen, FullscreenExit, Refresh } from '@mui/icons-material'; -import { AppContext, type UseAppStoreType } from '../../contexts/AppContext'; +import { AppContext, hostUrl, origin, type UseAppStoreType } from '../../contexts/AppContext'; const ClickThroughDataGrid = styled(DataGrid)({ '& .MuiDataGrid-overlayWrapperInner': { @@ -66,7 +66,7 @@ interface BookTableProps { showControls?: boolean; showFooter?: boolean; showNoResults?: boolean; - onOrderClicked?: (id: number) => void; + onOrderClicked?: (id: number, shortAlias: string) => void; } const BookTable = ({ @@ -83,11 +83,24 @@ const BookTable = ({ showNoResults = true, onOrderClicked = () => null, }: BookTableProps): JSX.Element => { - const { book, fetchBook, fav, setFav, baseUrl } = useContext(AppContext); + const { + book, + federation, + fetchFederationBook, + fav, + setFav, + setFocusedCoordinator, + settings, + setOpen, + } = useContext(AppContext); const { t } = useTranslation(); const theme = useTheme(); const orders = orderList ?? book.orders; + const loadingProgress = useMemo(() => { + return (book.loadedCoordinators / book.totalCoordinators) * 100; + }, [book.loadedCoordinators, book.totalCoordinators]); + const [paginationModel, setPaginationModel] = useState({ pageSize: 0, page: 0, @@ -177,7 +190,12 @@ const BookTable = ({ width: width * fontSize, renderCell: (params: any) => { return ( - + { + onOrderClicked(params.row.id, params.row.coordinatorShortAlias); + }} + > @@ -205,21 +223,60 @@ const BookTable = ({ width: width * fontSize, renderCell: (params: any) => { return ( -
- +
{ + onOrderClicked(params.row.id, params.row.coordinatorShortAlias); + }} + > + +
+ ); + }, + }; + }, []); + + const onClickCoordinator = function (shortAlias: string) { + setFocusedCoordinator(shortAlias); + setOpen((open) => { + return { ...open, coordinator: true }; + }); + }; + + const coordinatorObj = useCallback((width: number) => { + return { + field: 'coordinatorShortAlias', + headerName: t('Host'), + width: width * fontSize, + renderCell: (params: any) => { + return ( + { + onClickCoordinator(params.row.coordinatorShortAlias); + }} + > + - -
+ +
); }, }; @@ -230,10 +287,20 @@ const BookTable = ({ field: 'type', headerName: t('Is'), width: width * fontSize, - renderCell: (params: any) => - params.row.type - ? t(fav.mode === 'fiat' ? 'Seller' : 'Swapping Out') - : t(fav.mode === 'fiat' ? 'Buyer' : 'Swapping In'), + renderCell: (params: any) => { + return ( +
{ + onOrderClicked(params.row.id, params.row.coordinatorShortAlias); + }} + > + {params.row.type + ? t(fav.mode === 'fiat' ? 'Seller' : 'Swapping Out') + : t(fav.mode === 'fiat' ? 'Buyer' : 'Swapping In')} +
+ ); + }, }; }, []); @@ -250,7 +317,12 @@ const BookTable = ({ const maxAmount = fav.mode === 'swap' ? params.row.max_amount * 100000 : params.row.max_amount; return ( -
+
{ + onOrderClicked(params.row.id, params.row.coordinatorShortAlias); + }} + > {amountToString(amount, params.row.has_range, minAmount, maxAmount) + (fav.mode === 'swap' ? 'K Sats' : '')}
@@ -274,6 +346,9 @@ const BookTable = ({ alignItems: 'center', flexWrap: 'wrap', }} + onClick={() => { + onOrderClicked(params.row.id, params.row.coordinatorShortAlias); + }} > {currencyCode}
@@ -291,7 +366,12 @@ const BookTable = ({ width: width * fontSize, renderCell: (params: any) => { return ( -
+
{ + onOrderClicked(params.row.id, params.row.coordinatorShortAlias); + }} + > { + onOrderClicked(params.row.id, params.row.coordinatorShortAlias); + }} > { const currencyCode = currencyDict[params.row.currency.toString()]; return ( -
{`${pn(params.row.price)} ${currencyCode}/BTC`}
+
{ + onOrderClicked(params.row.id, params.row.coordinatorShortAlias); + }} + > + {`${pn(params.row.price)} ${currencyCode}/BTC`} +
); }, }; @@ -383,7 +473,12 @@ const BookTable = ({ enterTouchDelay={0} title={pn(params.row.price) + ' ' + currencyCode + '/BTC'} > -
+
{ + onOrderClicked(params.row.id, params.row.coordinatorShortAlias); + }} + > {parseFloat(parseFloat(params.row.premium).toFixed(4)) + '%'} @@ -405,7 +500,16 @@ const BookTable = ({ renderCell: (params: any) => { const hours = Math.round(params.row.escrow_duration / 3600); const minutes = Math.round((params.row.escrow_duration - hours * 3600) / 60); - return
{hours > 0 ? `${hours}h` : `${minutes}m`}
; + return ( +
{ + onOrderClicked(params.row.id, params.row.coordinatorShortAlias); + }} + > + {hours > 0 ? `${hours}h` : `${minutes}m`} +
+ ); }, }; }, []); @@ -423,7 +527,12 @@ const BookTable = ({ const hours = Math.round(timeToExpiry / (3600 * 1000)); const minutes = Math.round((timeToExpiry - hours * (3600 * 1000)) / 60000); return ( - + { + onOrderClicked(params.row.id, params.row.coordinatorShortAlias); + }} + > { return ( -
+
{ + onOrderClicked(params.row.id, params.row.coordinatorShortAlias); + }} + > {params.row.satoshis_now > 1000000 ? `${pn(Math.round(params.row.satoshis_now / 10000) / 100)} M` : `${pn(Math.round(params.row.satoshis_now / 1000))} K`} @@ -478,7 +592,12 @@ const BookTable = ({ width: width * fontSize, renderCell: (params: any) => { return ( -
+
{ + onOrderClicked(params.row.id, params.row.coordinatorShortAlias); + }} + > {`#${params.row.id}`} @@ -495,7 +614,14 @@ const BookTable = ({ type: 'number', width: width * fontSize, renderCell: (params: any) => { - return
{`${Number(params.row.bond_size)}%`}
; + return ( +
{ + onOrderClicked(params.row.id, params.row.coordinatorShortAlias); + }} + >{`${Number(params.row.bond_size)}%`}
+ ); }, }; }, []); @@ -504,7 +630,7 @@ const BookTable = ({ return { amount: { priority: 1, - order: 4, + order: 5, normal: { width: fav.mode === 'swap' ? 9.5 : 6.5, object: amountObj, @@ -512,7 +638,7 @@ const BookTable = ({ }, currency: { priority: 2, - order: 5, + order: 6, normal: { width: fav.mode === 'swap' ? 0 : 5.9, object: currencyObj, @@ -520,7 +646,7 @@ const BookTable = ({ }, premium: { priority: 3, - order: 11, + order: 12, normal: { width: 6, object: premiumObj, @@ -528,7 +654,7 @@ const BookTable = ({ }, payment_method: { priority: 4, - order: 6, + order: 7, normal: { width: 12.85, object: paymentObj, @@ -550,9 +676,17 @@ const BookTable = ({ object: robotSmallObj, }, }, + coordinatorShortAlias: { + priority: 5, + order: 3, + normal: { + width: 4.1, + object: coordinatorObj, + }, + }, price: { priority: 6, - order: 10, + order: 11, normal: { width: 10, object: priceObj, @@ -560,7 +694,7 @@ const BookTable = ({ }, expires_at: { priority: 7, - order: 7, + order: 8, normal: { width: 5, object: expiryObj, @@ -568,7 +702,7 @@ const BookTable = ({ }, escrow_duration: { priority: 8, - order: 8, + order: 9, normal: { width: 4.8, object: timerObj, @@ -576,7 +710,7 @@ const BookTable = ({ }, satoshis_now: { priority: 9, - order: 9, + order: 10, normal: { width: 6, object: satoshisObj, @@ -592,7 +726,7 @@ const BookTable = ({ }, bond_size: { priority: 11, - order: 10, + order: 11, normal: { width: 4.2, object: bondObj, @@ -600,7 +734,7 @@ const BookTable = ({ }, id: { priority: 12, - order: 12, + order: 13, normal: { width: 4.8, object: idObj, @@ -666,11 +800,7 @@ const BookTable = ({ - { - fetchBook(); - }} - > + @@ -766,6 +896,7 @@ const BookTable = ({ rowHeight={3.714 * theme.typography.fontSize} headerHeight={3.25 * theme.typography.fontSize} rows={filteredOrders} + getRowId={(params: PublicOrder) => `${params.coordinatorShortAlias}/${params.id}`} loading={book.loading} columns={columns} columnVisibilityModel={columnVisibilityModel} @@ -782,15 +913,16 @@ const BookTable = ({ paymentMethod: paymentMethods, setPaymentMethods, }, + loadingOverlay: { + variant: 'determinate', + value: loadingProgress, + }, }} paginationModel={paginationModel} pageSizeOptions={width < 22 ? [] : [0, defaultPageSize, defaultPageSize * 2, 50, 100]} onPaginationModelChange={(newPaginationModel) => { setPaginationModel(newPaginationModel); }} - onRowClick={(params: any) => { - onOrderClicked(params.row.id); - }} /> ); @@ -825,9 +957,6 @@ const BookTable = ({ onPaginationModelChange={(newPaginationModel) => { setPaginationModel(newPaginationModel); }} - onRowClick={(params: any) => { - onOrderClicked(params.row.id); - }} /> diff --git a/frontend/src/components/Charts/DepthChart/index.tsx b/frontend/src/components/Charts/DepthChart/index.tsx index 5302f52dc..e7599b90a 100644 --- a/frontend/src/components/Charts/DepthChart/index.tsx +++ b/frontend/src/components/Charts/DepthChart/index.tsx @@ -20,13 +20,13 @@ import { } from '@mui/material'; import { AddCircleOutline, RemoveCircleOutline } from '@mui/icons-material'; import { useTranslation } from 'react-i18next'; -import { type PublicOrder, LimitList, type Order } from '../../../models'; +import { type PublicOrder, type Order } from '../../../models'; import RobotAvatar from '../../RobotAvatar'; import { amountToString, matchMedian, statusBadgeColor } from '../../../utils'; import currencyDict from '../../../../static/assets/currencies.json'; import { PaymentStringAsIcons } from '../../PaymentMethods'; import getNivoScheme from '../NivoScheme'; -import { type UseAppStoreType, AppContext } from '../../../contexts/AppContext'; +import { type UseAppStoreType, AppContext, hostUrl, origin } from '../../../contexts/AppContext'; interface DepthChartProps { maxWidth: number; @@ -43,7 +43,8 @@ const DepthChart: React.FC = ({ elevation = 6, onOrderClicked = () => null, }) => { - const { book, fav, info, limits, baseUrl } = useContext(AppContext); + const { book, federation, fav, exchange, limits, settings } = + useContext(AppContext); const { t } = useTranslation(); const theme = useTheme(); const [enrichedOrders, setEnrichedOrders] = useState([]); @@ -94,16 +95,16 @@ const DepthChart: React.FC = ({ setXRange(maxRange); setRangeSteps(rangeSteps); } else { - if (info.last_day_nonkyc_btc_premium === undefined) { + if (exchange.info?.last_day_nonkyc_btc_premium === undefined) { const premiums: number[] = enrichedOrders.map((order) => order?.premium || 0); setCenter(~~matchMedian(premiums)); } else { - setCenter(info.last_day_nonkyc_btc_premium); + setCenter(exchange.info?.last_day_nonkyc_btc_premium); } setXRange(8); setRangeSteps(0.5); } - }, [enrichedOrders, xType, info.last_day_nonkyc_btc_premium, currencyCode]); + }, [enrichedOrders, xType, exchange.info, currencyCode]); const generateSeries: () => void = () => { const sortedOrders: PublicOrder[] = @@ -225,7 +226,7 @@ const DepthChart: React.FC = ({ orderType={order.type} statusColor={statusBadgeColor(order.maker_status)} tooltip={t(order.maker_status)} - baseUrl={baseUrl} + baseUrl={federation[order.coordinatorShortAlias][settings.network][origin]} small={true} /> diff --git a/frontend/src/components/Dialogs/Info.tsx b/frontend/src/components/Dialogs/About.tsx similarity index 99% rename from frontend/src/components/Dialogs/Info.tsx rename to frontend/src/components/Dialogs/About.tsx index 27578abaf..461305e68 100644 --- a/frontend/src/components/Dialogs/Info.tsx +++ b/frontend/src/components/Dialogs/About.tsx @@ -22,7 +22,7 @@ interface Props { onClose: () => void; } -const InfoDialog = ({ maxAmount, open, onClose }: Props): JSX.Element => { +const AboutDialog = ({ maxAmount, open, onClose }: Props): JSX.Element => { const { t } = useTranslation(); return ( @@ -263,4 +263,4 @@ const InfoDialog = ({ maxAmount, open, onClose }: Props): JSX.Element => { ); }; -export default InfoDialog; +export default AboutDialog; diff --git a/frontend/src/components/Dialogs/Client.tsx b/frontend/src/components/Dialogs/Client.tsx new file mode 100644 index 000000000..8c6952004 --- /dev/null +++ b/frontend/src/components/Dialogs/Client.tsx @@ -0,0 +1,80 @@ +import React, { useContext } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { + Dialog, + DialogContent, + Divider, + List, + ListItemText, + ListItem, + ListItemIcon, + Typography, +} from '@mui/material'; + +import BoltIcon from '@mui/icons-material/Bolt'; +import PublicIcon from '@mui/icons-material/Public'; +import FavoriteIcon from '@mui/icons-material/Favorite'; + +import { RoboSatsNoTextIcon } from '../Icons'; +import { AppContext, type AppContextProps } from '../../contexts/AppContext'; + +interface Props { + open: boolean; + onClose: () => void; +} + +const ClientDialog = ({ open = false, onClose }: Props): JSX.Element => { + const { t } = useTranslation(); + const { clientVersion } = useContext(AppContext); + + return ( + + + + {t('Client info')} + + + + + + + + + + + + + + + + + + + {`${t('Made with')} `} + + {` ${t('and')} `} + +
+ } + secondary={t('... somewhere on Earth!')} + /> + + + + + ); +}; + +export default ClientDialog; diff --git a/frontend/src/components/Dialogs/Coordinator.tsx b/frontend/src/components/Dialogs/Coordinator.tsx new file mode 100644 index 000000000..7c8a19ed9 --- /dev/null +++ b/frontend/src/components/Dialogs/Coordinator.tsx @@ -0,0 +1,734 @@ +import React, { useContext, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { + Dialog, + DialogContent, + Divider, + Grid, + List, + ListItemText, + ListItem, + ListItemIcon, + Typography, + IconButton, + Tooltip, + Link, + Box, + CircularProgress, + Accordion, + AccordionDetails, + AccordionSummary, +} from '@mui/material'; + +import { + Inventory, + Sell, + SmartToy, + Percent, + PriceChange, + Book, + Reddit, + Key, + Bolt, + Description, + Dns, + Email, + Equalizer, + ExpandMore, + GitHub, + Language, + Send, + Tag, + Twitter, +} from '@mui/icons-material'; +import LinkIcon from '@mui/icons-material/Link'; + +import { pn } from '../../utils'; +import { type Contact, type Coordinator } from '../../models'; +import RobotAvatar from '../RobotAvatar'; +import { + AmbossIcon, + BitcoinSignIcon, + RoboSatsNoTextIcon, + BadgeFounder, + BadgeDevFund, + BadgePrivacy, + BadgeLoved, + BadgeLimits, + NostrIcon, +} from '../Icons'; +import { AppContext, type AppContextProps, hostUrl } from '../../contexts/AppContext'; +import { systemClient } from '../../services/System'; +import { type Badges } from '../../models/Coordinator.model'; + +interface Props { + open: boolean; + onClose: () => void; + coordinator: Coordinator | undefined; + network: 'mainnet' | 'testnet' | undefined; +} + +const ContactButtons = ({ + nostr, + pgp, + email, + telegram, + twitter, + matrix, + website, + reddit, +}: Contact): JSX.Element => { + const { t } = useTranslation(); + const [showMatrix, setShowMatrix] = useState(false); + const [showNostr, setShowNostr] = useState(false); + + return ( + + {nostr ? ( + + + + {t('...Opening on Nostr gateway. Pubkey copied!')} + + + {nostr} + +
+ } + open={showNostr} + > + { + setShowNostr(true); + setTimeout(() => window.open(`https://gateway.nostr.com/${nostr}`, '_blank'), 1500); + setTimeout(() => { + setShowNostr(false); + }, 10000); + systemClient.copyToClipboard(nostr); + }} + > + + + + + ) : ( + <> + )} + + {pgp ? ( + + + + + + + + ) : ( + <> + )} + + {email ? ( + + + + + + + + ) : ( + <> + )} + + {telegram ? ( + + + + + + + + ) : ( + <> + )} + + {twitter ? ( + + + + + + + + ) : ( + <> + )} + + {reddit ? ( + + + + + + + + ) : ( + <> + )} + + {website ? ( + + + + + + + + ) : ( + <> + )} + + {matrix ? ( + + + {t('Matrix channel copied! {{matrix}}', { matrix })} + + } + open={showMatrix} + > + { + setShowMatrix(true); + setTimeout(() => { + setShowMatrix(false); + }, 10000); + systemClient.copyToClipboard(matrix); + }} + > + + + + + ) : ( + <> + )} + + ); +}; + +interface BadgesProps { + badges: Badges | undefined; +} + +const BadgesHall = ({ badges }: BadgesProps): JSX.Element => { + const { t } = useTranslation(); + const sxProps = { + width: '3em', + height: '3em', + filter: 'drop-shadow(3px 3px 3px RGB(0,0,0,0.3))', + }; + const tooltipProps = { enterTouchDelay: 0, enterNextDelay: 2000 }; + return ( + + {badges?.isFounder ? ( + + {t('Founder: coordinating trades since the testnet federation.')} + + } + > + + + + + ) : null} + + {badges?.donatesToDevFund > 20 ? ( + + {t('Development fund supporter: donates {{percent}}% to make RoboSats better.', { + percent: badges?.donatesToDevFund, + })} + + } + > + + + + + ) : null} + + {badges?.hasGoodOpSec ? ( + + {t( + 'Good OpSec: the coordinator follows best practices to protect his and your privacy.', + )} + + } + > + + + + + ) : null} + + {badges?.robotsLove ? ( + + {t('Loved by robots: receives positive comments by robots over the internet.')} + + } + > + + + + + ) : null} + + {badges?.hasLargeLimits ? ( + + {t('Large limits: the coordinator has large trade limits.')} + + } + > + + + + + ) : null} + + ); +}; + +const CoordinatorDialog = ({ open = false, onClose, coordinator, network }: Props): JSX.Element => { + const { t } = useTranslation(); + const { clientVersion } = useContext(AppContext); + + const [expanded, setExpanded] = useState<'summary' | 'stats' | 'policies' | undefined>(undefined); + + const listItemProps = { sx: { maxHeight: '3em' } }; + const coordinatorVersion = `v${coordinator?.info?.version?.major ?? '?'}.${ + coordinator?.info?.version?.minor ?? '?' + }.${coordinator?.info?.version?.patch ?? '?'}`; + + return ( + + + + {`${coordinator?.longAlias}`} + + + + + + + + + + {`${coordinator?.motto}`} + + + + + + + + + + + + + + + + + + + + + {coordinator?.mainnetNodesPubkeys[0] && network == 'mainnet' ? ( + + + + + + + {`${coordinator?.mainnetNodesPubkeys[0].slice(0, 12)}... (AMBOSS)`} + + + + ) : ( + <> + )} + + {coordinator?.testnetNodesPubkeys[0] && network == 'testnet' ? ( + + + + + + + {`${coordinator?.testnetNodesPubkeys[0].slice(0, 12)}... (1ML)`} + + + + ) : ( + <> + )} + + + {coordinator?.loadingInfo ? ( + + + + ) : coordinator?.info != null ? ( + + {coordinator?.policies ? ( + { + setExpanded(expanded === 'policies' ? undefined : 'policies'); + }} + > + }> + {t('Policies')} + + + + {Object.keys(coordinator?.policies).map((key, index) => ( + + {index + 1} + + + ))} + + + + ) : null} + { + setExpanded(expanded === 'summary' ? undefined : 'summary'); + }} + > + }> + {t('Summary')} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {(coordinator?.info?.maker_fee * 100).toFixed(3)}% + + + + + + {(coordinator?.info?.taker_fee * 100).toFixed(3)}% + + + + + + + + + + + + + + + + + + + { + setExpanded(expanded === 'stats' ? undefined : 'stats'); + }} + > + }> + {t('Stats for Nerds')} + + + + + + + + + + + + + {coordinator?.info?.lnd_version && ( + + + + + + + )} + + {coordinator?.info?.cln_version && ( + + + + + + + )} + + + + {coordinator?.info?.network === 'testnet' ? ( + + + + + + + {`${coordinator?.info?.node_id.slice(0, 12)}... (1ML)`} + + + + ) : ( + + + + + + + {`${coordinator?.info?.node_id.slice(0, 12)}... (AMBOSS)`} + + + + )} + + + + + + + + + + {`${coordinator?.info?.robosats_running_commit_hash.slice(0, 12)}...`} + + + + + + + + + + + +
+ {pn(coordinator?.info?.last_day_volume)} + +
+
+
+ + + + + + + + +
+ {pn(coordinator?.info?.lifetime_volume)} + +
+
+
+
+
+
+
+ ) : ( + + {t('Coordinator offline')} + + )} +
+
+ ); +}; + +export default CoordinatorDialog; diff --git a/frontend/src/components/Dialogs/CoordinatorSummary.tsx b/frontend/src/components/Dialogs/CoordinatorSummary.tsx deleted file mode 100644 index 1983e260d..000000000 --- a/frontend/src/components/Dialogs/CoordinatorSummary.tsx +++ /dev/null @@ -1,174 +0,0 @@ -import React from 'react'; -import { useTranslation } from 'react-i18next'; - -import { - Dialog, - DialogContent, - Divider, - Grid, - List, - ListItemText, - ListItem, - ListItemIcon, - Typography, - LinearProgress, -} from '@mui/material'; - -import InventoryIcon from '@mui/icons-material/Inventory'; -import SellIcon from '@mui/icons-material/Sell'; -import SmartToyIcon from '@mui/icons-material/SmartToy'; -import PercentIcon from '@mui/icons-material/Percent'; -import PriceChangeIcon from '@mui/icons-material/PriceChange'; -import BookIcon from '@mui/icons-material/Book'; -import LinkIcon from '@mui/icons-material/Link'; - -import { pn } from '../../utils'; -import { type Info } from '../../models'; - -interface Props { - open: boolean; - onClose: () => void; - info: Info; -} - -const CoordinatorSummaryDialog = ({ open = false, onClose, info }: Props): JSX.Element => { - const { t } = useTranslation(); - if (info.current_swap_fee_rate === null || info.current_swap_fee_rate === undefined) { - info.current_swap_fee_rate = 0; - } - - return ( - -
- -
- - - {t('Coordinator Summary')} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {(info.maker_fee * 100).toFixed(3)}% - - - - - - {(info.taker_fee * 100).toFixed(3)}% - - - - - - - - - - - - - - - - -
- ); -}; - -export default CoordinatorSummaryDialog; diff --git a/frontend/src/components/Dialogs/Exchange.tsx b/frontend/src/components/Dialogs/Exchange.tsx new file mode 100644 index 000000000..4dd744686 --- /dev/null +++ b/frontend/src/components/Dialogs/Exchange.tsx @@ -0,0 +1,188 @@ +import React, { useContext, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { + Dialog, + DialogContent, + Divider, + List, + ListItemText, + ListItem, + ListItemIcon, + Typography, + LinearProgress, +} from '@mui/material'; + +import { + Inventory, + Sell, + SmartToy, + PriceChange, + Book, + Groups3, + Equalizer, +} from '@mui/icons-material'; + +import { pn } from '../../utils'; +import { BitcoinSignIcon } from '../Icons'; +import { AppContext, type AppContextProps } from '../../contexts/AppContext'; + +interface Props { + open: boolean; + onClose: () => void; +} + +const ExchangeDialog = ({ open = false, onClose }: Props): JSX.Element => { + const { t } = useTranslation(); + const { exchange } = useContext(AppContext); + + const loadingProgress = useMemo(() => { + return (exchange.onlineCoordinators / exchange.totalCoordinators) * 100; + }, [exchange.onlineCoordinators, exchange.totalCoordinators]); + + return ( + +
+ +
+ + + {t('Exchange Summary')} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ {pn(exchange.info.last_day_volume)} + +
+
+
+ + + + + + + + +
+ {pn(exchange.info.lifetime_volume)} + +
+
+
+
+
+
+ ); +}; + +export default ExchangeDialog; diff --git a/frontend/src/components/Dialogs/Stats.tsx b/frontend/src/components/Dialogs/Stats.tsx deleted file mode 100644 index 6f796e3ae..000000000 --- a/frontend/src/components/Dialogs/Stats.tsx +++ /dev/null @@ -1,223 +0,0 @@ -import React from 'react'; -import { useTranslation } from 'react-i18next'; - -import { - Dialog, - DialogContent, - Divider, - Link, - List, - ListItemText, - ListItem, - ListItemIcon, - Typography, - LinearProgress, -} from '@mui/material'; - -import BoltIcon from '@mui/icons-material/Bolt'; -import PublicIcon from '@mui/icons-material/Public'; -import DnsIcon from '@mui/icons-material/Dns'; -import WebIcon from '@mui/icons-material/Web'; -import FavoriteIcon from '@mui/icons-material/Favorite'; -import GitHubIcon from '@mui/icons-material/GitHub'; -import EqualizerIcon from '@mui/icons-material/Equalizer'; - -import { AmbossIcon, BitcoinSignIcon, RoboSatsNoTextIcon } from '../Icons'; - -import { pn } from '../../utils'; -import { type Info } from '../../models'; - -interface Props { - open: boolean; - onClose: () => void; - info: Info; -} - -const StatsDialog = ({ open = false, onClose, info }: Props): JSX.Element => { - const { t } = useTranslation(); - - return ( - -
- -
- - - - {t('Stats For Nerds')} - - - - - - - - - - - - - - - {info.lnd_version ? ( - - - - - - - ) : null} - - {info.cln_version ? ( - - - - - - - ) : null} - - - - {info.network === 'testnet' ? ( - - - - - - - {`${info.node_id.slice(0, 12)}... (1ML)`} - - - - ) : ( - - - - - - - {`${info.node_id.slice(0, 12)}... (AMBOSS)`} - - - - )} - - - - - - - - - - {`${info.alternative_site.slice(0, 12)}...onion`} - - - - - - - - - - - - - {`${info.robosats_running_commit_hash.slice(0, 12)}...`} - - - - - - - - - - - -
- {pn(info.last_day_volume)} - -
-
-
- - - - - - - - -
- {pn(info.lifetime_volume)} - -
-
-
- - - - - - - - {`${t('Made with')} `} - - {` ${t('and')} `} - -
- } - secondary={t('... somewhere on Earth!')} - /> - - - - - ); -}; - -export default StatsDialog; diff --git a/frontend/src/components/Dialogs/UpdateClient.tsx b/frontend/src/components/Dialogs/Update.tsx similarity index 73% rename from frontend/src/components/Dialogs/UpdateClient.tsx rename to frontend/src/components/Dialogs/Update.tsx index 9dd1b4536..9e283d1d6 100644 --- a/frontend/src/components/Dialogs/UpdateClient.tsx +++ b/frontend/src/components/Dialogs/Update.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { @@ -17,24 +17,32 @@ import { import WebIcon from '@mui/icons-material/Web'; import AndroidIcon from '@mui/icons-material/Android'; import UpcomingIcon from '@mui/icons-material/Upcoming'; +import { checkVer } from '../../utils'; +import { type Version } from '../../models'; interface Props { - open: boolean; - clientVersion: string; - coordinatorVersion: string; + coordinatorVersion: Version; + clientVersion: Version; onClose: () => void; } -const UpdateClientDialog = ({ - open = false, - clientVersion, - coordinatorVersion, - onClose, -}: Props): JSX.Element => { +const UpdateDialog = ({ coordinatorVersion, clientVersion }: Props): JSX.Element => { const { t } = useTranslation(); + const [open, setOpen] = useState(() => checkVer(coordinatorVersion)); + const coordinatorString = `v${coordinatorVersion.major}-${coordinatorVersion.minor}-${coordinatorVersion.patch}`; + const clientString = `v${clientVersion.major}-${clientVersion.minor}-${clientVersion.patch}`; + + useEffect(() => { + setOpen(checkVer(coordinatorVersion)); + }, [coordinatorVersion]); return ( - + { + setOpen(false); + }} + > {t('Update your RoboSats client')} @@ -45,7 +53,7 @@ const UpdateClientDialog = ({ {t( 'The RoboSats coordinator is on version {{coordinatorVersion}}, but your client app is {{clientVersion}}. This version mismatch might lead to a bad user experience.', - { coordinatorVersion, clientVersion }, + { coordinatorString, clientString }, )} @@ -107,7 +115,13 @@ const UpdateClientDialog = ({ - + @@ -115,4 +129,4 @@ const UpdateClientDialog = ({ ); }; -export default UpdateClientDialog; +export default UpdateDialog; diff --git a/frontend/src/components/Dialogs/index.ts b/frontend/src/components/Dialogs/index.ts index 18b35e561..5b73effdd 100644 --- a/frontend/src/components/Dialogs/index.ts +++ b/frontend/src/components/Dialogs/index.ts @@ -1,12 +1,13 @@ export { default as AuditPGPDialog } from './AuditPGP'; export { default as CommunityDialog } from './Community'; -export { default as InfoDialog } from './Info'; +export { default as AboutDialog } from './About'; export { default as LearnDialog } from './Learn'; export { default as NoRobotDialog } from './NoRobot'; export { default as StoreTokenDialog } from './StoreToken'; export { default as ConfirmationDialog } from './Confirmation'; -export { default as CoordinatorSummaryDialog } from './CoordinatorSummary'; +export { default as ExchangeDialog } from './Exchange'; +export { default as CoordinatorDialog } from './Coordinator'; export { default as ProfileDialog } from './Profile'; -export { default as StatsDialog } from './Stats'; +export { default as ClientDialog } from './Client'; export { default as EnableTelegramDialog } from './EnableTelegram'; -export { default as UpdateClientDialog } from './UpdateClient'; +export { default as UpdateDialog } from './Update'; diff --git a/frontend/src/components/FederationTable/index.tsx b/frontend/src/components/FederationTable/index.tsx new file mode 100644 index 000000000..4001092d1 --- /dev/null +++ b/frontend/src/components/FederationTable/index.tsx @@ -0,0 +1,237 @@ +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Box, useTheme, Checkbox, CircularProgress, Typography, Grid } from '@mui/material'; +import { DataGrid } from '@mui/x-data-grid'; +import { type Coordinator } from '../../models'; + +import RobotAvatar from '../RobotAvatar'; +import { Link, LinkOff } from '@mui/icons-material'; +import { hostUrl } from '../../contexts/AppContext'; + +interface FederationTableProps { + federation: Record; + dispatchFederation: () => void; + setFocusedCoordinator: (state: number) => void; + openCoordinator: () => void; + maxWidth?: number; + maxHeight?: number; + fillContainer?: boolean; +} + +const FederationTable = ({ + federation, + dispatchFederation, + setFocusedCoordinator, + openCoordinator, + maxWidth = 90, + maxHeight = 50, + fillContainer = false, +}: FederationTableProps): JSX.Element => { + const { t } = useTranslation(); + const theme = useTheme(); + const [pageSize, setPageSize] = useState(0); + + // all sizes in 'em' + const fontSize = theme.typography.fontSize; + const verticalHeightFrame = 3.25; + const verticalHeightRow = 3.25; + const defaultPageSize = Math.max( + Math.floor((maxHeight - verticalHeightFrame) / verticalHeightRow), + 1, + ); + const height = defaultPageSize * verticalHeightRow + verticalHeightFrame; + + const [useDefaultPageSize, setUseDefaultPageSize] = useState(true); + useEffect(() => { + if (useDefaultPageSize) { + setPageSize(defaultPageSize); + } + }); + + const localeText = { + MuiTablePagination: { labelRowsPerPage: t('Coordinators per page:') }, + noResultsOverlayLabel: t('No coordinators found.'), + }; + + const onClickCoordinator = function (shortAlias: string) { + setFocusedCoordinator(shortAlias); + openCoordinator(); + }; + + const aliasObj = function (width: number) { + return { + field: 'longAlias', + headerName: t('Coordinator'), + width: width * fontSize, + renderCell: (params: any) => { + return ( + { + onClickCoordinator(params.row.shortAlias); + }} + alignItems='center' + spacing={1} + > + + + + + {params.row.longAlias} + + + ); + }, + }; + }; + + const enabledObj = function (width: number) { + return { + field: 'enabled', + headerName: t('Enabled'), + width: width * fontSize, + renderCell: (params: any) => { + return ( + { + onEnableChange(params.row.shortAlias); + }} + /> + ); + }, + }; + }; + + const upObj = function (width: number) { + return { + field: 'up', + headerName: t('Up'), + width: width * fontSize, + renderCell: (params: any) => { + return ( +
{ + onClickCoordinator(params.row.shortAlias); + }} + > + {params.row.loadingInfo && params.row.enabled ? ( + + ) : params.row.info ? ( + + ) : ( + + )} +
+ ); + }, + }; + }; + + const columnSpecs = { + alias: { + priority: 2, + order: 1, + normal: { + width: 12.1, + object: aliasObj, + }, + }, + up: { + priority: 3, + order: 2, + normal: { + width: 3.5, + object: upObj, + }, + }, + enabled: { + priority: 1, + order: 3, + normal: { + width: 5, + object: enabledObj, + }, + }, + }; + + const filteredColumns = function () { + const useSmall = maxWidth < 30; + const selectedColumns: object[] = []; + let width: number = 0; + + for (const [key, value] of Object.entries(columnSpecs)) { + const colWidth = useSmall && value.small ? value.small?.width : value.normal.width; + const colObject = useSmall && value.small ? value.small?.object : value.normal.object; + + if (width + colWidth < maxWidth || selectedColumns.length < 2) { + width = width + colWidth; + selectedColumns.push([colObject(colWidth, false), value.order]); + } else { + selectedColumns.push([colObject(colWidth, true), value.order]); + } + } + + // sort columns by column.order value + selectedColumns.sort(function (first, second) { + return first[1] - second[1]; + }); + + const columns = selectedColumns.map(function (item) { + return item[0]; + }); + + return [columns, width * 0.9]; + }; + + const [columns, width] = filteredColumns(); + + const onEnableChange = function (shortAlias: string) { + if (federation[shortAlias].enabled) { + dispatchFederation({ type: 'disable', payload: { shortAlias } }); + } else { + dispatchFederation({ type: 'enable', payload: { shortAlias } }); + } + }; + + return ( + + params.shortAlias} + columns={columns} + checkboxSelection={false} + pageSize={pageSize} + rowsPerPageOptions={width < 22 ? [] : [0, pageSize, defaultPageSize * 2, 50, 100]} + onPageSizeChange={(newPageSize) => { + setPageSize(newPageSize); + setUseDefaultPageSize(false); + }} + hideFooter={true} + /> + + ); +}; + +export default FederationTable; diff --git a/frontend/src/components/Icons/BadgeDevFund.tsx b/frontend/src/components/Icons/BadgeDevFund.tsx new file mode 100644 index 000000000..cb18949f3 --- /dev/null +++ b/frontend/src/components/Icons/BadgeDevFund.tsx @@ -0,0 +1,150 @@ +import React from 'react'; +import { SvgIcon } from '@mui/material'; + +export default function BadgeDevFund(props) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/frontend/src/components/Icons/BadgeFounder.tsx b/frontend/src/components/Icons/BadgeFounder.tsx new file mode 100644 index 000000000..3c633de7c --- /dev/null +++ b/frontend/src/components/Icons/BadgeFounder.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { SvgIcon } from '@mui/material'; + +export default function BadgeFounder(props) { + return ( + + + + + + + + + + + ); +} diff --git a/frontend/src/components/Icons/BadgeLimits.tsx b/frontend/src/components/Icons/BadgeLimits.tsx new file mode 100644 index 000000000..6a0b6d0d1 --- /dev/null +++ b/frontend/src/components/Icons/BadgeLimits.tsx @@ -0,0 +1,184 @@ +import React from 'react'; +import { SvgIcon } from '@mui/material'; + +export default function BadgeLimits(props) { + return ( + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/frontend/src/components/Icons/BadgeLoved.tsx b/frontend/src/components/Icons/BadgeLoved.tsx new file mode 100644 index 000000000..76f892c30 --- /dev/null +++ b/frontend/src/components/Icons/BadgeLoved.tsx @@ -0,0 +1,96 @@ +import React from 'react'; +import { SvgIcon } from '@mui/material'; + +export default function BadgeLoved(props) { + return ( + + + + + + + + + + + + + + + + + + ); +} diff --git a/frontend/src/components/Icons/BadgePrivacy.tsx b/frontend/src/components/Icons/BadgePrivacy.tsx new file mode 100644 index 000000000..1682a8992 --- /dev/null +++ b/frontend/src/components/Icons/BadgePrivacy.tsx @@ -0,0 +1,276 @@ +import React from 'react'; +import { SvgIcon } from '@mui/material'; + +export default function BadgePrivacy(props) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/frontend/src/components/Icons/Nostr.tsx b/frontend/src/components/Icons/Nostr.tsx new file mode 100644 index 000000000..c141493d1 --- /dev/null +++ b/frontend/src/components/Icons/Nostr.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import { SvgIcon } from '@mui/material'; + +// By SatsCoffee https://github.com/satscoffee/nostr_icons/blob/main/nostr_logo_blk.svg +export default function NostrIcon(props) { + return ( + + + + ); +} diff --git a/frontend/src/components/Icons/index.ts b/frontend/src/components/Icons/index.ts index 58047c6e3..41907da58 100644 --- a/frontend/src/components/Icons/index.ts +++ b/frontend/src/components/Icons/index.ts @@ -1,6 +1,7 @@ export { default as AmbossIcon } from './Amboss'; export { default as BitcoinIcon } from './Bitcoin'; export { default as BitcoinSignIcon } from './BitcoinSign'; +export { default as NostrIcon } from './Nostr'; export { default as BuySatsIcon } from './BuySats'; export { default as BuySatsCheckedIcon } from './BuySatsChecked'; export { default as EarthIcon } from './Earth'; @@ -16,6 +17,13 @@ export { default as ExportIcon } from './Export'; export { default as UserNinjaIcon } from './UserNinja'; export { default as TorIcon } from './Tor'; +// Badges +export { default as BadgeFounder } from './BadgeFounder'; +export { default as BadgeDevFund } from './BadgeDevFund'; +export { default as BadgePrivacy } from './BadgePrivacy'; +export { default as BadgeLimits } from './BadgeLimits'; +export { default as BadgeLoved } from './BadgeLoved'; + // Flags with props export { default as FlagWithProps } from './WorldFlags'; // Some Flags missing on react-flags diff --git a/frontend/src/components/MakerForm/MakerForm.tsx b/frontend/src/components/MakerForm/MakerForm.tsx index d63dc566f..dec6938f2 100644 --- a/frontend/src/components/MakerForm/MakerForm.tsx +++ b/frontend/src/components/MakerForm/MakerForm.tsx @@ -40,7 +40,7 @@ import { amountToString, computeSats, pn } from '../../utils'; import { SelfImprovement, Lock, HourglassTop, DeleteSweep, Edit } from '@mui/icons-material'; import { LoadingButton } from '@mui/lab'; -import { AppContext, type UseAppStoreType } from '../../contexts/AppContext'; +import { AppContext, hostUrl, type UseAppStoreType } from '../../contexts/AppContext'; interface MakerFormProps { disableRequest?: boolean; @@ -63,7 +63,7 @@ const MakerForm = ({ onOrderCreated = () => null, onClickGenerateRobot = () => null, }: MakerFormProps): JSX.Element => { - const { fav, setFav, limits, fetchLimits, info, maker, setMaker, baseUrl, robot } = + const { fav, setFav, limits, fetchFederationLimits, info, maker, setMaker, robot } = useContext(AppContext); const { t } = useTranslation(); @@ -86,17 +86,17 @@ const MakerForm = ({ useEffect(() => { setCurrencyCode(currencyDict[fav.currency == 0 ? 1 : fav.currency]); if (Object.keys(limits.list).length === 0) { - fetchLimits().then((data) => { - updateAmountLimits(data, fav.currency, maker.premium); - updateCurrentPrice(data, fav.currency, maker.premium); - updateSatoshisLimits(data); - }); + // fetchFederationLimits().then((data) => { + // updateAmountLimits(data, fav.currency, maker.premium); + // updateCurrentPrice(data, fav.currency, maker.premium); + // updateSatoshisLimits(data); + // }); } else { updateAmountLimits(limits.list, fav.currency, maker.premium); updateCurrentPrice(limits.list, fav.currency, maker.premium); updateSatoshisLimits(limits.list); - fetchLimits(); + fetchFederationLimits(); } }, []); @@ -260,7 +260,7 @@ const MakerForm = ({ bond_size: maker.bondSize, }; apiClient - .post(baseUrl, '/api/make/', body, { tokenSHA256: robot.tokenSHA256 }) + .post(hostUrl, '/api/make/', body, { tokenSHA256: robot.tokenSHA256 }) .then((data: object) => { setBadRequest(data.bad_request); if (data.id) { diff --git a/frontend/src/components/RobotAvatar/index.tsx b/frontend/src/components/RobotAvatar/index.tsx index bdef62b56..a91e2acfe 100644 --- a/frontend/src/components/RobotAvatar/index.tsx +++ b/frontend/src/components/RobotAvatar/index.tsx @@ -8,6 +8,7 @@ import placeholder from './placeholder.json'; interface Props { nickname: string | undefined; smooth?: boolean; + coordinator?: boolean; small?: boolean; flipHorizontally?: boolean; style?: object; @@ -41,12 +42,14 @@ const RobotAvatar: React.FC = ({ avatarClass = 'flippedSmallAvatar', imageStyle = {}, onLoad = () => {}, + coordinator = false, baseUrl, }) => { const [avatarSrc, setAvatarSrc] = useState(); const [nicknameReady, setNicknameReady] = useState(false); const [activeBackground, setActiveBackground] = useState(true); + const path = coordinator ? '/static/federation/' : '/static/assets/avatars/'; const [backgroundData] = useState( placeholderType == 'generating' ? placeholder.generating : placeholder.loading, ); @@ -56,12 +59,12 @@ const RobotAvatar: React.FC = ({ useEffect(() => { if (nickname != undefined) { if (window.NativeRobosats === undefined) { - setAvatarSrc(`${baseUrl}/static/assets/avatars/${nickname}${small ? '.small' : ''}.webp`); + setAvatarSrc(`${baseUrl}${path}${nickname}${small ? '.small' : ''}.webp`); setNicknameReady(true); } else { setNicknameReady(true); apiClient - .fileImageUrl(baseUrl, `/static/assets/avatars/${nickname}${small ? '.small' : ''}.webp`) + .fileImageUrl(baseUrl, `${path}${nickname}${small ? '.small' : ''}.webp`) .then(setAvatarSrc); } } else { diff --git a/frontend/src/components/SettingsForm/index.tsx b/frontend/src/components/SettingsForm/index.tsx index 9c2636984..def77a744 100644 --- a/frontend/src/components/SettingsForm/index.tsx +++ b/frontend/src/components/SettingsForm/index.tsx @@ -32,10 +32,9 @@ import SwapCalls from '@mui/icons-material/SwapCalls'; interface SettingsFormProps { dense?: boolean; - showNetwork?: boolean; } -const SettingsForm = ({ dense = false, showNetwork = false }: SettingsFormProps): JSX.Element => { +const SettingsForm = ({ dense = false }: SettingsFormProps): JSX.Element => { const { fav, setFav, settings, setSettings } = useContext(AppContext); const theme = useTheme(); const { t } = useTranslation(); @@ -215,30 +214,26 @@ const SettingsForm = ({ dense = false, showNetwork = false }: SettingsFormProps) - {showNetwork ? ( - - - - - { - setSettings({ ...settings, network }); - systemClient.setItem('settings_network', network); - }} - > - - {t('Mainnet')} - - - {t('Testnet')} - - - - ) : ( - <> - )} + + + + + { + setSettings({ ...settings, network }); + systemClient.setItem('settings_network', network); + }} + > + + {t('Mainnet')} + + + {t('Testnet')} + + + diff --git a/frontend/src/components/TorConnection/index.tsx b/frontend/src/components/TorConnection/index.tsx new file mode 100644 index 000000000..deffc0da1 --- /dev/null +++ b/frontend/src/components/TorConnection/index.tsx @@ -0,0 +1,97 @@ +import React, { useContext } from 'react'; +import { Box, CircularProgress, Tooltip } from '@mui/material'; +import { TorIcon } from '../Icons'; +import { useTranslation } from 'react-i18next'; +import { AppContext, type AppContextProps } from '../contexts/AppContext'; + +interface TorIndicatorProps { + color: 'inherit' | 'error' | 'warning' | 'success' | 'primary' | 'secondary' | 'info' | undefined; + tooltipOpen?: boolean | undefined; + title: string; + progress: boolean; +} + +const TorIndicator = ({ + color, + tooltipOpen = undefined, + title, + progress, +}: TorIndicatorProps): JSX.Element => { + return ( + + + {progress ? ( + <> + + + + + + ) : ( + + + + )} + + + ); +}; + +const TorConnectionBadge = (): JSX.Element => { + const { torStatus } = useContext(AppContext); + const { t } = useTranslation(); + + if (window?.NativeRobosats == null) { + return <>; + } + + if (torStatus === 'NOTINIT') { + return ( + + ); + } else if (torStatus === 'STARTING') { + return ( + + ); + } else if (torStatus === '"Done"' || torStatus === 'DONE') { + return ; + } else { + return ( + + ); + } +}; + +export default TorConnectionBadge; diff --git a/frontend/src/components/TradeBox/Forms/LightningPayout.tsx b/frontend/src/components/TradeBox/Forms/LightningPayout.tsx index b1e3d672d..253be2af2 100644 --- a/frontend/src/components/TradeBox/Forms/LightningPayout.tsx +++ b/frontend/src/components/TradeBox/Forms/LightningPayout.tsx @@ -33,7 +33,7 @@ import { apiClient } from '../../../services/api'; import { systemClient } from '../../../services/System'; import lnproxies from '../../../../static/lnproxies.json'; -let filteredProxies: { [key: string]: any }[] = []; +let filteredProxies: Array> = []; export interface LightningForm { invoice: string; amount: number; @@ -146,7 +146,7 @@ export const LightningPayoutForm = ({ } }, [lightning.lnproxyInvoice, lightning.lnproxyAmount]); - //filter lnproxies when the network settings are updated + // filter lnproxies when the network settings are updated let bitcoinNetwork: string = 'mainnet'; let internetNetwork: 'Clearnet' | 'I2P' | 'TOR' = 'Clearnet'; useEffect(() => { @@ -162,7 +162,7 @@ export const LightningPayoutForm = ({ .filter((node) => node.network == bitcoinNetwork); }, [settings]); - //if "use lnproxy" checkbox is enabled, but there are no matching proxies, enter error state + // if "use lnproxy" checkbox is enabled, but there are no matching proxies, enter error state useEffect(() => { setNoMatchingLnProxies(''); if (filteredProxies.length === 0) { @@ -177,15 +177,15 @@ export const LightningPayoutForm = ({ const fetchLnproxy = function () { setLoadingLnproxy(true); - let body: { invoice: string; description: string; routing_msat?: string } = { + const body: { invoice: string; description: string; routing_msat?: string } = { invoice: lightning.lnproxyInvoice, description: '', }; if (lightning.lnproxyBudgetSats > 0) { - body['routing_msat'] = String(lightning.lnproxyBudgetSats * 1000); + body.routing_msat = String(lightning.lnproxyBudgetSats * 1000); } apiClient - .post(filteredProxies[lightning.lnproxyServer]['url'], '', body) + .post(filteredProxies[lightning.lnproxyServer].url, '', body) .then((data) => { if (data.reason) { setLightning({ ...lightning, badLnproxy: data.reason }); diff --git a/frontend/src/contexts/AppContext.ts b/frontend/src/contexts/AppContext.ts index 9cddc0c5f..4bb67f8fb 100644 --- a/frontend/src/contexts/AppContext.ts +++ b/frontend/src/contexts/AppContext.ts @@ -1,30 +1,33 @@ -import React, { createContext, useEffect, useState } from 'react'; +import React, { createContext, useEffect, useReducer, useState } from 'react'; import { type Page } from '../basic/NavBar'; import { type OpenDialogs } from '../basic/MainDialogs'; import { type Book, - type LimitList, type Maker, Robot, Garage, - type Info, Settings, type Favorites, defaultMaker, - defaultInfo, - type Coordinator, + Coordinator, + type Exchange, type Order, + type PublicOrder, + type Limits, + defaultExchange, + type Federation, } from '../models'; import { apiClient } from '../services/api'; -import { checkVer, getHost, hexToBase91, validateTokenEntropy } from '../utils'; +import { systemClient } from '../services/System'; +import { getClientVersion, getHost, hexToBase91, validateTokenEntropy } from '../utils'; import { sha256 } from 'js-sha256'; -import defaultCoordinators from '../../static/federation.json'; +import defaultFederation from '../../static/federation.json'; +import { updateExchangeInfo } from '../models/Exchange.model'; import { createTheme, type Theme } from '@mui/material/styles'; import i18n from '../i18n/Web'; -import { systemClient } from '../services/System'; const getWindowSize = function (fontSize: number) { // returns window size in EM units @@ -34,6 +37,31 @@ const getWindowSize = function (fontSize: number) { }; }; +const getHostUrl = (network = 'mainnet') => { + let host = ''; + let protocol = ''; + let origin = ''; + if (window.NativeRobosats === undefined) { + host = getHost(); + protocol = location.protocol; + } else { + host = defaultFederation.exp[network].Onion; + protocol = 'http:'; + } + const hostUrl = `${protocol}//${host}`; + if (window.NativeRobosats != undefined || host.includes('.onion')) { + origin = 'onion'; + } else if (host.includes('i2p')) { + origin = 'i2p'; + } else { + origin = 'clearnet'; + } + + return [hostUrl, origin]; +}; + +export const [hostUrl, origin] = getHostUrl(); + // Refresh delays (ms) according to Order status const statusToDelay = [ 3000, // 'Waiting for maker bond' @@ -71,7 +99,91 @@ export interface fetchRobotProps { export type TorStatus = 'NOTINIT' | 'STARTING' | '"Done"' | 'DONE'; -let entryPage: Page | '' | 'index.html' = +const initialFederation: Federation = Object.entries(defaultFederation).reduce( + (acc, [key, value]) => { + acc[key] = new Coordinator(value); + return acc; + }, + {}, +); + +interface ActionFederation { + type: 'reset' | 'enable' | 'disable' | 'updateBook' | 'updateLimits' | 'updateInfo'; + action: any; // TODO +} + +const reduceFederation = (federation: Federation, action: ActionFederation) => { + switch (action.type) { + case 'reset': + return initialFederation; + case 'enable': + return { + ...federation, + [action.payload.shortAlias]: { + ...federation[action.payload.shortAlias], + enabled: true, + }, + }; + case 'disable': + return { + ...federation, + [action.payload.shortAlias]: { + ...federation[action.payload.shortAlias], + enabled: false, + info: undefined, + }, + }; + case 'updateBook': + return { + ...federation, + [action.payload.shortAlias]: { + ...federation[action.payload.shortAlias], + book: action.payload.book, + loadingBook: action.payload.loadingBook, + }, + }; + case 'updateLimits': + return { + ...federation, + [action.payload.shortAlias]: { + ...federation[action.payload.shortAlias], + limits: action.payload.limits, + loadingLimits: action.payload.loadingLimits, + }, + }; + case 'updateInfo': + return { + ...federation, + [action.payload.shortAlias]: { + ...federation[action.payload.shortAlias], + info: action.payload.info, + loadingInfo: action.payload.loadingInfo, + }, + }; + default: + throw new Error(`Unhandled action type: ${action.type}`); + } +}; + +const totalCoordinators = Object.keys(initialFederation).length; + +const initialBook: Book = { + orders: [], + loading: true, + loadedCoordinators: 0, + totalCoordinators, +}; + +const initialLimits: Limits = { + list: [], + loading: true, + loadedCoordinators: 0, + totalCoordinators, +}; + +const initialExchange: Exchange = { ...defaultExchange, totalCoordinators }; + +const entryPage: Page | '' | 'index.html' = window.NativeRobosats === undefined ? window.location.pathname.split('/')[1] : ''; export const closeAll = { @@ -121,11 +233,8 @@ export const useAppStore = () => { // All app data structured const [torStatus, setTorStatus] = useState('NOTINIT'); - const [book, setBook] = useState({ orders: [], loading: true }); - const [limits, setLimits] = useState<{ list: LimitList; loading: boolean }>({ - list: [], - loading: true, - }); + const [book, setBook] = useState(initialBook); + const [limits, setLimits] = useState(initialLimits); const [garage, setGarage] = useState(() => { return new Garage(); }); @@ -136,13 +245,16 @@ export const useAppStore = () => { return new Robot(garage.slots[currentSlot].robot); }); const [maker, setMaker] = useState(defaultMaker); - const [info, setInfo] = useState(defaultInfo); - const [coordinators, setCoordinators] = useState(defaultCoordinators); - const [baseUrl, setBaseUrl] = useState(''); - const [fav, setFav] = useState({ type: null, mode: 'fiat', currency: 0 }); + const [exchange, setExchange] = useState(initialExchange); + const [federation, dispatchFederation] = useReducer(reduceFederation, initialFederation); + + const [focusedCoordinator, setFocusedCoordinator] = useState(''); + const [fav, setFav] = useState({ type: null, currency: 0, mode: 'fiat' }); const [delay, setDelay] = useState(60000); - const [timer, setTimer] = useState(setInterval(() => null, delay)); + const [timer, setTimer] = useState(() => + setInterval(() => null, delay), + ); const [order, setOrder] = useState(undefined); const [badOrder, setBadOrder] = useState(undefined); @@ -153,12 +265,17 @@ export const useAppStore = () => { in: undefined, out: undefined, }); - const [currentOrder, setCurrentOrder] = useState(undefined); + const [currentOrder, setCurrentOrder] = useState<{ + shortAlias: string | null; + id: number | null; + }>({ shortAlias: null, id: null }); const navbarHeight = 2.5; + const clientVersion = getClientVersion(); + const [open, setOpen] = useState(closeAll); - const [windowSize, setWindowSize] = useState<{ width: number; height: number }>( + const [windowSize, setWindowSize] = useState<{ width: number; height: number }>(() => getWindowSize(theme.typography.fontSize), ); @@ -179,33 +296,21 @@ export const useAppStore = () => { window.addEventListener('resize', onResize); } - if (baseUrl != '') { - setBook({ orders: [], loading: true }); - setLimits({ list: [], loading: true }); - fetchBook(); - fetchLimits(); - } return () => { if (typeof window !== undefined) { window.removeEventListener('resize', onResize); } }; - }, [baseUrl]); + }, []); useEffect(() => { - let host = ''; - let protocol = ''; - if (window.NativeRobosats === undefined) { - host = getHost(); - protocol = location.protocol; - } else { - protocol = 'http:'; - host = - settings.network === 'mainnet' - ? coordinators[0].mainnetOnion - : coordinators[0].testnetOnion; - } - setBaseUrl(`${protocol}//${host}`); + // On bitcoin network change we reset book, limits and federation info and fetch everything again + setBook(initialBook); + setLimits(initialLimits); + dispatchFederation({ type: 'reset' }); + fetchFederationBook(); + fetchFederationInfo(); + fetchFederationLimits(); }, [settings.network]); useEffect(() => { @@ -216,62 +321,172 @@ export const useAppStore = () => { setWindowSize(getWindowSize(theme.typography.fontSize)); }; - const fetchBook = function () { - setBook((book) => { - return { ...book, loading: true }; + // fetch Limits + const fetchCoordinatorLimits = async (coordinator: Coordinator) => { + const url = coordinator[settings.network][origin]; + const limits = await apiClient + .get(url, '/api/limits/') + .then((data) => { + return data; + }) + .catch(() => { + return undefined; + }); + dispatchFederation({ + type: 'updateLimits', + payload: { shortAlias: coordinator.shortAlias, limits, loadingLimits: false }, }); - apiClient.get(baseUrl, '/api/book/').then((data: any) => { - setBook({ - loading: false, - orders: data.not_found ? [] : data, + }; + + const fetchFederationLimits = function () { + Object.entries(federation).map(([shortAlias, coordinator]) => { + if (coordinator.enabled === true) { + // set limitLoading=true + dispatchFederation({ + type: 'updateLimits', + payload: { shortAlias, limits: coordinator.limits, loadingLimits: true }, + }); + // fetch new limits + fetchCoordinatorLimits(coordinator); + } + }); + }; + + // fetch Books + const fetchCoordinatorBook = async (coordinator: Coordinator) => { + const url = coordinator[settings.network][origin]; + const book = await apiClient + .get(url, '/api/book/') + .then((data) => { + return data.not_found ? [] : data; + }) + .catch(() => { + return []; }); + dispatchFederation({ + type: 'updateBook', + payload: { shortAlias: coordinator.shortAlias, book, loadingBook: false }, }); }; - const fetchLimits = async () => { - setLimits({ ...limits, loading: true }); - const data = apiClient.get(baseUrl, '/api/limits/').then((data) => { - setLimits({ list: data ?? [], loading: false }); - return data; + const fetchFederationBook = function () { + Object.entries(federation).map(([shortAlias, coordinator]) => { + if (coordinator.enabled === true) { + dispatchFederation({ + type: 'updateBook', + payload: { shortAlias, book: coordinator.book, loadingBook: true }, + }); + fetchCoordinatorBook(coordinator); + } }); - return await data; }; - const fetchInfo = function () { - setInfo({ ...info, loading: true }); - apiClient.get(baseUrl, '/api/info/').then((data: Info) => { - const versionInfo: any = checkVer(data.version.major, data.version.minor, data.version.patch); - setInfo({ - ...data, - openUpdateClient: versionInfo.updateAvailable, - coordinatorVersion: versionInfo.coordinatorVersion, - clientVersion: versionInfo.clientVersion, - loading: false, + // fetch Info + const fetchCoordinatorInfo = async (coordinator: Coordinator) => { + const url = coordinator[settings.network][origin]; + const info = await apiClient + .get(url, '/api/info/') + .then((data) => { + return data; + }) + .catch(() => { + return undefined; }); - setSettings({ ...settings, network: data.network }); + dispatchFederation({ + type: 'updateInfo', + payload: { shortAlias: coordinator.shortAlias, info, loadingInfo: false }, }); }; - useEffect(() => { - if (open.stats || open.coordinator || info.coordinatorVersion == 'v?.?.?') { - if (window.NativeRobosats === undefined || torStatus == '"Done"') { - fetchInfo(); + const fetchFederationInfo = function () { + Object.entries(federation).map(([shortAlias, coordinator]) => { + if (coordinator.enabled === true) { + dispatchFederation({ + type: 'updateInfo', + payload: { shortAlias, info: coordinator.info, loadingInfo: true }, + }); + fetchCoordinatorInfo(coordinator); } - } - }, [open.stats, open.coordinator]); + }); + }; + + const updateBook = () => { + setBook((book) => { + return { ...book, loading: true, loadedCoordinators: 0 }; + }); + let orders: PublicOrder[] = book.orders; + let loadedCoordinators: number = 0; + let totalCoordinators: number = 0; + Object.values(federation).map((coordinator) => { + if (coordinator.enabled) { + totalCoordinators = totalCoordinators + 1; + if (!coordinator.loadingBook) { + const existingOrders = orders.filter( + (order) => order.coordinatorShortAlias !== coordinator.shortAlias, + ); + console.log('Existing Orders', existingOrders); + const newOrders: PublicOrder[] = coordinator.book.map((order) => ({ + ...order, + coordinatorShortAlias: coordinator.shortAlias, + })); + orders = [...existingOrders, ...newOrders]; + // orders.push.apply(existingOrders, newOrders); + loadedCoordinators = loadedCoordinators + 1; + } + } + const loading = loadedCoordinators != totalCoordinators; + setBook({ orders, loading, loadedCoordinators, totalCoordinators }); + }); + }; + + const updateLimits = () => { + const newLimits: LimitList | never[] = []; + Object.entries(federation).map(([shortAlias, coordinator]) => { + if (coordinator.limits) { + for (const currency in coordinator.limits) { + newLimits[currency] = compareUpdateLimit( + newLimits[currency], + coordinator.limits[currency], + ); + } + } + }); + setLimits(newLimits); + }; + + const updateExchange = () => { + const onlineCoordinators = Object.keys(federation).reduce((count, shortAlias) => { + if (!federation[shortAlias].loadingInfo && federation[shortAlias].info) { + return count + 1; + } else { + return count; + } + }, 0); + const totalCoordinators = Object.keys(federation).reduce((count, shortAlias) => { + return federation[shortAlias].enabled ? count + 1 : count; + }, 0); + setExchange({ info: updateExchangeInfo(federation), onlineCoordinators, totalCoordinators }); + }; useEffect(() => { - // Sets Setting network from coordinator API param if accessing via web - if (settings.network == undefined && info.network) { - setSettings((settings: Settings) => { - return { ...settings, network: info.network }; - }); + updateBook(); + // updateLimits(); + updateExchange(); + }, [federation]); + + useEffect(() => { + if (open.exchange) { + fetchFederationInfo(); } - }, [info]); + }, [open.exchange, torStatus]); + + useEffect(() => { + fetchFederationInfo(); + }, []); // Fetch current order at load and in a loop useEffect(() => { - if (currentOrder != undefined && (page == 'order' || (order == badOrder) == undefined)) { + if (currentOrder.id != null && (page == 'order' || (order == badOrder) == undefined)) { fetchOrder(); } }, [currentOrder, page]); @@ -303,9 +518,13 @@ export const useAppStore = () => { }; const fetchOrder = function () { - if (currentOrder) { + if (currentOrder.shortAlias != null) { apiClient - .get(baseUrl, '/api/order/?order_id=' + currentOrder, { tokenSHA256: robot.tokenSHA256 }) + .get( + federation[currentOrder.shortAlias][settings.network][origin], + '/api/order/?order_id=' + currentOrder.id, + { tokenSHA256: robot.tokenSHA256 }, + ) .then(orderReceived); } }; @@ -354,7 +573,7 @@ export const useAppStore = () => { } apiClient - .get(baseUrl, '/api/robot/', auth) + .get(hostUrl, '/api/robot/', auth) .then((data: any) => { const newRobot = { avatarLoaded: isRefresh ? robot.avatarLoaded : false, @@ -397,14 +616,14 @@ export const useAppStore = () => { }; useEffect(() => { - if (baseUrl != '' && page != 'robot') { + if (hostUrl != '' && page != 'robot') { if (open.profile && robot.avatarLoaded) { fetchRobot({ isRefresh: true }); // refresh/update existing robot } else if (!robot.avatarLoaded && robot.token && robot.encPrivKey && robot.pubKey) { fetchRobot({}); // create new robot with existing token and keys (on network and coordinator change) } } - }, [open.profile, baseUrl]); + }, [open.profile, hostUrl]); return { theme, @@ -413,23 +632,26 @@ export const useAppStore = () => { setSettings, book, setBook, + federation, + dispatchFederation, garage, setGarage, currentSlot, setCurrentSlot, - fetchBook, + fetchFederationBook, limits, - info, setLimits, - fetchLimits, + fetchFederationLimits, maker, setMaker, clearOrder, robot, setRobot, fetchRobot, - baseUrl, - setBaseUrl, + exchange, + setExchange, + focusedCoordinator, + setFocusedCoordinator, fav, setFav, order, @@ -447,6 +669,7 @@ export const useAppStore = () => { open, setOpen, windowSize, + clientVersion, }; }; diff --git a/frontend/src/models/Book.model.ts b/frontend/src/models/Book.model.ts index 403a44e3d..7638145ab 100644 --- a/frontend/src/models/Book.model.ts +++ b/frontend/src/models/Book.model.ts @@ -20,11 +20,14 @@ export interface PublicOrder { maker_nick: string; price: number; maker_status: 'Active' | 'Seen recently' | 'Inactive'; + coordinatorShortAlias?: string; } export interface Book { orders: PublicOrder[]; loading: boolean; + loadedCoordinators: number; + totalCoordinators: number; } export default PublicOrder; diff --git a/frontend/src/models/Coordinator.model.ts b/frontend/src/models/Coordinator.model.ts index d4c356e07..2f0627c4d 100644 --- a/frontend/src/models/Coordinator.model.ts +++ b/frontend/src/models/Coordinator.model.ts @@ -1,23 +1,98 @@ -export interface Coordinator { - alias: string; - enabled: boolean; - description: string | undefined; - coverLetter: string | undefined; - logo: string; - color: string; - contact: { - email: string | undefined; - telegram: string | undefined; - matrix: string | undefined; - twitter: string | undefined; - website: string | undefined; - }; - mainnetOnion: string | undefined; - mainnetClearnet: string | undefined; - testnetOnion: string | undefined; - testnetClearnet: string | undefined; - mainnetNodesPubkeys: string[]; - testnetNodesPubkeys: string[]; +import { type LimitList, type PublicOrder } from '.'; + +export interface Contact { + nostr?: string | undefined; + pgp?: string | undefined; + fingerprint?: string | undefined; + email?: string | undefined; + telegram?: string | undefined; + reddit?: string | undefined; + matrix?: string | undefined; + twitter?: string | undefined; + website?: string | undefined; +} + +export interface Version { + major: number | null; + minor: number | null; + patch: number | null; +} + +export interface Badges { + isFounder?: boolean | undefined; + donatesToDevFund?: number | undefined; + hasGoodOpSec?: boolean | undefined; + robotsLove?: boolean | undefined; + hasLargeLimits?: string | undefined; +} + +export interface Info { + num_public_buy_orders: number; + num_public_sell_orders: number; + book_liquidity: number; + active_robots_today: number; + last_day_nonkyc_btc_premium: number; + last_day_volume: number; + lifetime_volume: number; + lnd_version?: string; + cln_version?: string; + robosats_running_commit_hash: string; + alternative_site: string; + alternative_name: string; + node_alias: string; + node_id: string; + version: Version; + maker_fee: number; + taker_fee: number; + bond_size: number; + current_swap_fee_rate: number; + network: 'mainnet' | 'testnet' | undefined; + openUpdateClient: boolean; + loading: boolean; +} + +export interface Origins { + clearnet: string | undefined; + onion: string | undefined; + i2p: string | undefined; +} + +export class Coordinator { + constructor(value: Coordinator) { + this.longAlias = value.longAlias; + this.shortAlias = value.shortAlias; + this.description = value.description; + this.motto = value.motto; + this.color = value.color; + this.policies = value.policies; + this.contact = value.contact; + this.badges = value.badges; + this.mainnet = value.mainnet; + this.testnet = value.testnet; + this.mainnetNodesPubkeys = value.mainnetNodesPubkeys; + this.testnetNodesPubkeys = value.testnetNodesPubkeys; + } + + public longAlias: string; + public shortAlias: string; + public enabled?: boolean = true; + public description: string; + public motto: string; + public color: string; + public policies: Object; + public contact: Contact | undefined; + public badges?: Badges | undefined; + public mainnet: Origins; + public testnet: Origins; + public mainnetNodesPubkeys: string[] | undefined; + public testnetNodesPubkeys: string[] | undefined; + + public orders: PublicOrder[] = []; + public loadingBook: boolean = true; + public info?: Info | undefined = undefined; + public loadingInfo: boolean = true; + public limits?: LimitList | never[] = []; + public loadingLimits: boolean = true; } export default Coordinator; diff --git a/frontend/src/models/Exchange.model.ts b/frontend/src/models/Exchange.model.ts new file mode 100644 index 000000000..acd3a5e1d --- /dev/null +++ b/frontend/src/models/Exchange.model.ts @@ -0,0 +1,75 @@ +import { weightedMean, getHigherVer } from '../utils'; +import { type Federation, type Version } from '.'; + +interface ExchangeInfo { + num_public_buy_orders: number; + num_public_sell_orders: number; + book_liquidity: number; + active_robots_today: number; + last_day_nonkyc_btc_premium: number; + last_day_volume: number; + lifetime_volume: number; + version: Version; +} + +const defaultExchangeInfo: ExchangeInfo = { + num_public_buy_orders: 0, + num_public_sell_orders: 0, + book_liquidity: 0, + active_robots_today: 0, + last_day_nonkyc_btc_premium: 0, + last_day_volume: 0, + lifetime_volume: 0, + version: { major: 0, minor: 0, patch: 0 }, +}; + +export const updateExchangeInfo = (federation: Federation) => { + const info: ExchangeInfo = {}; + + const toSum = [ + 'num_public_buy_orders', + 'num_public_sell_orders', + 'book_liquidity', + 'active_robots_today', + 'last_day_volume', + 'lifetime_volume', + ]; + + toSum.map((key) => { + let value = 0; + Object.entries(federation).map(([shortAlias, coordinator]) => { + if (coordinator.info) { + value = value + coordinator.info[key]; + } + }); + info[key] = value; + }); + + const premiums: number[] = []; + const volumes: number[] = []; + let highestVersion: Version = { major: 0, minor: 0, patch: 0 }; + Object.entries(federation).map(([shortAlias, coordinator], index) => { + if (coordinator.info && coordinator.enabled) { + premiums[index] = coordinator.info.last_day_nonkyc_btc_premium; + volumes[index] = coordinator.info.last_day_volume; + highestVersion = getHigherVer(highestVersion, coordinator.info.version); + } + }); + info.last_day_nonkyc_btc_premium = weightedMean(premiums, volumes); + info.version = highestVersion; + return info; +}; + +export interface Exchange { + info: ExchangeInfo; + onlineCoordinators: number; + totalCoordinators: number; +} + +export const defaultExchange: Exchange = { + info: defaultExchangeInfo, + onlineCoordinators: 0, + totalCoordinators: 0, +}; + +export default Exchange; diff --git a/frontend/src/models/Info.model.ts b/frontend/src/models/Info.model.ts deleted file mode 100644 index 0b2a18d41..000000000 --- a/frontend/src/models/Info.model.ts +++ /dev/null @@ -1,58 +0,0 @@ -import packageJson from '../../package.json'; - -export interface Info { - num_public_buy_orders: number; - num_public_sell_orders: number; - book_liquidity: number; - active_robots_today: number; - last_day_nonkyc_btc_premium: number; - last_day_volume: number; - lifetime_volume: number; - lnd_version?: string; - cln_version?: string; - robosats_running_commit_hash: string; - alternative_site: string; - alternative_name: string; - node_alias: string; - node_id: string; - version: { major: number | null; minor: number | null; patch: number | null }; - maker_fee: number; - taker_fee: number; - bond_size: number; - current_swap_fee_rate: number; - network: 'mainnet' | 'testnet'; - coordinatorVersion: string; - clientVersion: string; - openUpdateClient: boolean; - loading: boolean; -} -const semver = packageJson.version.split('.'); - -export const defaultInfo: Info = { - num_public_buy_orders: 0, - num_public_sell_orders: 0, - book_liquidity: 0, - active_robots_today: 0, - last_day_nonkyc_btc_premium: 0, - last_day_volume: 0, - lifetime_volume: 0, - lnd_version: '0.0.0-beta', - cln_version: '0.0.0', - robosats_running_commit_hash: '000000000000000', - alternative_site: 'RoboSats6tkf3eva7x2voqso3a5wcorsnw34jveyxfqi2fu7oyheasid.onion', - alternative_name: 'RoboSats Mainnet', - node_alias: '🤖RoboSats⚡(RoboDevs)', - node_id: '033b58d7681fe5dd2fb21fd741996cda5449616f77317dd1156b80128d6a71b807', - version: { major: null, minor: null, patch: null }, - maker_fee: 0, - taker_fee: 0, - bond_size: 0, - current_swap_fee_rate: 0, - network: undefined, - coordinatorVersion: 'v?.?.?', - clientVersion: `v${semver[0]}.${semver[1]}.${semver[2]}`, - openUpdateClient: false, - loading: true, -}; - -export default Info; diff --git a/frontend/src/models/Limit.model.ts b/frontend/src/models/Limit.model.ts index 0bd892c63..0a4139f02 100644 --- a/frontend/src/models/Limit.model.ts +++ b/frontend/src/models/Limit.model.ts @@ -8,4 +8,23 @@ export interface Limit { export type LimitList = Record; +export interface Limits { + list: LimitList | never[]; + loading: boolean; + loadedCoordinators: number; + totalCoordinators: number; +} + +const compareUpdateLimit = (baseL: Limit, newL: Limit) => { + if (!baseL) { + return newL; + } else { + const price = (baseL.price + newL.price) / 2; + const max_amount = Math.max(baseL.max_amount, newL.max_amount); + const min_amount = Math.min(baseL.min_amount, newL.min_amount); + const max_bondless_amount = Math.max(baseL.max_bondless_amount, newL.max_bondless_amount); + return { code: newL.code, price, max_amount, min_amount, max_bondless_amount }; + } +}; + export default Limit; diff --git a/frontend/src/models/Settings.model.ts b/frontend/src/models/Settings.model.ts index 9d7940d9d..ddaba64b1 100644 --- a/frontend/src/models/Settings.model.ts +++ b/frontend/src/models/Settings.model.ts @@ -1,7 +1,6 @@ import i18n from '../i18n/Web'; import { systemClient } from '../services/System'; import { getHost } from '../utils'; -import type Coordinator from './Coordinator.model'; export type Language = | 'en' @@ -53,7 +52,6 @@ class BaseSettings { public language?: Language; public freezeViewports: boolean = false; public network: 'mainnet' | 'testnet' = 'mainnet'; - public coordinator: Coordinator | undefined = undefined; public host?: string; public unsafeClient: boolean = false; public selfhostedClient: boolean = false; diff --git a/frontend/src/models/index.ts b/frontend/src/models/index.ts index ccfdbc729..296e55fdb 100644 --- a/frontend/src/models/index.ts +++ b/frontend/src/models/index.ts @@ -1,19 +1,18 @@ import Robot from './Robot.model'; import Garage from './Garage.model'; import Settings from './Settings.default.basic'; -export { Robot, Garage, Settings }; +import Coordinator from './Coordinator.model'; +export { Robot, Garage, Settings, Coordinator }; -export type { LimitList } from './Limit.model'; -export type { Limit } from './Limit.model'; +export type { LimitList, Limit, Limits } from './Limit.model'; +export type { Exchange } from './Exchange.model'; export type { Maker } from './Maker.model'; export type { Order } from './Order.model'; -export type { PublicOrder } from './Book.model'; -export type { Book } from './Book.model'; -export type { Info } from './Info.model'; +export type { Book, PublicOrder } from './Book.model'; export type { Slot } from './Garage.model'; export type { Language } from './Settings.model'; export type { Favorites } from './Favorites.model'; -export type { Coordinator } from './Coordinator.model'; +export type { Federation, Contact, Info, Version } from './Coordinator.model'; export { defaultMaker } from './Maker.model'; -export { defaultInfo } from './Info.model'; +export { defaultExchange } from './Exchange.model'; diff --git a/frontend/src/pro/Main.tsx b/frontend/src/pro/Main.tsx index c55255b61..81f46b6bc 100644 --- a/frontend/src/pro/Main.tsx +++ b/frontend/src/pro/Main.tsx @@ -8,6 +8,7 @@ import { BookWidget, DepthChartWidget, SettingsWidget, + FederationWidget, } from '../pro/Widgets'; import ToolBar from '../pro/ToolBar'; import LandingDialog from '../pro/LandingDialog'; @@ -41,35 +42,7 @@ const defaultLayout: Layout = [ ]; const Main = (): JSX.Element => { - const { - book, - fetchBook, - maker, - setMaker, - setSettings, - clearOrder, - torStatus, - settings, - limits, - fetchLimits, - robot, - setRobot, - fetchRobot, - setOrder, - setDelay, - info, - fav, - setFav, - baseUrl, - order, - currentOrder, - setCurrentOrder, - open, - setOpen, - windowSize, - badOrder, - setBadOrder, - } = useContext(AppContext); + const { settings, windowSize } = useContext(AppContext); const theme = useTheme(); const em: number = theme.typography.fontSize; @@ -82,7 +55,7 @@ const Main = (): JSX.Element => { return ( - + { @@ -131,8 +104,8 @@ const Main = (): JSX.Element => {
-
- +
+
diff --git a/frontend/src/pro/ToolBar/index.tsx b/frontend/src/pro/ToolBar/index.tsx index b2246a006..0c259ac7c 100644 --- a/frontend/src/pro/ToolBar/index.tsx +++ b/frontend/src/pro/ToolBar/index.tsx @@ -1,17 +1,17 @@ -import React from 'react'; +import React, { useContext } from 'react'; +import { AppContext, type AppContextProps } from '../../contexts/AppContext'; import { Paper, Grid, IconButton, Tooltip, Typography } from '@mui/material'; import { Lock, LockOpen } from '@mui/icons-material'; import { useTranslation } from 'react-i18next'; import { type Settings } from '../../models'; interface ToolBarProps { - settings: Settings; height?: string; - setSettings: (state: Settings) => void; } -const ToolBar = ({ height = '3em', settings, setSettings }: ToolBarProps): JSX.Element => { +const ToolBar = ({ height = '3em' }: ToolBarProps): JSX.Element => { const { t } = useTranslation(); + const { settings, setSettings } = useContext(AppContext); return ( void; - fav: Favorites; - setFav: (state: Favorites) => void; - windowSize: { width: number; height: number }; style?: Object; className?: string; onMouseDown?: () => void; diff --git a/frontend/src/pro/Widgets/Depth.tsx b/frontend/src/pro/Widgets/Depth.tsx index cfafaf634..7e8fa8116 100644 --- a/frontend/src/pro/Widgets/Depth.tsx +++ b/frontend/src/pro/Widgets/Depth.tsx @@ -1,6 +1,6 @@ import React, { useContext } from 'react'; import { AppContext, type UseAppStoreType } from '../../contexts/AppContext'; -import { Paper, useTheme } from '@mui/material'; +import { Paper } from '@mui/material'; import DepthChart from '../../components/Charts/DepthChart'; interface DepthChartWidgetProps { @@ -26,8 +26,7 @@ const DepthChartWidget = React.forwardRef( }: DepthChartWidgetProps, ref, ) => { - const theme = useTheme(); - const { fav, book, limits } = useContext(AppContext); + const { fav, book, limits, exchange } = useContext(AppContext); return React.useMemo(() => { return ( @@ -39,7 +38,7 @@ const DepthChartWidget = React.forwardRef( /> ); - }, [fav.currency, book, limits, layout]); + }, [fav.currency, book, limits, exchange, layout]); }, ); diff --git a/frontend/src/pro/Widgets/Federation.tsx b/frontend/src/pro/Widgets/Federation.tsx new file mode 100644 index 000000000..6f9077fbf --- /dev/null +++ b/frontend/src/pro/Widgets/Federation.tsx @@ -0,0 +1,54 @@ +import React, { useContext } from 'react'; +import { AppContext, type AppContextProps } from '../../contexts/AppContext'; +import { Paper } from '@mui/material'; +import { type GridItem } from 'react-grid-layout'; +import FederationTable from '../../components/FederationTable'; + +interface FederationWidgetProps { + layout: GridItem; + gridCellSize: number; + style?: Object; + className?: string; + onMouseDown?: () => void; + onMouseUp?: () => void; + onTouchEnd?: () => void; +} + +const FederationWidget = React.forwardRef( + ( + { + layout, + gridCellSize, + style, + className, + onMouseDown, + onMouseUp, + onTouchEnd, + }: FederationWidgetProps, + ref, + ) => { + const { + federation, + // setFederation, + setFocusedCoordinator, + open, + setOpen, + } = useContext(AppContext); + return React.useMemo(() => { + return ( + + setOpen({ ...open, coordinator: true })} + maxWidth={layout.w * gridCellSize} // EM units + maxHeight={layout.h * gridCellSize} // EM units + /> + + ); + }, [federation]); + }, +); + +export default FederationWidget; diff --git a/frontend/src/pro/Widgets/Maker.tsx b/frontend/src/pro/Widgets/Maker.tsx index 9acd962fd..112a1cbf3 100644 --- a/frontend/src/pro/Widgets/Maker.tsx +++ b/frontend/src/pro/Widgets/Maker.tsx @@ -2,7 +2,6 @@ import React, { useContext } from 'react'; import { AppContext, type UseAppStoreType } from '../../contexts/AppContext'; import MakerForm from '../../components/MakerForm'; -import { LimitList, Maker, Favorites } from '../../models'; import { Paper } from '@mui/material'; interface MakerWidgetProps { diff --git a/frontend/src/pro/Widgets/Settings.tsx b/frontend/src/pro/Widgets/Settings.tsx index 2e86b0706..c56ca4c31 100644 --- a/frontend/src/pro/Widgets/Settings.tsx +++ b/frontend/src/pro/Widgets/Settings.tsx @@ -1,6 +1,5 @@ import React, { useContext } from 'react'; import { type UseAppStoreType, AppContext } from '../../contexts/AppContext'; -import { Settings } from '../../models'; import { Paper } from '@mui/material'; import SettingsForm from '../../components/SettingsForm'; diff --git a/frontend/src/pro/Widgets/index.ts b/frontend/src/pro/Widgets/index.ts index 604b38569..7ac7fcbc8 100644 --- a/frontend/src/pro/Widgets/index.ts +++ b/frontend/src/pro/Widgets/index.ts @@ -1,5 +1,6 @@ export { default as MakerWidget } from './Maker'; export { default as BookWidget } from './Book'; export { default as DepthChartWidget } from './Depth'; +export { default as FederationWidget } from './Federation'; export { default as SettingsWidget } from './Settings'; export { default as PlaceholderWidget } from './Placeholder'; diff --git a/frontend/src/utils/aggregateInfo.ts b/frontend/src/utils/aggregateInfo.ts new file mode 100644 index 000000000..e203e53c9 --- /dev/null +++ b/frontend/src/utils/aggregateInfo.ts @@ -0,0 +1,115 @@ +import { type Coordinator } from '../models'; + +interface Version { + major: number | null; + minor: number | null; + patch: number | null; +} +export interface AggregatedInfo { + onlineCoordinators: number; + totalCoordinators: number; + num_public_buy_orders: number; + num_public_sell_orders: number; + book_liquidity: number; + active_robots_today: number; + last_day_nonkyc_btc_premium: number; + last_day_volume: number; + lifetime_volume: number; + version: Version; +} + +type toAdd = + | 'num_public_buy_orders' + | 'num_public_sell_orders' + | 'book_liquidity' + | 'active_robots_today' + | 'last_day_volume' + | 'lifetime_volume'; + +export const weightedMean = (arrValues: number[], arrWeights: number[]) => { + if (arrValues.length === 0) { + return 0; + } + const result = arrValues + .map((value, i) => { + const weight = arrWeights[i]; + const sum = value * weight; + return [sum, weight]; + }) + .reduce((p, c) => [p[0] + c[0], p[1] + c[1]], [0, 0]); + + return result[0] / result[1]; +}; + +const getHigherVer = (ver0: Version, ver1: Version) => { + if (ver1.major == null || ver0.minor == null || ver0.patch == null) { + return ver0; + } else if (ver0.major > ver1.major) { + return ver0; + } else if (ver0.major < ver1.major) { + return ver1; + } else if (ver0.minor > ver1.minor) { + return ver0; + } else if (ver0.minor < ver1.minor) { + return ver1; + } else if (ver0.patch > ver1.patch) { + return ver0; + } else if (ver0.patch < ver1.patch) { + return ver1; + } else { + return ver0; + } +}; + +export const aggregateInfo = (federation: Coordinator[]): AggregatedInfo => { + const info = { + onlineCoordinators: 0, + totalCoordinators: 0, + num_public_buy_orders: 0, + num_public_sell_orders: 0, + book_liquidity: 0, + active_robots_today: 0, + last_day_nonkyc_btc_premium: 0, + last_day_volume: 0, + lifetime_volume: 0, + version: { major: 0, minor: 0, patch: 0 }, + }; + info.totalCoordinators = federation.length; + const addUp: toAdd[] = [ + 'num_public_buy_orders', + 'num_public_sell_orders', + 'book_liquidity', + 'active_robots_today', + 'last_day_volume', + 'lifetime_volume', + ]; + + addUp.map((key) => { + let value = 0; + federation.map((coordinator) => { + if (coordinator.info != null) { + value = value + coordinator.info[key]; + } + }); + info[key] = value; + }); + + const premiums: number[] = []; + const volumes: number[] = []; + let highestVersion = { major: 0, minor: 0, patch: 0 }; + federation.map((coordinator, index) => { + if (coordinator.info != null) { + info.onlineCoordinators = info.onlineCoordinators + 1; + premiums[index] = coordinator.info.last_day_nonkyc_btc_premium; + volumes[index] = coordinator.info.last_day_volume; + highestVersion = getHigherVer(highestVersion, coordinator.info.version); + } + }); + + info.last_day_nonkyc_btc_premium = weightedMean(premiums, volumes); + info.version = highestVersion; + + return info; +}; + +export default aggregateInfo; diff --git a/frontend/src/utils/checkVer.ts b/frontend/src/utils/checkVer.ts index b5f763ebd..ce19c98ee 100644 --- a/frontend/src/utils/checkVer.ts +++ b/frontend/src/utils/checkVer.ts @@ -1,25 +1,48 @@ import packageJson from '../../package.json'; - +import { type Version } from '../models'; // Gets SemVer from backend /api/info and compares to local imported frontend version "localVer". Uses major,minor,patch. // If minor of backend > minor of frontend, updateAvailable = true. -export const checkVer: ( - major: number | null, - minor: number | null, - patch: number | null, -) => object = (major, minor, patch) => { - if (major === null || minor === null || patch === null) { - return { updateAvailable: null }; + +export const getClientVersion = function () { + const ver = packageJson.version.split('.'); + const semver = { major: ver[0], minor: ver[1], patch: ver[2] }; + const short = `v${ver[0]}.${ver[1]}.${ver[2]}`; + const long = `v${packageJson.version}-alpha`; + return { semver, short, long }; +}; + +export const getHigherVer = (ver0: Version, ver1: Version): Version => { + if (ver1.major == null || ver0.minor == null || ver0.patch == null) { + return ver0; + } else if (ver0.major > ver1.major) { + return ver0; + } else if (ver0.major < ver1.major) { + return ver1; + } else if (ver0.minor > ver1.minor) { + return ver0; + } else if (ver0.minor < ver1.minor) { + return ver1; + } else if (ver0.patch > ver1.patch) { + return ver0; + } else if (ver0.patch < ver1.patch) { + return ver1; + } else { + return ver0; + } +}; + +export const checkVer: (coordinatorVersion: Version | null) => boolean = (coordinatorVersion) => { + let updateAvailable: boolean = false; + if (coordinatorVersion != null) { + const { major, minor, patch } = coordinatorVersion; + if (!(major === null || minor === null || patch === null)) { + const client = getClientVersion().semver; + updateAvailable = major > Number(client.major) || minor > Number(client.minor); + // const patchAvailable: boolean = !updateAvailable && patch > Number(client.semver[2]); + } } - const semver = packageJson.version.split('.'); - const updateAvailable: boolean = major > Number(semver[0]) || minor > Number(semver[1]); - const patchAvailable: boolean = !updateAvailable && patch > Number(semver[2]); - return { - updateAvailable, - patchAvailable, - coordinatorVersion: `v${major}.${minor}.${patch}`, - clientVersion: `v${semver[0]}.${semver[1]}.${semver[2]}`, - }; + return updateAvailable; }; export default checkVer; diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts index 28a4db72f..83ef5e2a3 100644 --- a/frontend/src/utils/index.ts +++ b/frontend/src/utils/index.ts @@ -1,9 +1,10 @@ -export { default as checkVer } from './checkVer'; +export { checkVer, getHigherVer, getClientVersion } from './checkVer'; export { default as filterOrders } from './filterOrders'; export { default as getHost } from './getHost'; export { default as hexToRgb } from './hexToRgb'; export { default as hexToBase91 } from './hexToBase91'; export { default as matchMedian } from './match'; +export { default as weightedMean } from './weightedMean'; export { default as pn } from './prettyNumbers'; export { amountToString } from './prettyNumbers'; export { default as saveAsJson } from './saveFile'; diff --git a/frontend/src/utils/weightedMean.ts b/frontend/src/utils/weightedMean.ts new file mode 100644 index 000000000..a1e8a134e --- /dev/null +++ b/frontend/src/utils/weightedMean.ts @@ -0,0 +1,16 @@ +export const weightedMean = (arrValues: number[], arrWeights: number[]) => { + if (arrValues.length === 0) { + return 0; + } + const result = arrValues + .map((value, i) => { + const weight = arrWeights[i]; + const sum = value * weight; + return [sum, weight]; + }) + .reduce((p, c) => [p[0] + c[0], p[1] + c[1]], [0, 0]); + + return result[0] / result[1]; +}; + +export default weightedMean; diff --git a/frontend/static/federation.json b/frontend/static/federation.json index 6c084a119..76b430735 100644 --- a/frontend/static/federation.json +++ b/frontend/static/federation.json @@ -1,23 +1,102 @@ -[ - { - "alias": "Inception", - "enabled": "true", - "description": "RoboSats original and experimental coordinator", - "coverLetter": "N/A", +{ + "exp": { + "longAlias": "Experimental", + "shortAlias": "exp", + "description": "RoboSats node for development and experimentation.", + "motto": "P2P FTW!", + "color": "#1976d2", "contact": { "email": "robosats@protonmail.com", - "telegram": "@robosats", - "twitter": "@robosats", + "telegram": "robosats", + "twitter": "robosats", + "reddit": "r/robosats", "matrix": "#robosats:matrix.org", - "website": "learn.robosats.com" + "website": "https://learn.robosats.com", + "nostr": "npub17pqgxxtga6u4du83f5tq9pgqk6fkuwlhvyrn4m7plvtepvgmjpys6dl4zl", + "pgp": "keys.openpgp.org/vks/v1/by-fingerprint/B4AB5F19113D4125DDF217739C4585B561315571", + "fingerprint": "B4AB5F19113D4125DDF217739C4585B561315571" + }, + "badges": { + "isFounder": true, + "donatesToDevFund": 25, + "hasGoodOpSec": true, + "robotsLove": true, + "hasLargeLimits": true + }, + "policies": { + "Policy Name 1": "Experimental coordinator used for development. Use at your own risk.", + "Policy Name 2": "Experimental coordinator used for development. Use at your own risk.", + "Privacy Policy": "...", + "Data Policy": "..." + }, + "mainnet": { + "onion": "http://robosats6tkf3eva7x2voqso3a5wcorsnw34jveyxfqi2fu7oyheasid.onion", + "clearnet": "https://unsafe.robosats.com", + "i2p": "http://r7r4sckft6ptmk4r2jajiuqbowqyxiwsle4iyg4fijtoordc6z7a.b32.i2p" + }, + "testnet": { + "onion": "http://robotestagw3dcxmd66r4rgksb4nmmr43fh77bzn2ia2eucduyeafnyd.onion", + "clearnet": "https://unsafe.testnet.robosats.com", + "i2p": "" + }, + "mainnetNodesPubkeys": ["0282eb467bc073833a039940392592bf10cf338a830ba4e392c1667d7697654c7e"], + "testnetNodesPubkeys": ["03ecb271b3e2e36f2b91c92c65bab665e5165f8cdfdada1b5f46cfdd3248c87fd6"] + }, + "temp": { + "longAlias": "Template", + "shortAlias": "temp", + "description": "This is the description for an example coordinator", + "motto": "Don't trust, verify", + "color": "#000000", + "contact": { + "email": "contact@contact.com", + "telegram": "examplecoordinator", + "twitter": "examplecoordinator", + "matrix": "#example:matrix.org", + "website": "https://example.coordinator.com" + }, + "badges": { + "isFounder": true, + "donatesToDevFund": 25, + "hasGoodOpSec": true, + "robotsLove": true, + "hasLargeLimits": true + }, + "policies": { + "Rule #1": "You do not talk about RoboSats Club", + "Rule #2": "You DO NOT talk about RoboSats Club", + "Privacy Policy": "...", + "Data Policy": "..." + }, + "mainnet": { + "onion": "http://robosats6tkf3eva7x2voqso3a5wcorsnw34jveyxfqi2fu7oyheasid.onion", + "clearnet": "http://127.0.0.1:12596" + }, + "testnet": { + "onion": "http://robotestagw3dcxmd66r4rgksb4nmmr43fh77bzn2ia2eucduyeafnyd.onion", + "clearnet": "https://unsafe.testnet.robosats.com" }, - "color": "#9C27B0", - "mainnetOnion": "robosats6tkf3eva7x2voqso3a5wcorsnw34jveyxfqi2fu7oyheasid.onion", - "mainnetClearnet": "unsafe.robosats.com", - "testnetOnion": "robotestagw3dcxmd66r4rgksb4nmmr43fh77bzn2ia2eucduyeafnyd.onion", - "testnetClearnet": "unsafe.testnet.robosats.com", "mainnetNodesPubkeys": ["0282eb467bc073833a039940392592bf10cf338a830ba4e392c1667d7697654c7e"], - "testnetNodesPubkeys": ["03ecb271b3e2e36f2b91c92c65bab665e5165f8cdfdada1b5f46cfdd3248c87fd6"], - "logo": "/static/federation/inception.svg" + "testnetNodesPubkeys": ["03ecb271b3e2e36f2b91c92c65bab665e5165f8cdfdada1b5f46cfdd3248c87fd6"] + }, + "local": { + "longAlias": "Local Dev", + "shortAlias": "local", + "description": "Local development backend", + "motto": "Buidl", + "color": "#000000", + "testnet": { + "onion": "http://robodevs7ixniseezbv7uryxhamtz3hvcelzfwpx3rvoipttjomrmpqd.onion", + "clearnet": "http://127.0.0.1:8000" + }, + "mainnet": { + "onion": "", + "clearnet": "" + }, + "policies": { + "Development Policy": "Don't look around, just buidl" + }, + "mainnetNodesPubkeys": ["..."], + "testnetNodesPubkeys": ["..."] } -] +} diff --git a/frontend/static/federation/exp.small.webp b/frontend/static/federation/exp.small.webp new file mode 100644 index 0000000000000000000000000000000000000000..09e3b44787f0851ec0d9304ca067d4fee0694032 GIT binary patch literal 2652 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 literal 0 HcmV?d00001 diff --git a/frontend/static/federation/exp.webp b/frontend/static/federation/exp.webp new file mode 100644 index 0000000000000000000000000000000000000000..53e080ad8ddfb40a2c7c0799ee2c3ad351ad43e3 GIT binary patch literal 49962 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%0DX1he_Dq-@6gHb`2W6|>CFN=+KT){c( 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 P3{FtU;qFBgMYA)zHLP=F1I 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 aPxM$a3;Jc82+sSSY8_aF1sn(f0001Ji-H&c literal 0 HcmV?d00001 diff --git a/frontend/static/federation/temp.small.webp b/frontend/static/federation/temp.small.webp new file mode 100644 index 0000000000000000000000000000000000000000..b4d9766cc1c49265d42896ed05950a11470f54c0 GIT binary patch literal 1070 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 kgdffB*mh0IP?ir2qf` literal 0 HcmV?d00001 diff --git a/frontend/urls.py b/frontend/urls.py index a45fd59b0..7fbd94a3a 100644 --- a/frontend/urls.py +++ b/frontend/urls.py @@ -8,7 +8,7 @@ path("robot/", basic), path("robot/", basic), path("offers/", basic), - path("order/", basic), + path("order///", basic), path("settings/", basic), path("", basic), path("pro/", pro),