diff --git a/frontend/src/basic/OrderPage/index.tsx b/frontend/src/basic/OrderPage/index.tsx index b47a46e5..20f8adf4 100644 --- a/frontend/src/basic/OrderPage/index.tsx +++ b/frontend/src/basic/OrderPage/index.tsx @@ -39,24 +39,26 @@ const OrderPage = (): JSX.Element => { useEffect(() => { const shortAlias = params.shortAlias; const coordinator = federation.getCoordinator(shortAlias ?? ''); - const { url, basePath } = coordinator.getEndpoint( - settings.network, - origin, - settings.selfhostedClient, - hostUrl, - ); + if (coordinator) { + const { url, basePath } = coordinator?.getEndpoint( + settings.network, + origin, + settings.selfhostedClient, + hostUrl, + ); - setBaseUrl(`${url}${basePath}`); + setBaseUrl(`${url}${basePath}`); - const orderId = Number(params.orderId); - if ( - orderId && - currentOrderId.id !== orderId && - currentOrderId.shortAlias !== shortAlias && - shortAlias - ) - setCurrentOrderId({ id: orderId, shortAlias }); - if (!acknowledgedWarning) setOpen({ ...closeAll, warning: true }); + const orderId = Number(params.orderId); + if ( + orderId && + currentOrderId.id !== orderId && + currentOrderId.shortAlias !== shortAlias && + shortAlias + ) + setCurrentOrderId({ id: orderId, shortAlias }); + if (!acknowledgedWarning) setOpen({ ...closeAll, warning: true }); + } }, [params, currentOrderId]); const onClickCoordinator = function (): void { @@ -98,7 +100,7 @@ const OrderPage = (): JSX.Element => { setOpen(closeAll); setAcknowledgedWarning(true); }} - longAlias={federation.getCoordinator(params.shortAlias ?? '').longAlias} + longAlias={federation.getCoordinator(params.shortAlias ?? '')?.longAlias} /> {currentOrder === null && badOrder === undefined && } {badOrder !== undefined ? ( diff --git a/frontend/src/basic/SettingsPage/index.tsx b/frontend/src/basic/SettingsPage/index.tsx index b618a2ed..3f2c339c 100644 --- a/frontend/src/basic/SettingsPage/index.tsx +++ b/frontend/src/basic/SettingsPage/index.tsx @@ -1,12 +1,34 @@ -import React, { useContext } from 'react'; -import { Grid, Paper } from '@mui/material'; +import React, { useContext, useState } from 'react'; +import { Button, Grid, List, ListItem, Paper, TextField, Typography } from '@mui/material'; import SettingsForm from '../../components/SettingsForm'; import { AppContext, type UseAppStoreType } from '../../contexts/AppContext'; import FederationTable from '../../components/FederationTable'; +import { t } from 'i18next'; +import { FederationContext, UseFederationStoreType } from '../../contexts/FederationContext'; const SettingsPage = (): JSX.Element => { const { windowSize, navbarHeight } = useContext(AppContext); + const { federation, addNewCoordinator } = useContext(FederationContext); const maxHeight = (windowSize.height - navbarHeight) * 0.85 - 3; + const [newAlias, setNewAlias] = useState(''); + const [newUrl, setNewUrl] = useState(''); + const [error, setError] = useState(); + // Regular expression to match a valid .onion URL + const onionUrlPattern = /^(http:\/\/|https:\/\/)?[a-zA-Z2-7]{16,56}\.onion$/; + + const addCoordinator = () => { + if (federation.coordinators[newAlias]) { + setError(t('Alias already exists')); + } else { + if (onionUrlPattern.test(newUrl)) { + addNewCoordinator(newAlias, newUrl); + setNewAlias(''); + setNewUrl(''); + } else { + setError(t('Invalid URL')); + } + } + }; return ( { + + + {error} + + + + + setNewAlias(e.target.value)} + /> + setNewUrl(e.target.value)} + /> + + + ); diff --git a/frontend/src/components/BookTable/BookControl.tsx b/frontend/src/components/BookTable/BookControl.tsx index ad4518d3..2cafa684 100644 --- a/frontend/src/components/BookTable/BookControl.tsx +++ b/frontend/src/components/BookTable/BookControl.tsx @@ -359,7 +359,8 @@ const BookControl = ({ >
{ + const coordinator = federation.coordinators[params.row.coordinatorShortAlias]; return ( )} - {pgp !== undefined && ( + {pgp && fingerprint && ( diff --git a/frontend/src/components/FederationTable/index.tsx b/frontend/src/components/FederationTable/index.tsx index 34b15228..9e64483b 100644 --- a/frontend/src/components/FederationTable/index.tsx +++ b/frontend/src/components/FederationTable/index.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState, useContext } from 'react'; +import React, { useCallback, useEffect, useState, useContext, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { Box, useTheme, Checkbox, CircularProgress, Typography, Grid } from '@mui/material'; import { DataGrid, type GridColDef, type GridValidRowModel } from '@mui/x-data-grid'; @@ -21,9 +21,9 @@ const FederationTable = ({ fillContainer = false, }: FederationTableProps): JSX.Element => { const { t } = useTranslation(); - const { federation, sortedCoordinators, coordinatorUpdatedAt } = + const { federation, sortedCoordinators, coordinatorUpdatedAt, federationUpdatedAt } = useContext(FederationContext); - const { setOpen } = useContext(AppContext); + const { setOpen, settings } = useContext(AppContext); const theme = useTheme(); const [pageSize, setPageSize] = useState(0); @@ -43,7 +43,7 @@ const FederationTable = ({ if (useDefaultPageSize) { setPageSize(defaultPageSize); } - }, [coordinatorUpdatedAt]); + }, [coordinatorUpdatedAt, federationUpdatedAt]); const localeText = { MuiTablePagination: { labelRowsPerPage: t('Coordinators per page:') }, @@ -62,6 +62,7 @@ const FederationTable = ({ headerName: t('Coordinator'), width: width * fontSize, renderCell: (params: any) => { + const coordinator = federation.coordinators[params.row.shortAlias]; return ( { - coordinators[key] = federation.coordinators[key]; - return coordinators; - }, {}); + const reorderedCoordinators = useMemo(() => { + return sortedCoordinators.reduce((coordinators, key) => { + coordinators[key] = federation.coordinators[key]; + + return coordinators; + }, {}); + }, [settings.network, federationUpdatedAt]); return ( = ({ setCoordinator, }) => { const { setOpen } = useContext(AppContext); - const { federation, sortedCoordinators, coordinatorUpdatedAt } = - useContext(FederationContext); + const { federation, sortedCoordinators } = useContext(FederationContext); const theme = useTheme(); const { t } = useTranslation(); @@ -41,10 +40,7 @@ const SelectCoordinator: React.FC = ({ setCoordinator(e.target.value); }; - const coordinator = useMemo( - () => federation.getCoordinator(coordinatorAlias), - [coordinatorUpdatedAt], - ); + const coordinator = federation.getCoordinator(coordinatorAlias); return ( @@ -83,7 +79,8 @@ const SelectCoordinator: React.FC = ({ > - + diff --git a/frontend/src/components/RobotAvatar/index.tsx b/frontend/src/components/RobotAvatar/index.tsx index 33fe8f5b..9f74db0a 100644 --- a/frontend/src/components/RobotAvatar/index.tsx +++ b/frontend/src/components/RobotAvatar/index.tsx @@ -70,8 +70,8 @@ const RobotAvatar: React.FC = ({ }, [hashId]); useEffect(() => { - if (shortAlias !== undefined) { - if (window.NativeRobosats === undefined) { + if (shortAlias && shortAlias !== '') { + if (!window.NativeRobosats) { setAvatarSrc( `${hostUrl}/static/federation/avatars/${shortAlias}${small ? '.small' : ''}.webp`, ); diff --git a/frontend/src/components/SettingsForm/index.tsx b/frontend/src/components/SettingsForm/index.tsx index 22eaef92..90f96981 100644 --- a/frontend/src/components/SettingsForm/index.tsx +++ b/frontend/src/components/SettingsForm/index.tsx @@ -26,6 +26,7 @@ import { AccountBalance, AttachMoney, QrCode, + ControlPoint, } from '@mui/icons-material'; import { systemClient } from '../../services/System'; import { TorIcon } from '../Icons'; diff --git a/frontend/src/contexts/FederationContext.tsx b/frontend/src/contexts/FederationContext.tsx index b7998111..4946ac7c 100644 --- a/frontend/src/contexts/FederationContext.tsx +++ b/frontend/src/contexts/FederationContext.tsx @@ -9,7 +9,7 @@ import React, { type ReactNode, } from 'react'; -import { type Order, Federation, Settings } from '../models'; +import { type Order, Federation, Settings, Coordinator } from '../models'; import { federationLottery } from '../utils'; @@ -59,6 +59,7 @@ export interface UseFederationStoreType { currentOrder: Order | null; coordinatorUpdatedAt: string; federationUpdatedAt: string; + addNewCoordinator: (alias: string, url: string) => void; } export const initialFederationContext: UseFederationStoreType = { @@ -70,6 +71,7 @@ export const initialFederationContext: UseFederationStoreType = { currentOrder: null, coordinatorUpdatedAt: '', federationUpdatedAt: '', + addNewCoordinator: () => {}, }; export const FederationContext = createContext(initialFederationContext); @@ -81,7 +83,7 @@ export const FederationContextProvider = ({ useContext(AppContext); const { setMaker, garage, setBadOrder } = useContext(GarageContext); const [federation] = useState(new Federation(origin, settings, hostUrl)); - const sortedCoordinators = useMemo(() => federationLottery(federation), []); + const [sortedCoordinators, setSortedCoordinators] = useState(federationLottery(federation)); const [coordinatorUpdatedAt, setCoordinatorUpdatedAt] = useState( new Date().toISOString(), ); @@ -164,6 +166,30 @@ export const FederationContextProvider = ({ } }; + const addNewCoordinator: (alias: string, url: string) => void = (alias, url) => { + if (!federation.coordinators[alias]) { + const attributes: Record = { + longAlias: alias, + shortAlias: alias, + federated: false, + enabled: true, + }; + if (settings.network === 'mainnet') { + attributes.mainnet = url; + } else { + attributes.testnet = url; + } + federation.addCoordinator(origin, settings, hostUrl, attributes); + const newCoordinator = federation.coordinators[alias]; + newCoordinator.update(() => { + setCoordinatorUpdatedAt(new Date().toISOString()); + }); + garage.syncCoordinator(newCoordinator); + setSortedCoordinators(federationLottery(federation)); + setFederationUpdatedAt(new Date().toISOString()); + } + }; + useEffect(() => { if (currentOrderId.id && currentOrderId.shortAlias) { setCurrentOrder(null); @@ -200,6 +226,7 @@ export const FederationContextProvider = ({ setDelay, coordinatorUpdatedAt, federationUpdatedAt, + addNewCoordinator, }} > {children} diff --git a/frontend/src/models/Coordinator.model.ts b/frontend/src/models/Coordinator.model.ts index c07e771b..6b2f7339 100644 --- a/frontend/src/models/Coordinator.model.ts +++ b/frontend/src/models/Coordinator.model.ts @@ -72,6 +72,39 @@ export interface Info { export type Origin = 'onion' | 'i2p' | 'clearnet'; +export const coordinatorDefaultValues = { + longAlias: '', + shortAlias: '', + description: '', + motto: '', + color: '#000', + size_limit: 21 * 100000000, + established: new Date(), + policies: {}, + contact: { + email: '', + telegram: '', + simplex: '', + matrix: '', + website: '', + nostr: '', + pgp: '', + fingerprint: '', + }, + badges: { + isFounder: false, + donatesToDevFund: 0, + hasGoodOpSec: false, + robotsLove: false, + hasLargeLimits: false, + }, + mainnet: undefined, + testnet: undefined, + mainnetNodesPubkeys: '', + testnetNodesPubkeys: '', + federated: true, +}; + export interface Origins { clearnet: Origin | undefined; onion: Origin | undefined; @@ -102,6 +135,7 @@ export class Coordinator { this.longAlias = value.longAlias; this.shortAlias = value.shortAlias; this.description = value.description; + this.federated = value.federated; this.motto = value.motto; this.color = value.color; this.size_limit = value.badges.isFounder ? 21 * 100000000 : calculateSizeLimit(established); @@ -122,6 +156,7 @@ export class Coordinator { // These properties are loaded from federation.json public longAlias: string; public shortAlias: string; + public federated: boolean; public enabled?: boolean = true; public description: string; public motto: string; diff --git a/frontend/src/models/Federation.model.ts b/frontend/src/models/Federation.model.ts index 2d31e6ce..46dfd5ff 100644 --- a/frontend/src/models/Federation.model.ts +++ b/frontend/src/models/Federation.model.ts @@ -9,39 +9,34 @@ import { } from '.'; import defaultFederation from '../../static/federation.json'; import { getHost } from '../utils'; +import { coordinatorDefaultValues } from './Coordinator.model'; import { updateExchangeInfo } from './Exchange.model'; type FederationHooks = 'onCoordinatorUpdate' | 'onFederationUpdate'; export class Federation { constructor(origin: Origin, settings: Settings, hostUrl: string) { - this.coordinators = Object.entries(defaultFederation).reduce( - (acc: Record, [key, value]: [string, any]) => { - if (getHost() !== '127.0.0.1:8000' && key === 'local') { - // Do not add `Local Dev` unless it is running on localhost - return acc; - } else { - acc[key] = new Coordinator(value, origin, settings, hostUrl); - return acc; - } - }, - {}, - ); - this.exchange = { - ...defaultExchange, - totalCoordinators: Object.keys(this.coordinators).length, - }; + this.coordinators = {}; + this.exchange = { ...defaultExchange }; this.book = []; this.hooks = { onCoordinatorUpdate: [], onFederationUpdate: [], }; - this.loading = true; + Object.keys(defaultFederation).forEach((key) => { + if (key !== 'local' || getHost() === '127.0.0.1:8000') { + // Do not add `Local Dev` unless it is running on localhost + this.addCoordinator(origin, settings, hostUrl, defaultFederation[key]); + } + }); + this.exchange.loadingCoordinators = Object.keys(this.coordinators).length; + this.loading = true; const host = getHost(); const url = `${window.location.protocol}//${host}`; + const tesnetHost = Object.values(this.coordinators).find((coor) => { return Object.values(coor.testnet).includes(url); }); @@ -55,6 +50,22 @@ export class Federation { public hooks: Record void>>; + addCoordinator = ( + origin: Origin, + settings: Settings, + hostUrl: string, + attributes: Record, + ) => { + const value = { + ...coordinatorDefaultValues, + ...attributes, + }; + this.coordinators[value.shortAlias] = new Coordinator(value, origin, settings, hostUrl); + this.exchange.totalCoordinators = Object.keys(this.coordinators).length; + this.updateEnabledCoordinators(); + this.triggerHook('onFederationUpdate'); + }; + // Hooks registerHook = (hookName: FederationHooks, fn: () => void): void => { this.hooks[hookName].push(fn); diff --git a/frontend/src/models/Garage.model.ts b/frontend/src/models/Garage.model.ts index 47204b91..acfcf40b 100644 --- a/frontend/src/models/Garage.model.ts +++ b/frontend/src/models/Garage.model.ts @@ -1,4 +1,4 @@ -import { type Order } from '.'; +import { Coordinator, type Order } from '.'; import { systemClient } from '../services/System'; import { saveAsJson } from '../utils'; import Slot from './Slot.model'; @@ -62,12 +62,10 @@ class Garage { this.slots[rawSlot.token] = new Slot(rawSlot.token, Object.keys(rawSlot.robots), {}, () => this.triggerHook('onRobotUpdate'), ); - Object.keys(rawSlot.robots).forEach((shortAlias) => { const rawRobot = rawSlot.robots[shortAlias]; this.updateRobot(rawSlot.token, shortAlias, rawRobot); }); - this.currentSlot = rawSlot?.token; } }); @@ -160,6 +158,13 @@ class Garage { this.triggerHook('onOrderUpdate'); } }; + + // Coordinators + syncCoordinator: (coordinator: Coordinator) => void = (coordinator) => { + Object.values(this.slots).forEach((slot) => { + slot.syncCoordinator(coordinator, this); + }); + }; } export default Garage; diff --git a/frontend/src/models/Slot.model.ts b/frontend/src/models/Slot.model.ts index 76909bf0..cf0a1b40 100644 --- a/frontend/src/models/Slot.model.ts +++ b/frontend/src/models/Slot.model.ts @@ -1,5 +1,5 @@ import { sha256 } from 'js-sha256'; -import { Robot, type Order } from '.'; +import { Coordinator, Garage, Robot, type Order } from '.'; import { roboidentitiesClient } from '../services/Roboidentities/Web'; class Slot { @@ -73,6 +73,18 @@ class Slot { return this.robots[shortAlias]; }; + + syncCoordinator: (coordinator: Coordinator, garage: Garage) => void = (coordinator, garage) => { + const defaultRobot = this.getRobot(); + if (defaultRobot?.token) { + this.robots[coordinator.shortAlias] = new Robot({ + token: defaultRobot.token, + pubKey: defaultRobot.pubKey, + encPrivKey: defaultRobot.encPrivKey, + }); + coordinator.fetchRobot(garage, defaultRobot.token); + } + }; } export default Slot;