diff --git a/apps/router/src/guardian-ui/components/dashboard/tabs/FederationTabsCard.tsx b/apps/router/src/guardian-ui/components/dashboard/tabs/FederationTabsCard.tsx index a7bf40c9..60ef7e11 100644 --- a/apps/router/src/guardian-ui/components/dashboard/tabs/FederationTabsCard.tsx +++ b/apps/router/src/guardian-ui/components/dashboard/tabs/FederationTabsCard.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { Card, CardBody, @@ -9,21 +9,38 @@ import { TabPanels, Tab, TabPanel, + Badge, } from '@chakra-ui/react'; import { githubLight } from '@uiw/codemirror-theme-github'; import { json } from '@codemirror/lang-json'; import CodeMirror from '@uiw/react-codemirror'; import { ClientConfig, + ConsensusMeta, MetaFields, + MetaSubmissions, ModuleKind, + ParsedConsensusMeta, SignedApiAnnouncement, } from '@fedimint/types'; -import { useTranslation } from '@fedimint/utils'; +import { useTranslation, hexToMeta } from '@fedimint/utils'; import { MetaManager } from './meta/MetaManager'; -import { ConsensusMetaFields } from './meta/ViewConsensusMeta'; import { ApiAnnouncements } from './ApiAnnouncements'; +import { ProposedMetas } from './meta/ProposedMetas'; +import { ModuleRpc } from '../../../types'; +import { useGuardianAdminApi } from '../../../../context/hooks'; + +export const DEFAULT_META_KEY = 0; +export const POLL_TIMEOUT_MS = 2000; + +type MetaSubmissionMap = { + [key: string]: { + peers: number[]; + meta: MetaFields; + }; +}; + interface FederationTabsCardProps { config: ClientConfig | undefined; ourPeer: { id: number; name: string }; @@ -39,9 +56,99 @@ export const FederationTabsCard: React.FC = ({ const [metaModuleId, setMetaModuleId] = useState( undefined ); - const [consensusMeta, setConsensusMeta] = useState(); - const [editedMetaFields, setEditedMetaFields] = useState([]); + const [consensusMeta, setConsensusMeta] = useState(); const [peers, setPeers] = useState<{ id: number; name: string }[]>([]); + const [metaSubmissions, setMetaSubmissions] = useState({}); + const pendingProposalsCount = Object.keys(metaSubmissions).length; + const [hasVoted, setHasVoted] = useState(false); + const [activeTab, setActiveTab] = useState(0); + const api = useGuardianAdminApi(); + + const pollMetaSubmissions = useCallback(async () => { + if (!metaModuleId) return; + + try { + const submissions = await api.moduleApiCall( + Number(metaModuleId), + ModuleRpc.getSubmissions, + DEFAULT_META_KEY + ); + + const metas: MetaSubmissionMap = {}; + let voted = false; + + Object.entries(submissions).forEach(([peer, hexString]) => { + if (hexString === '7b7d') return; // Filter out empty submissions + const metaObject = hexToMeta(hexString); + const meta = Object.entries(metaObject).filter( + ([, value]) => value !== undefined && value !== '' + ) as [string, string][]; + + const metaKey = JSON.stringify(meta); // Use JSON string as a key to group identical metas + + if (metas[metaKey]) { + metas[metaKey].peers.push(Number(peer)); + } else { + metas[metaKey] = { + peers: [Number(peer)], + meta: meta as MetaFields, + }; + } + + if (Number(peer) === ourPeer.id) { + voted = true; + } + }); + + setMetaSubmissions(metas); + setHasVoted(voted); + } catch (err) { + console.warn('Failed to poll for meta submissions', err); + } + }, [api, metaModuleId, ourPeer.id]); + + useEffect(() => { + const pollSubmissionInterval = setInterval( + pollMetaSubmissions, + POLL_TIMEOUT_MS + ); + return () => { + clearInterval(pollSubmissionInterval); + }; + }, [pollMetaSubmissions]); + + useEffect(() => { + const pollConsensusMeta = setInterval(async () => { + try { + const meta = await api.moduleApiCall( + Number(metaModuleId), + ModuleRpc.getConsensus, + DEFAULT_META_KEY + ); + if (!meta) return; + const parsedConsensusMeta: ParsedConsensusMeta = { + revision: meta.revision, + value: Object.entries(hexToMeta(meta.value)).filter( + ([, value]) => value !== undefined && value !== '' + ) as [string, string][], + }; + // Compare the new meta with the current state + setConsensusMeta((currentMeta) => { + if ( + JSON.stringify(currentMeta) !== JSON.stringify(parsedConsensusMeta) + ) { + return parsedConsensusMeta; + } + return currentMeta; + }); + } catch (err) { + console.warn('Failed to poll for consensus meta', err); + } + }, POLL_TIMEOUT_MS); + return () => { + clearInterval(pollConsensusMeta); + }; + }, [api, metaModuleId]); useEffect(() => { if (config) { @@ -66,14 +173,46 @@ export const FederationTabsCard: React.FC = ({ return config ? ( - + - {t('federation-dashboard.config.manage-meta.label')} - {t('federation-dashboard.config.view-config')} - {t('federation-dashboard.api-announcements.label')} + + {t('federation-dashboard.config.view-meta')} + + + {t('federation-dashboard.config.view-config')} + + + {t('federation-dashboard.api-announcements.label')} + + {pendingProposalsCount > 0 && ( + + + {t( + 'federation-dashboard.config.manage-meta.proposed-meta-label' + )} + + {pendingProposalsCount} + + + + )} @@ -84,11 +223,7 @@ export const FederationTabsCard: React.FC = ({ @@ -108,6 +243,16 @@ export const FederationTabsCard: React.FC = ({ config={config} /> + + + diff --git a/apps/router/src/guardian-ui/components/dashboard/tabs/meta/ConfirmNewMetaModal.tsx b/apps/router/src/guardian-ui/components/dashboard/tabs/meta/ConfirmNewMetaModal.tsx new file mode 100644 index 00000000..324e642a --- /dev/null +++ b/apps/router/src/guardian-ui/components/dashboard/tabs/meta/ConfirmNewMetaModal.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { + Modal, + ModalOverlay, + ModalContent, + ModalHeader, + ModalBody, + ModalFooter, + Button, + Text, +} from '@chakra-ui/react'; +import { useTranslation } from '@fedimint/utils'; +import { MetaFields } from '@fedimint/types'; +import { Table, TableColumn } from '@fedimint/ui'; +import { formatJsonValue } from './ProposedMetas'; + +interface ConfirmNewMetaModalProps { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + selectedMeta: MetaFields | null; +} + +export const ConfirmNewMetaModal: React.FC = ({ + isOpen, + onClose, + onConfirm, + selectedMeta, +}) => { + const { t } = useTranslation(); + + const columnsWithoutEffect: TableColumn<'metaKey' | 'value'>[] = [ + { + key: 'metaKey', + heading: t('set-config.meta-fields-key'), + }, + { + key: 'value', + heading: t('set-config.meta-fields-value'), + }, + ]; + + return ( + + + + + {t('federation-dashboard.config.manage-meta.confirm-modal.title')} + + + + {t( + 'federation-dashboard.config.manage-meta.confirm-modal.description' + )} + + {selectedMeta && ( + ({ + key: `${key}-${value}`, + metaKey: {key}, + value: formatJsonValue(value), + }))} + /> + )} + + + + + + + + ); +}; diff --git a/apps/router/src/guardian-ui/components/dashboard/tabs/meta/MetaManager.tsx b/apps/router/src/guardian-ui/components/dashboard/tabs/meta/MetaManager.tsx index ac7c9b40..813fb01e 100644 --- a/apps/router/src/guardian-ui/components/dashboard/tabs/meta/MetaManager.tsx +++ b/apps/router/src/guardian-ui/components/dashboard/tabs/meta/MetaManager.tsx @@ -1,29 +1,20 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { - Flex, - Text, + Box, Button, - ModalHeader, - ModalCloseButton, - ModalBody, - ModalContent, - ModalOverlay, - Modal, - useDisclosure, - ModalFooter, - useTheme, Divider, + Flex, + FormLabel, + Input, + Link, + Text, } from '@chakra-ui/react'; import { fieldsToMeta, metaToHex, useTranslation } from '@fedimint/utils'; -import { MetaFields } from '@fedimint/types'; -import { ViewConsensusMeta, ConsensusMetaFields } from './ViewConsensusMeta'; -import { ProposedMetas } from './ProposedMetas'; -import { EditMetaField } from './EditMetaField'; +import { ParsedConsensusMeta } from '@fedimint/types'; import { ModuleRpc } from '../../../../types'; +import { DEFAULT_META_KEY } from '../FederationTabsCard'; import { useGuardianAdminApi } from '../../../../../context/hooks'; - -export const DEFAULT_META_KEY = 0; -const POLL_TIMEOUT_MS = 2000; +import { RequiredMeta } from './RequiredMeta'; const metaArrayToObject = ( metaArray: [string, string][] @@ -36,167 +27,216 @@ const metaArrayToObject = ( interface MetaManagerProps { metaModuleId?: string; - consensusMeta?: ConsensusMetaFields; - setConsensusMeta: (meta: ConsensusMetaFields) => void; - editedMetaFields: MetaFields; - setEditedMetaFields: (fields: MetaFields) => void; - ourPeer: { id: number; name: string }; - peers: { id: number; name: string }[]; + consensusMeta?: ParsedConsensusMeta; + setActiveTab: (tab: number) => void; } export const MetaManager = React.memo(function MetaManager({ metaModuleId, - ourPeer, - peers, consensusMeta, - setConsensusMeta, - editedMetaFields, - setEditedMetaFields, + setActiveTab, }: MetaManagerProps): JSX.Element { const { t } = useTranslation(); const api = useGuardianAdminApi(); - const { isOpen, onOpen: originalOnOpen, onClose } = useDisclosure(); - const theme = useTheme(); + const [requiredMeta, setRequiredMeta] = useState>({ + federation_name: '', + welcome_message: '', + popup_end_timestamp: '', + federation_icon_url: '', + }); + const [isRequiredMetaValid, setIsRequiredMetaValid] = useState(true); + const [optionalMeta, setOptionalMeta] = useState>({}); - const onOpen = useCallback(() => { - if (consensusMeta) { - setEditedMetaFields([...consensusMeta.value]); // Ensure a new array reference + useEffect(() => { + if (consensusMeta?.value) { + const metaObj = metaArrayToObject(consensusMeta.value); + const { + federation_name, + welcome_message, + popup_end_timestamp, + federation_icon_url, + ...rest + } = metaObj; + setRequiredMeta({ + federation_name, + welcome_message, + popup_end_timestamp, + federation_icon_url, + }); + setOptionalMeta(rest); } - originalOnOpen(); - }, [consensusMeta, originalOnOpen, setEditedMetaFields]); + }, [consensusMeta]); - const proposeMetaEdits = useCallback(() => { - if (metaModuleId === undefined) { - return; + const isAnyRequiredFieldEmpty = useCallback(() => { + // Popup end timestamp is optional but placed in required for simplicity + return ['federation_name', 'welcome_message', 'federation_icon_url'].some( + (key) => requiredMeta[key].trim() === '' + ); + }, [requiredMeta]); + + const isMetaUnchanged = useCallback(() => { + if (!consensusMeta?.value) return false; + const consensusMetaObj = metaArrayToObject(consensusMeta.value); + + // Check if all current fields (required and optional) match the consensus meta + const allCurrentFields = { ...requiredMeta, ...optionalMeta }; + const currentUnchanged = Object.entries(allCurrentFields).every( + ([key, value]) => consensusMetaObj[key] === value + ); + + // Check if any fields from consensus meta are missing in the current fields + const noFieldsRemoved = Object.keys(consensusMetaObj).every( + (key) => key in allCurrentFields + ); + + return currentUnchanged && noFieldsRemoved; + }, [requiredMeta, optionalMeta, consensusMeta]); + + const canSubmit = useCallback(() => { + return ( + !isAnyRequiredFieldEmpty() && isRequiredMetaValid && !isMetaUnchanged() + ); + }, [isAnyRequiredFieldEmpty, isRequiredMetaValid, isMetaUnchanged]); + + const resetToConsensus = useCallback(() => { + if (consensusMeta?.value) { + const metaObj = metaArrayToObject(consensusMeta.value); + const { + federation_name, + welcome_message, + popup_end_timestamp, + federation_icon_url, + ...rest + } = metaObj; + setRequiredMeta({ + federation_name, + welcome_message, + popup_end_timestamp, + federation_icon_url, + }); + setOptionalMeta(rest); } + }, [consensusMeta]); + const handleOptionalMetaChange = (key: string, value: string) => { + setOptionalMeta((prev) => ({ ...prev, [key]: value })); + }; + + const addCustomField = () => { + const timestamp = Date.now(); + const newKey = `custom_field_${timestamp}`; + setOptionalMeta((prev) => ({ ...prev, [newKey]: '' })); + }; + + const removeCustomField = (key: string) => { + setOptionalMeta((prev) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { [key]: _, ...rest } = prev; + return rest; + }); + }; + + const proposeMetaEdits = useCallback(() => { + if (metaModuleId === undefined || isAnyRequiredFieldEmpty()) return; + const updatedMetaArray = Object.entries({ + ...requiredMeta, + ...optionalMeta, + }).filter(([key, value]) => key !== 'popup_end_timestamp' || value !== ''); api .moduleApiCall<{ metaValue: string }[]>( Number(metaModuleId), ModuleRpc.submitMeta, { key: DEFAULT_META_KEY, - value: metaToHex(fieldsToMeta(editedMetaFields)), + value: metaToHex(fieldsToMeta(updatedMetaArray)), } ) .then(() => { - setEditedMetaFields([]); - onClose(); + setActiveTab(3); }) .catch((error) => { console.error(error); alert('Failed to propose meta edits. Please try again.'); }); - }, [api, metaModuleId, editedMetaFields, onClose, setEditedMetaFields]); - - return metaModuleId ? ( - - - setConsensusMeta(meta) - } - pollTimeout={POLL_TIMEOUT_MS} - /> - - { - setEditedMetaFields([...fields]); - }} - pollTimeout={POLL_TIMEOUT_MS} - onOpen={onOpen} - /> - - - - + + {t('federation-dashboard.config.manage-meta.header')} + + + + {t('federation-dashboard.config.manage-meta.description')} + + - - {t('federation-dashboard.config.manage-meta.edit-meta-label')} - - - - - {t('federation-dashboard.config.manage-meta.setup-meta-title')} - - - {t( - 'federation-dashboard.config.manage-meta.setup-meta-description' - )} - - - {t('federation-dashboard.config.manage-meta.propose-updates')} - - - {t('federation-dashboard.config.manage-meta.core-meta-fields')} - - - - - federation_expiry_timestamp:{' '} - {t('federation-dashboard.config.manage-meta.meta-field-expiry')} - - - - federation_name:{' '} - {t('federation-dashboard.config.manage-meta.meta-field-name')} - - - - federation_icon_url:{' '} - {t('federation-dashboard.config.manage-meta.meta-field-icon')} - - - - welcome_message:{' '} - {t( - 'federation-dashboard.config.manage-meta.meta-field-welcome' - )} - - - - vetted_gateways:{' '} - {t( - 'federation-dashboard.config.manage-meta.meta-field-gateways' - )} - + {t('federation-dashboard.config.manage-meta.learn-more')} + + + + + + + {Object.entries(optionalMeta).map(([key, value]) => ( + + + + {key} + + - - {t('federation-dashboard.config.manage-meta.your-own-fields')} - - - handleOptionalMetaChange(key, e.target.value)} /> - - - - - - - + + ))} + + + + {consensusMeta?.value && !isMetaUnchanged() && ( + + )} + + - ) : ( - {t('federation-dashboard.config.missing-meta-module')} ); }); diff --git a/apps/router/src/guardian-ui/components/dashboard/tabs/meta/NoPendingProposals.tsx b/apps/router/src/guardian-ui/components/dashboard/tabs/meta/NoPendingProposals.tsx new file mode 100644 index 00000000..5bcad3f8 --- /dev/null +++ b/apps/router/src/guardian-ui/components/dashboard/tabs/meta/NoPendingProposals.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { Box, Button, Flex, Text } from '@chakra-ui/react'; +import { useTranslation } from '@fedimint/utils'; + +interface NoPendingProposalsProps { + setActiveTab: (tab: number) => void; +} + +export const NoPendingProposals: React.FC = ({ + setActiveTab, +}) => { + const { t } = useTranslation(); + + return ( + + + {t( + 'federation-dashboard.config.manage-meta.no-pending-proposals-header' + )} + + + + + {t( + 'federation-dashboard.config.manage-meta.no-pending-proposals-description' + )} + + + + + + ); +}; diff --git a/apps/router/src/guardian-ui/components/dashboard/tabs/meta/ProposeMetaModal.tsx b/apps/router/src/guardian-ui/components/dashboard/tabs/meta/ProposeMetaModal.tsx new file mode 100644 index 00000000..cd37af89 --- /dev/null +++ b/apps/router/src/guardian-ui/components/dashboard/tabs/meta/ProposeMetaModal.tsx @@ -0,0 +1,105 @@ +import React from 'react'; +import { + Text, + Button, + ModalHeader, + ModalCloseButton, + ModalBody, + ModalContent, + ModalOverlay, + Modal, + ModalFooter, + Flex, + Divider, +} from '@chakra-ui/react'; +import { useTranslation } from '@fedimint/utils'; +import { MetaFields } from '@fedimint/types'; +import { EditMetaField } from './EditMetaField'; + +interface ProposeMetaModalProps { + isOpen: boolean; + onClose: () => void; + editedMetaFields: MetaFields; + setEditedMetaFields: (fields: MetaFields) => void; + proposeMetaEdits: () => void; +} + +export const ProposeMetaModal: React.FC = ({ + isOpen, + onClose, + editedMetaFields, + setEditedMetaFields, + proposeMetaEdits, +}) => { + const { t } = useTranslation(); + + return ( + + + + + {t('federation-dashboard.config.manage-meta.edit-meta-label')} + + + + + {t('federation-dashboard.config.manage-meta.setup-meta-title')} + + + {t( + 'federation-dashboard.config.manage-meta.setup-meta-description' + )} + + + {t('federation-dashboard.config.manage-meta.propose-updates')} + + + {t('federation-dashboard.config.manage-meta.core-meta-fields')} + + + + - federation_expiry_timestamp:{' '} + {t('federation-dashboard.config.manage-meta.meta-field-expiry')} + + + - federation_name:{' '} + {t('federation-dashboard.config.manage-meta.meta-field-name')} + + + - federation_icon_url:{' '} + {t('federation-dashboard.config.manage-meta.meta-field-icon')} + + + - welcome_message:{' '} + {t('federation-dashboard.config.manage-meta.meta-field-welcome')} + + + - vetted_gateways:{' '} + {t('federation-dashboard.config.manage-meta.meta-field-gateways')} + + + + {t('federation-dashboard.config.manage-meta.your-own-fields')} + + + + + + + + + + + ); +}; diff --git a/apps/router/src/guardian-ui/components/dashboard/tabs/meta/ProposedMetas.tsx b/apps/router/src/guardian-ui/components/dashboard/tabs/meta/ProposedMetas.tsx index c4778481..b32455e9 100644 --- a/apps/router/src/guardian-ui/components/dashboard/tabs/meta/ProposedMetas.tsx +++ b/apps/router/src/guardian-ui/components/dashboard/tabs/meta/ProposedMetas.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useCallback } from 'react'; import { Flex, Text, @@ -9,28 +9,31 @@ import { CardFooter, Icon, useDisclosure, - Modal, - ModalOverlay, - ModalContent, - ModalHeader, - ModalBody, - ModalFooter, + useBreakpointValue, } from '@chakra-ui/react'; import { ReactComponent as CheckIcon } from '../../../../assets/svgs/check.svg'; -import { - useTranslation, - hexToMeta, - metaToHex, - fieldsToMeta, -} from '@fedimint/utils'; -import { MetaFields, MetaSubmissions } from '@fedimint/types'; +import { useTranslation, metaToHex, fieldsToMeta } from '@fedimint/utils'; +import { MetaFields, ParsedConsensusMeta } from '@fedimint/types'; import { ModuleRpc } from '../../../../types'; import { Table, TableColumn } from '@fedimint/ui'; -import { DEFAULT_META_KEY } from './MetaManager'; -import { bftHonest, generateSimpleHash } from '../../../../utils'; +import { bftHonest, generateSimpleHash, isJsonString } from '../../../../utils'; +import { DEFAULT_META_KEY } from '../FederationTabsCard'; +import { ConfirmNewMetaModal } from './ConfirmNewMetaModal'; import { useGuardianAdminApi } from '../../../../../context/hooks'; +export const formatJsonValue = (value: string): JSX.Element => { + if (isJsonString(value)) { + const parsedJson = JSON.parse(value); + return ( +
+        {JSON.stringify(parsedJson, null, 2)}
+      
+ ); + } + return {value}; +}; + type MetaSubmissionMap = { [key: string]: { peers: number[]; @@ -38,82 +41,33 @@ type MetaSubmissionMap = { }; }; +type TableKey = 'metaKey' | 'value' | 'effect'; + interface ProposedMetasProps { ourPeer: { id: number; name: string }; peers: { id: number; name: string }[]; metaModuleId: string; - updateEditedMetaFields: (fields: MetaFields) => void; - pollTimeout: number; - onOpen: () => void; - consensusMeta: Record; + consensusMeta?: ParsedConsensusMeta; + metaSubmissions: MetaSubmissionMap; + hasVoted: boolean; } -type TableKey = 'metaKey' | 'value' | 'effect'; - export const ProposedMetas = React.memo(function ProposedMetas({ ourPeer, peers, metaModuleId, - pollTimeout, - onOpen, consensusMeta, + metaSubmissions, }: ProposedMetasProps): JSX.Element { const { t } = useTranslation(); const api = useGuardianAdminApi(); const { isOpen, onOpen: openModal, onClose } = useDisclosure(); - const [metaSubmissions, setMetaSubmissions] = useState(); - const [hasVoted, setHasVoted] = useState(false); const [selectedMeta, setSelectedMeta] = useState(null); + const isMobile = useBreakpointValue({ base: true, md: false }); const totalGuardians = peers.length; const threshold = bftHonest(totalGuardians); - useEffect(() => { - const pollSubmissionInterval = setInterval(async () => { - try { - const submissions = await api.moduleApiCall( - Number(metaModuleId), - ModuleRpc.getSubmissions, - 0 - ); - - const metas: MetaSubmissionMap = {}; - let voted = false; - - Object.entries(submissions).forEach(([peer, hexString]) => { - if (hexString === '7b7d') return; // Filter out empty submissions - const metaObject = hexToMeta(hexString); - const meta = Object.entries(metaObject).filter( - ([, value]) => value !== undefined && value !== '' - ) as [string, string][]; - - const metaKey = JSON.stringify(meta); // Use JSON string as a key to group identical metas - - if (metas[metaKey]) { - metas[metaKey].peers.push(Number(peer)); - } else { - metas[metaKey] = { - peers: [Number(peer)], - meta: meta as MetaFields, - }; - } - - if (Number(peer) === ourPeer.id) { - voted = true; - } - }); - - setMetaSubmissions(metas); - setHasVoted(voted); - } catch (err) { - console.warn('Failed to poll for meta submissions', err); - } - }, pollTimeout); - return () => { - clearInterval(pollSubmissionInterval); - }; - }, [api, metaModuleId, pollTimeout, ourPeer.id]); - const handleClear = useCallback(async () => { try { await api.moduleApiCall<{ metaValue: string }[]>( @@ -148,7 +102,7 @@ export const ProposedMetas = React.memo(function ProposedMetas({ [api, metaModuleId] ); - const columns: TableColumn[] = [ + const columnsWithEffect: TableColumn[] = [ { key: 'metaKey', heading: t('set-config.meta-fields-key'), @@ -163,16 +117,36 @@ export const ProposedMetas = React.memo(function ProposedMetas({ }, ]; - const getEffect = (key: string, value: string): JSX.Element => { - console.log('consensusMeta', consensusMeta); - if (consensusMeta[key] === undefined) { + const isEqual = (a: string, b: string): boolean => { + try { + return JSON.stringify(JSON.parse(a)) === JSON.stringify(JSON.parse(b)); + } catch { + return a === b; + } + }; + + const getEffect = ( + key: string, + value: string, + consensusMeta: ParsedConsensusMeta | undefined + ): JSX.Element => { + if (!consensusMeta?.value) { return ( {t('federation-dashboard.config.manage-meta.meta-effect-add')} ); } - if (String(consensusMeta[key]) !== value) { + + const consensusValue = consensusMeta.value.find(([k]) => k === key)?.[1]; + + if (consensusValue === undefined) { + return ( + + {t('federation-dashboard.config.manage-meta.meta-effect-add')} + + ); + } else if (!isEqual(consensusValue, value)) { return ( {t('federation-dashboard.config.manage-meta.meta-effect-modify')} @@ -225,47 +199,108 @@ export const ProposedMetas = React.memo(function ProposedMetas({ ) : null} {metaSubmissions && Object.entries(metaSubmissions).map(([key, submission]) => { - const submissionKeys = new Set(submission.meta.map(([key]) => key)); + const submissionMap = new Map(submission.meta); const rows = [ - ...submission.meta.map(([key, value]) => ({ - key: `${key}-${value}`, - metaKey: {key}, - value: {value}, - effect: getEffect(key, value), - })), - ...Object.entries(consensusMeta) - .filter(([key]) => !submissionKeys.has(key)) + ...submission.meta + .filter(([key, value]) => { + const consensusValue = consensusMeta?.value.find( + ([k]) => k === key + )?.[1]; + return !consensusValue || !isEqual(consensusValue, value); + }) .map(([key, value]) => ({ key: `${key}-${value}`, - metaKey: {key}, - value: {value}, - effect: {t('common.remove')}, + metaKey: {key}, + value: ( +
+                    {formatJsonValue(value)}
+                  
+ ), + effect: getEffect(key, value, consensusMeta), })), + ...(consensusMeta + ? consensusMeta.value + .filter(([key]) => !submissionMap.has(key)) + .map(([key, value]) => ({ + key: `${key}-${value}`, + metaKey: {key}, + value: {value}, + effect: {t('common.remove')}, + })) + : []), ]; const totalGuardians = peers.length; const currentApprovals = submission.peers.length; return ( - + - - + + {`Proposal ID: ${generateSimpleHash(key)}`} + {submission.peers.includes(ourPeer.id) ? ( + + ) : ( + + )} - -
+ + {isMobile ? ( + + {rows.map((row) => ( + + {row.metaKey} + {row.value} + {row.effect} + + ))} + + ) : ( +
+ )} - - - - {t('common.approvals')}: ( {currentApprovals} / - {totalGuardians} ) - + + + {t('common.approvals')}: ( {currentApprovals} /{' '} + {totalGuardians} ) + + {submission.peers.map((peerId) => ( - + - + {ourPeer.id === Number(peerId) ? t('common.you') : peers.find((p) => p.id === Number(peerId))?.name} @@ -284,73 +319,17 @@ export const ProposedMetas = React.memo(function ProposedMetas({ ))} - {submission.peers.includes(ourPeer.id) ? ( - - ) : ( - - )} ); })} - {hasVoted ? null : ( - - )} - - - - - {t('federation-dashboard.config.manage-meta.confirm-modal.title')} - - - - {t( - 'federation-dashboard.config.manage-meta.confirm-modal.description' - )} - - {selectedMeta && ( -
({ - key: `${key}-${value}`, - metaKey: {key}, - value: {value}, - effect: getEffect(key, value), - }))} - /> - )} - - - - - - - + ); }); diff --git a/apps/router/src/guardian-ui/components/dashboard/tabs/meta/RequiredMeta.tsx b/apps/router/src/guardian-ui/components/dashboard/tabs/meta/RequiredMeta.tsx new file mode 100644 index 00000000..d642cb74 --- /dev/null +++ b/apps/router/src/guardian-ui/components/dashboard/tabs/meta/RequiredMeta.tsx @@ -0,0 +1,136 @@ +import React, { useState, useEffect } from 'react'; +import { Flex, FormLabel, Input, Image } from '@chakra-ui/react'; +import { snakeToTitleCase } from '@fedimint/utils'; + +interface RequiredMetaProps { + requiredMeta: Record; + setRequiredMeta: React.Dispatch>>; + isValid: boolean; + setIsValid: React.Dispatch>; +} + +export const RequiredMeta: React.FC = ({ + requiredMeta, + setRequiredMeta, + setIsValid, +}) => { + const [iconPreview, setIconPreview] = useState(null); + const [isIconValid, setIsIconValid] = useState(true); + + useEffect(() => { + let objectURL: string | null = null; + + const validateIcon = async (url: string) => { + try { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const blob = await response.blob(); + if (!blob.type.startsWith('image/')) { + throw new Error('Invalid image format'); + } + objectURL = URL.createObjectURL(blob); + setIconPreview(objectURL); + setIsIconValid(true); + } catch (error) { + setIconPreview(null); + setIsIconValid(false); + if (error instanceof Error) { + console.error(`Icon validation failed: ${error.message}`); + } + } + }; + + if (requiredMeta.federation_icon_url) { + validateIcon(requiredMeta.federation_icon_url); + } else { + setIconPreview(null); + setIsIconValid(true); + } + + return () => { + if (objectURL) { + URL.revokeObjectURL(objectURL); + } + }; + }, [requiredMeta.federation_icon_url]); + + useEffect(() => { + setIsValid(isIconValid); + }, [isIconValid, setIsValid]); + + const handleChange = (key: string, value: string) => { + setRequiredMeta((prev) => ({ ...prev, [key]: value })); + }; + + return ( + + {Object.entries(requiredMeta).map(([key, value]) => ( + + + + {snakeToTitleCase(key)} + + + handleChange(key, e.target.value)} + borderColor={ + (['federation_name', 'welcome_message'].includes(key) && + value.trim() === '') || + (key === 'federation_icon_url' && !isIconValid) + ? 'yellow.400' + : 'inherit' + } + /> + {key === 'federation_icon_url' && ( + + {iconPreview ? ( + + ) : ( + + ? + + )} + + )} + + + + ))} + + ); +}; diff --git a/apps/router/src/guardian-ui/components/dashboard/tabs/meta/ViewConsensusMeta.tsx b/apps/router/src/guardian-ui/components/dashboard/tabs/meta/ViewConsensusMeta.tsx index b0cebe61..7f1c5840 100644 --- a/apps/router/src/guardian-ui/components/dashboard/tabs/meta/ViewConsensusMeta.tsx +++ b/apps/router/src/guardian-ui/components/dashboard/tabs/meta/ViewConsensusMeta.tsx @@ -1,60 +1,21 @@ -import React, { useEffect, useMemo } from 'react'; +import React, { useMemo } from 'react'; import { Flex, Text, useTheme } from '@chakra-ui/react'; -import { useTranslation, hexToMeta, metaToFields } from '@fedimint/utils'; -import { ConsensusMeta, MetaFields } from '@fedimint/types'; -import { ModuleRpc } from '../../../../types'; +import { useTranslation } from '@fedimint/utils'; +import { ParsedConsensusMeta } from '@fedimint/types'; import { Table, TableColumn, TableRow } from '@fedimint/ui'; -import { useGuardianAdminApi } from '../../../../../context/hooks'; interface ViewConsensusMetaProps { - metaKey: number; - metaModuleId: string; - consensusMeta?: ConsensusMetaFields; - pollTimeout: number; - updateConsensusMeta: (meta: ConsensusMetaFields) => void; -} - -export interface ConsensusMetaFields { - revision: number; - value: MetaFields; + consensusMeta: ParsedConsensusMeta; } type TableKey = 'metaKey' | 'value'; export const ViewConsensusMeta = React.memo(function ConsensusMetaFields({ consensusMeta, - metaModuleId, - metaKey, - pollTimeout, - updateConsensusMeta, }: ViewConsensusMetaProps): JSX.Element { const { t } = useTranslation(); - const api = useGuardianAdminApi(); const theme = useTheme(); - useEffect(() => { - const pollConsensusMeta = setInterval(async () => { - try { - const meta = await api.moduleApiCall( - Number(metaModuleId), - ModuleRpc.getConsensus, - metaKey - ); - if (!meta) return; - const consensusMeta: ConsensusMetaFields = { - revision: meta.revision, - value: metaToFields(hexToMeta(meta.value)), - }; - updateConsensusMeta(consensusMeta); - } catch (err) { - console.warn('Failed to poll for consensus meta', err); - } - }, pollTimeout); - return () => { - clearInterval(pollConsensusMeta); - }; - }, [api, metaModuleId, metaKey, pollTimeout, updateConsensusMeta]); - const columns: TableColumn[] = useMemo( () => [ { @@ -70,8 +31,7 @@ export const ViewConsensusMeta = React.memo(function ConsensusMetaFields({ ); const rows: TableRow[] = useMemo(() => { - if (!consensusMeta) return [] as TableRow[]; - return consensusMeta?.value.map(([key, value]) => { + return consensusMeta.value.map(([key, value]) => { return { key: `${key}-${value}`, metaKey: {key}, @@ -87,64 +47,16 @@ export const ViewConsensusMeta = React.memo(function ConsensusMetaFields({ pl={4} borderLeft={`1px solid ${theme.colors.border.input}`} > - {consensusMeta ? ( - <> - - - {t( - 'federation-dashboard.config.manage-meta.consensus-meta-label' - )} - - - {t('federation-dashboard.config.manage-meta.revision')}:{' '} - {consensusMeta?.revision} - - -
- - ) : ( - - - {t('federation-dashboard.config.manage-meta.setup-meta-title')} - - - {t( - 'federation-dashboard.config.manage-meta.setup-meta-description' - )} - - - {t('federation-dashboard.config.manage-meta.propose-updates')} - - - {t('federation-dashboard.config.manage-meta.core-meta-fields')} - - - - - federation_expiry_timestamp:{' '} - {t('federation-dashboard.config.manage-meta.meta-field-expiry')} - - - - federation_name:{' '} - {t('federation-dashboard.config.manage-meta.meta-field-name')} - - - - federation_icon_url:{' '} - {t('federation-dashboard.config.manage-meta.meta-field-icon')} - - - - welcome_message:{' '} - {t('federation-dashboard.config.manage-meta.meta-field-welcome')} - - - - vetted_gateways:{' '} - {t('federation-dashboard.config.manage-meta.meta-field-gateways')} - - - {t('federation-dashboard.config.manage-meta.your-own-fields')} - - - - )} + + + {t('federation-dashboard.config.manage-meta.consensus-meta-label')} + + + {t('federation-dashboard.config.manage-meta.revision')}:{' '} + {consensusMeta?.revision} + + +
); }); diff --git a/apps/router/src/guardian-ui/components/setup/screens/connectGuardians/ConnectGuardians.tsx b/apps/router/src/guardian-ui/components/setup/screens/connectGuardians/ConnectGuardians.tsx index f948784c..3cac40ad 100644 --- a/apps/router/src/guardian-ui/components/setup/screens/connectGuardians/ConnectGuardians.tsx +++ b/apps/router/src/guardian-ui/components/setup/screens/connectGuardians/ConnectGuardians.tsx @@ -64,7 +64,12 @@ export const ConnectGuardians: React.FC = ({ next }) => { } else if (role === GuardianRole.Host) { content = ( - + {t('connect-guardians.invite-guardians')} { return url; } }; + +export function isJsonString(str: string): boolean { + try { + JSON.parse(str); + return true; + } catch (e) { + return false; + } +} diff --git a/apps/router/src/languages/en.json b/apps/router/src/languages/en.json index a9811b99..ef0212d6 100644 --- a/apps/router/src/languages/en.json +++ b/apps/router/src/languages/en.json @@ -89,28 +89,34 @@ "no-gateways-info-description": "Lightning node operators can connect to your federation to provide Lightning Network interoperability. Once connected, they will appear here." }, "api-announcements": { - "label": "API Announcements", + "label": "API", "guardian": "Guardian", "api-url": "API URL", "revision": "Revision" }, "config": { "label": "Federation Config", - "view-config": "View Config", + "view-config": "Config", + "view-meta": "Meta", "missing-meta-module": "Editing Meta fields is not possible. The Meta module is not available for this federation.", "manage-meta": { + "header": "Your Federation's Metadata", + "description": "Fedimint can supply your users with additional information (metadata) about the Federation, such as a name, welcome message, or image. While this information does not affect the custody of funds, all the Guardians must agree on the metadata.", + "learn-more": "Learn more about Federation Metadata.", "label": "Manage Meta", "cancel-button": "Cancel", "confirm-modal": { "title": "Confirm Approval", "description": "Your approval will reach the threshold to adopt this meta change. Your new meta will look like:" }, - "consensus-meta-label": "Current meta in consensus", + "consensus-meta-label": "Consensus Metadata", "revoke-button": "Revoke", "no-consensus-meta-message": "there are no meta in consensus", "proposed-meta-label": "Meta Proposals", "propose-meta": "Propose Meta", - "propose-new-meta-button": "Propose New Meta", + "propose-new-meta-button": "Propose New Metadata", + "add-custom-field-button": "Add Custom Field", + "reset-to-consensus-button": "Reset to Consensus", "proposal-approved": "You have approved this proposal", "no-submitted-meta-message": "there are no meta edits to review", "edit-meta-label": "Edit meta", diff --git a/packages/types/src/meta.ts b/packages/types/src/meta.ts index 94c6a9ce..0f3f8581 100644 --- a/packages/types/src/meta.ts +++ b/packages/types/src/meta.ts @@ -17,3 +17,9 @@ export interface ConsensusMeta { revision: number; value: string; } + +// Parsed ConsensusMeta +export interface ParsedConsensusMeta { + revision: number; + value: MetaFields; +} diff --git a/packages/utils/src/index.tsx b/packages/utils/src/index.tsx index 36fea8e5..ab37d146 100644 --- a/packages/utils/src/index.tsx +++ b/packages/utils/src/index.tsx @@ -28,3 +28,17 @@ export const sha256Hash = async (input: string): Promise => { .map((b) => b.toString(16).padStart(2, '0')) .join(''); }; + +export const snakeToTitleCase = (str: string): string => { + return str + .split('_') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); +}; + +export const titleToSnakeCase = (str: string): string => { + return str + .split(' ') + .map((word) => word.toLowerCase()) + .join('_'); +};