feat: add identity and feed management (#272)

* feat(wip): add basic feed operations

* ci: bump checks

* ci: bump checks

* feat: rework stamps and add feed functionalities

* refactor: polish and fixes

* feat(wip): add formulas

* feat: show bzz.link for websites

* feat: add stamp empty states and formatBzz

* feat: add feed download

* chore: update manifest-js version

* feat: dev mode support with bee-js 3.1.0 (#273)

* feat: dev mode support with bee-js 3.1.0

* fix: added missing package-lock.json file

* build: remove PR preview

* style: work on design

* feat: add TroubleshootConnectionCard

* build: remove depcheck

Co-authored-by: Attila Gazso <agazso@gmail.com>
This commit is contained in:
Cafe137
2021-12-21 10:58:54 +01:00
committed by GitHub
parent d7c59a1495
commit 25b65c3fb7
46 changed files with 4354 additions and 6378 deletions
+15 -12
View File
@@ -6,6 +6,7 @@ import { BrowserRouter as Router } from 'react-router-dom'
import './App.css'
import Dashboard from './layout/Dashboard'
import { Provider as BeeProvider } from './providers/Bee'
import { Provider as FeedsProvider } from './providers/Feeds'
import { Provider as FileProvider } from './providers/File'
import { Provider as PlatformProvider } from './providers/Platform'
import { Provider as SettingsProvider } from './providers/Settings'
@@ -26,18 +27,20 @@ const App = ({ beeApiUrl, beeDebugApiUrl, lockedApiSettings }: Props): ReactElem
<BeeProvider>
<StampsProvider>
<FileProvider>
<PlatformProvider>
<SnackbarProvider>
<Router>
<>
<CssBaseline />
<Dashboard>
<BaseRouter />
</Dashboard>
</>
</Router>
</SnackbarProvider>
</PlatformProvider>
<FeedsProvider>
<PlatformProvider>
<SnackbarProvider>
<Router>
<>
<CssBaseline />
<Dashboard>
<BaseRouter />
</Dashboard>
</>
</Router>
</SnackbarProvider>
</PlatformProvider>
</FeedsProvider>
</FileProvider>
</StampsProvider>
</BeeProvider>
+26
View File
@@ -0,0 +1,26 @@
import { createStyles, makeStyles, Theme } from '@material-ui/core'
import { Close } from '@material-ui/icons'
import { ReactElement } from 'react'
interface Props {
onClose: () => void
}
const useStyles = makeStyles((theme: Theme) =>
createStyles({
wrapper: {
padding: theme.spacing(1),
cursor: 'pointer',
},
}),
)
export function CloseButton({ onClose }: Props): ReactElement {
const classes = useStyles()
return (
<div className={classes.wrapper} onClick={onClose}>
<Close />
</div>
)
}
+38
View File
@@ -0,0 +1,38 @@
import { createStyles, makeStyles, Theme } from '@material-ui/core'
import { ReactElement } from 'react'
interface Props {
children: string
prettify?: boolean
}
const useStyles = makeStyles((theme: Theme) =>
createStyles({
wrapper: {
overflow: 'scroll',
background: '#ffffff',
},
pre: {
maxHeight: '6em',
padding: theme.spacing(2),
},
}),
)
function prettifyString(string: string): string {
try {
return JSON.stringify(JSON.parse(string), null, 4)
} catch {
return string
}
}
export function Code({ children, prettify }: Props): ReactElement {
const classes = useStyles()
return (
<div className={classes.wrapper}>
<pre className={classes.pre}>{prettify ? prettifyString(children) : children}</pre>
</div>
)
}
+21
View File
@@ -0,0 +1,21 @@
import { createStyles, makeStyles, Typography } from '@material-ui/core'
import { ReactElement } from 'react'
interface Props {
children: (string | ReactElement)[] | (string | ReactElement)
}
const useStyles = makeStyles(() =>
createStyles({
text: {
color: '#606060',
fontSize: '0.9rem',
},
}),
)
export function DocumentationText({ children }: Props): ReactElement {
const classes = useStyles()
return <Typography className={classes.text}>{children}</Typography>
}
+9 -5
View File
@@ -4,8 +4,12 @@ import { ReactElement, ReactNode } from 'react'
const useStyles = makeStyles((theme: Theme) =>
createStyles({
wrapper: {
display: 'flex',
flexDirection: 'row',
flexWrap: 'wrap',
},
action: {
marginTop: theme.spacing(0.75),
marginBottom: theme.spacing(1),
marginRight: theme.spacing(1),
},
@@ -21,16 +25,16 @@ export default function ExpandableListItemActions({ children }: Props): ReactEle
if (Array.isArray(children)) {
return (
<Grid container direction="row">
<div className={classes.wrapper}>
{children
// Exclude falsy values to allow conditional rendering
.filter(x => x)
.map((a, i) => (
<Grid key={i} className={classes.action}>
<div key={i} className={classes.action}>
{a}
</Grid>
</div>
))}
</Grid>
</div>
)
}
+15 -11
View File
@@ -1,10 +1,11 @@
import { Button, Grid, IconButton, InputBase, ListItem, Typography } from '@material-ui/core'
import { Grid, IconButton, InputBase, ListItem, Typography } from '@material-ui/core'
import Collapse from '@material-ui/core/Collapse'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import { ChangeEvent, ReactElement, useState } from 'react'
import { Check, Edit, Minus, RotateCcw } from 'react-feather'
import { Edit, Minus, Search, X } from 'react-feather'
import ExpandableListItemActions from './ExpandableListItemActions'
import ExpandableListItemNote from './ExpandableListItemNote'
import { SwarmButton } from './SwarmButton'
const useStyles = makeStyles((theme: Theme) =>
createStyles({
@@ -52,6 +53,7 @@ interface Props {
expandedOnly?: boolean
confirmLabel?: string
confirmLabelDisabled?: boolean
loading?: boolean
onChange?: (value: string) => void
onConfirm: (value: string) => void
mapperFn?: (value: string) => string
@@ -68,6 +70,7 @@ export default function ExpandableListItemKey({
expandedOnly,
helperText,
placeholder,
loading,
mapperFn,
locked,
}: Props): ReactElement | null {
@@ -126,26 +129,27 @@ export default function ExpandableListItemKey({
<Collapse in={open} timeout="auto" unmountOnExit>
{helperText && <ExpandableListItemNote>{helperText}</ExpandableListItemNote>}
<ExpandableListItemActions>
<Button
variant="contained"
<SwarmButton
disabled={
loading ||
inputValue === value ||
Boolean(confirmLabelDisabled) || // Disable if external validation is provided
(inputValue === '' && value === undefined) // Disable if no initial value was not provided and the field is empty. The undefined check is improtant so that it is possible to submit with empty input in other cases
}
startIcon={<Check size="1rem" />}
loading={loading}
iconType={Search}
onClick={() => onConfirm(inputValue)}
>
{confirmLabel || 'Save'}
</Button>
<Button
variant="contained"
disabled={inputValue === value || inputValue === ''}
startIcon={<RotateCcw size="1rem" />}
</SwarmButton>
<SwarmButton
disabled={loading || inputValue === value || inputValue === ''}
iconType={X}
onClick={() => setInputValue(value || '')}
cancel
>
Cancel
</Button>
</SwarmButton>
</ExpandableListItemActions>
</Collapse>
</>
+55
View File
@@ -0,0 +1,55 @@
import { createStyles, Grid, makeStyles, Typography } from '@material-ui/core'
import { ReactElement } from 'react'
interface Props {
steps: string[]
index: number
}
const useStyles = makeStyles(() =>
createStyles({
wrapper: {
height: '52px',
display: 'flex',
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
todo: {
background: '#f7f7f7',
color: '#c9c9c9',
},
inProgress: {
background: '#ffffff',
color: '#242424',
height: '52px',
},
done: {
background: '#f7f7f7',
color: '#606060',
height: '52px',
},
}),
)
export function ProgressIndicator({ steps, index }: Props): ReactElement {
const classes = useStyles()
function pickClass(i: number): string {
if (i === index) {
return classes.inProgress
}
return i < index ? classes.done : classes.todo
}
return (
<Grid container justifyContent="space-between">
{steps.map((x, i) => (
<div key={i} className={`${classes.wrapper} ${pickClass(i)}`}>
<Typography>{x}</Typography>
</div>
))}
</Grid>
)
}
+6 -1
View File
@@ -2,7 +2,7 @@ import { Divider, Drawer, Grid, Link as MUILink, List } from '@material-ui/core'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import { OpenInNewSharp } from '@material-ui/icons'
import type { ReactElement } from 'react'
import { BookOpen, DollarSign, FileText, Home, Layers, Settings } from 'react-feather'
import { Bookmark, BookOpen, DollarSign, FileText, Home, Layers, Settings } from 'react-feather'
import { Link } from 'react-router-dom'
import Logo from '../assets/logo.svg'
import { config } from '../config'
@@ -21,6 +21,11 @@ const navBarItems = [
path: ROUTES.UPLOAD,
icon: FileText,
},
{
label: 'Feeds',
path: ROUTES.FEEDS,
icon: Bookmark,
},
{
label: 'Stamps',
path: ROUTES.STAMPS,
+30 -5
View File
@@ -9,13 +9,16 @@ interface Props {
className?: string
disabled?: boolean
loading?: boolean
cancel?: boolean
}
const useStyles = makeStyles(() =>
createStyles({
button: {
height: '52px',
position: 'relative',
whiteSpace: 'nowrap',
color: '#242424',
'&:hover, &:focus': {
'& svg': {
stroke: '#fff',
@@ -23,6 +26,10 @@ const useStyles = makeStyles(() =>
},
},
},
cancelButton: {
background: '#f7f7f7',
color: '#606060',
},
spinnerWrapper: {
position: 'absolute',
left: '50%',
@@ -34,19 +41,37 @@ const useStyles = makeStyles(() =>
}),
)
export function SwarmButton({ children, onClick, iconType, className, disabled, loading }: Props): ReactElement {
export function SwarmButton({
children,
onClick,
iconType,
className,
disabled,
loading,
cancel,
}: Props): ReactElement {
const classes = useStyles()
function getIconColor() {
if (loading || disabled) {
return 'rgba(0, 0, 0, 0.26)'
}
return cancel ? '#606060' : '#dd7700'
}
function getButtonClassName() {
return [className, classes.button, cancel && classes.cancelButton].filter(x => x).join(' ')
}
const icon = React.createElement(iconType, {
size: '1.25rem',
color: disabled ? 'rgba(0, 0, 0, 0.26)' : '#dd7700',
color: getIconColor(),
})
const classNames = className ? [className, classes.button].join(' ') : classes.button
return (
<Button
className={classNames}
className={getButtonClassName()}
onClick={(event: React.MouseEvent<HTMLButtonElement>) => {
onClick()
event.currentTarget.blur()
+23
View File
@@ -0,0 +1,23 @@
import { Box, Dialog, Grid } from '@material-ui/core'
import { ReactElement } from 'react'
interface Props {
children: ReactElement | ReactElement[]
}
export function SwarmDialog({ children }: Props): ReactElement {
return (
<Dialog
open={true}
PaperProps={{
style: { borderRadius: 0, background: '#efefef' },
}}
>
<Box p={4} sx={{ maxWidth: '100%', width: '650px' }}>
<Grid container direction="column">
{children}
</Grid>
</Box>
</Dialog>
)
}
+83
View File
@@ -0,0 +1,83 @@
import { createStyles, FormHelperText, makeStyles, MenuItem, Select as SimpleSelect, Theme } from '@material-ui/core'
import { Field } from 'formik'
import { Select } from 'formik-material-ui'
import { ReactElement } from 'react'
export type SelectEvent = React.ChangeEvent<{
name?: string | undefined
value: unknown
}>
interface Props {
label?: string
name?: string
options: { value: string; label: string }[]
onChange?: (event: SelectEvent) => void
formik?: boolean
defaultValue?: string
}
const useStyles = makeStyles((theme: Theme) =>
createStyles({
select: {
borderRadius: 0,
background: theme.palette.background.paper,
'& fieldset': {
border: 0,
},
},
option: {
height: '52px',
},
}),
)
export function SwarmSelect({ defaultValue, formik, name, options, onChange, label }: Props): ReactElement {
const classes = useStyles()
if (formik) {
return (
<>
{label && <FormHelperText>{label}</FormHelperText>}
<Field
required
component={Select}
name={name}
fullWidth
variant="outlined"
defaultValue={defaultValue || ''}
className={classes.select}
placeholder={label}
>
{options.map((x, i) => (
<MenuItem key={i} value={x.value} className={classes.option}>
{x.label}
</MenuItem>
))}
</Field>
</>
)
}
return (
<>
{label && <FormHelperText>{label}</FormHelperText>}
<SimpleSelect
required
name={name}
fullWidth
variant="outlined"
className={classes.select}
defaultValue={defaultValue || ''}
onChange={onChange}
placeholder={label}
>
{options.map((x, i) => (
<MenuItem key={i} value={x.value} className={classes.option}>
{x.label}
</MenuItem>
))}
</SimpleSelect>
</>
)
}
+58
View File
@@ -0,0 +1,58 @@
import { createStyles, makeStyles, TextField as SimpleTextField, Theme } from '@material-ui/core'
import { Field } from 'formik'
import { TextField } from 'formik-material-ui'
import { ChangeEvent, ReactElement } from 'react'
interface Props {
name: string
label: string
password?: boolean
formik?: boolean
optional?: boolean
onChange?: (event: ChangeEvent<HTMLTextAreaElement>) => void
}
const useStyles = makeStyles((theme: Theme) =>
createStyles({
field: {
background: theme.palette.background.paper,
height: '52px',
'& fieldset': {
border: 0,
},
},
}),
)
export function SwarmTextInput({ name, label, password, optional, formik, onChange }: Props): ReactElement {
const classes = useStyles()
if (formik) {
return (
<Field
component={TextField}
type={password ? 'password' : undefined}
required={!optional}
name={name}
label={label}
fullWidth
variant="outlined"
className={classes.field}
defaultValue=""
/>
)
}
return (
<SimpleTextField
type={password ? 'password' : undefined}
required
label={label}
fullWidth
variant="outlined"
className={classes.field}
defaultValue=""
onChange={onChange}
/>
)
}
+31
View File
@@ -0,0 +1,31 @@
import { createStyles, Grid, makeStyles, Typography } from '@material-ui/core'
import { ReactElement } from 'react'
import { CloseButton } from './CloseButton'
interface Props {
children: string
onClose: () => void
}
const useStyles = makeStyles(() =>
createStyles({
text: {
color: '#606060',
fontWeight: 'bold',
},
}),
)
export function TitleWithClose({ children, onClose }: Props): ReactElement {
const classes = useStyles()
return (
<Grid container justifyContent="space-between" alignItems="center">
<span>&nbsp;</span>
<Typography className={classes.text} align="center">
{children}
</Typography>
<CloseButton onClose={onClose} />
</Grid>
)
}
+133
View File
@@ -0,0 +1,133 @@
import { Box, Grid, Typography } from '@material-ui/core'
import { Form, Formik } from 'formik'
import { useSnackbar } from 'notistack'
import { ReactElement, useContext, useState } from 'react'
import { Check, X } from 'react-feather'
import { useHistory } from 'react-router'
import { DocumentationText } from '../../components/DocumentationText'
import ExpandableListItemActions from '../../components/ExpandableListItemActions'
import ExpandableListItemKey from '../../components/ExpandableListItemKey'
import { HistoryHeader } from '../../components/HistoryHeader'
import { SwarmButton } from '../../components/SwarmButton'
import { SwarmSelect } from '../../components/SwarmSelect'
import { SwarmTextInput } from '../../components/SwarmTextInput'
import { Context as FeedsContext, IdentityType } from '../../providers/Feeds'
import { Context as SettingsContext } from '../../providers/Settings'
import { ROUTES } from '../../routes'
import { convertWalletToIdentity, generateWallet, persistIdentity } from '../../utils/identity'
interface FormValues {
identityName?: string
type?: IdentityType
password?: string
}
const initialValues: FormValues = {
identityName: '',
type: 'PRIVATE_KEY',
password: '',
}
export default function CreateNewFeed(): ReactElement {
const { beeApi, beeDebugApi } = useContext(SettingsContext)
const { identities, setIdentities } = useContext(FeedsContext)
const [loading, setLoading] = useState(false)
const { enqueueSnackbar } = useSnackbar()
const history = useHistory()
async function onSubmit(values: FormValues) {
setLoading(true)
if (!beeApi) {
enqueueSnackbar(<span>Bee API unavailabe</span>, { variant: 'error' })
setLoading(false)
return
}
const wallet = generateWallet()
const stamps = await beeDebugApi?.getAllPostageBatch()
if (!stamps || !stamps.length) {
enqueueSnackbar(<span>No stamp available</span>, { variant: 'error' })
setLoading(false)
return
}
if (!values.identityName || !values.type) {
enqueueSnackbar(<span>Form is unfinished</span>, { variant: 'error' })
setLoading(false)
return
}
const identity = await convertWalletToIdentity(wallet, values.type, values.identityName, values.password)
persistIdentity(identities, identity)
setIdentities(identities)
history.push(ROUTES.FEEDS)
setLoading(false)
}
function cancel() {
history.goBack()
}
return (
<div>
<HistoryHeader>Create new feed</HistoryHeader>
<Box mb={4}>
<DocumentationText>
To create a feed you will need to create an identity. Please refer to the{' '}
<a
href="https://docs.ethswarm.org/api/#tag/Feed/paths/~1feeds~1{owner}~1{topic}/post"
target="_blank"
rel="noreferrer"
>
official Bee documentation
</a>{' '}
to understand how feeds work.
</DocumentationText>
</Box>
<Formik initialValues={initialValues} onSubmit={onSubmit}>
{({ submitForm, values }) => (
<Form>
<Box mb={0.25}>
<SwarmTextInput name="identityName" label="Identity name" formik />
</Box>
<Box mb={0.25}>
<SwarmSelect
formik
name="type"
options={[
{ label: 'Keypair Only', value: 'PRIVATE_KEY' },
{ label: 'Password Protected', value: 'V3' },
]}
/>
</Box>
{values.type === 'V3' && <SwarmTextInput name="password" label="Password" password formik />}
<Box mt={2}>
<ExpandableListItemKey label="Topic" value={'00'.repeat(32)} />
</Box>
<Box mt={2} sx={{ bgcolor: '#fcf2e8' }} p={2}>
<Grid container justifyContent="space-between">
<Typography>Feeds name</Typography>
<Typography>{values.identityName} Website</Typography>
</Grid>
</Box>
<Box mt={1.25}>
<ExpandableListItemActions>
<SwarmButton onClick={submitForm} iconType={Check} disabled={loading} loading={loading}>
Create Feed
</SwarmButton>
<SwarmButton onClick={cancel} iconType={X} disabled={loading} cancel>
Cancel
</SwarmButton>
</ExpandableListItemActions>
</Box>
</Form>
)}
</Formik>
</div>
)
}
+35
View File
@@ -0,0 +1,35 @@
import { Box, Typography } from '@material-ui/core'
import { ReactElement } from 'react'
import { Trash, X } from 'react-feather'
import ExpandableListItemActions from '../../components/ExpandableListItemActions'
import { SwarmButton } from '../../components/SwarmButton'
import { SwarmDialog } from '../../components/SwarmDialog'
import { TitleWithClose } from '../../components/TitleWithClose'
import { Identity } from '../../providers/Feeds'
interface Props {
identity: Identity
onConfirm: (identity: Identity) => void
onClose: () => void
}
export function DeleteFeedDialog({ identity, onConfirm, onClose }: Props): ReactElement {
return (
<SwarmDialog>
<Box mb={4}>
<TitleWithClose onClose={onClose}>Delete</TitleWithClose>
</Box>
<Box mb={2}>
<Typography align="center">{`You are about to delete feed ${identity.name} Website. It is strongly advised to export this feed first.`}</Typography>
</Box>
<ExpandableListItemActions>
<SwarmButton iconType={Trash} onClick={() => onConfirm(identity)}>
Delete
</SwarmButton>
<SwarmButton iconType={X} onClick={onClose} cancel>
Cancel
</SwarmButton>
</ExpandableListItemActions>
</SwarmDialog>
)
}
+71
View File
@@ -0,0 +1,71 @@
import { Box, createStyles, makeStyles, Typography } from '@material-ui/core'
import { saveAs } from 'file-saver'
import { useSnackbar } from 'notistack'
import { ReactElement } from 'react'
import { Clipboard, Download } from 'react-feather'
import { Code } from '../../components/Code'
import ExpandableListItemActions from '../../components/ExpandableListItemActions'
import { SwarmButton } from '../../components/SwarmButton'
import { SwarmDialog } from '../../components/SwarmDialog'
import { TitleWithClose } from '../../components/TitleWithClose'
import { Identity } from '../../providers/Feeds'
interface Props {
identity: Identity
onClose: () => void
}
const useStyles = makeStyles(() =>
createStyles({
wrapper: {
maxWidth: '100%',
},
}),
)
export function ExportFeedDialog({ identity, onClose }: Props): ReactElement {
const { enqueueSnackbar } = useSnackbar()
const classes = useStyles()
function onDownload() {
saveAs(
new Blob([identity.identity], {
type: 'application/json',
}),
identity.name + '.json',
)
}
function getExportText() {
return identity.type === 'V3' ? 'JSON file' : 'the private key string'
}
function onCopy() {
navigator.clipboard
.writeText(identity.identity)
.then(() => enqueueSnackbar('Copied to Clipboard', { variant: 'success' }))
}
return (
<SwarmDialog>
<Box mb={4}>
<TitleWithClose onClose={onClose}>Export</TitleWithClose>
</Box>
<Box mb={2}>
<Typography align="center">{`We exported the identity associated with this feed as ${getExportText()}.`}</Typography>
</Box>
<Box mb={4} className={classes.wrapper}>
<Code prettify>{identity.identity}</Code>
</Box>
<ExpandableListItemActions>
<SwarmButton iconType={Download} onClick={onDownload}>
Download JSON File
</SwarmButton>
<SwarmButton iconType={Clipboard} onClick={onCopy}>
Copy To Clipboard
</SwarmButton>
</ExpandableListItemActions>
</SwarmDialog>
)
}
+52
View File
@@ -0,0 +1,52 @@
import { Box, Typography } from '@material-ui/core'
import { ReactElement, useState } from 'react'
import { Check, X } from 'react-feather'
import ExpandableListItemActions from '../../components/ExpandableListItemActions'
import { SwarmButton } from '../../components/SwarmButton'
import { SwarmDialog } from '../../components/SwarmDialog'
import { SwarmTextInput } from '../../components/SwarmTextInput'
import { TitleWithClose } from '../../components/TitleWithClose'
interface Props {
feedName: string
onProceed: (password: string) => void
onCancel: () => void
loading: boolean
}
export function FeedPasswordDialog({ feedName, onProceed, onCancel, loading }: Props): ReactElement {
const [password, setPassword] = useState('')
function onProceedClick() {
return onProceed(password)
}
return (
<SwarmDialog>
<Box mb={4}>
<TitleWithClose onClose={onCancel}>Update Feed</TitleWithClose>
</Box>
<Box mb={2}>
<Typography>Please enter the password for {feedName}:</Typography>
</Box>
<Box mb={4}>
<SwarmTextInput
label="Password"
name="password"
onChange={event => {
setPassword(event.target.value)
}}
password
/>
</Box>
<ExpandableListItemActions>
<SwarmButton iconType={Check} onClick={onProceedClick} disabled={loading} loading={loading}>
Proceed
</SwarmButton>
<SwarmButton iconType={X} onClick={onCancel} cancel disabled={loading}>
Cancel
</SwarmButton>
</ExpandableListItemActions>
</SwarmDialog>
)
}
+94
View File
@@ -0,0 +1,94 @@
import * as swarmCid from '@ethersphere/swarm-cid'
import { Box } from '@material-ui/core'
import { ReactElement, useContext, useEffect, useState } from 'react'
import { X } from 'react-feather'
import { RouteComponentProps, useHistory } from 'react-router-dom'
import { DocumentationText } from '../../components/DocumentationText'
import ExpandableListItemActions from '../../components/ExpandableListItemActions'
import ExpandableListItemKey from '../../components/ExpandableListItemKey'
import ExpandableListItemLink from '../../components/ExpandableListItemLink'
import { HistoryHeader } from '../../components/HistoryHeader'
import { SwarmButton } from '../../components/SwarmButton'
import { Context as BeeContext } from '../../providers/Bee'
import { Context as IdentityContext } from '../../providers/Feeds'
import { Context as SettingsContext } from '../../providers/Settings'
import { ROUTES } from '../../routes'
import { UploadArea } from '../files/UploadArea'
interface MatchParams {
uuid: string
}
export function FeedSubpage(props: RouteComponentProps<MatchParams>): ReactElement {
const { identities } = useContext(IdentityContext)
const { beeApi } = useContext(SettingsContext)
const { status } = useContext(BeeContext)
const history = useHistory()
const [available, setAvailable] = useState(false)
const uuid = props.match.params.uuid
const identity = identities.find(x => x.uuid === uuid)
useEffect(() => {
if (!identity || !identity.feedHash) {
return
}
try {
beeApi?.downloadData(identity.feedHash).then(() => setAvailable(true))
} catch {
setAvailable(false)
}
}, [beeApi, uuid, identity])
if (!identity || !status.all) {
history.replace(ROUTES.FEEDS)
return <></>
}
function onClose() {
history.push(ROUTES.FEEDS)
}
return (
<div>
<HistoryHeader>{`${identity.name} Website`}</HistoryHeader>
<UploadArea showHelp={false} uploadOrigin={{ origin: 'FEED', uuid }} />
{available && identity.feedHash ? (
<>
<Box mb={0.25}>
<ExpandableListItemKey label="Feed hash" value={identity.feedHash} />
</Box>
<Box mb={4}>
<ExpandableListItemLink
label="BZZ Link"
value={`https://${swarmCid.encodeFeedReference(identity.feedHash)}.bzz.link`}
/>
</Box>
</>
) : (
<Box mb={4}>
<DocumentationText>
This feed is curently not pointing anywhere, you can update the feed to fix this. Please refer to the{' '}
<a
href="https://docs.ethswarm.org/api/#tag/Feed/paths/~1feeds~1{owner}~1{topic}/post"
target="_blank"
rel="noreferrer"
>
official Bee documentation
</a>
.
</DocumentationText>
</Box>
)}
<ExpandableListItemActions>
<SwarmButton iconType={X} onClick={onClose} cancel>
Close
</SwarmButton>
</ExpandableListItemActions>
</div>
)
}
+113
View File
@@ -0,0 +1,113 @@
import { Box, createStyles, makeStyles, TextareaAutosize, Theme } from '@material-ui/core'
import { useSnackbar } from 'notistack'
import React, { ReactElement, useContext, useRef, useState } from 'react'
import { Check, Upload } from 'react-feather'
import ExpandableListItemActions from '../../components/ExpandableListItemActions'
import { SwarmButton } from '../../components/SwarmButton'
import { SwarmDialog } from '../../components/SwarmDialog'
import { SwarmTextInput } from '../../components/SwarmTextInput'
import { TitleWithClose } from '../../components/TitleWithClose'
import { Context, Identity } from '../../providers/Feeds'
import { importIdentity, persistIdentity } from '../../utils/identity'
interface Props {
onClose: () => void
}
const useStyles = makeStyles((theme: Theme) =>
createStyles({
textarea: {
width: '100%',
border: 0,
padding: theme.spacing(1),
},
displayNone: {
display: 'none',
},
}),
)
export function ImportFeedDialog({ onClose }: Props): ReactElement {
const [textareaValue, setTextareaValue] = useState('')
const [name, setName] = useState('')
const fileInputRef = useRef(null)
const { identities, setIdentities } = useContext(Context)
const { enqueueSnackbar } = useSnackbar()
const classes = useStyles()
async function onImport() {
const feed = await importIdentity(name, textareaValue)
if (feed) {
onFeedReady(feed)
} else {
enqueueSnackbar('Feed is not valid', { variant: 'error' })
}
}
function onUploadIdentityFile() {
if (fileInputRef.current) {
const input = fileInputRef.current as HTMLInputElement
input.click()
}
}
function onIdentityFileSelected(event: React.ChangeEvent<HTMLInputElement>) {
const fileReader = new FileReader()
const file = event.target?.files?.[0]
fileReader.onload = async event => {
const string = event.target?.result
if (string) {
const feed = await importIdentity(name, string as string)
if (feed) {
onFeedReady(feed)
} else {
enqueueSnackbar('Feed is not valid', { variant: 'error' })
}
}
}
if (file) {
fileReader.readAsText(file)
}
}
function onFeedReady(identity: Identity) {
persistIdentity(identities, identity)
setIdentities(identities)
enqueueSnackbar('Feed imported successfully', { variant: 'success' })
onClose()
}
return (
<SwarmDialog>
<input onChange={onIdentityFileSelected} ref={fileInputRef} className={classes.displayNone} type="file" />
<Box mb={4}>
<TitleWithClose onClose={onClose}>Import</TitleWithClose>
</Box>
<Box mb={2}>
<SwarmTextInput label="Identity Name" name="name" onChange={event => setName(event.target.value)} />
</Box>
<Box mb={4}>
<TextareaAutosize
className={classes.textarea}
minRows={5}
onChange={event => setTextareaValue(event.target.value)}
/>
</Box>
<ExpandableListItemActions>
<SwarmButton iconType={Upload} onClick={onUploadIdentityFile}>
Upload Json File
</SwarmButton>
<SwarmButton iconType={Check} onClick={onImport}>
Use Pasted Text
</SwarmButton>
</ExpandableListItemActions>
</SwarmDialog>
)
}
+151
View File
@@ -0,0 +1,151 @@
import { Box, Grid, Typography } from '@material-ui/core'
import { useSnackbar } from 'notistack'
import { ReactElement, useContext, useEffect, useState } from 'react'
import { Bookmark, X } from 'react-feather'
import { RouteComponentProps, useHistory } from 'react-router'
import ExpandableListItemActions from '../../components/ExpandableListItemActions'
import { HistoryHeader } from '../../components/HistoryHeader'
import { SwarmButton } from '../../components/SwarmButton'
import { SelectEvent, SwarmSelect } from '../../components/SwarmSelect'
import TroubleshootConnectionCard from '../../components/TroubleshootConnectionCard'
import { Context as BeeContext } from '../../providers/Bee'
import { Context as IdentityContext, Identity } from '../../providers/Feeds'
import { Context as SettingsContext } from '../../providers/Settings'
import { Context as StampContext } from '../../providers/Stamps'
import { ROUTES } from '../../routes'
import { persistIdentity, updateFeed } from '../../utils/identity'
import { FeedPasswordDialog } from './FeedPasswordDialog'
interface MatchParams {
hash: string
}
export default function UpdateFeed(props: RouteComponentProps<MatchParams>): ReactElement {
const { identities, setIdentities } = useContext(IdentityContext)
const { beeApi, beeDebugApi } = useContext(SettingsContext)
const { stamps, refresh } = useContext(StampContext)
const { status } = useContext(BeeContext)
const [selectedStamp, setSelectedStamp] = useState<string | null>(null)
const [selectedIdentity, setSelectedIdentity] = useState<Identity | null>(null)
const [loading, setLoading] = useState(false)
const { enqueueSnackbar } = useSnackbar()
const [showPasswordPrompt, setShowPasswordPrompt] = useState(false)
const history = useHistory()
useEffect(() => {
refresh()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
function onFeedChange(event: SelectEvent) {
const uuid = event.target.value
setSelectedIdentity(identities.find(x => x.uuid === uuid) || null)
}
function onStampChange(event: SelectEvent) {
const batchId = event.target.value as string
setSelectedStamp(batchId)
}
function onCancel() {
history.goBack()
}
function onBeginUpdatingFeed() {
if (!selectedIdentity) {
return
}
if (selectedIdentity.type === 'V3') {
setShowPasswordPrompt(true)
} else {
onFeedUpdate(selectedIdentity)
}
}
async function onFeedUpdate(identity: Identity, password?: string) {
setLoading(true)
if (!beeApi || !beeDebugApi || !selectedStamp) {
enqueueSnackbar(<span>Bee API unavailabe</span>, { variant: 'error' })
setLoading(false)
return
}
try {
await updateFeed(beeApi, identity, props.match.params.hash, selectedStamp, password as string)
persistIdentity(identities, identity)
setIdentities([...identities])
history.push(ROUTES.FEEDS_PAGE.replace(':uuid', identity.uuid))
} catch (error: unknown) {
setLoading(false)
const message = (typeof error === 'object' && error !== null && Reflect.get(error, 'message')) || ''
if (message.includes('possibly wrong passphrase')) {
enqueueSnackbar('Wrong password, please try again', { variant: 'error' })
} else {
enqueueSnackbar('Could not update feed at this time, please try again later', { variant: 'error' })
}
}
}
if (!status.all) return <TroubleshootConnectionCard />
return (
<div>
{showPasswordPrompt && selectedIdentity && (
<FeedPasswordDialog
feedName={selectedIdentity.name + ' Website'}
onCancel={() => {
setShowPasswordPrompt(false)
}}
onProceed={(password: string) => {
onFeedUpdate(selectedIdentity, password)
}}
loading={loading}
/>
)}
<HistoryHeader>Update feed</HistoryHeader>
<Box mb={2}>
<Grid container>
<SwarmSelect
options={identities.map(x => ({ value: x.uuid, label: `${x.name} Website` }))}
onChange={onFeedChange}
label="Feed"
/>
</Grid>
</Box>
<Box mb={4}>
<Grid container>
{stamps ? (
<SwarmSelect
options={stamps.map(x => ({ value: x.batchID, label: x.batchID.slice(0, 8) }))}
onChange={onStampChange}
label="Stamp"
/>
) : (
<Typography>You need to buy a stamp first to be able to update a feed.</Typography>
)}
</Grid>
</Box>
<ExpandableListItemActions>
<SwarmButton
onClick={onBeginUpdatingFeed}
iconType={Bookmark}
loading={!showPasswordPrompt && loading}
disabled={loading || !selectedStamp || !selectedIdentity}
>
Update Selected Feed
</SwarmButton>
<SwarmButton onClick={onCancel} iconType={X} disabled={loading} cancel>
Close
</SwarmButton>
</ExpandableListItemActions>
</div>
)
}
+115
View File
@@ -0,0 +1,115 @@
import { Box, Typography } from '@material-ui/core'
import { ReactElement, useContext, useState } from 'react'
import { Download, Info, PlusSquare, Trash } from 'react-feather'
import { useHistory } from 'react-router'
import ExpandableList from '../../components/ExpandableList'
import ExpandableListItem from '../../components/ExpandableListItem'
import ExpandableListItemActions from '../../components/ExpandableListItemActions'
import ExpandableListItemKey from '../../components/ExpandableListItemKey'
import { SwarmButton } from '../../components/SwarmButton'
import { Context as BeeContext } from '../../providers/Bee'
import { Context as IdentityContext, Identity } from '../../providers/Feeds'
import { ROUTES } from '../../routes'
import { formatEnum } from '../../utils'
import { persistIdentitiesWithoutUpdate } from '../../utils/identity'
import { DeleteFeedDialog } from './DeleteFeedDialog'
import { ExportFeedDialog } from './ExportFeedDialog'
import { ImportFeedDialog } from './ImportFeedDialog'
export default function Feeds(): ReactElement {
const { identities, setIdentities } = useContext(IdentityContext)
const { status } = useContext(BeeContext)
const history = useHistory()
const [selectedIdentity, setSelectedIdentity] = useState<Identity | null>(null)
const [showImport, setShowImport] = useState(false)
const [showExport, setShowExport] = useState(false)
const [showDelete, setShowDelete] = useState(false)
function createNewFeed() {
return history.push(ROUTES.FEEDS_NEW)
}
function viewFeed(uuid: string) {
history.push(ROUTES.FEEDS_PAGE.replace(':uuid', uuid))
}
function onDialogClose() {
setShowDelete(false)
setShowExport(false)
setShowImport(false)
setSelectedIdentity(null)
}
function onDelete(identity: Identity) {
onDialogClose()
const updatedFeeds = identities.filter(x => x.uuid !== identity.uuid)
setIdentities(updatedFeeds)
persistIdentitiesWithoutUpdate(updatedFeeds)
}
function onShowExport(identity: Identity) {
setSelectedIdentity(identity)
setShowExport(true)
}
function onShowDelete(identity: Identity) {
setSelectedIdentity(identity)
setShowDelete(true)
}
return (
<div>
{showImport && <ImportFeedDialog onClose={() => setShowImport(false)} />}
{showExport && selectedIdentity && <ExportFeedDialog identity={selectedIdentity} onClose={onDialogClose} />}
{showDelete && selectedIdentity && (
<DeleteFeedDialog
identity={selectedIdentity}
onClose={onDialogClose}
onConfirm={(identity: Identity) => onDelete(identity)}
/>
)}
<Box mb={4}>
<Typography variant="h1">Feeds</Typography>
</Box>
<Box mb={4}>
<ExpandableListItemActions>
<SwarmButton iconType={PlusSquare} onClick={createNewFeed}>
Create New Feed
</SwarmButton>
<SwarmButton iconType={PlusSquare} onClick={() => setShowImport(true)}>
Import Feed
</SwarmButton>
</ExpandableListItemActions>
</Box>
{identities.map((x, i) => (
<ExpandableList key={i} label={`${x.name} Website`} defaultOpen>
<Box mb={0.5}>
<ExpandableList label={x.name} level={1}>
<ExpandableListItemKey label="Identity address" value={x.address} />
<ExpandableListItem label="Identity type" value={formatEnum(x.type)} />
</ExpandableList>
</Box>
<ExpandableListItemKey label="Topic" value={'00'.repeat(32)} />
{x.feedHash && <ExpandableListItemKey label="Feed hash" value={x.feedHash} />}
<Box mt={0.75}>
<ExpandableListItemActions>
{status.all && (
<SwarmButton onClick={() => viewFeed(x.uuid)} iconType={Info}>
View Feed Page
</SwarmButton>
)}
<SwarmButton onClick={() => onShowExport(x)} iconType={Download}>
Export...
</SwarmButton>
<SwarmButton onClick={() => onShowDelete(x)} iconType={Trash}>
Delete...
</SwarmButton>
</ExpandableListItemActions>
</Box>
</ExpandableList>
))}
</div>
)
}
+15 -4
View File
@@ -1,24 +1,35 @@
import { Box, Typography } from '@material-ui/core'
import * as swarmCid from '@ethersphere/swarm-cid'
import { Box } from '@material-ui/core'
import { ReactElement } from 'react'
import { DocumentationText } from '../../components/DocumentationText'
import ExpandableListItemKey from '../../components/ExpandableListItemKey'
import ExpandableListItemLink from '../../components/ExpandableListItemLink'
import { detectIndexHtml } from '../../utils/file'
import { SwarmFile } from '../../utils/SwarmFile'
interface Props {
files: SwarmFile[]
hash: string
}
export function AssetSummary({ hash }: Props): ReactElement {
export function AssetSummary({ files, hash }: Props): ReactElement {
return (
<>
<Box mb={4}>
<ExpandableListItemKey label="Swarm hash" value={hash} />
<ExpandableListItemLink label="Share on Swarm Gateway" value={`https://gateway.ethswarm.org/access/${hash}`} />
{detectIndexHtml(files) && (
<ExpandableListItemLink
label="BZZ Link"
value={`https://${swarmCid.encodeManifestReference(hash).toString()}.bzz.link`}
/>
)}
</Box>
<Typography>
<DocumentationText>
The Swarm Gateway is graciously provided by the Swarm Foundation. This service is under development and provided
for testing purposes only. Learn more at{' '}
<a href="https://gateway.ethswarm.org/">https://gateway.ethswarm.org/</a>.
</Typography>
</DocumentationText>
</>
)
}
+15 -1
View File
@@ -5,6 +5,7 @@ import { ReactElement, useContext, useState } from 'react'
import { useHistory } from 'react-router-dom'
import ExpandableListItemInput from '../../components/ExpandableListItemInput'
import { History } from '../../components/History'
import { Context, defaultUploadOrigin } from '../../providers/File'
import { Context as SettingsContext } from '../../providers/Settings'
import { ROUTES } from '../../routes'
import { extractSwarmHash } from '../../utils'
@@ -16,6 +17,8 @@ export function Download(): ReactElement {
const { beeApi } = useContext(SettingsContext)
const [referenceError, setReferenceError] = useState<string | undefined>(undefined)
const { setUploadOrigin } = useContext(Context)
const { enqueueSnackbar } = useSnackbar()
const history = useHistory()
@@ -28,12 +31,21 @@ export function Download(): ReactElement {
}
async function onSwarmIdentifier(identifier: string) {
setLoading(true)
if (!beeApi) {
setLoading(false)
return
}
try {
const manifestJs = new ManifestJs(beeApi)
const feedIdentifier = await manifestJs.resolveFeedManifest(identifier)
if (feedIdentifier) {
identifier = feedIdentifier
}
const isManifest = await manifestJs.isManifest(identifier)
if (!isManifest) {
@@ -41,6 +53,7 @@ export function Download(): ReactElement {
}
const indexDocument = await manifestJs.getIndexDocumentPath(identifier)
putHistory(HISTORY_KEYS.DOWNLOAD_HISTORY, identifier, determineHistoryName(identifier, indexDocument))
setUploadOrigin(defaultUploadOrigin)
history.push(ROUTES.HASH.replace(':hash', identifier))
} catch (error: unknown) {
let message = typeof error === 'object' && error !== null && Reflect.get(error, 'message')
@@ -80,11 +93,12 @@ export function Download(): ReactElement {
onConfirm={value => onSwarmIdentifier(value)}
onChange={validateChange}
helperText={referenceError}
confirmLabel={'Search'}
confirmLabel={'Find'}
confirmLabelDisabled={Boolean(referenceError) || loading}
placeholder="e.g. 31fb0362b1a42536134c86bc58b97ac0244e5c6630beec3e27c2d1cecb38c605"
expandedOnly
mapperFn={value => recognizeSwarmHash(value)}
loading={loading}
/>
<History title="Download History" localStorageKey={HISTORY_KEYS.DOWNLOAD_HISTORY} />
</>
+31 -17
View File
@@ -1,32 +1,46 @@
import { Button } from '@material-ui/core'
import { Clear } from '@material-ui/icons'
import { Box, Grid } from '@material-ui/core'
import { ReactElement } from 'react'
import { Download, Link } from 'react-feather'
import { Bookmark, Download, Link, X } from 'react-feather'
import ExpandableListItemActions from '../../components/ExpandableListItemActions'
import { SwarmButton } from '../../components/SwarmButton'
interface Props {
onOpen: () => void
onDownload: () => void
onCancel: () => void
onDownload: () => void
onUpdateFeed: () => void
hasIndexDocument: boolean
loading: boolean
}
export function DownloadActionBar({ onOpen, onDownload, onCancel, hasIndexDocument, loading }: Props): ReactElement {
export function DownloadActionBar({
onOpen,
onCancel,
onDownload,
onUpdateFeed,
hasIndexDocument,
loading,
}: Props): ReactElement {
return (
<ExpandableListItemActions>
{hasIndexDocument && (
<SwarmButton onClick={onOpen} iconType={Link} disabled={loading}>
View Website
<Grid container justifyContent="space-between">
<ExpandableListItemActions>
{hasIndexDocument && (
<SwarmButton onClick={onOpen} iconType={Link} disabled={loading}>
View Website
</SwarmButton>
)}
<SwarmButton onClick={onDownload} iconType={Download} disabled={loading} loading={loading}>
Download
</SwarmButton>
)}
<SwarmButton onClick={onDownload} iconType={Download} disabled={loading} loading={loading}>
Download
</SwarmButton>
<Button onClick={onCancel} variant="contained" startIcon={<Clear />} disabled={loading}>
Close
</Button>
</ExpandableListItemActions>
<SwarmButton onClick={onCancel} iconType={X} disabled={loading} loading={loading} cancel>
Close
</SwarmButton>
</ExpandableListItemActions>
<Box mb={1} mr={1}>
<SwarmButton onClick={onUpdateFeed} iconType={Bookmark}>
Update Feed
</SwarmButton>
</Box>
</Grid>
)
}
+33 -5
View File
@@ -1,10 +1,14 @@
import { ManifestJs } from '@ethersphere/manifest-js'
import { Box } from '@material-ui/core'
import { Box, Typography } from '@material-ui/core'
import { saveAs } from 'file-saver'
import JSZip from 'jszip'
import { useSnackbar } from 'notistack'
import { ReactElement, useContext, useEffect, useState } from 'react'
import { RouteComponentProps, useHistory } from 'react-router-dom'
import { HistoryHeader } from '../../components/HistoryHeader'
import { Loading } from '../../components/Loading'
import TroubleshootConnectionCard from '../../components/TroubleshootConnectionCard'
import { Context as BeeContext } from '../../providers/Bee'
import { Context as SettingsContext } from '../../providers/Settings'
import { ROUTES } from '../../routes'
import { convertBeeFileToBrowserFile, convertManifestToFiles } from '../../utils/file'
@@ -21,17 +25,22 @@ interface MatchParams {
export function Share(props: RouteComponentProps<MatchParams>): ReactElement {
const { apiUrl, beeApi } = useContext(SettingsContext)
const { status } = useContext(BeeContext)
const reference = props.match.params.hash
const history = useHistory()
const { enqueueSnackbar } = useSnackbar()
const [loading, setLoading] = useState(true)
const [downloading, setDownloading] = useState(false)
const [files, setFiles] = useState<SwarmFile[]>([])
const [swarmEntries, setSwarmEntries] = useState<Record<string, string>>({})
const [indexDocument, setIndexDocument] = useState<string | null>(null)
const [notFound, setNotFound] = useState(false)
async function prepare() {
if (!beeApi) {
if (!beeApi || !status.all) {
return
}
@@ -39,7 +48,10 @@ export function Share(props: RouteComponentProps<MatchParams>): ReactElement {
const isManifest = await manifestJs.isManifest(reference)
if (!isManifest) {
throw Error('The specified hash does not contain valid content.')
setNotFound(true)
enqueueSnackbar('The specified hash does not contain valid content.', { variant: 'error' })
return
}
const entries = await manifestJs.getHashes(reference)
setSwarmEntries(entries)
@@ -67,9 +79,13 @@ export function Share(props: RouteComponentProps<MatchParams>): ReactElement {
}
}
function onUpdateFeed() {
history.push(ROUTES.FEEDS_UPDATE.replace(':hash', reference))
}
useEffect(() => {
setLoading(true)
prepare().then(() => {
prepare().finally(() => {
setLoading(false)
})
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -97,22 +113,34 @@ export function Share(props: RouteComponentProps<MatchParams>): ReactElement {
const assetName = shortenHash(reference)
if (!status.all) return <TroubleshootConnectionCard />
if (loading) {
return <Loading />
}
if (notFound) {
return (
<>
<HistoryHeader>Not Found</HistoryHeader>
<Typography>The specified hash is not found.</Typography>
</>
)
}
return (
<>
<Box mb={4}>
<AssetPreview files={files} assetName={assetName} />
</Box>
<Box mb={4}>
<AssetSummary hash={reference} />
<AssetSummary files={files} hash={reference} />
</Box>
<DownloadActionBar
onOpen={onOpen}
onCancel={onClose}
onDownload={onDownload}
onUpdateFeed={onUpdateFeed}
hasIndexDocument={Boolean(indexDocument && files.length > 1)}
loading={downloading}
/>
+107 -35
View File
@@ -1,41 +1,71 @@
import { Box } from '@material-ui/core'
import { useSnackbar } from 'notistack'
import { ReactElement, useContext, useEffect, useState } from 'react'
import { useHistory } from 'react-router-dom'
import { DocumentationText } from '../../components/DocumentationText'
import { HistoryHeader } from '../../components/HistoryHeader'
import { ProgressIndicator } from '../../components/ProgressIndicator'
import TroubleshootConnectionCard from '../../components/TroubleshootConnectionCard'
import { Context as BeeContext } from '../../providers/Bee'
import { Context as IdentityContext, Identity } from '../../providers/Feeds'
import { Context as FileContext } from '../../providers/File'
import { Context as SettingsContext } from '../../providers/Settings'
import { Context, EnrichedPostageBatch } from '../../providers/Stamps'
import { Context as StampsContext, EnrichedPostageBatch } from '../../providers/Stamps'
import { ROUTES } from '../../routes'
import { detectIndexHtml, getAssetNameFromFiles } from '../../utils/file'
import { persistIdentity, updateFeed } from '../../utils/identity'
import { HISTORY_KEYS, putHistory } from '../../utils/local-storage'
import { CreatePostageStampModal } from '../stamps/CreatePostageStampModal'
import { SelectPostageStampModal } from '../stamps/SelectPostageStampModal'
import { FeedPasswordDialog } from '../feeds/FeedPasswordDialog'
import { PostageStampCreation } from '../stamps/PostageStampCreation'
import { PostageStampSelector } from '../stamps/PostageStampSelector'
import { AssetPreview } from './AssetPreview'
import { StampPreview } from './StampPreview'
import { UploadActionBar } from './UploadActionBar'
export function Upload(): ReactElement {
const [isBuyingStamp, setBuyingStamp] = useState(false)
const [isSelectingStamp, setSelectingStamp] = useState(false)
const [step, setStep] = useState(0)
const [stampMode, setStampMode] = useState<'SELECT' | 'BUY'>('SELECT')
const [stamp, setStamp] = useState<EnrichedPostageBatch | null>(null)
const [isUploading, setUploading] = useState(false)
const [showPasswordPrompt, setShowPasswordPrompt] = useState(false)
const { stamps, refresh } = useContext(Context)
const { refresh } = useContext(StampsContext)
const { beeApi } = useContext(SettingsContext)
const { files, setFiles } = useContext(FileContext)
const { files, setFiles, uploadOrigin } = useContext(FileContext)
const { identities, setIdentities } = useContext(IdentityContext)
const { status } = useContext(BeeContext)
const { enqueueSnackbar } = useSnackbar()
const history = useHistory()
if (!files.length) {
setFiles([])
history.replace(ROUTES.UPLOAD)
}
useEffect(() => {
refresh()
}, []) // eslint-disable-line react-hooks/exhaustive-deps
const uploadFiles = () => {
if (!status.all) return <TroubleshootConnectionCard />
if (!files.length) {
setFiles([])
history.replace(ROUTES.UPLOAD)
return <></>
}
const identity = uploadOrigin.uuid ? identities.find(x => x.uuid === uploadOrigin.uuid) : null
const onUpload = () => {
if (uploadOrigin.origin === 'UPLOAD') {
uploadFiles()
} else {
if ((identity as Identity).type === 'PRIVATE_KEY') {
uploadFiles()
} else {
setShowPasswordPrompt(true)
}
}
}
const uploadFiles = (password?: string) => {
if (!beeApi || !files.length || !stamp) {
return
}
@@ -48,7 +78,16 @@ export function Upload(): ReactElement {
.uploadFiles(stamp.batchID, files as unknown as File[], { indexDocument })
.then(hash => {
putHistory(HISTORY_KEYS.UPLOAD_HISTORY, hash.reference, getAssetNameFromFiles(files))
history.replace(ROUTES.HASH.replace(':hash', hash.reference))
if (uploadOrigin.origin === 'UPLOAD') {
history.replace(ROUTES.HASH.replace(':hash', hash.reference))
} else {
updateFeed(beeApi, identity as Identity, hash.reference, stamp.batchID, password as string).then(() => {
persistIdentity(identities, identity as Identity)
setIdentities([...identities])
history.replace(ROUTES.FEEDS_PAGE.replace(':uuid', uploadOrigin.uuid as string))
})
}
})
.catch(e => {
enqueueSnackbar(`Error uploading: ${e.message}`, { variant: 'error' })
@@ -57,36 +96,69 @@ export function Upload(): ReactElement {
}
const reset = () => {
setStep(0)
setFiles([])
setStamp(null)
setUploading(false)
}
const onFeedPasswordGiven = (password: string) => {
uploadFiles(password)
}
return (
<>
<HistoryHeader>Upload</HistoryHeader>
{files.length && <AssetPreview files={files} />}
{stamp !== null ? <StampPreview stamp={stamp} /> : null}
{files.length && (
<UploadActionBar
canSelectStamp={stamps !== null && stamps.length > 0}
hasSelectedStamp={stamp !== null}
onCancel={reset}
onBuy={() => setBuyingStamp(true)}
onSelect={() => setSelectingStamp(true)}
onUpload={uploadFiles}
onClearStamp={() => setStamp(null)}
isUploading={isUploading}
{showPasswordPrompt && (
<FeedPasswordDialog
loading={isUploading}
feedName={(identity as Identity).name}
onCancel={() => setShowPasswordPrompt(false)}
onProceed={onFeedPasswordGiven}
/>
)}
{isBuyingStamp ? <CreatePostageStampModal onClose={() => setBuyingStamp(false)} /> : null}
{stamps && isSelectingStamp ? (
<SelectPostageStampModal
stamps={stamps}
onClose={() => setSelectingStamp(false)}
onSelect={stamp => setStamp(stamp)}
/>
) : null}
{identity && <HistoryHeader>{`Update "${identity.name}"`}</HistoryHeader>}
{!identity && <HistoryHeader>Upload</HistoryHeader>}
<Box mb={4}>
<ProgressIndicator steps={['Preview', 'Add postage stamp', 'Upload to node']} index={step} />
</Box>
{(step === 0 || step === 2) && <AssetPreview files={files} />}
{step === 1 && (
<>
<Box mb={2}>
{stampMode === 'SELECT' ? (
<PostageStampSelector onSelect={stamp => setStamp(stamp)} defaultValue={stamp?.batchID} />
) : (
<PostageStampCreation onFinished={() => setStampMode('SELECT')} />
)}
</Box>
<Box mb={4}>
<DocumentationText>
Please refer to the{' '}
<a
href="https://docs.ethswarm.org/debug-api/#tag/Postage-Stamps/paths/~1stamps~1{amount}~1{depth}/post"
target="_blank"
rel="noreferrer"
>
official Bee documentation
</a>{' '}
to understand these values.
</DocumentationText>
</Box>
</>
)}
{step === 2 && stamp && <StampPreview stamp={stamp} />}
<UploadActionBar
step={step}
onCancel={reset}
onGoBack={() => setStep(step => step - 1)}
onProceed={() => setStep(step => step + 1)}
onUpload={onUpload}
isUploading={isUploading}
hasStamp={Boolean(stamp)}
uploadLabel={identity ? 'Update Feed' : 'Upload To Your Node'}
stampMode={stampMode}
setStampMode={setStampMode}
/>
</>
)
}
+69 -50
View File
@@ -1,69 +1,88 @@
import { Button, Typography } from '@material-ui/core'
import { Clear } from '@material-ui/icons'
import { Box, Grid } from '@material-ui/core'
import { ReactElement } from 'react'
import { Check, Layers, PlusSquare, RefreshCcw } from 'react-feather'
import { ArrowLeft, Check, Layers, PlusSquare, X } from 'react-feather'
import { DocumentationText } from '../../components/DocumentationText'
import ExpandableListItemActions from '../../components/ExpandableListItemActions'
import { SwarmButton } from '../../components/SwarmButton'
interface Props {
canSelectStamp: boolean
hasSelectedStamp: boolean
step: number
onUpload: () => void
onBuy: () => void
onSelect: () => void
onCancel: () => void
onClearStamp: () => void
onGoBack: () => void
onProceed: () => void
isUploading: boolean
hasStamp: boolean
uploadLabel: string
stampMode: 'BUY' | 'SELECT'
setStampMode: (mode: 'BUY' | 'SELECT') => void
}
export function UploadActionBar({
canSelectStamp,
hasSelectedStamp,
step,
onUpload,
onBuy,
onSelect,
onCancel,
onClearStamp,
onGoBack,
onProceed,
isUploading,
hasStamp,
uploadLabel,
stampMode,
setStampMode,
}: Props): ReactElement {
const showBuy = !hasSelectedStamp
const showSelect = canSelectStamp && !hasSelectedStamp
const showUpload = hasSelectedStamp
const showChange = canSelectStamp && hasSelectedStamp
if (step === 0) {
return (
<>
<Box mb={1}>
<ExpandableListItemActions>
<SwarmButton onClick={onProceed} iconType={Layers}>
Add Postage Stamp
</SwarmButton>
<SwarmButton onClick={onCancel} iconType={X} cancel>
Cancel
</SwarmButton>
</ExpandableListItemActions>
</Box>
<DocumentationText>You need a postage stamp to upload.</DocumentationText>
</>
)
}
return (
<>
if (step === 1) {
return (
<Grid container direction="row" justifyContent="space-between">
<ExpandableListItemActions>
{stampMode === 'SELECT' && (
<SwarmButton onClick={onProceed} iconType={Check} disabled={!hasStamp}>
Proceed With Selected Stamp
</SwarmButton>
)}
<SwarmButton onClick={onGoBack} iconType={ArrowLeft} cancel>
Back To Preview
</SwarmButton>
</ExpandableListItemActions>
<SwarmButton
onClick={() => setStampMode(stampMode === 'BUY' ? 'SELECT' : 'BUY')}
iconType={stampMode === 'BUY' ? Layers : PlusSquare}
>
{stampMode === 'BUY' ? 'Use Existing Stamp' : 'Buy New Stamp'}
</SwarmButton>
</Grid>
)
}
if (step === 2) {
return (
<ExpandableListItemActions>
{showBuy ? (
<SwarmButton onClick={onBuy} iconType={PlusSquare}>
Buy New Postage Stamp
</SwarmButton>
) : null}
{showSelect ? (
<SwarmButton onClick={onSelect} iconType={Layers}>
Use Existing Postage Stamp
</SwarmButton>
) : null}
{showUpload ? (
<SwarmButton onClick={onUpload} iconType={Check} disabled={isUploading} loading={isUploading}>
Upload To Your Node
</SwarmButton>
) : null}
{showChange ? (
<SwarmButton onClick={onClearStamp} iconType={RefreshCcw} disabled={isUploading}>
Change Postage Stamp
</SwarmButton>
) : null}
<Button onClick={onCancel} variant="contained" startIcon={<Clear />}>
Cancel
</Button>
<SwarmButton onClick={onUpload} iconType={Check} disabled={isUploading} loading={isUploading}>
{uploadLabel}
</SwarmButton>
<SwarmButton onClick={onGoBack} iconType={ArrowLeft} disabled={isUploading} cancel>
Change Postage Stamp
</SwarmButton>
</ExpandableListItemActions>
{showSelect ? (
<Typography>
You need a postage stamp to upload. Please refer to the official Bee documentation to understand how postage
stamps work.
</Typography>
) : null}
</>
)
)
}
return <></>
}
+17 -10
View File
@@ -1,19 +1,23 @@
import { createStyles, makeStyles, Theme, Typography } from '@material-ui/core'
import { createStyles, makeStyles, Theme } from '@material-ui/core'
import { DropzoneArea } from 'material-ui-dropzone'
import { useSnackbar } from 'notistack'
import { ReactElement, useContext, useState } from 'react'
import { FilePlus, FolderPlus, PlusCircle } from 'react-feather'
import { useHistory } from 'react-router-dom'
import { DocumentationText } from '../../components/DocumentationText'
import { SwarmButton } from '../../components/SwarmButton'
import { Context } from '../../providers/File'
import { Context, UploadOrigin } from '../../providers/File'
import { ROUTES } from '../../routes'
import { detectIndexHtml } from '../../utils/file'
import { SwarmFile } from '../../utils/SwarmFile'
interface Props {
maximumSizeInBytes: number
uploadOrigin: UploadOrigin
showHelp: boolean
}
const MAX_FILE_SIZE = 1_000_000_000 // 1 gigabyte
const useStyles = makeStyles((theme: Theme) =>
createStyles({
areaWrapper: { position: 'relative', marginBottom: theme.spacing(2) },
@@ -44,8 +48,8 @@ const useStyles = makeStyles((theme: Theme) =>
}),
)
export function UploadArea({ maximumSizeInBytes }: Props): ReactElement {
const { setFiles } = useContext(Context)
export function UploadArea({ uploadOrigin, showHelp }: Props): ReactElement {
const { setFiles, setUploadOrigin } = useContext(Context)
const classes = useStyles()
const history = useHistory()
const { enqueueSnackbar } = useSnackbar()
@@ -110,6 +114,7 @@ export function UploadArea({ maximumSizeInBytes }: Props): ReactElement {
setFiles(swarmFiles)
if (files.length) {
setUploadOrigin(uploadOrigin)
history.push(ROUTES.UPLOAD_IN_PROGRESS)
}
}
@@ -123,7 +128,7 @@ export function UploadArea({ maximumSizeInBytes }: Props): ReactElement {
dropzoneClass={classes.dropzone}
onChange={handleChange}
filesLimit={1e9}
maxFileSize={maximumSizeInBytes}
maxFileSize={MAX_FILE_SIZE}
showPreviews={false}
/>
<div className={classes.buttonWrapper}>
@@ -138,10 +143,12 @@ export function UploadArea({ maximumSizeInBytes }: Props): ReactElement {
</SwarmButton>
</div>
</div>
<Typography>
You can click the buttons above or simply drag and drop to add a file or folder. To upload a website to Swarm,
make sure that your folder contains an index.html file.
</Typography>
{showHelp && (
<DocumentationText>
You can click the buttons above or simply drag and drop to add a file or folder. To upload a website to Swarm,
make sure that your folder contains an index.html file.
</DocumentationText>
)}
</>
)
}
+9 -4
View File
@@ -1,16 +1,21 @@
import { ReactElement } from 'react'
import { ReactElement, useContext } from 'react'
import { History } from '../../components/History'
import TroubleshootConnectionCard from '../../components/TroubleshootConnectionCard'
import { Context as BeeContext } from '../../providers/Bee'
import { defaultUploadOrigin } from '../../providers/File'
import { HISTORY_KEYS } from '../../utils/local-storage'
import { FileNavigation } from './FileNavigation'
import { UploadArea } from './UploadArea'
const MAX_FILE_SIZE = 1_000_000_000 // 1 gigabyte
export function UploadLander(): ReactElement {
const { status } = useContext(BeeContext)
if (!status.all) return <TroubleshootConnectionCard />
return (
<>
<FileNavigation active="UPLOAD" />
<UploadArea maximumSizeInBytes={MAX_FILE_SIZE} />
<UploadArea showHelp={true} uploadOrigin={defaultUploadOrigin} />
<History title="Upload History" localStorageKey={HISTORY_KEYS.UPLOAD_HISTORY} />
</>
)
@@ -1,152 +0,0 @@
import Button from '@material-ui/core/Button'
import CircularProgress from '@material-ui/core/CircularProgress'
import Dialog from '@material-ui/core/Dialog'
import DialogActions from '@material-ui/core/DialogActions'
import DialogContent from '@material-ui/core/DialogContent'
import DialogContentText from '@material-ui/core/DialogContentText'
import DialogTitle from '@material-ui/core/DialogTitle'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import BigNumber from 'bignumber.js'
import { Field, Form, Formik, FormikHelpers } from 'formik'
import { TextField } from 'formik-material-ui'
import { useSnackbar } from 'notistack'
import React, { ReactElement, useContext } from 'react'
import { Context as SettingsContext } from '../../providers/Settings'
import { Context } from '../../providers/Stamps'
interface FormValues {
depth?: string
amount?: string
label?: string
}
type FormErrors = Partial<FormValues>
const initialFormValues: FormValues = {
depth: '',
amount: '',
label: '',
}
const useStyles = makeStyles((theme: Theme) =>
createStyles({
wrapper: {
margin: theme.spacing(1),
position: 'relative',
},
field: {
marginTop: theme.spacing(1),
marginBottom: theme.spacing(1),
},
buttonProgress: {
position: 'absolute',
top: '50%',
left: '50%',
marginTop: -12,
marginBottom: -12,
},
}),
)
interface Props {
onClose: () => void
}
export function CreatePostageStampModal({ onClose }: Props): ReactElement {
const classes = useStyles()
const { refresh } = useContext(Context)
const { beeDebugApi } = useContext(SettingsContext)
const { enqueueSnackbar } = useSnackbar()
return (
<Formik
initialValues={initialFormValues}
onSubmit={async (values: FormValues, actions: FormikHelpers<FormValues>) => {
try {
// This is really just a typeguard, the validation pretty much guarantees these will have the right values
if (!values.depth || !values.amount) return
if (!beeDebugApi) return
const amount = BigInt(values.amount)
const depth = Number.parseInt(values.depth)
const options = values.label ? { label: values.label } : undefined
await beeDebugApi.createPostageBatch(amount.toString(), depth, options)
actions.resetForm()
await refresh()
onClose()
} catch (e) {
enqueueSnackbar(`Error: ${(e as Error).message}`, { variant: 'error' })
actions.setSubmitting(false)
}
}}
validate={(values: FormValues) => {
const errors: FormErrors = {}
// Depth
if (!values.depth) errors.depth = 'Required field'
else {
const depth = new BigNumber(values.depth)
if (!depth.isInteger()) errors.depth = 'Depth must be an integer'
else if (depth.isLessThan(16)) errors.depth = 'Minimal depth is 16'
else if (depth.isGreaterThan(255)) errors.depth = 'Depth has to be at most 255'
}
// Amount
if (!values.amount) errors.amount = 'Required field'
else {
const amount = new BigNumber(values.amount)
if (!amount.isInteger()) errors.amount = 'Amount must be an integer'
else if (amount.isLessThanOrEqualTo(0)) errors.amount = 'Amount must be greater than 0'
}
// Label
if (values.label && !/^[0-9a-z]*$/i.test(values.label)) errors.label = 'Label must be an alphanumeric string'
return errors
}}
>
{({ submitForm, isValid, isSubmitting, values }) => (
<Form>
<Dialog open={true} onClose={onClose} aria-labelledby="form-dialog-title">
<DialogTitle id="form-dialog-title">Buy new postage stamp</DialogTitle>
<DialogContent>
<Field
component={TextField}
required
name="depth"
autoFocus
label="Depth"
fullWidth
className={classes.field}
/>
<Field component={TextField} required name="amount" label="Amount" fullWidth className={classes.field} />
<Field component={TextField} name="label" label="Label" fullWidth className={classes.field} />
</DialogContent>
<DialogActions>
<Button onClick={onClose} variant="contained">
Cancel
</Button>
<div className={classes.wrapper}>
<Button
disabled={isSubmitting || !isValid || !values.amount || !values.depth}
type="submit"
variant="contained"
onClick={submitForm}
>
Create
{isSubmitting && <CircularProgress size={24} className={classes.buttonProgress} />}
</Button>
</div>
</DialogActions>
<DialogContent>
<DialogContentText>
Please refer to the official Bee documentation to understand these values.
</DialogContentText>
</DialogContent>
</Dialog>
</Form>
)}
</Formik>
)
}
@@ -0,0 +1,20 @@
import { ReactElement } from 'react'
import { useHistory } from 'react-router'
import { HistoryHeader } from '../../components/HistoryHeader'
import { ROUTES } from '../../routes'
import { PostageStampCreation } from './PostageStampCreation'
export function CreatePostageStampPage(): ReactElement {
const history = useHistory()
function onFinished() {
history.push(ROUTES.STAMPS)
}
return (
<div>
<HistoryHeader>Buy new postage stamp</HistoryHeader>
<PostageStampCreation onFinished={onFinished} />
</div>
)
}
+157
View File
@@ -0,0 +1,157 @@
import { Box, Grid, Typography } from '@material-ui/core'
import BigNumber from 'bignumber.js'
import { Form, Formik, FormikHelpers } from 'formik'
import { useSnackbar } from 'notistack'
import React, { ReactElement, useContext } from 'react'
import { Check } from 'react-feather'
import { SwarmButton } from '../../components/SwarmButton'
import { SwarmTextInput } from '../../components/SwarmTextInput'
import { Context as SettingsContext } from '../../providers/Settings'
import { Context } from '../../providers/Stamps'
import {
calculateStampPrice,
convertAmountToSeconds,
convertDepthToBytes,
formatBzz,
secondsToTimeString,
} from '../../utils'
import { getHumanReadableFileSize } from '../../utils/file'
interface FormValues {
depth?: string
amount?: string
label?: string
}
type FormErrors = Partial<FormValues>
const initialFormValues: FormValues = {
depth: '',
amount: '',
label: '',
}
interface Props {
onFinished: () => void
}
export function PostageStampCreation({ onFinished }: Props): ReactElement {
const { refresh } = useContext(Context)
const { beeDebugApi } = useContext(SettingsContext)
const { enqueueSnackbar } = useSnackbar()
function getFileSize(depth: number): string {
if (isNaN(depth) || depth < 17 || depth > 255) {
return '-'
}
return `~${getHumanReadableFileSize(convertDepthToBytes(depth))}`
}
function getTtl(amount: number): string {
if (isNaN(amount) || amount <= 0) {
return '-'
}
return secondsToTimeString(convertAmountToSeconds(amount))
}
function getPrice(depth: number, amount: number): string {
if (isNaN(amount) || amount <= 0 || isNaN(depth) || depth < 17 || depth > 255) {
return '-'
}
const price = calculateStampPrice(depth, amount)
return `${formatBzz(price)} BZZ`
}
return (
<Formik
initialValues={initialFormValues}
onSubmit={async (values: FormValues, actions: FormikHelpers<FormValues>) => {
try {
// This is really just a typeguard, the validation pretty much guarantees these will have the right values
if (!values.depth || !values.amount) return
if (!beeDebugApi) return
const amount = BigInt(values.amount)
const depth = Number.parseInt(values.depth)
const options = values.label ? { label: values.label } : undefined
await beeDebugApi.createPostageBatch(amount.toString(), depth, options)
actions.resetForm()
await refresh()
onFinished()
} catch (e) {
enqueueSnackbar(`Error: ${(e as Error).message}`, { variant: 'error' })
actions.setSubmitting(false)
}
}}
validate={(values: FormValues) => {
const errors: FormErrors = {}
// Depth
if (!values.depth) errors.depth = 'Required field'
else {
const depth = new BigNumber(values.depth)
if (!depth.isInteger()) errors.depth = 'Depth must be an integer'
else if (depth.isLessThan(16)) errors.depth = 'Minimal depth is 16'
else if (depth.isGreaterThan(255)) errors.depth = 'Depth has to be at most 255'
}
// Amount
if (!values.amount) errors.amount = 'Required field'
else {
const amount = new BigNumber(values.amount)
if (!amount.isInteger()) errors.amount = 'Amount must be an integer'
else if (amount.isLessThanOrEqualTo(0)) errors.amount = 'Amount must be greater than 0'
}
// Label
if (values.label && !/^[0-9a-z]*$/i.test(values.label)) errors.label = 'Label must be an alphanumeric string'
return errors
}}
>
{({ submitForm, isValid, isSubmitting, values }) => (
<Form>
<Box mb={2}>
<SwarmTextInput name="depth" label="Depth" formik />
<Box mt={0.25} sx={{ bgcolor: '#f6f6f6' }} p={2}>
<Grid container justifyContent="space-between">
<Typography>Corresponding file size</Typography>
<Typography>{getFileSize(parseInt(values.depth || '0', 10))}</Typography>
</Grid>
</Box>
</Box>
<Box mb={2}>
<SwarmTextInput name="amount" label="Amount" formik />
<Box mt={0.25} sx={{ bgcolor: '#f6f6f6' }} p={2}>
<Grid container justifyContent="space-between">
<Typography>Corresponding TTL (Time to live)</Typography>
<Typography>{getTtl(parseInt(values.amount || '0', 10))}</Typography>
</Grid>
</Box>
</Box>
<Box mb={2}>
<SwarmTextInput name="label" label="Label" optional formik />
</Box>
<Box mb={4} sx={{ bgcolor: '#fcf2e8' }} p={2}>
<Grid container justifyContent="space-between">
<Typography>Indicative Price</Typography>
<Typography>{getPrice(parseInt(values.depth || '0', 10), parseInt(values.amount || '0', 10))}</Typography>
</Grid>
</Box>
<SwarmButton
disabled={isSubmitting || !isValid || !values.amount || !values.depth}
onClick={submitForm}
iconType={Check}
loading={isSubmitting}
>
Buy New Stamp
</SwarmButton>
</Form>
)}
</Formik>
)
}
+31
View File
@@ -0,0 +1,31 @@
import React, { ReactElement, useContext } from 'react'
import { SwarmSelect } from '../../components/SwarmSelect'
import { Context, EnrichedPostageBatch } from '../../providers/Stamps'
interface Props {
onSelect: (stamp: EnrichedPostageBatch) => void
defaultValue?: string
}
export function PostageStampSelector({ onSelect, defaultValue }: Props): ReactElement {
const { stamps } = useContext(Context)
function onChange(stampId: string) {
if (!stamps) {
return
}
const stamp = stamps.find(x => x.batchID === stampId)
if (stamp) {
onSelect(stamp)
}
}
return (
<SwarmSelect
options={(stamps || []).map(x => ({ label: x.batchID.slice(0, 8), value: x.batchID }))}
onChange={event => onChange(event.target.value as string)}
defaultValue={defaultValue}
/>
)
}
+6 -24
View File
@@ -1,4 +1,4 @@
import { createStyles, FormControl, makeStyles, MenuItem, Select, Theme } from '@material-ui/core'
import { createStyles, makeStyles, Theme } 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'
@@ -6,6 +6,7 @@ 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 { SwarmSelect } from '../../components/SwarmSelect'
import { EnrichedPostageBatch } from '../../providers/Stamps'
interface Props {
@@ -26,14 +27,6 @@ const useStyles = makeStyles((theme: Theme) =>
color: '#606060',
textAlign: 'center',
},
select: {
background: theme.palette.background.paper,
borderRadius: 0,
border: 0,
},
option: {
height: '52px',
},
hint: {
marginBottom: '16px',
},
@@ -72,21 +65,10 @@ export function SelectPostageStampModal({ stamps, onSelect, onClose }: Props): R
Select postage stamp
</DialogTitle>
<DialogContent>
<FormControl fullWidth>
<Select
onChange={event => onChange(event.target.value as string)}
fullWidth
variant="outlined"
className={classes.select}
defaultValue=""
>
{stamps.map(x => (
<MenuItem key={x.batchID} value={x.batchID} className={classes.option}>
{x.batchID.slice(0, 8)}
</MenuItem>
))}
</Select>
</FormControl>
<SwarmSelect
options={stamps.map(x => ({ label: x.batchID, value: x.batchID }))}
onChange={event => onChange(event.target.value as string)}
/>
</DialogContent>
<DialogContent>
<ExpandableListItemActions>
+15 -1
View File
@@ -1,8 +1,10 @@
import type { ReactElement } from 'react'
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 { getHumanReadableFileSize } from '../../utils/file'
import { PostageStamp } from './PostageStamp'
interface Props {
@@ -17,7 +19,19 @@ function StampsTable({ postageStamps }: Props): ReactElement | null {
{postageStamps.map(stamp => (
<ExpandableElement
key={stamp.batchID}
expandable={<ExpandableListItemKey label="Batch ID" value={stamp.batchID} />}
expandable={
<>
<ExpandableListItemKey label="Batch ID" value={stamp.batchID} />
<ExpandableListItem label="Depth" value={String(stamp.depth)} />
<ExpandableListItem
label="Capacity"
value={`${getHumanReadableFileSize(2 ** stamp.depth * 4096 * stamp.usage)} / ${getHumanReadableFileSize(
2 ** stamp.depth * 4096,
)}`}
/>
<ExpandableListItem label="Amount" value={parseInt(stamp.amount, 10).toLocaleString()} />
</>
}
>
<PostageStamp stamp={stamp} shorten={true} />
</ExpandableElement>
+9 -6
View File
@@ -1,12 +1,13 @@
import { CircularProgress, Container } from '@material-ui/core'
import { createStyles, makeStyles } from '@material-ui/core/styles'
import { ReactElement, useContext, useEffect, useState } from 'react'
import { ReactElement, useContext, useEffect } from 'react'
import { PlusSquare } from 'react-feather'
import { useHistory } from 'react-router'
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 { ROUTES } from '../../routes'
import StampsTable from './StampsTable'
const useStyles = makeStyles(() =>
@@ -28,7 +29,7 @@ const useStyles = makeStyles(() =>
export default function Stamp(): ReactElement {
const classes = useStyles()
const [isBuyingStamp, setBuyingStamp] = useState(false)
const history = useHistory()
const { stamps, isLoading, error, start, stop } = useContext(StampsContext)
const { status } = useContext(BeeContext)
@@ -42,6 +43,10 @@ export default function Stamp(): ReactElement {
if (!status.all) return <TroubleshootConnectionCard />
function navigateToNewStamp() {
history.push(ROUTES.STAMPS_NEW)
}
return (
<div className={classes.root}>
{error && (
@@ -52,9 +57,7 @@ export default function Stamp(): ReactElement {
{!error && (
<>
<div className={classes.actions}>
{isBuyingStamp ? <CreatePostageStampModal onClose={() => setBuyingStamp(false)} /> : null}
<SwarmButton onClick={() => setBuyingStamp(true)} iconType={PlusSquare}>
<SwarmButton onClick={navigateToNewStamp} iconType={PlusSquare}>
Buy New Postage Stamp
</SwarmButton>
<div style={{ height: '5px' }}>{isLoading && <CircularProgress />}</div>
+15 -1
View File
@@ -3,6 +3,7 @@ import type {
Health,
LastChequesResponse,
NodeAddresses,
NodesInfo,
Peer,
Topology,
} from '@ethersphere/bee-js'
@@ -35,6 +36,7 @@ interface ContextInterface {
apiHealth: boolean
debugApiHealth: Health | null
nodeAddresses: NodeAddresses | null
nodeInfo: NodesInfo | null
topology: Topology | null
chequebookAddress: ChequebookAddressResponse | null
peers: Peer[] | null
@@ -72,6 +74,7 @@ const initialValues: ContextInterface = {
apiHealth: false,
debugApiHealth: null,
nodeAddresses: null,
nodeInfo: null,
topology: null,
chequebookAddress: null,
peers: null,
@@ -98,6 +101,7 @@ interface Props {
function getStatus(
debugApiHealth: Health | null,
nodeAddresses: NodeAddresses | null,
nodeInfo: NodesInfo | null,
apiHealth: boolean,
topology: Topology | null,
chequebookAddress: ChequebookAddressResponse | null,
@@ -105,7 +109,7 @@ function getStatus(
error: Error | null,
): Status {
// FIXME: `devMode` is a temporary workaround to be able to develop with only one node
const devMode = startedInDevMode || Boolean(process.env.REACT_APP_DEV_MODE)
const devMode = startedInDevMode || Boolean(process.env.REACT_APP_DEV_MODE) || nodeInfo?.beeMode === 'dev'
const status = {
version: Boolean(
debugApiHealth &&
@@ -132,6 +136,7 @@ export function Provider({ children }: Props): ReactElement {
const [apiHealth, setApiHealth] = useState<boolean>(false)
const [debugApiHealth, setDebugApiHealth] = useState<Health | null>(null)
const [nodeAddresses, setNodeAddresses] = useState<NodeAddresses | null>(null)
const [nodeInfo, setNodeInfo] = useState<NodesInfo | null>(null)
const [topology, setNodeTopology] = useState<Topology | null>(null)
const [chequebookAddress, setChequebookAddress] = useState<ChequebookAddressResponse | null>(null)
const [peers, setPeers] = useState<Peer[] | null>(null)
@@ -165,6 +170,7 @@ export function Provider({ children }: Props): ReactElement {
setDebugApiHealth(null)
setNodeAddresses(null)
setNodeTopology(null)
setNodeInfo(null)
setPeers(null)
setChequebookAddress(null)
setChequebookBalance(null)
@@ -241,6 +247,12 @@ export function Provider({ children }: Props): ReactElement {
.then(setNodeAddresses)
.catch(() => setNodeAddresses(null)),
// NodeInfo
beeDebugApi
.getNodeInfo()
.then(setNodeInfo)
.catch(() => setNodeInfo(null)),
// Network Topology
beeDebugApi
.getTopology()
@@ -312,6 +324,7 @@ export function Provider({ children }: Props): ReactElement {
status: getStatus(
debugApiHealth,
nodeAddresses,
nodeInfo,
apiHealth,
topology,
chequebookAddress,
@@ -333,6 +346,7 @@ export function Provider({ children }: Props): ReactElement {
apiHealth,
debugApiHealth,
nodeAddresses,
nodeInfo,
topology,
chequebookAddress,
peers,
+43
View File
@@ -0,0 +1,43 @@
import { createContext, ReactChild, ReactElement, useEffect, useState } from 'react'
export type IdentityType = 'V3' | 'PRIVATE_KEY'
export interface Identity {
uuid: string
name: string
feedHash?: string
identity: string
address: string
type: IdentityType
}
interface ContextInterface {
identities: Identity[]
setIdentities: (identities: Identity[]) => void
}
const initialValues: ContextInterface = {
identities: [],
setIdentities: () => {}, // eslint-disable-line
}
export const Context = createContext<ContextInterface>(initialValues)
export const Consumer = Context.Consumer
interface Props {
children: ReactChild
}
export function Provider({ children }: Props): ReactElement {
const [identities, setIdentities] = useState<Identity[]>(initialValues.identities)
useEffect(() => {
try {
setIdentities(JSON.parse(localStorage.getItem('feeds') || '[]'))
} catch {
setIdentities([])
}
}, []) // eslint-disable-line react-hooks/exhaustive-deps
return <Context.Provider value={{ identities, setIdentities }}>{children}</Context.Provider>
}
+13 -2
View File
@@ -1,14 +1,24 @@
/* eslint-disable @typescript-eslint/no-empty-function */
import { createContext, ReactChild, ReactElement, useState } from 'react'
import { SwarmFile } from '../utils/SwarmFile'
export type UploadOrigin = { origin: 'UPLOAD' | 'FEED'; uuid?: string }
export const defaultUploadOrigin: UploadOrigin = { origin: 'UPLOAD' }
interface ContextInterface {
files: SwarmFile[]
setFiles: (files: SwarmFile[]) => void
uploadOrigin: UploadOrigin
setUploadOrigin: (uploadOrigin: UploadOrigin) => void
}
const initialValues: ContextInterface = {
files: [],
setFiles: () => {}, // eslint-disable-line
setFiles: () => {},
uploadOrigin: defaultUploadOrigin,
setUploadOrigin: () => {},
}
export const Context = createContext<ContextInterface>(initialValues)
@@ -20,6 +30,7 @@ interface Props {
export function Provider({ children }: Props): ReactElement {
const [files, setFiles] = useState<SwarmFile[]>(initialValues.files)
const [uploadOrigin, setUploadOrigin] = useState<UploadOrigin>(initialValues.uploadOrigin)
return <Context.Provider value={{ files, setFiles }}>{children}</Context.Provider>
return <Context.Provider value={{ files, setFiles, uploadOrigin, setUploadOrigin }}>{children}</Context.Provider>
}
+15
View File
@@ -1,6 +1,10 @@
import type { ReactElement } from 'react'
import { Route, Switch } from 'react-router-dom'
import Accounting from './pages/accounting'
import Feeds from './pages/feeds'
import CreateNewFeed from './pages/feeds/CreateNewFeed'
import { FeedSubpage } from './pages/feeds/FeedSubpage'
import UpdateFeed from './pages/feeds/UpdateFeed'
import { Download } from './pages/files/Download'
import { Share } from './pages/files/Share'
import { Upload } from './pages/files/Upload'
@@ -8,6 +12,7 @@ import { UploadLander } from './pages/files/UploadLander'
import Info from './pages/info'
import Settings from './pages/settings'
import Stamps from './pages/stamps'
import { CreatePostageStampPage } from './pages/stamps/CreatePostageStampPage'
import Status from './pages/status'
export enum ROUTES {
@@ -20,7 +25,12 @@ export enum ROUTES {
ACCOUNTING = '/accounting',
SETTINGS = '/settings',
STAMPS = '/stamps',
STAMPS_NEW = '/stamps/new',
STATUS = '/status',
FEEDS = '/feeds',
FEEDS_NEW = '/feeds/new',
FEEDS_UPDATE = '/feeds/update/:hash',
FEEDS_PAGE = '/feeds/:uuid',
}
const BaseRouter = (): ReactElement => (
@@ -32,7 +42,12 @@ const BaseRouter = (): ReactElement => (
<Route exact path={ROUTES.ACCOUNTING} component={Accounting} />
<Route exact path={ROUTES.SETTINGS} component={Settings} />
<Route exact path={ROUTES.STAMPS} component={Stamps} />
<Route exact path={ROUTES.STAMPS_NEW} component={CreatePostageStampPage} />
<Route exact path={ROUTES.STATUS} component={Status} />
<Route exact path={ROUTES.FEEDS} component={Feeds} />
<Route exact path={ROUTES.FEEDS_NEW} component={CreateNewFeed} />
<Route exact path={ROUTES.FEEDS_UPDATE} component={UpdateFeed} />
<Route exact path={ROUTES.FEEDS_PAGE} component={FeedSubpage} />
<Route path={ROUTES.INFO} component={Info} />
</Switch>
)
+16
View File
@@ -30,6 +30,18 @@ export function detectIndexHtml(files: SwarmFile[]): string | false {
}
export function getHumanReadableFileSize(bytes: number): string {
if (bytes >= 1e15) {
return (bytes / 1e15).toFixed(2) + ' PB'
}
if (bytes >= 1e12) {
return (bytes / 1e12).toFixed(2) + ' TB'
}
if (bytes >= 1e9) {
return (bytes / 1e9).toFixed(2) + ' GB'
}
if (bytes >= 1e6) {
return (bytes / 1e6).toFixed(2) + ' MB'
}
@@ -65,6 +77,10 @@ export function convertManifestToFiles(files: Record<string, string>): SwarmFile
}
export function getAssetNameFromFiles(files: SwarmFile[]): string {
if (!files.length) {
return 'Unknown'
}
if (files.length === 1) {
return files[0].name
}
+110
View File
@@ -0,0 +1,110 @@
import { Bee, Reference } from '@ethersphere/bee-js'
import Wallet from 'ethereumjs-wallet'
import { uuidV4 } from '.'
import { Identity, IdentityType } from '../providers/Feeds'
export function generateWallet(): Wallet {
const buffer = new Uint8Array(32)
crypto.getRandomValues(buffer)
const wallet = new Wallet(Buffer.from(buffer))
return wallet
}
export function persistIdentity(identities: Identity[], identity: Identity): void {
const existingIndex = identities.findIndex(x => x.uuid === identity.uuid)
if (existingIndex !== -1) {
identities.splice(existingIndex, 1)
}
identities.unshift(identity)
localStorage.setItem('feeds', JSON.stringify(identities))
}
export function persistIdentitiesWithoutUpdate(identities: Identity[]): void {
localStorage.setItem('feeds', JSON.stringify(identities))
}
export async function convertWalletToIdentity(
identity: Wallet,
type: IdentityType,
name: string,
password?: string,
): Promise<Identity> {
if (type === 'V3' && !password) {
throw Error('V3 passwords require password')
}
const identityString =
type === 'PRIVATE_KEY' ? identity.getPrivateKeyString() : await identity.toV3String(password as string)
return {
uuid: uuidV4(),
name,
type: password ? 'V3' : 'PRIVATE_KEY',
address: identity.getAddressString(),
identity: identityString,
}
}
export async function importIdentity(name: string, data: string): Promise<Identity | null> {
if (data.length === 64) {
const wallet = await getWallet('PRIVATE_KEY', data)
return {
uuid: uuidV4(),
name,
type: 'PRIVATE_KEY',
identity: data,
address: wallet.getAddressString(),
}
}
if (data.length === 66 && data.toLowerCase().startsWith('0x')) {
const wallet = await getWallet('PRIVATE_KEY', data.slice(2))
return { uuid: uuidV4(), name, type: 'PRIVATE_KEY', identity: data, address: wallet.getAddressString() }
}
try {
const { address } = JSON.parse(data)
return { uuid: uuidV4(), name, type: 'V3', identity: data, address }
} catch {
return null
}
}
function getWalletFromIdentity(identity: Identity, password?: string): Promise<Wallet> {
return getWallet(identity.type, identity.identity, password)
}
async function getWallet(type: IdentityType, data: string, password?: string): Promise<Wallet> {
return type === 'PRIVATE_KEY'
? Wallet.fromPrivateKey(Buffer.from(trimHexString(data), 'hex'))
: await Wallet.fromV3(data, password as string)
}
export async function updateFeed(
beeApi: Bee,
identity: Identity,
hash: string,
stamp: string,
password?: string,
): Promise<void> {
const wallet = await getWalletFromIdentity(identity, password)
if (!identity.feedHash) {
identity.feedHash = await beeApi.createFeedManifest(stamp, 'sequence', '00'.repeat(32), wallet.getAddressString())
}
const writer = beeApi.makeFeedWriter('sequence', '00'.repeat(32), wallet.getPrivateKeyString())
await writer.upload(stamp, hash as Reference)
}
function trimHexString(string: string): string {
if (string.toLowerCase().startsWith('0x')) {
return string.slice(2)
}
return string
}
+77
View File
@@ -112,3 +112,80 @@ export function extractSwarmHash(string: string): string | null {
return (matches && matches[0]) || null
}
export function uuidV4(): string {
const pattern = '10000000-1000-4000-8000-100000000000'
return pattern.replace(/[018]/g, (s: string) => {
const c = parseInt(s, 10)
return (c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))).toString(16)
})
}
export function formatEnum(string: string): string {
return (string.charAt(0).toUpperCase() + string.slice(1).toLowerCase()).replaceAll('_', ' ')
}
export function secondsToTimeString(seconds: number): string {
let unit = seconds
if (unit < 120) {
return `${seconds} seconds`
}
unit /= 60
if (unit < 120) {
return `${Math.round(unit)} minutes`
}
unit /= 60
if (unit < 48) {
return `${Math.round(unit)} hours`
}
unit /= 24
if (unit < 14) {
return `${Math.round(unit)} days`
}
unit /= 7
if (unit < 52) {
return `${Math.round(unit)} weeks`
}
unit /= 52
return `${unit.toFixed(1)} years`
}
export function formatBzz(amount: number): string {
const asString = amount.toFixed(16)
let indexOfSignificantDigit = -1
let reachedDecimalPoint = false
for (let i = 0; i < asString.length; i++) {
const char = asString[i]
if (char === '.') {
reachedDecimalPoint = true
} else if (reachedDecimalPoint && char !== '0') {
indexOfSignificantDigit = i
break
}
}
return asString.slice(0, indexOfSignificantDigit + 4)
}
export function convertDepthToBytes(depth: number): number {
return 2 ** depth * 4096
}
export function convertAmountToSeconds(amount: number): number {
return amount / 10 / 1
}
export function calculateStampPrice(depth: number, amount: number): number {
return (amount * 2 ** (depth - 16) * 2) / 1e16
}