sigit.io/src/pages/home/index.tsx

590 lines
16 KiB
TypeScript
Raw Normal View History

import {
Box,
Button,
FormControl,
IconButton,
InputLabel,
List,
ListItem,
ListSubheader,
MenuItem,
Select,
TextField,
Typography
} from '@mui/material'
import { MuiFileInput } from 'mui-file-input'
import styles from './style.module.scss'
import { Dispatch, SetStateAction, useEffect, useState } from 'react'
import placeholderAvatar from '../../assets/images/nostr-logo.jpg'
import { ProfileMetadata } from '../../types'
import { MetadataController, NostrController } from '../../controllers'
import { Link } from 'react-router-dom'
import {
encryptArrayBuffer,
generateEncryptionKey,
getFileHash,
pubToHex,
queryNip05,
shorten
} from '../../utils'
import { LoadingSpinner } from '../../components/LoadingSpinner'
import { getProfileRoute } from '../../routes'
import { Clear } from '@mui/icons-material'
import JSZip from 'jszip'
import { toast } from 'react-toastify'
import { useSelector } from 'react-redux'
import { State } from '../../store/rootReducer'
import { EventTemplate } from 'nostr-tools'
import axios from 'axios'
enum SelectionType {
signer = 'Signer',
viewer = 'Viewer'
}
type MetadataMap = {
[key: string]: ProfileMetadata
}
export const HomePage = () => {
const [inputValue, setInputValue] = useState('')
const [type, setType] = useState<SelectionType>(SelectionType.signer)
const [error, setError] = useState<string>()
const [signers, setSigners] = useState<string[]>([])
const [viewers, setViewers] = useState<string[]>([])
const [metadataMap, setMetadataMap] = useState<MetadataMap>({})
const [selectedFiles, setSelectedFiles] = useState<File[]>([])
const [isLoading, setIsLoading] = useState(false)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
const [authUrl, setAuthUrl] = useState<string>()
const usersPubkey = useSelector((state: State) => state.auth.usersPubkey)
const nostrController = NostrController.getInstance()
const handleAddClick = async () => {
setError(undefined)
const addPubkey = (pubkey: string) => {
const addElement = (prev: string[]) => {
// if key is already in the list just return that
if (prev.includes(pubkey)) return prev
return [...prev, pubkey]
}
if (type === SelectionType.signer) {
setSigners(addElement)
} else {
setViewers(addElement)
}
}
if (inputValue.startsWith('npub')) {
const pubkey = await pubToHex(inputValue)
if (pubkey) {
addPubkey(pubkey)
setInputValue('')
} else {
setError('Provided npub is not valid. Please enter correct npub.')
}
return
}
if (inputValue.includes('@')) {
setIsLoading(true)
setLoadingSpinnerDesc('Querying for nip05')
const nip05Profile = await queryNip05(inputValue)
.catch((err) => {
console.error(`error occurred in querying nip05: ${inputValue}`, err)
return null
})
.finally(() => {
setIsLoading(false)
setLoadingSpinnerDesc('')
})
if (nip05Profile) {
const pubkey = nip05Profile.pubkey
addPubkey(pubkey)
setInputValue('')
} else {
setError('Provided nip05 is not valid. Please enter correct nip05.')
}
return
}
setError('Invalid input! Make sure to provide correct npub or nip05.')
}
const handleRemove = (pubkey: string, selectionType: SelectionType) => {
if (selectionType === SelectionType.signer) {
setSigners((prev) => prev.filter((signer) => signer !== pubkey))
} else {
setViewers((prev) => prev.filter((viewer) => viewer !== pubkey))
}
}
const handleSelectFiles = (files: File[]) => {
setSelectedFiles((prev) => {
const prevFileNames = prev.map((file) => file.name)
const newFiles = files.filter(
(file) => !prevFileNames.includes(file.name)
)
return [...prev, ...newFiles]
})
}
const handleRemoveFile = (fileToRemove: File) => {
setSelectedFiles((prevFiles) =>
prevFiles.filter((file) => file.name !== fileToRemove.name)
)
}
const handleSubmit = async () => {
if (signers.length === 0) {
toast.error('No signer is provided. At least provide one signer.')
return
}
if (viewers.length === 0) {
toast.error('No viewer is provided. At least provide one viewer.')
return
}
if (selectedFiles.length === 0) {
toast.error('No file is provided. At least provide one file.')
return
}
setIsLoading(true)
setLoadingSpinnerDesc('Generating hashes for files')
const fileHashes: { [key: string]: string } = {}
for (const file of selectedFiles) {
const hash = await getFileHash(file)
fileHashes[file.name] = hash
}
const zip = new JSZip()
selectedFiles.forEach((file) => {
zip.file(`files/${file.name}`, file)
})
const event: EventTemplate = {
kind: 1,
tags: [['r', signers[0]]],
content: JSON.stringify(fileHashes),
created_at: Math.floor(Date.now() / 1000)
}
setLoadingSpinnerDesc('Signing nostr event')
const signedEvent = await nostrController.signEvent(event).catch((err) => {
console.error(err)
toast.error(err.message || 'Error occurred in signing nostr event')
setIsLoading(false)
return null
})
if (!signedEvent) return
const meta = {
signers,
viewers,
fileHashes,
submittedBy: usersPubkey,
signedEvents: {
[signedEvent.pubkey]: JSON.stringify(signedEvent, null, 2)
}
}
try {
const stringifiedMeta = JSON.stringify(meta, null, 2)
zip.file('meta.json', stringifiedMeta)
} catch (err) {
toast.error('An error occurred in converting meta json to string')
}
setLoadingSpinnerDesc('Generating zip file')
const arraybuffer = await zip
.generateAsync({
type: 'arraybuffer',
compression: 'DEFLATE',
compressionOptions: {
level: 6
}
})
.catch((err) => {
console.log('err in zip:>> ', err)
setIsLoading(false)
toast.error(err.message || 'Error occurred in generating zip file')
return null
})
if (!arraybuffer) return
const encryptionKey = await generateEncryptionKey()
setLoadingSpinnerDesc('Encrypting zip file')
const encryptedArrayBuffer = await encryptArrayBuffer(
arraybuffer,
encryptionKey
)
const blob = new Blob([encryptedArrayBuffer])
setLoadingSpinnerDesc('Uploading zip file to file storage.')
const fileUrl = await uploadToFileStorage(blob)
.then((url) => {
toast.success('zip file uploaded to file storage')
return url
})
.catch((err) => {
console.log('err in upload:>> ', err)
toast.error(err.message || 'Error occurred in uploading zip file')
return null
})
if (!fileUrl) return
await sendDMToFirstSigner(fileUrl, encryptionKey, signers[0])
setIsLoading(false)
}
const uploadToFileStorage = async (blob: Blob) => {
const unixNow = Math.floor(Date.now() / 1000)
const file = new File([blob], `zipped-${unixNow}.zip`, {
type: 'application/zip'
})
const event: EventTemplate = {
kind: 24242,
content: 'Authorize Upload',
created_at: Math.floor(Date.now() / 1000),
tags: [
['t', 'upload'],
['expiration', String(unixNow + 60 * 5)],
['name', file.name],
['size', String(file.size)]
]
}
setLoadingSpinnerDesc('Signing auth event for uploading zip')
const authEvent = await nostrController.signEvent(event)
const FILE_STORAGE_URL = 'https://blossom.sigit.io'
const response = await axios.put(`${FILE_STORAGE_URL}/upload`, file, {
headers: {
Authorization: 'Nostr ' + btoa(JSON.stringify(authEvent)),
'Content-Type': 'application/zip'
}
})
return response.data.url as string
}
const sendDMToFirstSigner = async (
fileUrl: string,
encryptionKey: string,
pubkey: string
) => {
const content = `You have been requested for a signature.\nHere is the url for zip file that you can download.\n
${fileUrl}\nHowever this zip file is encrypted and you need to decrypt it using https://app.sigit.io\n Encryption key: ${encryptionKey}`
nostrController.on('nsecbunker-auth', (url) => {
setAuthUrl(url)
})
setLoadingSpinnerDesc('encrypting content for DM')
// todo: add timeout
const encrypted = await nostrController
.nip04Encrypt(pubkey, content)
.then((res) => {
return res
})
.catch((err) => {
console.log('err :>> ', err)
toast.error(
err.message || 'An error occurred while encrypting DM content'
)
return null
})
.finally(() => {
setAuthUrl(undefined)
})
if (!encrypted) return
const event: EventTemplate = {
kind: 4,
content: encrypted,
created_at: Math.floor(Date.now() / 1000),
tags: [['p', signers[0]]]
}
setLoadingSpinnerDesc('signing event for DM')
const signedEvent = await nostrController.signEvent(event).catch((err) => {
console.log('err :>> ', err)
toast.error(err.message || 'An error occurred while signing event for DM')
return null
})
if (!signedEvent) return
// const metadata = metadataMap[pubkey]
setLoadingSpinnerDesc('Publishing encrypted DM')
// todo: do not use hardcoded relay
await nostrController
2024-04-08 17:53:47 +05:00
.publishEvent(signedEvent, 'wss://relay.snort.social')
.then(() => {
toast.success('DM sent to first signer')
})
.catch((err) => {
console.log('err :>> ', err)
toast.error(err.message || 'An error occurred while publishing DM')
return null
})
}
if (authUrl) {
return (
<iframe
title='Nsecbunker auth'
src={authUrl}
width='100%'
height='500px'
/>
)
}
return (
<>
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
<Box className={styles.container}>
<Typography component='label' variant='h6'>
Select signers and viewers
</Typography>
<Box className={styles.inputBlock}>
<TextField
label='nip05 / npub'
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
helperText={error}
error={!!error}
/>
<FormControl fullWidth>
<InputLabel id='select-type-label'>Type</InputLabel>
<Select
labelId='select-type-label'
id='demo-simple-select'
value={type}
label='Type'
onChange={(e) => setType(e.target.value as SelectionType)}
>
<MenuItem value={SelectionType.signer}>
{SelectionType.signer}
</MenuItem>
<MenuItem value={SelectionType.viewer}>
{SelectionType.viewer}
</MenuItem>
</Select>
</FormControl>
<Box sx={{ mt: 1, display: 'flex', justifyContent: 'center' }}>
<Button
disabled={!inputValue}
onClick={handleAddClick}
variant='contained'
>
Add
</Button>
</Box>
</Box>
<Typography component='label' variant='h6'>
Select files
</Typography>
<MuiFileInput
multiple
placeholder='Choose Files'
value={selectedFiles}
onChange={(value) => handleSelectFiles(value)}
/>
<ul>
{selectedFiles.map((file, index) => (
<li key={index}>
<Typography component='label'>{file.name}</Typography>
<IconButton onClick={() => handleRemoveFile(file)}>
<Clear style={{ color: 'red' }} />{' '}
</IconButton>
</li>
))}
</ul>
{signers.length > 0 && (
<List
sx={{
bgcolor: 'background.paper',
marginTop: 2
}}
subheader={
<ListSubheader
sx={{
paddingBottom: 1,
paddingTop: 1,
fontSize: '1.5rem'
}}
className={styles.subHeader}
>
Signers
</ListSubheader>
}
>
{signers.map((signer, index) => (
<DisplaySignerOrViewer
key={`signer-${index}`}
pubkey={signer}
metadataMap={metadataMap}
setMetadataMap={setMetadataMap}
remove={() => handleRemove(signer, SelectionType.signer)}
/>
))}
</List>
)}
{viewers.length > 0 && (
<List
sx={{
bgcolor: 'background.paper',
marginTop: 2
}}
subheader={
<ListSubheader
sx={{
paddingBottom: 1,
paddingTop: 1,
fontSize: '1.5rem'
}}
className={styles.subHeader}
>
Viewers
</ListSubheader>
}
>
{viewers.map((viewer, index) => (
<DisplaySignerOrViewer
key={`viewer-${index}`}
pubkey={viewer}
metadataMap={metadataMap}
setMetadataMap={setMetadataMap}
remove={() => handleRemove(viewer, SelectionType.viewer)}
/>
))}
</List>
)}
<Box sx={{ mt: 1, display: 'flex', justifyContent: 'center' }}>
<Button onClick={handleSubmit} variant='contained'>
Submit
</Button>
</Box>
</Box>
</>
)
}
type DisplaySignerOrViewerProps = {
pubkey: string
metadataMap: MetadataMap
setMetadataMap: Dispatch<SetStateAction<MetadataMap>>
remove: () => void
}
const DisplaySignerOrViewer = ({
pubkey,
metadataMap,
setMetadataMap,
remove
}: DisplaySignerOrViewerProps) => {
const [metadata, setMetadata] = useState<ProfileMetadata>()
useEffect(() => {
const getMetadata = async (pubkey: string) => {
console.log('1 :>> ', 1)
const metadataController = new MetadataController()
const metadataEvent = await metadataController
.findMetadata(pubkey)
.catch((err) => {
console.error(
`error occurred in finding metadata for: ${pubkey}`,
err
)
return null
})
if (metadataEvent) {
const metadataContent =
metadataController.extractProfileMetadataContent(metadataEvent)
if (metadataContent) {
setMetadata(metadataContent)
setMetadataMap((prev) => ({
...prev,
[pubkey]: metadataContent
}))
}
}
}
const existingMetadata = metadataMap[pubkey]
console.log('metadataMap :>> ', metadataMap)
if (existingMetadata) {
setMetadata(existingMetadata)
} else {
getMetadata(pubkey)
}
}, [pubkey, metadataMap])
const imageLoadError = (event: any) => {
event.target.src = placeholderAvatar
}
return (
<ListItem sx={{ marginTop: 1 }} className={styles.listItem}>
<img
onError={imageLoadError}
src={metadata?.picture || placeholderAvatar}
alt='Profile Image'
className={styles.img}
/>
<Link to={getProfileRoute(pubkey)}>
<Typography component='label' className={styles.name}>
{metadata?.display_name || metadata?.name || shorten(pubkey)}
</Typography>
</Link>
<IconButton onClick={remove}>
<Clear style={{ color: 'red' }} />
</IconButton>
</ListItem>
)
}