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 0a0c8c362..f539cb9fe 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 5bc046011..4e14fc9bc 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': { @@ -86,7 +86,7 @@ interface BookTableProps { showControls?: boolean; showFooter?: boolean; showNoResults?: boolean; - onOrderClicked?: (id: number) => void; + onOrderClicked?: (id: number, shortAlias: string) => void; } const BookTable = ({ @@ -103,11 +103,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, @@ -197,7 +210,12 @@ const BookTable = ({ width: width * fontSize, renderCell: (params: any) => { return ( - + { + onOrderClicked(params.row.id, params.row.coordinatorShortAlias); + }} + > @@ -225,21 +243,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); + }} + > + - -
+ +
); }, }; @@ -250,10 +307,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')} +
+ ); + }, }; }, []); @@ -270,7 +337,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' : '')}
@@ -294,6 +366,9 @@ const BookTable = ({ alignItems: 'center', flexWrap: 'wrap', }} + onClick={() => { + onOrderClicked(params.row.id, params.row.coordinatorShortAlias); + }} > {currencyCode}
@@ -311,7 +386,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`} +
); }, }; @@ -403,7 +493,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)) + '%'} @@ -425,7 +520,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`} +
+ ); }, }; }, []); @@ -443,7 +547,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`} @@ -498,7 +612,12 @@ const BookTable = ({ width: width * fontSize, renderCell: (params: any) => { return ( -
+
{ + onOrderClicked(params.row.id, params.row.coordinatorShortAlias); + }} + > {`#${params.row.id}`} @@ -515,7 +634,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)}%`}
+ ); }, }; }, []); @@ -524,7 +650,7 @@ const BookTable = ({ return { amount: { priority: 1, - order: 4, + order: 5, normal: { width: fav.mode === 'swap' ? 9.5 : 6.5, object: amountObj, @@ -532,7 +658,7 @@ const BookTable = ({ }, currency: { priority: 2, - order: 5, + order: 6, normal: { width: fav.mode === 'swap' ? 0 : 5.9, object: currencyObj, @@ -540,7 +666,7 @@ const BookTable = ({ }, premium: { priority: 3, - order: 11, + order: 12, normal: { width: 6, object: premiumObj, @@ -548,7 +674,7 @@ const BookTable = ({ }, payment_method: { priority: 4, - order: 6, + order: 7, normal: { width: 12.85, object: paymentObj, @@ -570,9 +696,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, @@ -580,7 +714,7 @@ const BookTable = ({ }, expires_at: { priority: 7, - order: 7, + order: 8, normal: { width: 5, object: expiryObj, @@ -588,7 +722,7 @@ const BookTable = ({ }, escrow_duration: { priority: 8, - order: 8, + order: 9, normal: { width: 4.8, object: timerObj, @@ -596,7 +730,7 @@ const BookTable = ({ }, satoshis_now: { priority: 9, - order: 9, + order: 10, normal: { width: 6, object: satoshisObj, @@ -612,7 +746,7 @@ const BookTable = ({ }, bond_size: { priority: 11, - order: 10, + order: 11, normal: { width: 4.2, object: bondObj, @@ -620,7 +754,7 @@ const BookTable = ({ }, id: { priority: 12, - order: 12, + order: 13, normal: { width: 4.8, object: idObj, @@ -686,11 +820,7 @@ const BookTable = ({ - { - fetchBook(); - }} - > + @@ -786,6 +916,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} @@ -802,15 +933,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); - }} /> ); @@ -845,9 +977,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/index.ts b/frontend/src/components/Icons/index.ts index f7a400693..c980d3c22 100644 --- a/frontend/src/components/Icons/index.ts +++ b/frontend/src/components/Icons/index.ts @@ -18,6 +18,13 @@ export { default as TorIcon } from './Tor'; export { default as SimplexIcon } from './Simplex'; export { default as NostrIcon } from './Nostr'; +// 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 d702c113e..138091aa9 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(); } }, []); @@ -269,7 +269,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 000000000..09e3b4478 Binary files /dev/null and b/frontend/static/federation/exp.small.webp differ diff --git a/frontend/static/federation/exp.webp b/frontend/static/federation/exp.webp new file mode 100644 index 000000000..53e080ad8 Binary files /dev/null and b/frontend/static/federation/exp.webp differ diff --git a/frontend/static/federation/local.small.webp b/frontend/static/federation/local.small.webp new file mode 100644 index 000000000..a3807b435 Binary files /dev/null and b/frontend/static/federation/local.small.webp differ diff --git a/frontend/static/federation/local.webp b/frontend/static/federation/local.webp new file mode 100644 index 000000000..ad3552d30 Binary files /dev/null and b/frontend/static/federation/local.webp differ diff --git a/frontend/static/federation/temp.small.webp b/frontend/static/federation/temp.small.webp new file mode 100644 index 000000000..b4d9766cc Binary files /dev/null and b/frontend/static/federation/temp.small.webp differ diff --git a/frontend/static/federation/temp.webp b/frontend/static/federation/temp.webp new file mode 100644 index 000000000..7eece612d Binary files /dev/null and b/frontend/static/federation/temp.webp differ 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),