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
+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>
)
}