From cb5adfe0310b94297c6c695b045b38d313c9bd78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ferenc=20S=C3=A1rai?= Date: Thu, 2 Apr 2026 14:53:20 +0200 Subject: [PATCH] feat: sync and update with all changes from solar-punk-ltd fork (#730) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: swap error caused by invalid id and batchcount * fix: enhance creation messages for admin drive and user drives * fix: identity and wallet creation * fix: asset preview types * fix: fm search unicode text * fix: feed identity and stamp usage * fix: ui display changes * fix: stamp buy and dilute * fix: vite polyfill warning for stream * fix: standard mode postage stamp purchase reserves incorrect size and duration * fix: add syncing message for Bee node and update page state handling * refactor: stamp depth and amount validation --------- Co-authored-by: Balint Ujvari Co-authored-by: Bálint Ujvári <58116288+bosi95@users.noreply.github.com> Co-authored-by: rolandlor <33499567+rolandlor@users.noreply.github.com> --- .env.development | 5 +- .gitignore | 1 + src/components/ExpandableListItem.tsx | 2 +- src/components/ExpandableListItemInput.tsx | 128 ++++++++++-------- src/components/ExpandableListItemKey.tsx | 12 +- src/components/ExpandableListItemLink.tsx | 10 +- src/components/SideBar.tsx | 2 +- src/components/StampExtensionModal.tsx | 112 ++++++++++++--- src/components/SwarmSelect.tsx | 1 + src/components/SwarmTextInput.tsx | 4 +- src/components/WithdrawDepositModal.tsx | 12 +- src/constants.ts | 2 + .../AdminStatusBar/AdminStatusBar.tsx | 8 +- .../components/ConfirmModal/ConfirmModal.tsx | 2 +- .../FileBrowserHeader/FileBrowserHeader.tsx | 3 +- .../PrivateKeyModal/PrivateKeyModal.tsx | 3 +- .../components/Sidebar/Sidebar.tsx | 6 +- .../filemanager/hooks/useFileFiltering.ts | 10 +- .../filemanager/utils/GetIconElement.tsx | 2 +- src/modules/filemanager/utils/download.ts | 2 +- src/modules/filemanager/utils/view.ts | 114 ---------------- src/pages/account/feeds/AccountFeeds.tsx | 2 +- src/pages/feeds/CreateNewFeed.tsx | 52 +++++-- src/pages/feeds/UpdateFeed.tsx | 27 ++-- src/pages/feeds/index.tsx | 2 +- src/pages/filemanager/index.tsx | 76 +++++++---- src/pages/files/AssetPreview.tsx | 28 ++-- src/pages/files/Share.tsx | 6 +- src/pages/files/Upload.tsx | 2 +- src/pages/info/WalletInfoCard.tsx | 3 +- .../stamps/PostageStampAdvancedCreation.tsx | 41 ++---- .../stamps/PostageStampStandardCreation.tsx | 47 ++++--- src/pages/stamps/StampsTable.tsx | 22 +-- src/providers/File.tsx | 3 +- src/utils/chain.ts | 28 +++- src/utils/file.ts | 116 ++++++++++++++++ src/utils/identity.ts | 7 +- src/utils/index.ts | 35 +---- src/utils/rpc.ts | 4 +- src/utils/stamp.ts | 63 +++++++++ vite.config.mts | 2 +- 41 files changed, 627 insertions(+), 380 deletions(-) delete mode 100644 src/modules/filemanager/utils/view.ts create mode 100644 src/utils/stamp.ts diff --git a/.env.development b/.env.development index b729870..e7c42ff 100644 --- a/.env.development +++ b/.env.development @@ -1,3 +1,6 @@ PORT=3002 +VITE_BEE_DESKTOP_URL=http://localhost:3054 VITE_FORMBRICKS_ENV_ID= -VITE_FORMBRICKS_APP_URL= \ No newline at end of file +VITE_FORMBRICKS_APP_URL= +VITE_DEFAULT_RPC_URL= +VITE_BEE_DESKTOP_ENABLED= \ No newline at end of file diff --git a/.gitignore b/.gitignore index e675c10..6e84d55 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ npm-debug.log* yarn-debug.log* yarn-error.log* +/**.log settings.json diff --git a/src/components/ExpandableListItem.tsx b/src/components/ExpandableListItem.tsx index ff051de..5a2a297 100644 --- a/src/components/ExpandableListItem.tsx +++ b/src/components/ExpandableListItem.tsx @@ -40,7 +40,7 @@ export default function ExpandableListItem({ label, value, tooltip }: Props): Re )} {value && ( - + {value} {tooltip && ( diff --git a/src/components/ExpandableListItemInput.tsx b/src/components/ExpandableListItemInput.tsx index 2c3a4c4..5a94325 100644 --- a/src/components/ExpandableListItemInput.tsx +++ b/src/components/ExpandableListItemInput.tsx @@ -16,11 +16,17 @@ const useStyles = makeStyles()(theme => ({ header: { backgroundColor: theme.palette.background.paper, marginBottom: theme.spacing(0.25), - borderLeft: `${theme.spacing(0.25)}px solid rgba(0,0,0,0)`, + borderLeft: `${theme.spacing(0.25)} solid rgba(0,0,0,0)`, wordBreak: 'break-word', + '&:hover': { + backgroundColor: theme.palette.background.paper, + }, + '&:focus-within': { + backgroundColor: theme.palette.background.paper, + }, }, headerOpen: { - borderLeft: `${theme.spacing(0.25)}px solid ${theme.palette.primary.main}`, + borderLeft: `${theme.spacing(0.25)} solid ${theme.palette.primary.main}`, }, copyValue: { cursor: 'pointer', @@ -95,35 +101,35 @@ export default function ExpandableListItemInput({ } return ( - - - - {label && ( - - - {label} - + <> + + + + {label && ( + + + {label} + + + )} + + {!open && value && ( + + {value} + + )} + {!expandedOnly && !locked && ( + + {open ? : } + + )} - )} - - {!open && value && ( - - {value} - - )} - {!expandedOnly && !locked && ( - - {open ? : } - - )} - - - + {helperText && {helperText}} - - - { - onConfirm?.(inputValue.trim()) - }} - > - {confirmLabel || 'Save'} - - setInputValue(value || '')} - cancel - > - Cancel - - - - - - - + + + + + + + { + onConfirm?.(inputValue.trim()) + }} + > + {confirmLabel || 'Save'} + + setInputValue(value || '')} + cancel + > + Cancel + + + + + ) } diff --git a/src/components/ExpandableListItemKey.tsx b/src/components/ExpandableListItemKey.tsx index feeba05..72449ce 100644 --- a/src/components/ExpandableListItemKey.tsx +++ b/src/components/ExpandableListItemKey.tsx @@ -10,11 +10,17 @@ const useStyles = makeStyles()(theme => ({ header: { backgroundColor: theme.palette.background.paper, marginBottom: theme.spacing(0.25), - borderLeft: `${theme.spacing(0.25)}px solid rgba(0,0,0,0)`, + borderLeft: `${theme.spacing(0.25)} solid rgba(0,0,0,0)`, wordBreak: 'break-word', + '&:hover': { + backgroundColor: theme.palette.background.paper, + }, + '&:focus-within': { + backgroundColor: theme.palette.background.paper, + }, }, headerOpen: { - borderLeft: `${theme.spacing(0.25)}px solid ${theme.palette.primary.main}`, + borderLeft: `${theme.spacing(0.25)} solid ${theme.palette.primary.main}`, }, copyValue: { cursor: 'pointer', @@ -69,7 +75,7 @@ export default function ExpandableListItemKey({ label, value, expanded }: Props) return ( - + {label && ( diff --git a/src/components/ExpandableListItemLink.tsx b/src/components/ExpandableListItemLink.tsx index 72a352b..483b481 100644 --- a/src/components/ExpandableListItemLink.tsx +++ b/src/components/ExpandableListItemLink.tsx @@ -10,11 +10,17 @@ const useStyles = makeStyles()(theme => ({ header: { backgroundColor: theme.palette.background.paper, marginBottom: theme.spacing(0.25), - borderLeft: `${theme.spacing(0.25)}px solid rgba(0,0,0,0)`, + borderLeft: `${theme.spacing(0.25)} solid rgba(0,0,0,0)`, wordBreak: 'break-word', + '&:hover': { + backgroundColor: theme.palette.background.paper, + }, + '&:focus-within': { + backgroundColor: theme.palette.background.paper, + }, }, headerOpen: { - borderLeft: `${theme.spacing(0.25)}px solid ${theme.palette.primary.main}`, + borderLeft: `${theme.spacing(0.25)} solid ${theme.palette.primary.main}`, }, openLinkIcon: { cursor: 'pointer', diff --git a/src/components/SideBar.tsx b/src/components/SideBar.tsx index e714139..b29d688 100644 --- a/src/components/SideBar.tsx +++ b/src/components/SideBar.tsx @@ -139,7 +139,7 @@ export default function SideBar(): ReactElement { label: 'File Manager', path: ROUTES.FILEMANAGER, icon: FileManagerIcon, - pathMatcherSubstring: '/filemanager/', + pathMatcherSubstring: '/filemanager', }, { label: 'Account', diff --git a/src/components/StampExtensionModal.tsx b/src/components/StampExtensionModal.tsx index 7c7981c..d72fd3a 100644 --- a/src/components/StampExtensionModal.tsx +++ b/src/components/StampExtensionModal.tsx @@ -1,4 +1,4 @@ -import { BatchId, Bee } from '@ethersphere/bee-js' +import { Bee, PostageBatch } from '@ethersphere/bee-js' import { Box } from '@mui/material' import Button from '@mui/material/Button' import Dialog from '@mui/material/Dialog' @@ -8,19 +8,48 @@ import DialogTitle from '@mui/material/DialogTitle' import Input from '@mui/material/Input' import { useSnackbar } from 'notistack' import React, { ReactElement, ReactNode, useState } from 'react' +import { makeStyles } from 'tss-react/mui' -interface Props { - type: 'Topup' | 'Dilute' - icon: ReactNode - bee: Bee - stamp: BatchId +import { CheckState } from '../providers/Bee' + +const useStyles = makeStyles()(theme => ({ + buttonSelected: { + color: 'white', + backgroundColor: theme.palette.primary.main, + '&:hover': { + color: theme.palette.secondary.main, + backgroundColor: 'white', + '@media (hover: none)': { + color: 'white', + backgroundColor: theme.palette.primary.main, + }, + }, + }, + buttonUnselected: { + color: '#dd7700', + backgroundColor: 'white', + }, +})) + +export enum StampExtensionType { + Topup = 'Topup', + Dilute = 'Dilute', } -export default function StampExtensionModal({ type, icon, bee, stamp }: Props): ReactElement { - const [open, setOpen] = useState(false) - const [amount, setAmount] = useState('') +interface Props { + type: StampExtensionType + icon: ReactNode + bee: Bee + stamp: PostageBatch + status: CheckState +} + +export default function StampExtensionModal({ type, icon, bee, stamp, status }: Props): ReactElement { + const { classes } = useStyles() + const [open, setOpen] = useState(false) + const [amount, setAmount] = useState('') const { enqueueSnackbar } = useSnackbar() - const label = `${type} ${stamp.toHex().substring(0, 8)}` + const label = `${type} ${stamp.batchID.toHex().substring(0, 8)}` const handleClickOpen = (e: React.MouseEvent) => { setOpen(true) @@ -32,23 +61,65 @@ export default function StampExtensionModal({ type, icon, bee, stamp }: Props): } const handleAction = async () => { - if (type === 'Topup') { + if (status !== CheckState.OK) { + enqueueSnackbar(`Node connection status is not ${CheckState.OK}: ${status}`, { variant: 'error' }) + + return + } + + if (type === StampExtensionType.Topup) { + const isAmountInvalid = BigInt(amount) <= BigInt(0) + + if (isAmountInvalid) { + enqueueSnackbar(`Invalid amount: ${amount}, it must be greate than 0`, { variant: 'error' }) + + return + } + try { - await bee.topUpBatch(stamp, amount) + await bee.topUpBatch(stamp.batchID, amount) enqueueSnackbar(`Successfully topped up stamp, your changes will appear soon`, { variant: 'success' }) } catch (error) { enqueueSnackbar(`Failed to topup stamp: ${error || 'Unknown reason'}`, { variant: 'error' }) } + + return } - if (type === 'Dilute') { + if (type === StampExtensionType.Dilute) { + const newDepth = parseInt(amount, 10) + const ttlDays = stamp.duration.toDays() + const currentDepth = stamp.depth + const maxHalvings = Math.floor(Math.log2(ttlDays)) + currentDepth + const isDepthInvalid = newDepth > maxHalvings || newDepth <= currentDepth + + if (isDepthInvalid) { + enqueueSnackbar(`Invalid depth: ${newDepth} (${currentDepth} < new depth < ${maxHalvings})`, { + variant: 'error', + }) + + return + } + + if (ttlDays <= 2) { + enqueueSnackbar(`TTL: ${ttlDays} <= 2 days, cannot dilute stamp (min. TTL is 1 day)`, { + variant: 'warning', + }) + + return + } + try { - await bee.diluteBatch(stamp, parseInt(amount, 10)) + await bee.diluteBatch(stamp.batchID, newDepth) enqueueSnackbar(`Successfully diluted stamp, your changes will appear soon`, { variant: 'success' }) } catch (error) { enqueueSnackbar(`Failed to dilute stamp: ${error || 'Unknown reason'}`, { variant: 'error' }) } + + return } + + enqueueSnackbar(`Failed to extend stamp, unknown operation: ${type}`, { variant: 'error' }) } const handleChange = (event: React.ChangeEvent) => { @@ -57,7 +128,7 @@ export default function StampExtensionModal({ type, icon, bee, stamp }: Props): return ( - @@ -68,7 +139,7 @@ export default function StampExtensionModal({ type, icon, bee, stamp }: Props): margin="dense" id="name" type="text" - placeholder={type === 'Topup' ? 'Amount to add' : 'New depth to dilute'} + placeholder={type === StampExtensionType.Topup ? 'Amount to add' : 'New depth to dilute'} fullWidth value={amount} onChange={handleChange} @@ -78,7 +149,14 @@ export default function StampExtensionModal({ type, icon, bee, stamp }: Props): - diff --git a/src/components/SwarmSelect.tsx b/src/components/SwarmSelect.tsx index 04243e6..2d77f1c 100644 --- a/src/components/SwarmSelect.tsx +++ b/src/components/SwarmSelect.tsx @@ -75,6 +75,7 @@ export function SwarmSelect({ value={value} className={classes.select} displayEmpty + onChange={onChange} renderValue={(value: unknown) => (value ? renderValue(value) : placeholder)} MenuProps={{ MenuListProps: { disablePadding: true }, PaperProps: { square: true } }} > diff --git a/src/components/SwarmTextInput.tsx b/src/components/SwarmTextInput.tsx index 3d6e6a7..25e5ad8 100644 --- a/src/components/SwarmTextInput.tsx +++ b/src/components/SwarmTextInput.tsx @@ -57,7 +57,7 @@ export function SwarmTextInput({ variant="filled" className={classes.field} defaultValue={defaultValue || ''} - InputProps={{ disableUnderline: true }} + slotProps={{ input: { disableUnderline: true } }} placeholder={placeholder} /> ) @@ -73,7 +73,7 @@ export function SwarmTextInput({ className={classes.field} defaultValue={defaultValue || ''} onChange={onChange} - InputProps={{ disableUnderline: true }} + slotProps={{ input: { disableUnderline: true } }} placeholder={placeholder} /> ) diff --git a/src/components/WithdrawDepositModal.tsx b/src/components/WithdrawDepositModal.tsx index 33d6c45..59dc01a 100644 --- a/src/components/WithdrawDepositModal.tsx +++ b/src/components/WithdrawDepositModal.tsx @@ -24,14 +24,14 @@ const useStyles = makeStyles()(theme => ({ }, }, buttonSelected: { - color: 'white', - backgroundColor: theme.palette.primary.main, + color: theme.palette.secondary.main, + backgroundColor: 'white', '&:hover': { - color: theme.palette.secondary.main, - backgroundColor: 'white', + color: 'white', + backgroundColor: theme.palette.primary.main, '@media (hover: none)': { - color: 'white', - backgroundColor: theme.palette.primary.main, + color: theme.palette.secondary.main, + backgroundColor: 'white', }, }, }, diff --git a/src/constants.ts b/src/constants.ts index 2a8faac..1afe8b8 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -12,3 +12,5 @@ export const BEE_DESKTOP_LATEST_RELEASE_PAGE_API = 'https://api.github.com/repos/ethersphere/bee-desktop/releases/latest' export const DEFAULT_BEE_API_HOST = 'http://localhost:1633' export const DEFAULT_RPC_URL = 'https://xdai.fairdatasociety.org' +export const MIN_STAMP_DEPTH = 17 +export const MAX_STAMP_DEPTH = 255 diff --git a/src/modules/filemanager/components/AdminStatusBar/AdminStatusBar.tsx b/src/modules/filemanager/components/AdminStatusBar/AdminStatusBar.tsx index 953c273..39d1ba0 100644 --- a/src/modules/filemanager/components/AdminStatusBar/AdminStatusBar.tsx +++ b/src/modules/filemanager/components/AdminStatusBar/AdminStatusBar.tsx @@ -179,7 +179,13 @@ export function AdminStatusBar({ const isBusy = loading || isUpgrading || isCreationInProgress const blurCls = isBusy ? ' is-loading' : '' const statusVerb = isCreationInProgress ? 'Creating' : 'Loading' - const statusText = statusVerb + ' admin drive, please do not reload' + const statusText = ( + <> + {statusVerb} admin drive — please do not reload the page. +
+ This may take a few minutes. + + ) const renderModalsAndOverlays = () => { return ( diff --git a/src/modules/filemanager/components/ConfirmModal/ConfirmModal.tsx b/src/modules/filemanager/components/ConfirmModal/ConfirmModal.tsx index f849cf6..f0f7f88 100644 --- a/src/modules/filemanager/components/ConfirmModal/ConfirmModal.tsx +++ b/src/modules/filemanager/components/ConfirmModal/ConfirmModal.tsx @@ -15,7 +15,7 @@ interface ConfirmModalProps { onCancel?: () => void showFooter?: boolean isProgress?: boolean - spinnerMessage?: string + spinnerMessage?: React.ReactNode showMinimize?: boolean onMinimize?: () => void background?: boolean diff --git a/src/modules/filemanager/components/FileBrowser/FileBrowserHeader/FileBrowserHeader.tsx b/src/modules/filemanager/components/FileBrowser/FileBrowserHeader/FileBrowserHeader.tsx index c0b7093..73a5767 100644 --- a/src/modules/filemanager/components/FileBrowser/FileBrowserHeader/FileBrowserHeader.tsx +++ b/src/modules/filemanager/components/FileBrowser/FileBrowserHeader/FileBrowserHeader.tsx @@ -3,8 +3,7 @@ import DownIcon from 'remixicon-react/ArrowDownSLineIcon' import { BulkActionsResult } from '../../../hooks/useBulkActions' import { SortDir, SortKey } from '../../../hooks/useSorting' - -import { capitalizeFirstLetter } from '@/modules/filemanager/utils/common' +import { capitalizeFirstLetter } from '../../../utils/common' interface FileBrowserHeaderProps { isSearchMode: boolean diff --git a/src/modules/filemanager/components/PrivateKeyModal/PrivateKeyModal.tsx b/src/modules/filemanager/components/PrivateKeyModal/PrivateKeyModal.tsx index a57d82b..d896316 100644 --- a/src/modules/filemanager/components/PrivateKeyModal/PrivateKeyModal.tsx +++ b/src/modules/filemanager/components/PrivateKeyModal/PrivateKeyModal.tsx @@ -3,6 +3,7 @@ import { ReactElement, useState } from 'react' import CheckDoubleLineIcon from 'remixicon-react/CheckDoubleLineIcon' import ClipboardIcon from 'remixicon-react/FileCopyLineIcon' +import { uuidV4 } from '../../../../utils' import { TOOLTIPS } from '../../constants/tooltips' import { getSigner, setSignerPk } from '../../utils/common' import { Button } from '../Button/Button' @@ -10,8 +11,6 @@ import { Tooltip } from '../Tooltip/Tooltip' import './PrivateKeyModal.scss' -import { uuidV4 } from '@/utils' - type Props = { onSaved: () => void } const generateNewPrivateKey = (): string => { diff --git a/src/modules/filemanager/components/Sidebar/Sidebar.tsx b/src/modules/filemanager/components/Sidebar/Sidebar.tsx index eeae854..55a2538 100644 --- a/src/modules/filemanager/components/Sidebar/Sidebar.tsx +++ b/src/modules/filemanager/components/Sidebar/Sidebar.tsx @@ -305,7 +305,11 @@ export function Sidebar({ setErrorMessage, loading }: SidebarProps): ReactElemen {isDriveCreationInProgress && ( -
Creating drive, please do not reload
+
+ Creating drive — please do not reload the page. +
+ This may take a few minutes. +
)} ) diff --git a/src/modules/filemanager/hooks/useFileFiltering.ts b/src/modules/filemanager/hooks/useFileFiltering.ts index 8fb92b3..ebd2b04 100644 --- a/src/modules/filemanager/hooks/useFileFiltering.ts +++ b/src/modules/filemanager/hooks/useFileFiltering.ts @@ -26,7 +26,7 @@ interface UseFileFilteringReturn { export function useFileFiltering(props: UseFileFilteringProps): UseFileFilteringReturn { const { files, currentDrive, view, isSearchMode, query, scope, includeActive, includeTrashed } = props - const q = query.trim().toLowerCase() + const q = query.trim().toLowerCase().normalize('NFC') const statusIncluded = useCallback( (fi: FileInfo): boolean => { @@ -44,9 +44,11 @@ export function useFileFiltering(props: UseFileFilteringProps): UseFileFiltering const matchesQuery = useCallback( (fi: FileInfo): boolean => { if (!q) return true - const name = fi.name.toLowerCase() - const mime = (fi.customMetadata?.mime || '').toLowerCase() - const topic = String(fi.topic ?? '').toLowerCase() + const name = fi.name.toLowerCase().normalize('NFC') + const mime = (fi.customMetadata?.mime || '').toLowerCase().normalize('NFC') + const topic = String(fi.topic ?? '') + .toLowerCase() + .normalize('NFC') return name.includes(q) || mime.includes(q) || topic.includes(q) }, diff --git a/src/modules/filemanager/utils/GetIconElement.tsx b/src/modules/filemanager/utils/GetIconElement.tsx index 3e93fe7..68aace7 100644 --- a/src/modules/filemanager/utils/GetIconElement.tsx +++ b/src/modules/filemanager/utils/GetIconElement.tsx @@ -2,7 +2,7 @@ import { ReactElement } from 'react' import FileIcon from 'remixicon-react/FileTextLineIcon' import ImageIcon from 'remixicon-react/Image2LineIcon' -import { guessMime } from './view' +import { guessMime } from '../../../utils/file' interface ContextMenuProps { name: string diff --git a/src/modules/filemanager/utils/download.ts b/src/modules/filemanager/utils/download.ts index 151d926..17dcba8 100644 --- a/src/modules/filemanager/utils/download.ts +++ b/src/modules/filemanager/utils/download.ts @@ -1,10 +1,10 @@ import { FileInfo, FileManager } from '@solarpunkltd/file-manager-lib' +import { guessMime, VIEWERS } from '../../../utils/file' import { DownloadProgress, DownloadState } from '../constants/transfers' import { AbortManager } from './abortManager' import { isDirectoryPickerSupported, isPickerSupported } from './fileOperations' -import { guessMime, VIEWERS } from './view' const DefaultDownloadFolder = 'downloads' diff --git a/src/modules/filemanager/utils/view.ts b/src/modules/filemanager/utils/view.ts deleted file mode 100644 index 163cec2..0000000 --- a/src/modules/filemanager/utils/view.ts +++ /dev/null @@ -1,114 +0,0 @@ -const EXT_TO_MIME: Record = { - mp4: 'video/mp4', - webm: 'video/webm', - ogv: 'video/ogg', - mp3: 'audio/mpeg', - m4a: 'audio/mp4', - aac: 'audio/aac', - wav: 'audio/wav', - ogg: 'audio/ogg', - png: 'image/png', - jpg: 'image/jpeg', - jpeg: 'image/jpeg', - gif: 'image/gif', - webp: 'image/webp', - avif: 'image/avif', - svg: 'image/svg+xml', - pdf: 'application/pdf', - txt: 'text/plain', - md: 'text/markdown', - json: 'application/json', - csv: 'text/csv', - html: 'text/html', - htm: 'text/html', -} - -export function getExtensionFromName(name: string): string { - const ext = name.split('.').pop()?.toLowerCase() || '' - const hasExtension = name.includes('.') && ext && ext !== name - - return hasExtension ? ext : '' -} - -export function guessMime(name: string, mtdt?: Record | undefined): { mime: string; ext: string } { - const md = mtdt?.mimeType || mtdt?.mime || mtdt?.['content-type'] - const ext = getExtensionFromName(name) - - if (md) return { mime: md, ext } - - const mime = EXT_TO_MIME[ext] || 'application/octet-stream' - - return { mime, ext } -} - -export type Viewer = { - name: string - test: (mime: string) => boolean - render: (win: Window, url: string, mime: string, name: string) => void -} - -const VIDEO_HTML = (u: string, title: string) => - `${title} - - ` - -const AUDIO_HTML = (u: string, title: string) => - `${title} - - ` - -const IMAGE_HTML = (u: string, title: string) => - `${title} - - ` - -export const VIEWERS: Viewer[] = [ - { - name: 'video', - test: m => m.startsWith('video/'), - render: (w, url, mime, name) => { - w.document.write(VIDEO_HTML(url, name)) - w.document.title = name - }, - }, - { - name: 'audio', - test: m => m.startsWith('audio/'), - render: (w, url, mime, name) => { - w.document.write(AUDIO_HTML(url, name)) - w.document.title = name - }, - }, - { - name: 'image', - test: m => m.startsWith('image/'), - render: (w, url, mime, name) => { - w.document.write(IMAGE_HTML(url, name)) - w.document.title = name - }, - }, - { - name: 'pdf', - test: m => m === 'application/pdf', - render: (w, url, mime, name) => { - w.document.title = name - w.location.href = url - }, - }, - { - name: 'html', - test: m => m === 'text/html', - render: (w, url, mime, name) => { - w.document.title = name - w.location.href = url - }, - }, - { - name: 'text-like', - test: m => m.startsWith('text/') || m === 'application/json' || m === 'text/markdown', - render: (w, url, mime, name) => { - w.document.title = name - w.location.href = url - }, - }, -] diff --git a/src/pages/account/feeds/AccountFeeds.tsx b/src/pages/account/feeds/AccountFeeds.tsx index 6a98f38..acdc07a 100644 --- a/src/pages/account/feeds/AccountFeeds.tsx +++ b/src/pages/account/feeds/AccountFeeds.tsx @@ -104,7 +104,7 @@ export function AccountFeeds(): ReactElement { {x.feedHash && } - viewFeed(x.uuid)} iconType={Info}> + viewFeed(x.uuid)} iconType={Info} disabled={Boolean(!x.feedHash)}> View Feed Page onShowExport(x)} iconType={Download}> diff --git a/src/pages/feeds/CreateNewFeed.tsx b/src/pages/feeds/CreateNewFeed.tsx index 7321bbc..5a78bf3 100644 --- a/src/pages/feeds/CreateNewFeed.tsx +++ b/src/pages/feeds/CreateNewFeed.tsx @@ -1,5 +1,6 @@ -import { NULL_TOPIC } from '@ethersphere/bee-js' +import { NULL_TOPIC, PostageBatch } from '@ethersphere/bee-js' import { Box, Grid, Typography } from '@mui/material' +import { Wallet } from 'ethers' import { Form, Formik } from 'formik' import { useSnackbar } from 'notistack' import { ReactElement, useContext, useState } from 'react' @@ -12,7 +13,7 @@ import ExpandableListItemActions from '../../components/ExpandableListItemAction import ExpandableListItemKey from '../../components/ExpandableListItemKey' import { HistoryHeader } from '../../components/HistoryHeader' import { SwarmButton } from '../../components/SwarmButton' -import { SwarmSelect } from '../../components/SwarmSelect' +import { SelectEvent, SwarmSelect } from '../../components/SwarmSelect' import { SwarmTextInput } from '../../components/SwarmTextInput' import { Context as FeedsContext, IdentityType } from '../../providers/Feeds' import { Context as SettingsContext } from '../../providers/Settings' @@ -34,7 +35,8 @@ const initialValues: FormValues = { export default function CreateNewFeed(): ReactElement { const { beeApi } = useContext(SettingsContext) const { identities, setIdentities } = useContext(FeedsContext) - const [loading, setLoading] = useState(false) + const [identityType, setIdentityType] = useState(IdentityType.PrivateKey) + const [loading, setLoading] = useState(false) const { enqueueSnackbar } = useSnackbar() const navigate = useNavigate() @@ -48,11 +50,24 @@ export default function CreateNewFeed(): ReactElement { return } - const wallet = generateWallet() - const stamps = await beeApi.getPostageBatches() + + let stamps: PostageBatch[] = [] + let wallet: Wallet + + try { + wallet = generateWallet() + stamps = (await beeApi.getPostageBatches()).filter(s => s.usable) + } catch (err) { + // eslint-disable-next-line no-console + console.log(err) + enqueueSnackbar(Error during wallet generation or postage stamp retrieval!, { variant: 'error' }) + setLoading(false) + + return + } if (!stamps || !stamps.length) { - enqueueSnackbar(No stamp available, { variant: 'error' }) + enqueueSnackbar(No usable stamp available, { variant: 'error' }) setLoading(false) return @@ -65,17 +80,29 @@ export default function CreateNewFeed(): ReactElement { return } - const identity = await convertWalletToIdentity(wallet, values.type, values.identityName, values.password) - persistIdentity(identities, identity) - setIdentities(identities) - navigate(ROUTES.ACCOUNT_FEEDS) - setLoading(false) + try { + const identity = await convertWalletToIdentity(wallet, values.type, values.identityName, values.password) + persistIdentity(identities, identity) + setIdentities(identities) + navigate(ROUTES.ACCOUNT_FEEDS) + } catch (err) { + // eslint-disable-next-line no-console + console.log(err) + enqueueSnackbar(Error identity creation!, { variant: 'error' }) + } finally { + setLoading(false) + } } function cancel() { navigate(-1) } + function onIdentityTypeChange(event: SelectEvent) { + const type = event.target.value as IdentityType + setIdentityType(type) + } + return (
Create new feed @@ -102,10 +129,13 @@ export default function CreateNewFeed(): ReactElement { {values.type === IdentityType.V3 && } diff --git a/src/pages/feeds/UpdateFeed.tsx b/src/pages/feeds/UpdateFeed.tsx index dd9a97a..b75718c 100644 --- a/src/pages/feeds/UpdateFeed.tsx +++ b/src/pages/feeds/UpdateFeed.tsx @@ -26,8 +26,8 @@ export default function UpdateFeed(): ReactElement { const { status } = useContext(BeeContext) const { hash } = useParams() - const [selectedStamp, setSelectedStamp] = useState(null) - const [selectedIdentity, setSelectedIdentity] = useState(null) + const [selectedStamp, setSelectedStamp] = useState(stamps ? stamps[0]?.batchID.toHex() : null) + const [selectedIdentity, setSelectedIdentity] = useState(identities[0] ?? null) const [loading, setLoading] = useState(false) const { enqueueSnackbar } = useSnackbar() const [showPasswordPrompt, setShowPasswordPrompt] = useState(false) @@ -119,19 +119,28 @@ export default function UpdateFeed(): ReactElement { Update feed - ({ value: x.uuid, label: `${x.name} Website` }))} - onChange={onFeedChange} - label="Feed" - /> + {identities && identities.length ? ( + ({ value: x.uuid, label: `${x.name} Website` }))} + onChange={onFeedChange} + label="Feed" + /> + ) : ( + You need to create an identiy first to be able to update its feed. + )} - {stamps ? ( + {stamps && stamps.length ? ( ({ value: x.batchID.toHex(), label: x.batchID.toHex().slice(0, 8) }))} + value={selectedStamp ?? ''} + options={stamps.map(x => ({ + value: x.batchID.toHex(), + label: x.label ? x.batchID.toHex().slice(0, 8) + ` (${x.label})` : x.batchID.toHex().slice(0, 8), + }))} onChange={onStampChange} label="Stamp" /> diff --git a/src/pages/feeds/index.tsx b/src/pages/feeds/index.tsx index bb6d548..6cd102c 100644 --- a/src/pages/feeds/index.tsx +++ b/src/pages/feeds/index.tsx @@ -104,7 +104,7 @@ export default function Feeds(): ReactElement { {x.feedHash && } - viewFeed(x.uuid)} iconType={Info}> + viewFeed(x.uuid)} iconType={Info} disabled={Boolean(!x.feedHash)}> View Feed Page onShowExport(x)} iconType={Download}> diff --git a/src/pages/filemanager/index.tsx b/src/pages/filemanager/index.tsx index 699ec03..fa1254b 100644 --- a/src/pages/filemanager/index.tsx +++ b/src/pages/filemanager/index.tsx @@ -1,26 +1,26 @@ import { DriveInfo, FileManagerBase } from '@solarpunkltd/file-manager-lib' import { ReactElement, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' +import { AdminStatusBar } from '../../modules/filemanager/components/AdminStatusBar/AdminStatusBar' +import { Button } from '../../modules/filemanager/components/Button/Button' +import { ConfirmModal } from '../../modules/filemanager/components/ConfirmModal/ConfirmModal' +import { ErrorModal } from '../../modules/filemanager/components/ErrorModal/ErrorModal' +import { FileBrowser } from '../../modules/filemanager/components/FileBrowser/FileBrowser' +import { FormbricksIntegration } from '../../modules/filemanager/components/FormbricksIntegration/FormbricksIntegration' +import { Header } from '../../modules/filemanager/components/Header/Header' +import { InitialModal } from '../../modules/filemanager/components/InitialModal/InitialModal' +import { PrivateKeyModal } from '../../modules/filemanager/components/PrivateKeyModal/PrivateKeyModal' +import { Sidebar } from '../../modules/filemanager/components/Sidebar/Sidebar' +import { getSignerPk, removeSignerPk } from '../../modules/filemanager/utils/common' +import { CheckState, Context as BeeContext } from '../../providers/Bee' +import { Context as FMContext } from '../../providers/FileManager' +import { BrowserPlatform, cacheClearUrls, detectBrowser } from '../../providers/Platform' + import { SearchProvider } from './SearchContext' import { ViewProvider } from './ViewContext' import './FileManager.scss' -import { AdminStatusBar } from '@/modules/filemanager/components/AdminStatusBar/AdminStatusBar' -import { Button } from '@/modules/filemanager/components/Button/Button' -import { ConfirmModal } from '@/modules/filemanager/components/ConfirmModal/ConfirmModal' -import { ErrorModal } from '@/modules/filemanager/components/ErrorModal/ErrorModal' -import { FileBrowser } from '@/modules/filemanager/components/FileBrowser/FileBrowser' -import { FormbricksIntegration } from '@/modules/filemanager/components/FormbricksIntegration/FormbricksIntegration' -import { Header } from '@/modules/filemanager/components/Header/Header' -import { InitialModal } from '@/modules/filemanager/components/InitialModal/InitialModal' -import { PrivateKeyModal } from '@/modules/filemanager/components/PrivateKeyModal/PrivateKeyModal' -import { Sidebar } from '@/modules/filemanager/components/Sidebar/Sidebar' -import { getSignerPk, removeSignerPk } from '@/modules/filemanager/utils/common' -import { CheckState, Context as BeeContext } from '@/providers/Bee' -import { Context as FMContext } from '@/providers/FileManager' -import { BrowserPlatform, cacheClearUrls, detectBrowser } from '@/providers/Platform' - function PrivateKeyModalBlock({ onSaved }: { onSaved: () => void }) { return (
@@ -95,6 +95,22 @@ function LoadingBlock() { ) } +function ChainSyncingBlock() { + return ( +
+
+ +
+ ) +} + function ErrorModalBlock({ onClick, label }: { onClick: () => void; label: string }) { return } @@ -157,6 +173,7 @@ enum PageState { Loading = 'loading', // bee ready, pk present, FM init in progress Reset = 'reset', // STATE_INVALID emitted and user has not yet acknowledged InitError = 'init-error', // FM init completed with an error (non-reset case) + ChainSyncing = 'chain-syncing', // bee node is still syncing postage batch state from chain Initial = 'initial', // FM ready but no admin stamp/drive → show InitialModal AdminError = 'admin-error', // drive creation failed Ready = 'ready', // fully operational @@ -172,7 +189,7 @@ export function FileManagerPage(): ReactElement { const [connectionErrorDismissed, setConnectionErrorDismissed] = useState(false) const [cacheHelpUrl, setCacheHelpUrl] = useState(cacheClearUrls[BrowserPlatform.Chrome]) - const { status } = useContext(BeeContext) + const { status, chainState } = useContext(BeeContext) const { fm, initDone, shallReset, adminDrive, initializationError, notifyPkSaved } = useContext(FMContext) useEffect(() => { @@ -207,6 +224,8 @@ export function FileManagerPage(): ReactElement { }, [isConnectionError]) const pageState = useMemo((): PageState => { + const isChainSyncing = chainState === null + if (!isBeeReady && !initDone) return PageState.Connecting if (!hasPk) return PageState.NoPrivateKey @@ -217,12 +236,15 @@ export function FileManagerPage(): ReactElement { if (initializationError && !shallReset) return PageState.InitError - if (showAdminErrorModal) return PageState.AdminError - const hasAdminStamp = Boolean(fm?.adminStamp) const hasAdminDrive = Boolean(adminDrive) + const setupIncomplete = !hasAdminStamp && !hasAdminDrive - if (!hasAdminStamp && !hasAdminDrive && !isCreationInProgress) return PageState.Initial + if (setupIncomplete && isChainSyncing) return PageState.ChainSyncing + + if (showAdminErrorModal) return PageState.AdminError + + if (setupIncomplete && !isCreationInProgress) return PageState.Initial return PageState.Ready }, [ @@ -236,6 +258,7 @@ export function FileManagerPage(): ReactElement { fm, adminDrive, isCreationInProgress, + chainState, ]) const handlePrivateKeySaved = useCallback(() => { @@ -255,6 +278,10 @@ export function FileManagerPage(): ReactElement { return } + if (pageState === PageState.ChainSyncing) { + return + } + if (pageState === PageState.NoPrivateKey) { return } @@ -289,12 +316,15 @@ export function FileManagerPage(): ReactElement { } if (pageState === PageState.AdminError) { + const adminErrorLabel = + chainState === null + ? 'Your Bee node is still syncing the postage batch state from the chain. Please wait for the sync to complete and try again.' + : errorMessage || + 'Error creating Admin Drive. Please try again. Possible causes include insufficient xDAI balance or a lost connection to the RPC.' + return ( { setAdminShowErrorModal(false) setErrorMessage('') diff --git a/src/pages/files/AssetPreview.tsx b/src/pages/files/AssetPreview.tsx index 797556d..8608d3c 100644 --- a/src/pages/files/AssetPreview.tsx +++ b/src/pages/files/AssetPreview.tsx @@ -8,7 +8,7 @@ import { FitAudio } from '../../components/FitAudio' import { FitImage } from '../../components/FitImage' import { FitVideo } from '../../components/FitVideo' import { shortenText } from '../../utils' -import { getHumanReadableFileSize } from '../../utils/file' +import { getHumanReadableFileSize, guessMime } from '../../utils/file' import { shortenHash } from '../../utils/hash' import { AssetIcon } from './AssetIcon' @@ -18,16 +18,20 @@ interface Props { metadata?: Metadata } -const getPreviewElement = (previewUri?: string, metadata?: Metadata) => { - if (metadata?.isVideo) { +const getPreviewElement = (previewUri?: string, metadata?: Metadata, type?: string) => { + const isVideoType = Boolean(type && /.*\.(mp4|webm|ogv)$/i.test(type)) + const isAudioType = Boolean(type && /.*\.(mp3|ogg|oga|wav|webm|m4a|aac|flac)$/i.test(type)) + const isImageType = Boolean(type && /.*\.(jpg|jpeg|png|gif|webp|svg|ico)$/i.test(type)) + + if (metadata?.isVideo || isVideoType) { return } - if (metadata?.isAudio) { + if (metadata?.isAudio || isAudioType) { return } - if (metadata?.isImage) { + if (metadata?.isImage || isImageType) { return } @@ -42,18 +46,26 @@ const getPreviewElement = (previewUri?: string, metadata?: Metadata) => { return } /> } -const getType = (metadata?: Metadata) => { +export const getType = (metadata?: Metadata): string => { if (metadata?.isWebsite) return 'Website' if (metadata?.type === 'folder') return 'Folder' - return metadata?.type + let metadataType = metadata?.type || 'unknown' + let typeFromExtension: string | undefined + + if (metadataType === 'unknown' && metadata?.name) { + const { mime } = guessMime(metadata.name) + typeFromExtension = mime === 'application/octet-stream' ? 'file' : mime + } + + return typeFromExtension || metadataType } // TODO: add optional prop for indexDocument when it is already known (e.g. downloading a manifest) export function AssetPreview({ metadata, previewUri }: Props): ReactElement | null { - const previewElement = useMemo(() => getPreviewElement(previewUri, metadata), [metadata, previewUri]) const type = useMemo(() => getType(metadata), [metadata]) + const previewElement = useMemo(() => getPreviewElement(previewUri, metadata, type), [metadata, type, previewUri]) return ( diff --git a/src/pages/files/Share.tsx b/src/pages/files/Share.tsx index a391cba..e027a68 100644 --- a/src/pages/files/Share.tsx +++ b/src/pages/files/Share.tsx @@ -16,7 +16,7 @@ import { ROUTES } from '../../routes' import { determineHistoryName, LocalStorageKeys, putHistory } from '../../utils/localStorage' import { loadManifest } from '../../utils/manifest' -import { AssetPreview } from './AssetPreview' +import { AssetPreview, getType } from './AssetPreview' import { AssetSummary } from './AssetSummary' import { AssetSyncing } from './AssetSyncing' import { DownloadActionBar } from './DownloadActionBar' @@ -46,7 +46,7 @@ export function Share(): ReactElement { const count = Object.keys(entries).length const isVideo = Boolean(indexDocument && /.*\.(mp4|webm|ogv)$/i.test(indexDocument)) const isAudio = Boolean(indexDocument && /.*\.(mp3|ogg|oga|wav|webm|m4a|aac|flac)$/i.test(indexDocument)) - const isImage = Boolean(indexDocument && /.*\.(jpg|jpeg|png|gif|webp|svg)$/i.test(indexDocument)) + const isImage = Boolean(indexDocument && /.*\.(jpg|jpeg|png|gif|webp|svg|ico)$/i.test(indexDocument)) if (isImage || isVideo || isAudio) { setPreview(`${apiUrl}/bzz/${hash}`) @@ -54,7 +54,7 @@ export function Share(): ReactElement { setMetadata({ hash, - type: count > 1 ? 'folder' : 'unknown', + type: count > 1 ? 'folder' : getType(), name: indexDocument || hash || '', count, isWebsite: Boolean(indexDocument && /.*\.html?$/i.test(indexDocument)), diff --git a/src/pages/files/Upload.tsx b/src/pages/files/Upload.tsx index 940ecb9..0879b90 100644 --- a/src/pages/files/Upload.tsx +++ b/src/pages/files/Upload.tsx @@ -14,10 +14,10 @@ import { Context as FileContext } from '../../providers/File' import { Context as SettingsContext } from '../../providers/Settings' import { Context as StampsContext, EnrichedPostageBatch } from '../../providers/Stamps' import { ROUTES } from '../../routes' -import { waitUntilStampUsable } from '../../utils' import { detectIndexHtml, getAssetNameFromFiles, packageFile } from '../../utils/file' import { persistIdentity, updateFeed } from '../../utils/identity' import { LocalStorageKeys, putHistory } from '../../utils/localStorage' +import { waitUntilStampUsable } from '../../utils/stamp' import { FeedPasswordDialog } from '../feeds/FeedPasswordDialog' import { PostageStampAdvancedCreation } from '../stamps/PostageStampAdvancedCreation' import { PostageStampSelector } from '../stamps/PostageStampSelector' diff --git a/src/pages/info/WalletInfoCard.tsx b/src/pages/info/WalletInfoCard.tsx index c8c8ca3..5181dd3 100644 --- a/src/pages/info/WalletInfoCard.tsx +++ b/src/pages/info/WalletInfoCard.tsx @@ -1,3 +1,4 @@ +import { BeeModes } from '@ethersphere/bee-js' import { useContext } from 'react' import { useNavigate } from 'react-router' import Upload from 'remixicon-react/UploadLineIcon' @@ -19,7 +20,7 @@ export function WalletInfoCard() { )} xBZZ | ${walletBalance.nativeTokenBalance.toSignificantDigits(4)} xDAI` } - if (nodeInfo?.beeMode && ['light', 'full', 'dev'].includes(nodeInfo.beeMode)) { + if (nodeInfo?.beeMode && [BeeModes.LIGHT, BeeModes.FULL, BeeModes.DEV].includes(nodeInfo.beeMode)) { return ( void @@ -80,7 +82,7 @@ export function PostageStampAdvancedCreation({ onFinished }: Props): ReactElemen } function getPrice(depth: number, amount: bigint): string { - const hasInvalidInput = amount <= 0 || isNaN(depth) || depth < 17 || depth > 255 + const hasInvalidInput = amount <= 0 || isNaN(depth) || depth < MIN_STAMP_DEPTH || depth > MAX_STAMP_DEPTH if (hasInvalidInput) { return '-' @@ -147,38 +149,15 @@ export function PostageStampAdvancedCreation({ onFinished }: Props): ReactElemen setAmountInput(validAmountInput) } - function validateDepthInput(depthInput: string) { - let validDepthInput = '0' - - if (!depthInput) { - setDepthError('Required field') - } else { - const depth = new BigNumber(depthInput) - - if (!depth.isInteger()) { - setDepthError('Depth must be an integer') - } else if (depth.isLessThan(17)) { - setDepthError('Minimal depth is 17') - } else if (depth.isGreaterThan(255)) { - setDepthError('Depth has to be at most 255') - } else { - setDepthError('') - validDepthInput = depthInput - } - } - - setDepthInput(validDepthInput) - } - function renderStampVolumesInfo() { const depth = parseInt(depthInput, 10) - if (depthError || isNaN(depth) || depth < 17 || depth > 255) { + if (depthError || isNaN(depth) || depth < MIN_STAMP_DEPTH || depth > MAX_STAMP_DEPTH) { return '-' } const theoreticalMaximumVolume = getHumanReadableFileSize(Utils.getStampTheoreticalBytes(depth)) - const effectiveVolume = getHumanReadableFileSize(Utils.getStampEffectiveBytes(depth)) + const effectiveVolume = getHumanReadableFileSize(Utils.getStampEffectiveBytes(depth, false, RedundancyLevel.OFF)) return ( @@ -217,7 +196,11 @@ export function PostageStampAdvancedCreation({ onFinished }: Props): ReactElemen - validateDepthInput(event.target.value)} /> + validateDepthInput(event.target.value, setDepthError, setDepthInput)} + /> Corresponding file size @@ -242,7 +225,7 @@ export function PostageStampAdvancedCreation({ onFinished }: Props): ReactElemen setImmutable(event.target.value === 'Yes')} options={[ { value: 'Yes', label: 'Yes' }, diff --git a/src/pages/stamps/PostageStampStandardCreation.tsx b/src/pages/stamps/PostageStampStandardCreation.tsx index 08f12ac..fb8f78e 100644 --- a/src/pages/stamps/PostageStampStandardCreation.tsx +++ b/src/pages/stamps/PostageStampStandardCreation.tsx @@ -1,4 +1,4 @@ -import { Duration, PostageBatchOptions, Size, Utils } from '@ethersphere/bee-js' +import { Duration, RedundancyLevel, Size, Utils } from '@ethersphere/bee-js' import { Box, Button, Grid, Slider, Typography } from '@mui/material' import { useSnackbar } from 'notistack' import { ReactElement, useContext, useState } from 'react' @@ -8,10 +8,12 @@ import { makeStyles } from 'tss-react/mui' import { SwarmButton } from '../../components/SwarmButton' import { SwarmTextInput } from '../../components/SwarmTextInput' +import { Context as BeeContext } from '../../providers/Bee' import { Context as SettingsContext } from '../../providers/Settings' import { Context as StampsContext } from '../../providers/Stamps' import { ROUTES } from '../../routes' import { secondsToTimeString } from '../../utils' +import { validateDepthInput } from '../../utils/stamp' interface Props { onFinished: () => void @@ -48,12 +50,17 @@ export function PostageStampStandardCreation({ onFinished }: Props): ReactElemen const { classes } = useStyles() const { refresh } = useContext(StampsContext) const { beeApi } = useContext(SettingsContext) - + const { chainState } = useContext(BeeContext) const [depthInput, setDepthInput] = useState(Utils.getDepthForSize(Size.fromGigabytes(4))) const [amountInput, setAmountInput] = useState(Utils.getAmountForDuration(Duration.fromDays(30), 26500, 5)) const [labelInput, setLabelInput] = useState('') const [submitting, setSubmitting] = useState(false) const [buttonValue, setButtonValue] = useState(4) + const [depthError, setDepthError] = useState('') + const [sliderValue, setSliderValue] = useState(30) + + const pricePerBlockDefault = 24000 + const currentPrice = chainState?.currentPrice ?? pricePerBlockDefault const getBatchValue = (value: number) => { return ( @@ -74,18 +81,18 @@ export function PostageStampStandardCreation({ onFinished }: Props): ReactElemen if (typeof newValue !== 'number') { return } - const amountValue = Utils.getAmountForDuration(Duration.fromDays(newValue), 26500, 5) + const amountValue = Utils.getAmountForDuration(Duration.fromDays(newValue), currentPrice, 5) + setAmountInput(amountValue) + setSliderValue(newValue) } const { enqueueSnackbar } = useSnackbar() function getTtl(amount: bigint): string { - const pricePerBlock = 24000 - return `${secondsToTimeString( - Utils.getStampDuration(amount, pricePerBlock, 5).toSeconds(), - )} (with price of ${pricePerBlock} PLUR per block)` + Utils.getStampDuration(amount, currentPrice, 5).toSeconds(), + )} (with price of ${currentPrice} PLUR per block)` } function getPrice(depth: number, amount: bigint): string { @@ -106,15 +113,15 @@ export function PostageStampStandardCreation({ onFinished }: Props): ReactElemen } setSubmitting(true) - const amount = BigInt(amountInput) - const depth = depthInput - const options: PostageBatchOptions = { - waitForUsable: false, - label: labelInput || undefined, - immutableFlag: true, - } - await beeApi.createPostageBatch(amount.toString(), depth, options) + await beeApi.buyStorage( + Size.fromGigabytes(buttonValue), + Duration.fromDays(sliderValue), + { label: labelInput, immutableFlag: true }, + undefined, + false, + RedundancyLevel.OFF, + ) await refresh() onFinished() } catch (e) { @@ -127,8 +134,8 @@ export function PostageStampStandardCreation({ onFinished }: Props): ReactElemen function handleBatchSize(gigabytes: number) { setButtonValue(gigabytes) - const capacity = Utils.getDepthForSize(Size.fromGigabytes(gigabytes)) - setDepthInput(capacity) + const capacity = Utils.getDepthForSize(Size.fromGigabytes(gigabytes), false, RedundancyLevel.OFF) + validateDepthInput(String(capacity), setDepthError, (v: string) => setDepthInput(Number(v))) } return ( @@ -162,6 +169,7 @@ export function PostageStampStandardCreation({ onFinished }: Props): ReactElemen {getBatchValue(32)} {getBatchValue(256)} + {depthError && {depthError}} Data persistence @@ -183,11 +191,12 @@ export function PostageStampStandardCreation({ onFinished }: Props): ReactElemen Corresponding TTL (Time to live) {amountInput ? getTtl(amountInput) : '-'} + {amountInput ? getTtl(amountInput) : '-'} - Current price of 24000 PLUR per block + Current price of {currentPrice} PLUR per block @@ -200,7 +209,7 @@ export function PostageStampStandardCreation({ onFinished }: Props): ReactElemen @@ -49,16 +51,18 @@ function StampsTable({ postageStamps }: Props): ReactElement | null { } bee={beeApi} - stamp={stamp.batchID} + stamp={stamp} + status={status.all} /> } bee={beeApi} - stamp={stamp.batchID} + stamp={stamp} + status={status.all} /> diff --git a/src/providers/File.tsx b/src/providers/File.tsx index d0617e9..9d098f3 100644 --- a/src/providers/File.tsx +++ b/src/providers/File.tsx @@ -1,11 +1,10 @@ import { createContext, ReactElement, ReactNode, useEffect, useState } from 'react' import { PREVIEW_DIMENSIONS } from '../constants' +import { FileOrigin } from '../pages/files/FileNavigation' import { getMetadata } from '../utils/file' import { resize } from '../utils/image' -import { FileOrigin } from '@/pages/files/FileNavigation' - export type UploadOrigin = { origin: FileOrigin.Upload | FileOrigin.Feed; uuid?: string } export const defaultUploadOrigin: UploadOrigin = { origin: FileOrigin.Upload } diff --git a/src/utils/chain.ts b/src/utils/chain.ts index 771e440..0b5d95c 100644 --- a/src/utils/chain.ts +++ b/src/utils/chain.ts @@ -1,5 +1,5 @@ import { EthAddress } from '@ethersphere/bee-js' -import { getAddress, JsonRpcProvider, Networkish } from 'ethers' +import { getAddress, JsonRpcPayload, JsonRpcProvider, JsonRpcResult, Networkish } from 'ethers' export const GNOIS_NETWORK_ID = 100 export const GnosisNetwork: Networkish = { chainId: GNOIS_NETWORK_ID, name: 'gnosis', ensAddress: undefined } @@ -39,6 +39,28 @@ export function ethAddressString(address: EthAddress | string): string { return typeof address === 'string' ? getAddress(address) : getAddress(address.toHex()) } -export function newGnosisProvider(url: string): JsonRpcProvider { - return new JsonRpcProvider(url, GnosisNetwork, { staticNetwork: true }) +/** + * Some RPC endpoints always return id:1 in their JSON-RPC responses regardless of the id in the request. + * Ethers v6 validates that response ids match request ids, so we patch them. + */ +class FixedIdJsonRpcProvider extends JsonRpcProvider { + async _send(payload: JsonRpcPayload | Array): Promise> { + const results = await super._send(payload) + const payloads = Array.isArray(payload) ? payload : [payload] + + return results.map((result, i) => ({ ...result, id: payloads[i]?.id ?? result.id })) + } +} + +export function newGnosisProvider(url: string): JsonRpcProvider { + return new FixedIdJsonRpcProvider(url, GnosisNetwork, { staticNetwork: true, batchMaxCount: 1 }) +} + +/** + * Provider for RPC validation only — no staticNetwork so getNetwork() actually + * calls eth_chainId, but still uses FixedIdJsonRpcProvider to handle endpoints + * that return a fixed/wrong id in their responses. + */ +export function newGnosisProviderForValidation(url: string): JsonRpcProvider { + return new FixedIdJsonRpcProvider(url, undefined, { batchMaxCount: 1 }) } diff --git a/src/utils/file.ts b/src/utils/file.ts index 1f504d5..40f5ea9 100644 --- a/src/utils/file.ts +++ b/src/utils/file.ts @@ -137,3 +137,119 @@ export function packageFile(file: FilePath, pathOverwrite?: string): FilePath { bytes: file.bytes, } } + +export function getExtensionFromName(name: string): string { + const ext = name.split('.').pop()?.toLowerCase() || '' + const hasExtension = name.includes('.') && ext && ext !== name + + return hasExtension ? ext : '' +} + +const EXT_TO_MIME: Record = { + mp4: 'video/mp4', + webm: 'video/webm', + ogv: 'video/ogg', + mp3: 'audio/mpeg', + m4a: 'audio/mp4', + aac: 'audio/aac', + wav: 'audio/wav', + ogg: 'audio/ogg', + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + gif: 'image/gif', + webp: 'image/webp', + avif: 'image/avif', + svg: 'image/svg+xml', + pdf: 'application/pdf', + txt: 'text/plain', + md: 'text/markdown', + json: 'application/json', + csv: 'text/csv', + html: 'text/html', + htm: 'text/html', + ico: 'image/vnd.microsoft.icon', +} + +export function guessMime(name: string, mtdt?: Record | undefined): { mime: string; ext: string } { + const md = mtdt?.mimeType || mtdt?.mime || mtdt?.['content-type'] + const ext = getExtensionFromName(name) + + if (md) return { mime: md, ext } + + const mime = EXT_TO_MIME[ext] || 'application/octet-stream' + + return { mime, ext } +} + +export type Viewer = { + name: string + test: (mime: string) => boolean + render: (win: Window, url: string, mime: string, name: string) => void +} + +const VIDEO_HTML = (u: string, title: string) => + `${title} + + ` + +const AUDIO_HTML = (u: string, title: string) => + `${title} + + ` + +const IMAGE_HTML = (u: string, title: string) => + `${title} + + ` + +export const VIEWERS: Viewer[] = [ + { + name: 'video', + test: m => m.startsWith('video/'), + render: (w, url, mime, name) => { + w.document.write(VIDEO_HTML(url, name)) + w.document.title = name + }, + }, + { + name: 'audio', + test: m => m.startsWith('audio/'), + render: (w, url, mime, name) => { + w.document.write(AUDIO_HTML(url, name)) + w.document.title = name + }, + }, + { + name: 'image', + test: m => m.startsWith('image/'), + render: (w, url, mime, name) => { + w.document.write(IMAGE_HTML(url, name)) + w.document.title = name + }, + }, + { + name: 'pdf', + test: m => m === 'application/pdf', + render: (w, url, mime, name) => { + w.document.title = name + w.location.href = url + }, + }, + { + name: 'html', + test: m => m === 'text/html', + render: (w, url, mime, name) => { + w.document.title = name + w.location.href = url + }, + }, + { + name: 'text-like', + test: m => m.startsWith('text/') || m === 'application/json' || m === 'text/markdown', + render: (w, url, mime, name) => { + w.document.title = name + w.location.href = url + }, + }, +] diff --git a/src/utils/identity.ts b/src/utils/identity.ts index 1a34ef3..9b73f03 100644 --- a/src/utils/identity.ts +++ b/src/utils/identity.ts @@ -1,13 +1,14 @@ -import { BatchId, Bee, NULL_TOPIC, PrivateKey, Reference } from '@ethersphere/bee-js' +import { BatchId, Bee, Bytes, NULL_TOPIC, PrivateKey, Reference } from '@ethersphere/bee-js' import { randomBytes, Wallet } from 'ethers' import { Identity, IdentityType } from '../providers/Feeds' import { LocalStorageKeys } from './localStorage' -import { uuidV4, waitUntilStampUsable } from '.' +import { waitUntilStampUsable } from './stamp' +import { uuidV4 } from '.' export function generateWallet(): Wallet { - const privateKey = randomBytes(PrivateKey.LENGTH).toString() + const privateKey = new Bytes(randomBytes(PrivateKey.LENGTH)).toString() return new Wallet(privateKey) } diff --git a/src/utils/index.ts b/src/utils/index.ts index 5d1a8bf..feb7a93 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,4 +1,4 @@ -import { BatchId, Bee, PostageBatch, Reference } from '@ethersphere/bee-js' +import { Reference } from '@ethersphere/bee-js' import { BigNumber } from 'bignumber.js' import { BZZ_LINK_DOMAIN } from '../constants' @@ -207,36 +207,3 @@ export function shortenText(text: string, length = 20, separator = '[…]'): str return `${text.slice(0, length)}${separator}${text.slice(-length)}` } - -const DEFAULT_POLLING_FREQUENCY = 1_000 -const DEFAULT_STAMP_USABLE_TIMEOUT = 5 * 60_000 - -interface Options { - pollingFrequency?: number - timeout?: number -} - -export function waitUntilStampUsable(batchId: BatchId | string, bee: Bee, options?: Options): Promise { - return waitForStamp(batchId, bee, options) -} - -async function waitForStamp(batchId: BatchId | string, bee: Bee, options?: Options): Promise { - const timeout = options?.timeout || DEFAULT_STAMP_USABLE_TIMEOUT - const pollingFrequency = options?.pollingFrequency || DEFAULT_POLLING_FREQUENCY - - for (let i = 0; i < timeout; i += pollingFrequency) { - try { - const stamp = await bee.getPostageBatch(batchId) - - if (stamp.usable) { - return stamp - } - } catch { - // ignore - } - - await sleepMs(pollingFrequency) - } - - throw new Error('Wait until stamp usable timeout has been reached') -} diff --git a/src/utils/rpc.ts b/src/utils/rpc.ts index 67ee665..eb0984c 100644 --- a/src/utils/rpc.ts +++ b/src/utils/rpc.ts @@ -3,10 +3,10 @@ import { debounce } from '@mui/material' import { Contract, JsonRpcProvider, TransactionReceipt, TransactionResponse, Wallet } from 'ethers' import { BZZ_TOKEN_ADDRESS, bzzABI } from './bzzAbi' -import { ethAddressString, newGnosisProvider } from './chain' +import { ethAddressString, newGnosisProvider, newGnosisProviderForValidation } from './chain' async function getNetworkChainId(url: string): Promise { - const provider = newGnosisProvider(url) + const provider = newGnosisProviderForValidation(url) const network = await provider.getNetwork() return network.chainId diff --git a/src/utils/stamp.ts b/src/utils/stamp.ts new file mode 100644 index 0000000..6ed87d3 --- /dev/null +++ b/src/utils/stamp.ts @@ -0,0 +1,63 @@ +import { BatchId, Bee, PostageBatch } from '@ethersphere/bee-js' +import BigNumber from 'bignumber.js' + +import { MAX_STAMP_DEPTH, MIN_STAMP_DEPTH } from '../constants' + +import { sleepMs } from '.' + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function validateDepthInput(depthInput: string, onError: (v: any) => void, onSuccess: (v: any) => void) { + let validDepthInput = '0' + + if (!depthInput) { + onError('Required field') + } else { + const depth = new BigNumber(depthInput) + + if (!depth.isInteger()) { + onError('Depth must be an integer') + } else if (depth.isLessThan(MIN_STAMP_DEPTH)) { + onError(`Minimal depth is ${MIN_STAMP_DEPTH}`) + } else if (depth.isGreaterThan(MAX_STAMP_DEPTH)) { + onError(`Depth has to be at most ${MAX_STAMP_DEPTH}`) + } else { + onError('') + validDepthInput = depthInput + } + } + + onSuccess(validDepthInput) +} + +const DEFAULT_POLLING_FREQUENCY = 1_000 +const DEFAULT_STAMP_USABLE_TIMEOUT = 5 * 60_000 + +interface Options { + pollingFrequency?: number + timeout?: number +} + +export function waitUntilStampUsable(batchId: BatchId | string, bee: Bee, options?: Options): Promise { + return waitForStamp(batchId, bee, options) +} + +async function waitForStamp(batchId: BatchId | string, bee: Bee, options?: Options): Promise { + const timeout = options?.timeout || DEFAULT_STAMP_USABLE_TIMEOUT + const pollingFrequency = options?.pollingFrequency || DEFAULT_POLLING_FREQUENCY + + for (let i = 0; i < timeout; i += pollingFrequency) { + try { + const stamp = await bee.getPostageBatch(batchId) + + if (stamp.usable) { + return stamp + } + } catch { + // ignore + } + + await sleepMs(pollingFrequency) + } + + throw new Error('Wait until stamp usable timeout has been reached') +} diff --git a/vite.config.mts b/vite.config.mts index 84ef7e9..9878e79 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -59,7 +59,7 @@ export default defineConfig(({ mode }) => { plugins: [ react(), nodePolyfills({ - include: ['util', 'buffer'], + include: ['util', 'buffer', 'stream'], globals: { Buffer: true, global: true,