import { Button, FormHelperText, ListItemIcon, ListItemText, MenuItem, Select, TextField, Tooltip } from '@mui/material' import type { Identifier, XYCoord } from 'dnd-core' import saveAs from 'file-saver' import JSZip from 'jszip' import { Event, kinds } from 'nostr-tools' import { useEffect, useRef, useState } from 'react' import { DndProvider, useDrag, useDrop } from 'react-dnd' import { HTML5Backend } from 'react-dnd-html5-backend' import { useSelector } from 'react-redux' import { useLocation, useNavigate } from 'react-router-dom' import { toast } from 'react-toastify' import { LoadingSpinner } from '../../components/LoadingSpinner' import { UserAvatar } from '../../components/UserAvatar' import { MetadataController, NostrController } from '../../controllers' import { appPrivateRoutes } from '../../routes' import { State } from '../../store/rootReducer' import { CreateSignatureEventContent, Meta, ProfileMetadata, User, UserRole } from '../../types' import { encryptArrayBuffer, formatTimestamp, generateEncryptionKey, generateKeys, generateKeysFile, getHash, hexToNpub, isOnline, unixNow, npubToHex, queryNip05, sendNotification, shorten, signEventForMetaFile, updateUsersAppData, uploadToFileStorage } from '../../utils' import { Container } from '../../components/Container' import styles from './style.module.scss' import fileListStyles from '../../components/FileList/style.module.scss' import { DrawTool, MarkType, PdfFile } from '../../types/drawing' import { DrawPDFFields } from '../../components/DrawPDFFields' import { Mark } from '../../types/mark.ts' import { StickySideColumns } from '../../layouts/StickySideColumns.tsx' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { fa1, faBriefcase, faCalendarDays, faCheckDouble, faCircleDot, faClock, faCreditCard, faEllipsis, faEye, faGripLines, faHeading, faIdCard, faImage, faPaperclip, faPen, faPhone, faPlus, faSignature, faSquareCaretDown, faSquareCheck, faStamp, faT, faTableCellsLarge, faTrash, faUpload } from '@fortawesome/free-solid-svg-icons' export const CreatePage = () => { const navigate = useNavigate() const location = useLocation() const { uploadedFiles } = location.state || {} const [currentFile, setCurrentFile] = useState() const isActive = (file: File) => file.name === currentFile?.name const [isLoading, setIsLoading] = useState(false) const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('') const [authUrl, setAuthUrl] = useState() const [title, setTitle] = useState(`sigit_${formatTimestamp(Date.now())}`) const [selectedFiles, setSelectedFiles] = useState([]) const fileInputRef = useRef(null) const handleUploadButtonClick = () => { if (fileInputRef.current) { fileInputRef.current.click() } } const [userInput, setUserInput] = useState('') const handleInputKeyDown = (event: React.KeyboardEvent) => { if (event.code === 'Enter' || event.code === 'NumpadEnter') { event.preventDefault() handleAddUser() } } 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([]) const [selectedTool, setSelectedTool] = useState() const [toolbox] = useState([ { identifier: MarkType.TEXT, icon: , label: 'Text', active: false }, { identifier: MarkType.SIGNATURE, icon: , label: 'Signature', active: false }, { identifier: MarkType.JOBTITLE, icon: , label: 'Job Title', active: false }, { identifier: MarkType.FULLNAME, icon: , label: 'Full Name', active: true }, { identifier: MarkType.INITIALS, icon: , label: 'Initials', active: false }, { identifier: MarkType.DATETIME, icon: , label: 'Date Time', active: false }, { identifier: MarkType.DATE, icon: , label: 'Date', active: false }, { identifier: MarkType.NUMBER, icon: , label: 'Number', active: false }, { identifier: MarkType.IMAGES, icon: , label: 'Images', active: false }, { identifier: MarkType.CHECKBOX, icon: , label: 'Checkbox', active: false }, { identifier: MarkType.MULTIPLE, icon: , label: 'Multiple', active: false }, { identifier: MarkType.FILE, icon: , label: 'File', active: false }, { identifier: MarkType.RADIO, icon: , label: 'Radio', active: false }, { identifier: MarkType.SELECT, icon: , label: 'Select', active: false }, { identifier: MarkType.CELLS, icon: , label: 'Cells', active: false }, { identifier: MarkType.STAMP, icon: , label: 'Stamp', active: false }, { identifier: MarkType.PAYMENT, icon: , label: 'Payment', active: false }, { identifier: MarkType.PHONE, icon: , label: 'Phone', active: false } ]) /** * Changes the drawing tool * @param drawTool to draw with */ const handleToolSelect = (drawTool: DrawTool) => { // If clicked on the same tool, unselect if (drawTool.identifier === selectedTool?.identifier) { setSelectedTool(undefined) return } setSelectedTool(drawTool) } 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 ) }) } }) }, [metadata, users]) // Set up event listener for authentication event nostrController.on('nsecbunker-auth', (url) => { setAuthUrl(url) }) useEffect(() => { if (uploadedFiles) { setSelectedFiles([...uploadedFiles]) } }, [uploadedFiles]) 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 = (event: React.ChangeEvent) => { if (event.target.files) { setSelectedFiles(Array.from(event.target.files)) } } const handleFileClick = (id: string) => { document.getElementById(id)?.scrollIntoView({ behavior: 'smooth' }) } const handleRemoveFile = ( event: React.MouseEvent, fileToRemove: File ) => { event.stopPropagation() 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: unknown) => { console.log( `Error while getting arrayBuffer of file ${file.name} :>> `, err ) if (err instanceof Error) { 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 } const createMarks = (fileHashes: { [key: string]: string }): Mark[] => { return drawnPdfs .flatMap((drawnPdf) => { const fileHash = fileHashes[drawnPdf.file.name] return drawnPdf.pages.flatMap((page, index) => { return page.drawnFields.map((drawnField) => { return { type: drawnField.type, location: { page: index, top: drawnField.top, left: drawnField.left, height: drawnField.height, width: drawnField.width }, npub: drawnField.counterpart, pdfFileHash: fileHash, fileName: drawnPdf.file.name } }) }) }) .map((mark, index) => { return { ...mark, id: index } }) } // Handle errors during zip file generation const handleZipError = (err: unknown) => { console.log('Error in zip:>> ', err) setIsLoading(false) if (err instanceof Error) { 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') return await zip .generateAsync({ type: 'arraybuffer', compression: 'DEFLATE', compressionOptions: { level: 6 } }) .catch(handleZipError) } // 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 for offline mode const createFinalZipFile = async ( encryptedArrayBuffer: ArrayBuffer, encryptionKey: string ): Promise => { // Get the current timestamp in seconds 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 return new File([new Blob([arraybuffer])], `${unixNow()}.sigit.zip`, { type: 'application/zip' }) } // Handle errors during file upload const handleUploadError = (err: unknown) => { console.log('Error in upload:>> ', err) setIsLoading(false) if (err instanceof Error) { toast.error(err.message || 'Error occurred in uploading file') } return null } // Upload the file to the storage const uploadFile = async ( arrayBuffer: ArrayBuffer ): Promise => { const blob = new Blob([arrayBuffer]) // Create a File object with the Blob data const file = new File([blob], `compressed-${unixNow()}.sigit`, { type: 'application/sigit' }) return await uploadToFileStorage(file) .then((url) => { toast.success('files.zip uploaded to file storage') return url }) .catch(handleUploadError) } // Manage offline scenarios for signing or viewing the file const handleOfflineFlow = async ( encryptedArrayBuffer: ArrayBuffer, encryptionKey: string ) => { const finalZipFile = await createFinalZipFile( encryptedArrayBuffer, encryptionKey ) if (!finalZipFile) { setIsLoading(false) return } saveAs(finalZipFile, `request-${unixNow()}.sigit.zip`) setIsLoading(false) } const generateFilesZip = async (): Promise => { const zip = new JSZip() selectedFiles.forEach((file) => { zip.file(file.name, file) }) return await zip .generateAsync({ type: 'arraybuffer', compression: 'DEFLATE', compressionOptions: { level: 6 } }) .catch(handleZipError) } const generateCreateSignature = async ( fileHashes: { [key: string]: string }, zipUrl: string ) => { const signers = users.filter((user) => user.role === UserRole.signer) const viewers = users.filter((user) => user.role === UserRole.viewer) const markConfig = createMarks(fileHashes) const content: CreateSignatureEventContent = { signers: signers.map((signer) => hexToNpub(signer.pubkey)), viewers: viewers.map((viewer) => hexToNpub(viewer.pubkey)), fileHashes, markConfig, zipUrl, title } setLoadingSpinnerDesc('Signing nostr event for create signature') const createSignature = await signEventForMetaFile( JSON.stringify(content), nostrController, setIsLoading ).catch(() => { console.log('An error occurred in signing event for meta file', error) toast.error('An error occurred in signing event for meta file') return null }) if (!createSignature) return null return JSON.stringify(createSignature, null, 2) } // Send notifications to signers and viewers const sendNotifications = (meta: Meta) => { const signers = users.filter((user) => user.role === UserRole.signer) const viewers = users.filter((user) => user.role === UserRole.viewer) // no need to send notification to self so remove it from the list const receivers = ( signers.length > 0 ? [signers[0].pubkey] : viewers.map((viewer) => viewer.pubkey) ).filter((receiver) => receiver !== usersPubkey) return receivers.map((receiver) => sendNotification(receiver, meta)) } const handleCreate = async () => { if (!validateInputs()) return setIsLoading(true) setLoadingSpinnerDesc('Generating file hashes') const fileHashes = await generateFileHashes() if (!fileHashes) { setIsLoading(false) return } setLoadingSpinnerDesc('Generating encryption key') const encryptionKey = await generateEncryptionKey() if (await isOnline()) { setLoadingSpinnerDesc('generating files.zip') const arrayBuffer = await generateFilesZip() if (!arrayBuffer) { setIsLoading(false) return } setLoadingSpinnerDesc('Encrypting files.zip') const encryptedArrayBuffer = await encryptZipFile( arrayBuffer, encryptionKey ) setLoadingSpinnerDesc('Uploading files.zip to file storage') const fileUrl = await uploadFile(encryptedArrayBuffer) if (!fileUrl) { setIsLoading(false) return } setLoadingSpinnerDesc('Generating create signature') const createSignature = await generateCreateSignature(fileHashes, fileUrl) if (!createSignature) { setIsLoading(false) return } setLoadingSpinnerDesc('Generating keys for decryption') // generate key pairs for decryption const pubkeys = users.map((user) => user.pubkey) // also add creator in the list if (pubkeys.includes(usersPubkey!)) { pubkeys.push(usersPubkey!) } const keys = await generateKeys(pubkeys, encryptionKey) if (!keys) { setIsLoading(false) return } const meta: Meta = { createSignature, keys, modifiedAt: unixNow(), docSignatures: {} } setLoadingSpinnerDesc('Updating user app data') const event = await updateUsersAppData(meta) if (!event) { setIsLoading(false) return } setLoadingSpinnerDesc('Sending notifications to counterparties') const promises = sendNotifications(meta) await Promise.all(promises) .then(() => { toast.success('Notifications sent successfully') }) .catch(() => { toast.error('Failed to publish notifications') }) navigate(appPrivateRoutes.sign, { state: { meta: meta } }) } else { const zip = new JSZip() selectedFiles.forEach((file) => { zip.file(`files/${file.name}`, file) }) setLoadingSpinnerDesc('Generating create signature') const createSignature = await generateCreateSignature(fileHashes, '') if (!createSignature) { setIsLoading(false) return } const meta: Meta = { createSignature, modifiedAt: unixNow(), docSignatures: {} } // add meta to zip try { const stringifiedMeta = JSON.stringify(meta, null, 2) zip.file('meta.json', stringifiedMeta) } catch (err) { console.error(err) toast.error('An error occurred in converting meta json to string') return null } const arrayBuffer = await generateZipFile(zip) if (!arrayBuffer) { setIsLoading(false) return } setLoadingSpinnerDesc('Encrypting zip file') const encryptedArrayBuffer = await encryptZipFile( arrayBuffer, encryptionKey ) await handleOfflineFlow(encryptedArrayBuffer, encryptionKey) } } const onDrawFieldsChange = (pdfFiles: PdfFile[]) => { setDrawnPdfs(pdfFiles) } if (authUrl) { return (