From 635621b04aea7124a99d00f9e31a86983063f5ce Mon Sep 17 00:00:00 2001 From: Cafe137 <77121044+Cafe137@users.noreply.github.com> Date: Thu, 25 Nov 2021 09:54:03 +0100 Subject: [PATCH] feat: improve upload flow (#240) * feat: separate flow for folder and file uploads * feat: add basic index document detection * feat(wip): separate preview step * fix: fix kb and mb units * feat: add post upload summary, add some styling * feat: upload flow * fix: change element order and add conditional rendering * refactor: remove unused variables for now * fix: put back stamp creation to stamp page * refactor: rework postage stamps and grid * feat: add website and folder icons * feat: add asset preview to download flow, add file icon * feat: add basic design to postage stamp selection dialog * feat: add web icon, shorten stamp in preview * feat: extract swarm hash in download flow * fix: extract swarmbutton and solve icon hover and focus color * fix: always show buy button on stamp page * refactor: downgrade * refactor: speed up icon transition * style: improve download buttons * style: change [back to upload] icon * style: add spacing before swarm gateway text * style: post upload summary spacing * refactor: drop verticalspacing and use box * refactor: merge icons to one component * refactor: use conditions instead of weird assignment * docs: explain filter(x => x) * refactor: generalize capacity * refactor: avoid passing arrow functions * refactor: get rid of PaperGridContainer and Container * fix: fix hover color for postage stamps * feat: add disabled and loading state to buttons * fix: make drag and drop work for websites * feat: handle folders and non existing hashes * fix: provide empty default value to select to avoid console warning * style: remove body2 font variants * fix: remove typo * feat: disable folder upload, add website upload * fix: disable showPreviews to avoid flickering * feat(temp): remove folder upload * fix: remove stuck focus on buttons even after rendering different buttons * style: merge hover and focus styles, fix safari text wrap issue * style: remove dropbox outline in safari --- src/components/AlertUploadSize.tsx | 8 +- src/components/Capacity.tsx | 22 +++ src/components/ExpandableElement.tsx | 59 ++++++ src/components/ExpandableListItemActions.tsx | 17 +- src/components/ExpandableListItemInput.tsx | 15 +- src/components/ExpandableListItemKey.tsx | 8 +- src/components/ExpandableListItemLink.tsx | 81 +++++++++ src/components/FitImage.tsx | 30 +++ src/components/StripedWrapper.tsx | 31 ++++ src/components/SwarmButton.tsx | 66 +++++++ src/containers/WithdrawModal.tsx | 7 +- src/pages/files/AssetIcon.tsx | 10 + src/pages/files/AssetPreview.tsx | 95 ++++++++++ src/pages/files/Download.tsx | 75 +++++++- src/pages/files/DownloadActionBar.tsx | 24 +++ src/pages/files/PostUploadSummary.tsx | 38 ++++ src/pages/files/StampPreview.tsx | 21 +++ src/pages/files/Upload.tsx | 171 ++++++------------ src/pages/files/UploadActionBar.tsx | 69 +++++++ src/pages/files/UploadArea.tsx | 119 ++++++++++++ src/pages/stamps/CreatePostageStampModal.tsx | 31 ++-- src/pages/stamps/PostageStamp.tsx | 20 ++ src/pages/stamps/SelectPostageStampModal.tsx | 118 ++++++++++++ src/pages/stamps/StampsTable.tsx | 17 +- src/pages/stamps/index.tsx | 25 ++- .../SetupSteps/DebugConnectionCheck.tsx | 7 +- src/utils/SwarmFile.ts | 24 +++ src/utils/file.test.ts | 57 ++++++ src/utils/file.ts | 51 ++++++ src/utils/index.test.ts | 48 ++++- src/utils/index.ts | 6 + 31 files changed, 1187 insertions(+), 183 deletions(-) create mode 100644 src/components/Capacity.tsx create mode 100644 src/components/ExpandableElement.tsx create mode 100644 src/components/ExpandableListItemLink.tsx create mode 100644 src/components/FitImage.tsx create mode 100644 src/components/StripedWrapper.tsx create mode 100644 src/components/SwarmButton.tsx create mode 100644 src/pages/files/AssetIcon.tsx create mode 100644 src/pages/files/AssetPreview.tsx create mode 100644 src/pages/files/DownloadActionBar.tsx create mode 100644 src/pages/files/PostUploadSummary.tsx create mode 100644 src/pages/files/StampPreview.tsx create mode 100644 src/pages/files/UploadActionBar.tsx create mode 100644 src/pages/files/UploadArea.tsx create mode 100644 src/pages/stamps/PostageStamp.tsx create mode 100644 src/pages/stamps/SelectPostageStampModal.tsx create mode 100644 src/utils/SwarmFile.ts create mode 100644 src/utils/file.test.ts create mode 100644 src/utils/file.ts diff --git a/src/components/AlertUploadSize.tsx b/src/components/AlertUploadSize.tsx index e854e25..18842b6 100644 --- a/src/components/AlertUploadSize.tsx +++ b/src/components/AlertUploadSize.tsx @@ -6,7 +6,7 @@ import { ReactElement } from 'react' const LIMIT = 100_000_000 // 100 megabytes interface Props { - file: File + files: File[] } const useStyles = makeStyles((theme: Theme) => @@ -22,14 +22,16 @@ const useStyles = makeStyles((theme: Theme) => export default function UploadSizeAlert(props: Props): ReactElement | null { const classes = useStyles() - const aboveLimit = props.file.size >= LIMIT + const totalSize = props.files.reduce((previous, current) => previous + current.size, 0) + + const aboveLimit = totalSize >= LIMIT return (
Warning - The file you are trying to upload is above the recommended size. The chunks may not be synchronised properly + The files you are trying to upload are above the recommended size. The chunks may not be synchronised properly over the network.
diff --git a/src/components/Capacity.tsx b/src/components/Capacity.tsx new file mode 100644 index 0000000..d784c43 --- /dev/null +++ b/src/components/Capacity.tsx @@ -0,0 +1,22 @@ +import { ReactElement } from 'react' + +interface Props { + width: string + usage: number +} + +export function Capacity({ width, usage }: Props): ReactElement { + const integerUsage = Math.round(usage * 100) + const used = integerUsage + '%' + const free = 100 - 2 - integerUsage + '%' + + return ( +
+
+
+
+
+
+
+ ) +} diff --git a/src/components/ExpandableElement.tsx b/src/components/ExpandableElement.tsx new file mode 100644 index 0000000..f742362 --- /dev/null +++ b/src/components/ExpandableElement.tsx @@ -0,0 +1,59 @@ +import { Collapse, ListItem } from '@material-ui/core' +import { createStyles, makeStyles, Theme } from '@material-ui/core/styles' +import { ExpandLess, ExpandMore } from '@material-ui/icons' +import { ReactElement, ReactNode, useState } from 'react' + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + root: { + width: '100%', + padding: 0, + margin: 0, + marginTop: theme.spacing(4), + '&:first-child': { + marginTop: 0, + }, + }, + rootLevel1: { marginTop: theme.spacing(1) }, + rootLevel2: { marginTop: theme.spacing(0.5) }, + header: { + backgroundColor: theme.palette.background.paper, + }, + contentLevel0: { + marginTop: theme.spacing(1), + }, + contentLevel12: { + marginTop: theme.spacing(0.25), + }, + infoText: { + color: '#c9c9c9', + }, + }), +) + +interface Props { + children: ReactNode + expandable: ReactNode + defaultOpen?: boolean +} + +export default function ExpandableElement({ children, expandable, defaultOpen }: Props): ReactElement | null { + const classes = useStyles() + const [open, setOpen] = useState(Boolean(defaultOpen)) + + const handleClick = () => { + setOpen(!open) + } + + return ( +
+ + {children} + {open ? : } + + +
{expandable}
+
+
+ ) +} diff --git a/src/components/ExpandableListItemActions.tsx b/src/components/ExpandableListItemActions.tsx index 748954f..8f2ff6f 100644 --- a/src/components/ExpandableListItemActions.tsx +++ b/src/components/ExpandableListItemActions.tsx @@ -1,6 +1,6 @@ -import { ReactElement, ReactNode } from 'react' -import { makeStyles, Theme, createStyles } from '@material-ui/core/styles' import { Grid } from '@material-ui/core' +import { createStyles, makeStyles, Theme } from '@material-ui/core/styles' +import { ReactElement, ReactNode } from 'react' const useStyles = makeStyles((theme: Theme) => createStyles({ @@ -22,11 +22,14 @@ export default function ExpandableListItemActions({ children }: Props): ReactEle if (Array.isArray(children)) { return ( - {children.map((a, i) => ( - - {a} - - ))} + {children + // Exclude falsy values to allow conditional rendering + .filter(x => x) + .map((a, i) => ( + + {a} + + ))} ) } diff --git a/src/components/ExpandableListItemInput.tsx b/src/components/ExpandableListItemInput.tsx index 66f94bc..2b4e224 100644 --- a/src/components/ExpandableListItemInput.tsx +++ b/src/components/ExpandableListItemInput.tsx @@ -1,9 +1,8 @@ -import { ReactElement, ChangeEvent, useState } from 'react' -import { makeStyles, Theme, createStyles } from '@material-ui/core/styles' +import { Button, Grid, IconButton, InputBase, ListItem, Typography } from '@material-ui/core' import Collapse from '@material-ui/core/Collapse' -import { ListItem, Typography, Grid, IconButton, InputBase, Button } from '@material-ui/core' -import { Edit, Minus, RotateCcw, Check } from 'react-feather' - +import { createStyles, makeStyles, Theme } from '@material-ui/core/styles' +import { ChangeEvent, ReactElement, useState } from 'react' +import { Check, Edit, Minus, RotateCcw } from 'react-feather' import ExpandableListItemActions from './ExpandableListItemActions' import ExpandableListItemNote from './ExpandableListItemNote' @@ -55,6 +54,7 @@ interface Props { confirmLabelDisabled?: boolean onChange?: (value: string) => void onConfirm: (value: string) => void + mapperFn?: (value: string) => string } export default function ExpandableListItemKey({ @@ -67,12 +67,17 @@ export default function ExpandableListItemKey({ expandedOnly, helperText, placeholder, + mapperFn, }: Props): ReactElement | null { const classes = useStyles() const [open, setOpen] = useState(Boolean(expandedOnly)) const [inputValue, setInputValue] = useState(value || '') const toggleOpen = () => setOpen(!open) const handleChange = (e: ChangeEvent) => { + if (mapperFn) { + e.target.value = mapperFn(e.target.value) + } + setInputValue(e.target.value) if (onChange) onChange(e.target.value) diff --git a/src/components/ExpandableListItemKey.tsx b/src/components/ExpandableListItemKey.tsx index 7a0208c..c874a41 100644 --- a/src/components/ExpandableListItemKey.tsx +++ b/src/components/ExpandableListItemKey.tsx @@ -1,9 +1,9 @@ -import { ReactElement, useState } from 'react' -import { makeStyles, Theme, createStyles } from '@material-ui/core/styles' +import { Grid, IconButton, ListItem, Tooltip, Typography } from '@material-ui/core' import Collapse from '@material-ui/core/Collapse' -import { ListItem, Typography, Grid, IconButton, Tooltip } from '@material-ui/core' -import { Eye, Minus } from 'react-feather' +import { createStyles, makeStyles, Theme } from '@material-ui/core/styles' +import { ReactElement, useState } from 'react' import { CopyToClipboard } from 'react-copy-to-clipboard' +import { Eye, Minus } from 'react-feather' const useStyles = makeStyles((theme: Theme) => createStyles({ diff --git a/src/components/ExpandableListItemLink.tsx b/src/components/ExpandableListItemLink.tsx new file mode 100644 index 0000000..706472f --- /dev/null +++ b/src/components/ExpandableListItemLink.tsx @@ -0,0 +1,81 @@ +import { Grid, IconButton, ListItem, Tooltip, Typography } from '@material-ui/core' +import { createStyles, makeStyles, Theme } from '@material-ui/core/styles' +import { OpenInNewSharp } from '@material-ui/icons' +import { ReactElement, useState } from 'react' +import CopyToClipboard from 'react-copy-to-clipboard' + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + header: { + backgroundColor: theme.palette.background.paper, + marginBottom: theme.spacing(0.25), + borderLeft: `${theme.spacing(0.25)}px solid rgba(0,0,0,0)`, + wordBreak: 'break-word', + }, + headerOpen: { + borderLeft: `${theme.spacing(0.25)}px solid ${theme.palette.primary.main}`, + }, + openLinkIcon: { + cursor: 'pointer', + padding: theme.spacing(1), + borderRadius: 0, + '&:hover': { + backgroundColor: '#fcf2e8', + color: theme.palette.primary.main, + }, + }, + content: { + marginTop: theme.spacing(2), + marginBottom: theme.spacing(2), + }, + keyMargin: { + marginRight: theme.spacing(1), + }, + copyValue: { + cursor: 'pointer', + padding: theme.spacing(1), + borderRadius: 0, + '&:hover': { + backgroundColor: '#fcf2e8', + color: theme.palette.primary.main, + }, + }, + }), +) + +interface Props { + label: string + value: string +} + +export default function ExpandableListItemLink({ label, value }: Props): ReactElement | null { + const classes = useStyles() + const [copied, setCopied] = useState(false) + + const tooltipClickHandler = () => setCopied(true) + const tooltipCloseHandler = () => setCopied(false) + + return ( + + + + {label && {label}} + +
+ + + + {value.slice(0, 19)}... + + + + + window.open(value)} strokeWidth={1} /> + +
+
+
+
+
+ ) +} diff --git a/src/components/FitImage.tsx b/src/components/FitImage.tsx new file mode 100644 index 0000000..460fcdd --- /dev/null +++ b/src/components/FitImage.tsx @@ -0,0 +1,30 @@ +import { createStyles, makeStyles } from '@material-ui/core' +import { ReactElement } from 'react' + +const useStyles = makeStyles(() => + createStyles({ + image: { + width: '100%', + height: '100%', + objectFit: 'cover', + }, + }), +) + +interface Props { + alt: string + src: string | undefined + maxHeight?: string + maxWidth?: string +} + +export function FitImage(props: Props): ReactElement { + const classes = useStyles() + + const inlineStyles: Record = {} + + props.maxHeight && (inlineStyles.maxHeight = props.maxHeight) + props.maxWidth && (inlineStyles.maxWidth = props.maxWidth) + + return {props.alt} +} diff --git a/src/components/StripedWrapper.tsx b/src/components/StripedWrapper.tsx new file mode 100644 index 0000000..30dbbf8 --- /dev/null +++ b/src/components/StripedWrapper.tsx @@ -0,0 +1,31 @@ +import { createStyles, makeStyles } from '@material-ui/core' +import { ReactElement } from 'react' + +interface Props { + children: ReactElement | ReactElement[] +} + +const useStyles = makeStyles(() => + createStyles({ + wrapper: { + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + width: '175px', + height: '175px', + background: `repeating-linear-gradient( + 45deg, + #efefef, + #efefef 4px, + #ffffff 4px, + #ffffff 8px + )`, + }, + }), +) + +export function StripedWrapper({ children }: Props): ReactElement { + const classes = useStyles() + + return
{children}
+} diff --git a/src/components/SwarmButton.tsx b/src/components/SwarmButton.tsx new file mode 100644 index 0000000..377e5c0 --- /dev/null +++ b/src/components/SwarmButton.tsx @@ -0,0 +1,66 @@ +import { Button, CircularProgress, createStyles, makeStyles } from '@material-ui/core' +import React, { ReactElement } from 'react' +import { IconProps } from 'react-feather' + +interface Props { + onClick: () => void + iconType: React.ComponentType + children: string + className?: string + disabled?: boolean + loading?: boolean +} + +const useStyles = makeStyles(() => + createStyles({ + button: { + position: 'relative', + whiteSpace: 'nowrap', + '&:hover, &:focus': { + '& svg': { + stroke: '#fff', + transition: '0.1s', + }, + }, + }, + spinnerWrapper: { + position: 'absolute', + left: '50%', + top: '50%', + width: '40px', + height: '40px', + transform: 'translate(-50%, -50%)', + }, + }), +) + +export function SwarmButton({ children, onClick, iconType, className, disabled, loading }: Props): ReactElement { + const classes = useStyles() + + const icon = React.createElement(iconType, { + size: '1.25rem', + color: disabled ? 'rgba(0, 0, 0, 0.26)' : '#dd7700', + }) + + const classNames = className ? [className, classes.button].join(' ') : classes.button + + return ( + + ) +} diff --git a/src/containers/WithdrawModal.tsx b/src/containers/WithdrawModal.tsx index ce9bbf3..9f2fc55 100644 --- a/src/containers/WithdrawModal.tsx +++ b/src/containers/WithdrawModal.tsx @@ -1,16 +1,15 @@ +import { BigNumber } from 'bignumber.js' import { ReactElement, useContext } from 'react' import { Upload } from 'react-feather' -import { Context as SettingsContext } from '../providers/Settings' - import WithdrawDepositModal from '../components/WithdrawDepositModal' -import { BigNumber } from 'bignumber.js' +import { Context as SettingsContext } from '../providers/Settings' export default function WithdrawModal(): ReactElement { const { beeDebugApi } = useContext(SettingsContext) return ( {icon} +} diff --git a/src/pages/files/AssetPreview.tsx b/src/pages/files/AssetPreview.tsx new file mode 100644 index 0000000..ea8fa9d --- /dev/null +++ b/src/pages/files/AssetPreview.tsx @@ -0,0 +1,95 @@ +import { Box, Grid, Typography } from '@material-ui/core' +import { Web } from '@material-ui/icons' +import { ReactElement, useEffect, useState } from 'react' +import { File, Folder } from 'react-feather' +import { FitImage } from '../../components/FitImage' +import { detectIndexHtml, getHumanReadableFileSize } from '../../utils/file' +import { SwarmFile } from '../../utils/SwarmFile' +import { AssetIcon } from './AssetIcon' + +interface Props { + files: SwarmFile[] +} + +export function AssetPreview({ files }: Props): ReactElement { + const [previewComponent, setPreviewComponent] = useState(undefined) + const [previewUri, setPreviewUri] = useState(undefined) + + useEffect(() => { + if (files.length === 1) { + // single image + if (files[0].type.startsWith('image/')) { + files[0].arrayBuffer().then(value => { + const blob = new Blob([value]) + setPreviewUri(URL.createObjectURL(blob)) + }) + // single non-image + } else { + setPreviewUri(undefined) + setPreviewComponent(} />) + } + // collection + } else if (detectIndexHtml(files)) { + setPreviewUri(undefined) + setPreviewComponent(} />) + } else { + setPreviewUri(undefined) + setPreviewComponent(} />) + } + }, [files]) + + const getPrimaryText = () => { + if (files.length === 1) { + return 'Filename: ' + files[0].name + } + + return 'Folder name: ' + files[0].path.split('/')[0] + } + + const getKind = () => { + if (files.length === 1) { + return files[0].type + } + + if (detectIndexHtml(files)) { + return 'Website' + } + + return 'Folder' + } + + const isFolder = () => ['Folder', 'Website'].includes(getKind()) + + const getSize = () => { + const bytes = files.reduce((total, item) => total + item.size, 0) + + return getHumanReadableFileSize(bytes) + } + + return ( + + + + {previewComponent ? ( + previewComponent + ) : ( + + )} + + {getPrimaryText()} + Kind: {getKind()} + Size: {getSize()} + + + + {isFolder() && ( + + + Folder content + {files.length} items + + + )} + + ) +} diff --git a/src/pages/files/Download.tsx b/src/pages/files/Download.tsx index c2994be..7bcd232 100644 --- a/src/pages/files/Download.tsx +++ b/src/pages/files/Download.tsx @@ -1,28 +1,91 @@ -import { ReactElement, useState, useContext } from 'react' -import { Context as SettingsContext } from '../../providers/Settings' -import ExpandableListItemInput from '../../components/ExpandableListItemInput' import { Utils } from '@ethersphere/bee-js' +import { Box } from '@material-ui/core' +import { useSnackbar } from 'notistack' +import { ReactElement, useContext, useState } from 'react' +import ExpandableListItemInput from '../../components/ExpandableListItemInput' +import { Context as SettingsContext } from '../../providers/Settings' +import { extractSwarmHash } from '../../utils' +import { convertBeeFileToBrowserFile } from '../../utils/file' +import { SwarmFile } from '../../utils/SwarmFile' +import { AssetPreview } from './AssetPreview' +import { DownloadActionBar } from './DownloadActionBar' export default function Files(): ReactElement { - const { apiUrl } = useContext(SettingsContext) + const { apiUrl, beeApi } = useContext(SettingsContext) + const [reference, setReference] = useState('') const [referenceError, setReferenceError] = useState(undefined) + const [downloadedFile, setDownloadedFile] = useState | null>(null) + + const { enqueueSnackbar } = useSnackbar() const validateChange = (value: string) => { if (Utils.isHexString(value, 64) || Utils.isHexString(value, 128)) setReferenceError(undefined) else setReferenceError('Incorrect format of swarm hash. Expected 64 or 128 hexstring characters.') } + function onDownload() { + window.open(`${apiUrl}/bzz/${reference}/`, '_blank') + } + + async function onSwarmIdentifier(identifier: string) { + if (!beeApi) { + return + } + setReference(identifier) + try { + const response = await beeApi.downloadFile(identifier) + setDownloadedFile(convertBeeFileToBrowserFile(response)) + } catch (error: unknown) { + let message = typeof error === 'object' && error !== null && Reflect.get(error, 'message') + + if (message.includes('path address not found')) { + message = 'The specified hash does not have an index document set.' + } + + if (message.includes('Not Found: Not Found')) { + message = 'The specified hash was not found.' + } + enqueueSnackbar(Error: {message || 'Unknown'}, { variant: 'error' }) + } + } + + if (downloadedFile) { + return ( + <> + + + + setDownloadedFile(null)} onDownload={onDownload} /> + + ) + } + + function recognizeSwarmHash(value: string) { + if (value.length < 64) { + return value + } + + const hash = extractSwarmHash(value) + + if (hash) { + return hash + } + + return value + } + return ( window.open(`${apiUrl}/bzz/${value}`, '_blank')} + onConfirm={value => onSwarmIdentifier(value)} onChange={validateChange} helperText={referenceError} - confirmLabel={'Download'} + confirmLabel={'Search'} confirmLabelDisabled={Boolean(referenceError)} placeholder="e.g. 31fb0362b1a42536134c86bc58b97ac0244e5c6630beec3e27c2d1cecb38c605" expandedOnly + mapperFn={value => recognizeSwarmHash(value)} /> ) } diff --git a/src/pages/files/DownloadActionBar.tsx b/src/pages/files/DownloadActionBar.tsx new file mode 100644 index 0000000..71252c1 --- /dev/null +++ b/src/pages/files/DownloadActionBar.tsx @@ -0,0 +1,24 @@ +import { Button } from '@material-ui/core' +import { Clear } from '@material-ui/icons' +import { ReactElement } from 'react' +import { Download } from 'react-feather' +import ExpandableListItemActions from '../../components/ExpandableListItemActions' +import { SwarmButton } from '../../components/SwarmButton' + +interface Props { + onDownload: () => void + onCancel: () => void +} + +export function DownloadActionBar({ onDownload, onCancel }: Props): ReactElement { + return ( + + + Download This File + + + + ) +} diff --git a/src/pages/files/PostUploadSummary.tsx b/src/pages/files/PostUploadSummary.tsx new file mode 100644 index 0000000..09fbddd --- /dev/null +++ b/src/pages/files/PostUploadSummary.tsx @@ -0,0 +1,38 @@ +import { Box, Typography } from '@material-ui/core' +import { ReactElement } from 'react' +import { CornerUpLeft } from 'react-feather' +import ExpandableListItemActions from '../../components/ExpandableListItemActions' +import ExpandableListItemKey from '../../components/ExpandableListItemKey' +import ExpandableListItemLink from '../../components/ExpandableListItemLink' +import { SwarmButton } from '../../components/SwarmButton' + +interface Props { + uploadReference: string + onUploadNewClick: () => void +} + +export function PostUploadSummary({ uploadReference, onUploadNewClick }: Props): ReactElement { + return ( + <> + + + + + + + + Back to Upload + + + + + The Swarm Gateway is graciously provided by the Swarm Foundation. This service is under development and provided + for testing purposes only. Learn more at{' '} + https://gateway.ethswarm.org/. + + + ) +} diff --git a/src/pages/files/StampPreview.tsx b/src/pages/files/StampPreview.tsx new file mode 100644 index 0000000..7e08c53 --- /dev/null +++ b/src/pages/files/StampPreview.tsx @@ -0,0 +1,21 @@ +import { Box, Typography } from '@material-ui/core' +import { ReactElement } from 'react' +import { EnrichedPostageBatch } from '../../providers/Stamps' +import { PostageStamp } from '../stamps/PostageStamp' + +interface Props { + stamp: EnrichedPostageBatch +} + +export function StampPreview({ stamp }: Props): ReactElement { + return ( + + + Associated postage stamp: + + + + + + ) +} diff --git a/src/pages/files/Upload.tsx b/src/pages/files/Upload.tsx index c19d3fc..fd4f57b 100644 --- a/src/pages/files/Upload.tsx +++ b/src/pages/files/Upload.tsx @@ -1,19 +1,17 @@ -import { Button, CircularProgress, Container, Avatar, Chip, Typography } from '@material-ui/core' -import { makeStyles, Theme, createStyles } from '@material-ui/core/styles' -import { DropzoneArea } from 'material-ui-dropzone' +import { createStyles, makeStyles, Theme } from '@material-ui/core/styles' import { useSnackbar } from 'notistack' -import { RotateCcw, Check } from 'react-feather' import { ReactElement, useContext, useEffect, useState } from 'react' -import UploadSizeAlert from '../../components/AlertUploadSize' -import ClipboardCopy from '../../components/ClipboardCopy' -import { Context, EnrichedPostageBatch } from '../../providers/Stamps' import { Context as SettingsContext } from '../../providers/Settings' -import CreatePostageStamp from '../stamps/CreatePostageStampModal' -import SelectStamp from './SelectStamp' -import ExpandableListItem from '../../components/ExpandableListItem' -import ExpandableListItemKey from '../../components/ExpandableListItemKey' -import ExpandableListItemNote from '../../components/ExpandableListItemNote' -import ExpandableListItemActions from '../../components/ExpandableListItemActions' +import { Context, EnrichedPostageBatch } from '../../providers/Stamps' +import { detectIndexHtml } from '../../utils/file' +import { SwarmFile } from '../../utils/SwarmFile' +import { CreatePostageStampModal } from '../stamps/CreatePostageStampModal' +import { SelectPostageStampModal } from '../stamps/SelectPostageStampModal' +import { AssetPreview } from './AssetPreview' +import { PostUploadSummary } from './PostUploadSummary' +import { StampPreview } from './StampPreview' +import { UploadActionBar } from './UploadActionBar' +import { UploadArea } from './UploadArea' const useStyles = makeStyles((theme: Theme) => createStyles({ @@ -27,13 +25,14 @@ const MAX_FILE_SIZE = 1_000_000_000 // 1 gigabyte export default function Files(): ReactElement { const classes = useStyles() const [dropzoneKey, setDropzoneKey] = useState(0) - const [file, setFile] = useState(null) + const [files, setFiles] = useState([]) const [uploadReference, setUploadReference] = useState('') - const [isUploadingFile, setIsUploadingFile] = useState(false) + const [isBuyingStamp, setBuyingStamp] = useState(false) + const [isSelectingStamp, setSelectingStamp] = useState(false) + const [stamp, setStamp] = useState(null) + const [isUploading, setUploading] = useState(false) - const [selectedStamp, setSelectedStamp] = useState(null) - - const { isLoading, error, stamps, refresh } = useContext(Context) + const { stamps, refresh } = useContext(Context) const { beeApi } = useContext(SettingsContext) const { enqueueSnackbar } = useSnackbar() @@ -41,123 +40,69 @@ export default function Files(): ReactElement { refresh() }, []) // eslint-disable-line react-hooks/exhaustive-deps - // Choose a postage stamp that has the lowest usage - useEffect(() => { - if (!selectedStamp && stamps && stamps.length > 0) { - const stamp = stamps.reduce((prev, curr) => { - if (curr.usage < prev.usage) return curr - - return prev - }, stamps[0]) - - setSelectedStamp(stamp) + const uploadFiles = () => { + if (!beeApi || !files.length || !stamp) { + return } - }, [isLoading, error, stamps, selectedStamp]) - const uploadFile = () => { - if (file === null || selectedStamp === null) return + const indexDocument = files.length === 1 ? files[0].name : detectIndexHtml(files) || undefined - if (!beeApi) return + setUploading(true) - setIsUploadingFile(true) beeApi - .uploadFile(selectedStamp.batchID, file) + .uploadFiles(stamp.batchID, files as unknown as File[], { indexDocument }) .then(hash => setUploadReference(hash.reference)) .catch(e => enqueueSnackbar(`Error uploading: ${e.message}`, { variant: 'error' })) - .finally(() => setIsUploadingFile(false)) + .finally(() => setUploading(false)) + } + + const reset = () => { + setFiles([]) + setStamp(null) + setUploading(false) } const uploadNew = () => { setTimeout(() => { - setFile(null) + reset() setDropzoneKey(dropzoneKey + 1) setUploadReference('') }, 0) } - const handleChange = (files?: File[]) => { - setUploadReference('') - - if (files) { - setFile(files[0]) - } - } - return ( <> - + {files.length ? ( + + ) : ( + + )} + {stamp !== null && !uploadReference ? : null} + {files.length && !uploadReference ? ( + 0} + hasSelectedStamp={stamp !== null} + onCancel={reset} + onBuy={() => setBuyingStamp(true)} + onSelect={() => setSelectingStamp(true)} + onUpload={uploadFiles} + onClearStamp={() => setStamp(null)} + isUploading={isUploading} + /> + ) : null}
- {/* We have file and can upload display stamp selection */} - {file && !isUploadingFile && !uploadReference && ( - <> - - To upload this file to your node, you need a postage stamp. You can buy a new one or you can use an - existing stamp (providing it’s sufficient for this file). - - {selectedStamp && ( - - Upload with Postage Stamp{' '} - {selectedStamp.usageText}} - label={{selectedStamp.batchID.substr(0, 8)}[…]} - deleteIcon={} - onDelete={() => {} /* eslint-disable-line*/} - variant="outlined" - /> - - } - value={} - /> - )} - {!selectedStamp && ( - - - - )} - - )} - - {/* We have file and can upload display upload button */} - {file && !uploadReference && ( - <> - - - {isUploadingFile && ( - - - - )} - - - - )} - - {/* File has already been uploaded */} {uploadReference && ( - <> - - - - - + uploadNew()} uploadReference={uploadReference} /> )}
+ {isBuyingStamp ? setBuyingStamp(false)} /> : null} + {stamps && isSelectingStamp ? ( + setSelectingStamp(false)} + onSelect={stamp => setStamp(stamp)} + /> + ) : null} ) } diff --git a/src/pages/files/UploadActionBar.tsx b/src/pages/files/UploadActionBar.tsx new file mode 100644 index 0000000..bbda2a5 --- /dev/null +++ b/src/pages/files/UploadActionBar.tsx @@ -0,0 +1,69 @@ +import { Button, Typography } from '@material-ui/core' +import { Clear } from '@material-ui/icons' +import { ReactElement } from 'react' +import { Check, Layers, PlusSquare, RefreshCcw } from 'react-feather' +import ExpandableListItemActions from '../../components/ExpandableListItemActions' +import { SwarmButton } from '../../components/SwarmButton' + +interface Props { + canSelectStamp: boolean + hasSelectedStamp: boolean + onUpload: () => void + onBuy: () => void + onSelect: () => void + onCancel: () => void + onClearStamp: () => void + isUploading: boolean +} + +export function UploadActionBar({ + canSelectStamp, + hasSelectedStamp, + onUpload, + onBuy, + onSelect, + onCancel, + onClearStamp, + isUploading, +}: Props): ReactElement { + const showBuy = !hasSelectedStamp + const showSelect = canSelectStamp && !hasSelectedStamp + const showUpload = hasSelectedStamp + const showChange = canSelectStamp && hasSelectedStamp + + return ( + <> + + {showBuy ? ( + + Buy New Postage Stamp + + ) : null} + {showSelect ? ( + + Use Existing Postage Stamp + + ) : null} + {showUpload ? ( + + Upload To Your Node + + ) : null} + {showChange ? ( + + Change Postage Stamp + + ) : null} + + + {showSelect ? ( + + You need a postage stamp to upload. Please refer to the official Bee documentation to understand how postage + stamps work. + + ) : null} + + ) +} diff --git a/src/pages/files/UploadArea.tsx b/src/pages/files/UploadArea.tsx new file mode 100644 index 0000000..5b0736d --- /dev/null +++ b/src/pages/files/UploadArea.tsx @@ -0,0 +1,119 @@ +import { createStyles, makeStyles, Theme, Typography } from '@material-ui/core' +import { DropzoneArea } from 'material-ui-dropzone' +import { useSnackbar } from 'notistack' +import { ReactElement } from 'react' +import { FilePlus } from 'react-feather' +import { SwarmButton } from '../../components/SwarmButton' +import { detectIndexHtml } from '../../utils/file' +import { SwarmFile } from '../../utils/SwarmFile' + +interface Props { + setFiles: (files: SwarmFile[]) => void + maximumSizeInBytes: number +} + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + areaWrapper: { position: 'relative', marginBottom: theme.spacing(2) }, + dropzone: { + background: theme.palette.background.default, + outline: 'none', + color: 'transparent', + zIndex: 1, + '& svg': { + opacity: 0, + }, + }, + buttonWrapper: { + top: '0', + left: '0', + position: 'absolute', + display: 'flex', + width: '100%', + height: '100%', + justifyContent: 'center', + alignItems: 'center', + }, + button: { + marginLeft: theme.spacing(0.5), + marginRight: theme.spacing(0.5), + zIndex: 2, + }, + }), +) + +export function UploadArea({ setFiles, maximumSizeInBytes }: Props): ReactElement { + const classes = useStyles() + + const { enqueueSnackbar } = useSnackbar() + + const getDropzoneInputDomElement = () => document.querySelector('.MuiDropzoneArea-root input') as HTMLInputElement + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const onUploadFolderClick = () => { + const element = getDropzoneInputDomElement() + + if (element) { + element.setAttribute('directory', '') + element.setAttribute('webkitdirectory', '') + element.setAttribute('mozdirectory', '') + element.click() + } + } + + const onUploadFileClick = () => { + const element = getDropzoneInputDomElement() + + if (element) { + element.removeAttribute('directory') + element.removeAttribute('webkitdirectory') + element.removeAttribute('mozdirectory') + element.click() + } + } + + const resetComponentOnAddingInvalidContent = (files: SwarmFile[]) => { + setFiles(files) + setTimeout(() => { + setFiles([]) + }, 0) + } + + const handleChange = (files?: File[]) => { + if (files) { + const swarmFiles = files.map(x => new SwarmFile(x)) + const indexDocument = files.length === 1 ? files[0].name : detectIndexHtml(swarmFiles) || undefined + + if (files.length && !indexDocument) { + enqueueSnackbar('To upload a website, there must be an index.html or index.htm in the root of the folder.', { + variant: 'error', + }) + resetComponentOnAddingInvalidContent(swarmFiles) + + return + } + + setFiles(swarmFiles) + } + } + + return ( + <> +
+ +
+ + Add File + +
+
+ You can click the button above or simply drag and drop to add a file. + + ) +} diff --git a/src/pages/stamps/CreatePostageStampModal.tsx b/src/pages/stamps/CreatePostageStampModal.tsx index fd2db13..5dd2faf 100644 --- a/src/pages/stamps/CreatePostageStampModal.tsx +++ b/src/pages/stamps/CreatePostageStampModal.tsx @@ -47,16 +47,13 @@ const useStyles = makeStyles((theme: Theme) => ) interface Props { - label?: string + onClose: () => void } -export default function FormDialog({ label }: Props): ReactElement { +export function CreatePostageStampModal({ onClose }: Props): ReactElement { const classes = useStyles() - const [open, setOpen] = React.useState(false) const { refresh } = useContext(Context) const { beeDebugApi } = useContext(SettingsContext) - const handleClickOpen = () => setOpen(true) - const handleClose = () => setOpen(false) const { enqueueSnackbar } = useSnackbar() return ( @@ -75,7 +72,7 @@ export default function FormDialog({ label }: Props): ReactElement { await beeDebugApi.createPostageBatch(amount.toString(), depth, options) actions.resetForm() await refresh() - handleClose() + onClose() } catch (e) { enqueueSnackbar(`Error: ${(e as Error).message}`, { variant: 'error' }) actions.setSubmitting(false) @@ -111,20 +108,9 @@ export default function FormDialog({ label }: Props): ReactElement { > {({ submitForm, isValid, isSubmitting, values }) => (
- - - Purchase new postage stamp + + Buy new postage stamp - - Provide the depth, amount and optionally the label of the postage stamp. Please refer to the{' '} - - official bee docs - {' '} - to understand these values. - -
@@ -153,6 +139,11 @@ export default function FormDialog({ label }: Props): ReactElement {
+ + + Please refer to the official Bee documentation to understand these values. + +
)} diff --git a/src/pages/stamps/PostageStamp.tsx b/src/pages/stamps/PostageStamp.tsx new file mode 100644 index 0000000..880e6af --- /dev/null +++ b/src/pages/stamps/PostageStamp.tsx @@ -0,0 +1,20 @@ +import { Box, Grid, Typography } from '@material-ui/core' +import { ReactElement } from 'react' +import { Capacity } from '../../components/Capacity' +import { EnrichedPostageBatch } from '../../providers/Stamps' + +interface Props { + stamp: EnrichedPostageBatch + shorten?: boolean +} + +export function PostageStamp({ stamp, shorten }: Props): ReactElement { + return ( + + + {shorten ? stamp.batchID.slice(0, 8) : stamp.batchID} + + + + ) +} diff --git a/src/pages/stamps/SelectPostageStampModal.tsx b/src/pages/stamps/SelectPostageStampModal.tsx new file mode 100644 index 0000000..55190b2 --- /dev/null +++ b/src/pages/stamps/SelectPostageStampModal.tsx @@ -0,0 +1,118 @@ +import { Box, createStyles, FormControl, makeStyles, MenuItem, Select, Theme, Typography } from '@material-ui/core' +import Button from '@material-ui/core/Button' +import Dialog from '@material-ui/core/Dialog' +import DialogContent from '@material-ui/core/DialogContent' +import DialogTitle from '@material-ui/core/DialogTitle' +import { Check, Clear } from '@material-ui/icons' +import React, { ReactElement, useState } from 'react' +import ExpandableListItemActions from '../../components/ExpandableListItemActions' +import { EnrichedPostageBatch } from '../../providers/Stamps' + +interface Props { + stamps: EnrichedPostageBatch[] + onSelect: (stamp: EnrichedPostageBatch) => void + onClose: () => void +} + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + dialog: { + background: theme.palette.background.default, + borderRadius: 0, + width: '100%', + maxWidth: '890px', + }, + title: { + color: '#606060', + textAlign: 'center', + }, + select: { + background: theme.palette.background.paper, + borderRadius: 0, + border: 0, + }, + option: { + height: '52px', + }, + hint: { + marginBottom: '16px', + }, + }), +) + +export function SelectPostageStampModal({ stamps, onSelect, onClose }: Props): ReactElement { + const [selectedStamp, setSelectedStamp] = useState(null) + + const classes = useStyles() + + function onChange(stampId: string) { + const stamp = stamps.find(x => x.batchID === stampId) + + if (stamp) { + setSelectedStamp(stamp) + } + } + + function onFinish() { + if (selectedStamp) { + onSelect(selectedStamp) + onClose() + } + } + + return ( + + + Select postage stamp + + + + + + + + + + + + + + + + + Please refer to the{' '} + + official Bee documentation + {' '} + to understand these values. + + + + ) +} diff --git a/src/pages/stamps/StampsTable.tsx b/src/pages/stamps/StampsTable.tsx index 4c5758f..4f61b7f 100644 --- a/src/pages/stamps/StampsTable.tsx +++ b/src/pages/stamps/StampsTable.tsx @@ -1,8 +1,9 @@ import type { ReactElement } from 'react' -import { EnrichedPostageBatch } from '../../providers/Stamps' +import ExpandableElement from '../../components/ExpandableElement' import ExpandableList from '../../components/ExpandableList' -import ExpandableListItem from '../../components/ExpandableListItem' import ExpandableListItemKey from '../../components/ExpandableListItemKey' +import { EnrichedPostageBatch } from '../../providers/Stamps' +import { PostageStamp } from './PostageStamp' interface Props { postageStamps: EnrichedPostageBatch[] | null @@ -13,11 +14,13 @@ function StampsTable({ postageStamps }: Props): ReactElement | null { return ( - {postageStamps.map(({ batchID, usageText }) => ( - - - - + {postageStamps.map(stamp => ( + } + > + + ))} ) diff --git a/src/pages/stamps/index.tsx b/src/pages/stamps/index.tsx index 49ea2f4..65cdb14 100644 --- a/src/pages/stamps/index.tsx +++ b/src/pages/stamps/index.tsx @@ -1,13 +1,13 @@ -import { ReactElement, useContext, useEffect } from 'react' +import { CircularProgress, Container } from '@material-ui/core' import { createStyles, makeStyles } from '@material-ui/core/styles' -import { Container, CircularProgress } from '@material-ui/core' - -import StampsTable from './StampsTable' -import CreatePostageStampModal from './CreatePostageStampModal' - -import { Context as StampsContext } from '../../providers/Stamps' +import { ReactElement, useContext, useEffect, useState } from 'react' +import { PlusSquare } from 'react-feather' +import { SwarmButton } from '../../components/SwarmButton' import TroubleshootConnectionCard from '../../components/TroubleshootConnectionCard' import { Context as BeeContext } from '../../providers/Bee' +import { Context as StampsContext } from '../../providers/Stamps' +import { CreatePostageStampModal } from './CreatePostageStampModal' +import StampsTable from './StampsTable' const useStyles = makeStyles(() => createStyles({ @@ -25,8 +25,11 @@ const useStyles = makeStyles(() => }), ) -export default function Accounting(): ReactElement { +export default function Stamp(): ReactElement { const classes = useStyles() + + const [isBuyingStamp, setBuyingStamp] = useState(false) + const { stamps, isLoading, error, start, stop } = useContext(StampsContext) const { status } = useContext(BeeContext) @@ -49,7 +52,11 @@ export default function Accounting(): ReactElement { {!error && ( <>
- + {isBuyingStamp ? setBuyingStamp(false)} /> : null} + + setBuyingStamp(true)} iconType={PlusSquare}> + Buy New Postage Stamp +
{isLoading && }
diff --git a/src/pages/status/SetupSteps/DebugConnectionCheck.tsx b/src/pages/status/SetupSteps/DebugConnectionCheck.tsx index 4fbb64d..af37a62 100644 --- a/src/pages/status/SetupSteps/DebugConnectionCheck.tsx +++ b/src/pages/status/SetupSteps/DebugConnectionCheck.tsx @@ -1,14 +1,13 @@ -import { ReactElement, useContext } from 'react' import MuiAlert from '@material-ui/lab/Alert' - +import { ReactElement, useContext } from 'react' import CodeBlockTabs from '../../../components/CodeBlockTabs' import ExpandableList from '../../../components/ExpandableList' import ExpandableListItem from '../../../components/ExpandableListItem' import ExpandableListItemInput from '../../../components/ExpandableListItemInput' import ExpandableListItemNote from '../../../components/ExpandableListItemNote' import StatusIcon from '../../../components/StatusIcon' -import { Context as SettingsContext } from '../../../providers/Settings' import { Context } from '../../../providers/Bee' +import { Context as SettingsContext } from '../../../providers/Settings' export default function NodeConnectionCheck(): ReactElement | null { const { status, isLoading } = useContext(Context) @@ -25,7 +24,7 @@ export default function NodeConnectionCheck(): ReactElement | null { > {isOk - ? 'The connection to the Bee nodes deug API has been successful' + ? 'The connection to the Bee nodes debug API has been successful' : 'We cannot connect to your nodes debug API. Please check the following to troubleshoot your issue.'} diff --git a/src/utils/SwarmFile.ts b/src/utils/SwarmFile.ts new file mode 100644 index 0000000..01357dc --- /dev/null +++ b/src/utils/SwarmFile.ts @@ -0,0 +1,24 @@ +export class SwarmFile { + public name: string + public path: string + public type: string + public size: number + public webkitRelativePath: string + public arrayBuffer: () => Promise + private data: Promise + + constructor(file: File) { + const path = Reflect.get(file, 'path') || file.webkitRelativePath || file.name + this.path = path.startsWith('/') ? path.slice(1) : path + this.webkitRelativePath = this.path + this.name = file.name + this.type = file.type + this.size = file.size + this.data = file.arrayBuffer() + this.arrayBuffer = async () => { + const data = await this.data + + return data + } + } +} diff --git a/src/utils/file.test.ts b/src/utils/file.test.ts new file mode 100644 index 0000000..5ed2dd5 --- /dev/null +++ b/src/utils/file.test.ts @@ -0,0 +1,57 @@ +import { detectIndexHtml } from './file' + +describe('file utils', () => { + it('detectIndexHtml should find index.html', () => { + expect( + detectIndexHtml([ + { name: 'swarm.png', path: 'swarm.png' }, + { name: 'index.html', path: 'index.html' }, + ]), + ).toBe('index.html') + }) + + it('detectIndexHtml should find index.htm', () => { + expect( + detectIndexHtml([ + { name: 'index.htm', path: 'index.htm' }, + { name: 'swarm.png', path: 'swarm.png' }, + ]), + ).toBe('index.htm') + }) + + it('detectIndexHtml should find nested index.html', () => { + expect( + detectIndexHtml([ + { name: 'swarm.png', path: 'sample-folder/swarm.png' }, + { name: 'index.html', path: 'sample-folder/index.html' }, + ]), + ).toBe('index.html') + }) + + it('detectIndexHtml should not find nested index.htm when ambigous', () => { + expect( + detectIndexHtml([ + { name: 'index.htm', path: 'sample-folder/index.htm' }, + { name: 'swarm.png', path: 'other-folder/swarm.png' }, + ]), + ).toBe(false) + }) + + it('detectIndexHtml should not find deep index.html', () => { + expect( + detectIndexHtml([ + { name: 'index.html', path: 'sample-folder/index.html' }, + { name: 'swarm.png', path: 'swarm.png' }, + ]), + ).toBe(false) + }) + + it('detectIndexHtml should return false when no matches appear', () => { + expect( + detectIndexHtml([ + { name: 'swarm.png', path: 'swarm.png' }, + { name: 'swarm.jpg', path: 'swarm.jpg' }, + ]), + ).toBe(false) + }) +}) diff --git a/src/utils/file.ts b/src/utils/file.ts new file mode 100644 index 0000000..c5c9d91 --- /dev/null +++ b/src/utils/file.ts @@ -0,0 +1,51 @@ +import { FileData } from '@ethersphere/bee-js' +import { SwarmFile } from './SwarmFile' + +const indexHtmls = ['index.html', 'index.htm'] + +export function detectIndexHtml(files: SwarmFile[]): string | false { + if (!files.length) { + return false + } + + const exactMatch = files.find(x => indexHtmls.includes(x.path)) + + if (exactMatch) { + return exactMatch.name + } + + const prefix = files[0].path.split('/')[0] + '/' + + const allStartWithSamePrefix = files.every(x => x.path.startsWith(prefix)) + + if (allStartWithSamePrefix) { + const match = files.find(x => indexHtmls.map(y => prefix + y).includes(x.path)) + + if (match) { + return match.name + } + } + + return false +} + +export function getHumanReadableFileSize(bytes: number): string { + if (bytes >= 1e6) { + return (bytes / 1e6).toFixed(2) + ' MB' + } + + if (bytes >= 1e3) { + return (bytes / 1e3).toFixed(2) + ' kB' + } + + return bytes + ' bytes' +} + +export function convertBeeFileToBrowserFile(file: FileData): Partial { + return { + name: file.name, + size: file.data.byteLength, + type: file.contentType, + arrayBuffer: () => new Promise(resolve => resolve(file.data)), + } +} diff --git a/src/utils/index.test.ts b/src/utils/index.test.ts index 67d201a..49a5cfd 100644 --- a/src/utils/index.test.ts +++ b/src/utils/index.test.ts @@ -1,5 +1,5 @@ import BigNumber from 'bignumber.js' -import { isInteger, makeBigNumber } from './index' +import { extractSwarmHash, isInteger, makeBigNumber } from './index' describe('utils', () => { describe('isInteger', () => { @@ -57,4 +57,50 @@ describe('utils', () => { }) }) }) + + describe('extractSwarmHash', () => { + test('should return 64 hash', () => { + expect(extractSwarmHash('7f0fe712cdd78bdea52d040369eb32b6af5ecd01fa5ae49b7506412abdd81ac3')).toBe( + '7f0fe712cdd78bdea52d040369eb32b6af5ecd01fa5ae49b7506412abdd81ac3', + ) + }) + + test('should return 128 hash', () => { + expect( + extractSwarmHash( + 'd1829242c4d08e9f914fedfb1f68aacd62826d75370a9a57a80c9e6e6a49983c767c013be9aa4319e34fd8323ef0f2a57426b30e66c87e219f6f6359e2595e7f', + ), + ).toBe( + 'd1829242c4d08e9f914fedfb1f68aacd62826d75370a9a57a80c9e6e6a49983c767c013be9aa4319e34fd8323ef0f2a57426b30e66c87e219f6f6359e2595e7f', + ) + }) + + test('should return 64 hash from url', () => { + expect( + extractSwarmHash('http://localhost:1633/bzz/7f0fe712cdd78bdea52d040369eb32b6af5ecd01fa5ae49b7506412abdd81ac3/'), + ).toBe('7f0fe712cdd78bdea52d040369eb32b6af5ecd01fa5ae49b7506412abdd81ac3') + }) + + test('should return 128 hash from url', () => { + expect( + extractSwarmHash( + 'http://localhost:1633/bzz/d1829242c4d08e9f914fedfb1f68aacd62826d75370a9a57a80c9e6e6a49983c767c013be9aa4319e34fd8323ef0f2a57426b30e66c87e219f6f6359e2595e7f/', + ), + ).toBe( + 'd1829242c4d08e9f914fedfb1f68aacd62826d75370a9a57a80c9e6e6a49983c767c013be9aa4319e34fd8323ef0f2a57426b30e66c87e219f6f6359e2595e7f', + ) + }) + + test('should return null when nothing is found', () => { + expect(extractSwarmHash('Bee Dashboard')).toBe(null) + }) + + test('should return null when length is incorrect', () => { + expect(extractSwarmHash('7f0fe712cdd78bdea52d040369eb32b6af5ecd01fa5ae49b7506412abdd81a')).toBe(null) + }) + + test('should return null when alphanumeric', () => { + expect(extractSwarmHash('gkQ6duo5iHJ099g908P0t17ZWFf8Ke2klrywLP5BGtLkcaEC5W0kLEfbe4wUnDI6')).toBe(null) + }) + }) }) diff --git a/src/utils/index.ts b/src/utils/index.ts index 7dfef23..179d86b 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -106,3 +106,9 @@ export function makeRetriablePromise(fn: () => Promise, maxRetries = 3, de } }) } + +export function extractSwarmHash(string: string): string | null { + const matches = string.match(/[a-fA-F0-9]{64,128}/) + + return (matches && matches[0]) || null +}