import { Clear, DragHandle } from '@mui/icons-material' import { Box, Button, FormControl, IconButton, InputLabel, MenuItem, Paper, Select, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TextField, Tooltip, Typography } from '@mui/material' import JSZip from 'jszip' import { MuiFileInput } from 'mui-file-input' import { useEffect, useRef, useState } from 'react' import { useSelector } from 'react-redux' import { useLocation, useNavigate } from 'react-router-dom' import { toast } from 'react-toastify' import { LoadingSpinner } from '../../components/LoadingSpinner' import { UserComponent } from '../../components/username' import { MetadataController, NostrController } from '../../controllers' import { appPrivateRoutes } from '../../routes' import { State } from '../../store/rootReducer' import { Meta, ProfileMetadata, User, UserRole } from '../../types' import { encryptArrayBuffer, generateEncryptionKey, generateKeysFile, getHash, hexToNpub, isOnline, npubToHex, queryNip05, sendDM, shorten, signEventForMetaFile, uploadToFileStorage } from '../../utils' import styles from './style.module.scss' import { DndProvider } from 'react-dnd' import { HTML5Backend } from 'react-dnd-html5-backend' import type { Identifier, XYCoord } from 'dnd-core' import { useDrag, useDrop } from 'react-dnd' import saveAs from 'file-saver' import { Event, kinds } from 'nostr-tools' import { DrawPDFFields } from '../../components/DrawPDFFields' import { PdfFile } from '../../types/drawing' export const CreatePage = () => { const navigate = useNavigate() const location = useLocation() const { uploadedFile } = location.state || {} const [isLoading, setIsLoading] = useState(false) const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('') const [authUrl, setAuthUrl] = useState() const [title, setTitle] = useState('') const [selectedFiles, setSelectedFiles] = useState([]) const [userInput, setUserInput] = useState('') const [userRole, setUserRole] = useState(UserRole.signer) const [error, setError] = useState() const [users, setUsers] = useState([]) const usersPubkey = useSelector((state: State) => state.auth.usersPubkey) const nostrController = NostrController.getInstance() const [metadata, setMetadata] = useState<{ [key: string]: ProfileMetadata }>( {} ) const [drawnPdfs, setDrawnPdfs] = useState([]) useEffect(() => { users.forEach((user) => { if (!(user.pubkey in metadata)) { const metadataController = new MetadataController() const handleMetadataEvent = (event: Event) => { const metadataContent = metadataController.extractProfileMetadataContent(event) if (metadataContent) setMetadata((prev) => ({ ...prev, [user.pubkey]: metadataContent })) } metadataController.on(user.pubkey, (kind: number, event: Event) => { if (kind === kinds.Metadata) { handleMetadataEvent(event) } }) metadataController .findMetadata(user.pubkey) .then((metadataEvent) => { if (metadataEvent) handleMetadataEvent(metadataEvent) }) .catch((err) => { console.error( `error occurred in finding metadata for: ${user.pubkey}`, err ) }) } }) }, []) useEffect(() => { if (uploadedFile) { setSelectedFiles([uploadedFile]) } }, [uploadedFile]) useEffect(() => { if (usersPubkey) { setUsers((prev) => { const existingUserIndex = prev.findIndex( (user) => user.pubkey === usersPubkey ) // make logged in user the first signer by default if (existingUserIndex === -1) return [{ pubkey: usersPubkey, role: UserRole.signer }, ...prev] return prev }) } }, [usersPubkey]) const handleAddUser = async () => { setError(undefined) const addUser = (pubkey: string) => { setUsers((prev) => { const signers = prev.filter((user) => user.role === UserRole.signer) const viewers = prev.filter((user) => user.role === UserRole.viewer) const existingUserIndex = prev.findIndex( (user) => user.pubkey === pubkey ) // add new if (existingUserIndex === -1) { if (userRole === UserRole.signer) { return [...signers, { pubkey, role: userRole }, ...viewers] } else { return [...signers, ...viewers, { pubkey, role: userRole }] } } const existingUser = prev[existingUserIndex] // return existing if (existingUser.role === userRole) return prev // change user role const updatedUsers = [...prev] const updatedUser = { ...updatedUsers[existingUserIndex] } updatedUser.role = userRole updatedUsers[existingUserIndex] = updatedUser // signers should be placed at the start of the array return [ ...updatedUsers.filter((user) => user.role === UserRole.signer), ...updatedUsers.filter((user) => user.role === UserRole.viewer) ] }) } const input = userInput.toLowerCase() if (input.startsWith('npub')) { const pubkey = npubToHex(input) if (pubkey) { addUser(pubkey) setUserInput('') } else { setError('Provided npub is not valid. Please enter correct npub.') } return } if (input.includes('@')) { setIsLoading(true) setLoadingSpinnerDesc('Querying for nip05') const nip05Profile = await queryNip05(input) .catch((err) => { console.error(`error occurred in querying nip05: ${input}`, err) return null }) .finally(() => { setIsLoading(false) setLoadingSpinnerDesc('') }) if (nip05Profile && nip05Profile.pubkey) { const pubkey = nip05Profile.pubkey addUser(pubkey) setUserInput('') } else { setError('Provided nip05 is not valid. Please enter correct nip05.') } return } setError('Invalid input! Make sure to provide correct npub or nip05.') } const handleUserRoleChange = (role: UserRole, pubkey: string) => { setUsers((prevUsers) => prevUsers.map((user) => { if (user.pubkey === pubkey) { return { ...user, role } } return user }) ) } const handleRemoveUser = (pubkey: string) => { setUsers((prev) => prev.filter((user) => user.pubkey !== pubkey)) } /** * changes the position of signer in the signers list * * @param dragIndex represents the current position of user * @param hoverIndex represents the target position of user */ const moveSigner = (dragIndex: number, hoverIndex: number) => { setUsers((prevUsers) => { const updatedUsers = [...prevUsers] const [draggedUser] = updatedUsers.splice(dragIndex, 1) updatedUsers.splice(hoverIndex, 0, draggedUser) return updatedUsers }) } 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) ) } // Validate inputs before proceeding const validateInputs = (): boolean => { if (!title.trim()) { toast.error('Title can not be empty') return false } if (users.length === 0) { toast.error( 'No signer/viewer is provided. At least add one signer or viewer.' ) return false } if (selectedFiles.length === 0) { toast.error('No file is selected. Select at least 1 file') return false } return true } // Handle errors during file arrayBuffer conversion const handleFileError = (file: File) => (err: any) => { console.log( `Error while getting arrayBuffer of file ${file.name} :>> `, err ) toast.error( err.message || `Error while getting arrayBuffer of file ${file.name}` ) return null } // Generate hash for each selected file const generateFileHashes = async (): Promise<{ [key: string]: string } | null> => { const fileHashes: { [key: string]: string } = {} for (const file of selectedFiles) { const arraybuffer = await file.arrayBuffer().catch(handleFileError(file)) if (!arraybuffer) return null const hash = await getHash(arraybuffer) if (!hash) { return null } fileHashes[file.name] = hash } return fileHashes } // Create a zip file with the selected files and sign the event const createZipFile = async (fileHashes: { [key: string]: string }): Promise<{ zip: JSZip; createSignature: string } | null> => { const zip = new JSZip() selectedFiles.forEach((file) => { zip.file(`files/${file.name}`, file) }) const signers = users.filter((user) => user.role === UserRole.signer) const viewers = users.filter((user) => user.role === UserRole.viewer) const markConfig = createMarkConfig(fileHashes) setLoadingSpinnerDesc('Signing nostr event') const createSignature = await signEventForMetaFile( JSON.stringify({ signers: signers.map((signer) => hexToNpub(signer.pubkey)), viewers: viewers.map((viewer) => hexToNpub(viewer.pubkey)), fileHashes }), nostrController, setIsLoading ) if (!createSignature) return null try { return { zip, createSignature: JSON.stringify(createSignature, null, 2) } } catch (error) { return null } } const createMarkConfig = (fileHashes: { [key: string]: string }) => { let markConfig: any = {} drawnPdfs.forEach(drawnPdf => { const fileHash = fileHashes[drawnPdf.file.name] drawnPdf.pages.forEach((page, pageIndex) => { page.drawnFields.forEach(drawnField => { if (!markConfig[drawnField.counterpart]) markConfig[drawnField.counterpart] = {} if (!markConfig[drawnField.counterpart][fileHash]) markConfig[drawnField.counterpart][fileHash] = [] markConfig[drawnField.counterpart][fileHash].push({ markType: drawnField.type, markLocation: `P:${pageIndex};X:${drawnField.left};Y:${drawnField.top}` }) }) }) }) return markConfig } // Add metadata and file hashes to the zip file const addMetaToZip = async ( zip: JSZip, createSignature: string ): Promise => { // create content for meta file const meta: Meta = { title, createSignature, docSignatures: {} } try { const stringifiedMeta = JSON.stringify(meta, null, 2) zip.file('meta.json', stringifiedMeta) const metaHash = await getHash(stringifiedMeta) if (!metaHash) return null const metaHashJson = { [usersPubkey!]: metaHash } zip.file('hashes.json', JSON.stringify(metaHashJson, null, 2)) return metaHash } catch (err) { console.error(err) toast.error('An error occurred in converting meta json to string') return null } } // Handle errors during zip file generation const handleZipError = (err: any) => { console.log('Error in zip:>> ', err) setIsLoading(false) toast.error(err.message || 'Error occurred in generating zip file') return null } // Generate the zip file const generateZipFile = async (zip: JSZip): Promise => { setLoadingSpinnerDesc('Generating zip file') const arraybuffer = await zip .generateAsync({ type: 'arraybuffer', compression: 'DEFLATE', compressionOptions: { level: 6 } }) .catch(handleZipError) return arraybuffer } // Encrypt the zip file with the generated encryption key const encryptZipFile = async ( arraybuffer: ArrayBuffer, encryptionKey: string ): Promise => { setLoadingSpinnerDesc('Encrypting zip file') return encryptArrayBuffer(arraybuffer, encryptionKey) } // create final zip file const createFinalZipFile = async ( encryptedArrayBuffer: ArrayBuffer, encryptionKey: string ): Promise => { // Get the current timestamp in seconds const unixNow = Math.floor(Date.now() / 1000) const blob = new Blob([encryptedArrayBuffer]) // Create a File object with the Blob data const file = new File([blob], `compressed.sigit`, { type: 'application/sigit' }) const firstSigner = users.filter((user) => user.role === UserRole.signer)[0] const keysFileContent = await generateKeysFile( [firstSigner.pubkey], encryptionKey ) if (!keysFileContent) return null const zip = new JSZip() zip.file(`compressed.sigit`, file) zip.file('keys.json', keysFileContent) const arraybuffer = await zip .generateAsync({ type: 'arraybuffer', compression: 'DEFLATE', compressionOptions: { level: 6 } }) .catch(handleZipError) if (!arraybuffer) return null const finalZipFile = new File( [new Blob([arraybuffer])], `${unixNow}.sigit.zip`, { type: 'application/zip' } ) return finalZipFile } const handleOnlineFlow = async ( encryptedArrayBuffer: ArrayBuffer, encryptionKey: string ) => { const unixNow = Math.floor(Date.now() / 1000) const blob = new Blob([encryptedArrayBuffer]) // Create a File object with the Blob data const file = new File([blob], `compressed-${unixNow}.sigit`, { type: 'application/sigit' }) const fileUrl = await uploadFile(file) if (!fileUrl) return await sendDMs(fileUrl, encryptionKey) } // Handle errors during file upload const handleUploadError = (err: any) => { console.log('Error in upload:>> ', err) setIsLoading(false) toast.error(err.message || 'Error occurred in uploading file') return null } // Upload the file to the storage const uploadFile = async (file: File): Promise => { setIsLoading(true) setLoadingSpinnerDesc('Uploading sigit to file storage.') const fileUrl = await uploadToFileStorage(file, nostrController) .then((url) => { toast.success('Sigit uploaded to file storage') return url }) .catch(handleUploadError) return fileUrl } // Send DMs to signers and viewers with the file URL const sendDMs = async (fileUrl: string, encryptionKey: string) => { setLoadingSpinnerDesc('Sending DM to signers/viewers') const signers = users.filter((user) => user.role === UserRole.signer) const viewers = users.filter((user) => user.role === UserRole.viewer) if (signers.length > 0) { await sendDM( fileUrl, encryptionKey, signers[0].pubkey, nostrController, true, setAuthUrl ) } else { for (const viewer of viewers) { await sendDM( fileUrl, encryptionKey, viewer.pubkey, nostrController, false, setAuthUrl ) } } } // Manage offline scenarios for signing or viewing the file const handleOfflineFlow = async ( encryptedArrayBuffer: ArrayBuffer, encryptionKey: string ) => { const finalZipFile = await createFinalZipFile( encryptedArrayBuffer, encryptionKey ) if (!finalZipFile) return saveAs(finalZipFile, 'request.sigit.zip') } const handleCreate = async () => { if (!validateInputs()) return setIsLoading(true) setLoadingSpinnerDesc('Generating hashes for files') const fileHashes = await generateFileHashes() if (!fileHashes) return const createZipResponse = await createZipFile(fileHashes) if (!createZipResponse) return const { zip, createSignature } = createZipResponse const metaHash = await addMetaToZip(zip, createSignature) if (!metaHash) return setLoadingSpinnerDesc('Generating zip file') const arrayBuffer = await generateZipFile(zip) if (!arrayBuffer) return const encryptionKey = await generateEncryptionKey() setLoadingSpinnerDesc('Encrypting zip file') const encryptedArrayBuffer = await encryptZipFile( arrayBuffer, encryptionKey ) if (await isOnline()) { await handleOnlineFlow(encryptedArrayBuffer, encryptionKey) } else { await handleOfflineFlow(encryptedArrayBuffer, encryptionKey) } navigate(appPrivateRoutes.sign, { state: { arrayBuffer } }) } const onDrawFieldsChange = (pdfFiles: PdfFile[]) => { setDrawnPdfs(pdfFiles) } if (authUrl) { return (