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

1499 lines
43 KiB
TypeScript
Raw Normal View History

2024-09-05 13:24:34 +02:00
import styles from './style.module.scss'
2024-11-19 12:03:41 +01:00
import {
Box,
Button,
CircularProgress,
FormHelperText,
TextField,
Tooltip
} from '@mui/material'
2024-06-28 14:24:14 +05:00
import type { Identifier, XYCoord } from 'dnd-core'
2024-05-16 16:22:05 +05:00
import JSZip from 'jszip'
2024-11-19 12:03:41 +01:00
import { useCallback, useEffect, useRef, useState } from 'react'
2024-09-05 13:24:34 +02:00
import { DndProvider, useDrag, useDrop } from 'react-dnd'
import { MultiBackend } from 'react-dnd-multi-backend'
2024-09-05 09:28:04 +02:00
import { HTML5toTouch } from 'rdndmb-html5-to-touch'
import { useAppSelector } from '../../hooks/store'
import { useLocation, useNavigate } from 'react-router-dom'
2024-05-16 16:22:05 +05:00
import { toast } from 'react-toastify'
import { LoadingSpinner } from '../../components/LoadingSpinner'
import { UserAvatar } from '../../components/UserAvatar'
import { NostrController } from '../../controllers'
import { appPrivateRoutes, appPublicRoutes } from '../../routes'
2024-07-05 13:38:04 +05:00
import {
CreateSignatureEventContent,
KeyboardCode,
2024-07-05 13:38:04 +05:00
Meta,
SigitNotification,
SignedEvent,
2024-07-05 13:38:04 +05:00
User,
UserRelaysType,
UserRole
2024-07-05 13:38:04 +05:00
} from '../../types'
import {
encryptArrayBuffer,
2024-07-09 01:16:47 +05:00
formatTimestamp,
generateEncryptionKey,
2024-06-28 14:24:14 +05:00
generateKeys,
generateKeysFile,
getHash,
2024-05-14 14:27:05 +05:00
hexToNpub,
unixNow,
npubToHex,
2024-05-14 14:27:05 +05:00
queryNip05,
signEventForMetaFile,
uploadToFileStorage,
DEFAULT_TOOLBOX,
settleAllFullfilfedPromises,
uploadMetaToFileStorage
} from '../../utils'
2024-08-06 12:47:48 +03:00
import { Container } from '../../components/Container'
2024-08-19 14:55:26 +02:00
import fileListStyles from '../../components/FileList/style.module.scss'
import { DrawTool } 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 {
faDownload,
2024-08-19 18:05:14 +02:00
faEllipsis,
faEye,
2024-09-04 14:05:36 +02:00
faFile,
faFileCirclePlus,
faGripLines,
faPen,
faPlus,
2024-11-19 12:03:41 +01:00
faSearch,
2024-09-04 14:05:36 +02:00
faToolbox,
faTrash,
faUpload
} from '@fortawesome/free-solid-svg-icons'
import { getSigitFile, SigitFile } from '../../utils/file.ts'
import { generateTimestamp } from '../../utils/opentimestamps.ts'
import { Autocomplete } from '@mui/material'
2024-11-19 12:03:41 +01:00
import _, { truncate } from 'lodash'
import * as React from 'react'
import { AvatarIconButton } from '../../components/UserAvatarIconButton'
import { NDKUserProfile, NostrEvent } from '@nostr-dev-kit/ndk'
import { useNDKContext } from '../../hooks/useNDKContext.ts'
import { useNDK } from '../../hooks/useNDK.ts'
import { useImmer } from 'use-immer'
import { ButtonUnderline } from '../../components/ButtonUnderline/index.tsx'
2024-11-19 12:03:41 +01:00
type FoundUser = NostrEvent & { npub: string }
2024-05-15 13:41:55 +05:00
export const CreatePage = () => {
const navigate = useNavigate()
const location = useLocation()
const { findMetadata, fetchEventsFromUserRelays } = useNDKContext()
const { updateUsersAppData, sendNotification } = useNDK()
const { uploadedFiles } = location.state || {}
2024-08-19 14:55:26 +02:00
const [currentFile, setCurrentFile] = useState<File>()
const isActive = (file: File) => file.name === currentFile?.name
const [isLoading, setIsLoading] = useState(false)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
2024-05-14 14:27:05 +05:00
2024-07-09 01:16:47 +05:00
const [title, setTitle] = useState(`sigit_${formatTimestamp(Date.now())}`)
const [selectedFiles, setSelectedFiles] = useState<File[]>([...uploadedFiles])
const fileInputRef = useRef<HTMLInputElement>(null)
const handleUploadButtonClick = () => {
if (fileInputRef.current) {
fileInputRef.current.click()
}
}
2024-05-14 14:27:05 +05:00
const [userInput, setUserInput] = useState('')
2024-11-19 12:03:41 +01:00
const [userSearchInput, setUserSearchInput] = useState('')
2024-11-21 11:06:31 +01:00
const [userRole] = useState<UserRole>(UserRole.signer)
2024-05-14 14:27:05 +05:00
const [error, setError] = useState<string>()
const [users, setUsers] = useState<User[]>([])
const signers = users.filter((u) => u.role === UserRole.signer)
const viewers = users.filter((u) => u.role === UserRole.viewer)
2024-05-14 14:27:05 +05:00
2024-11-19 12:03:41 +01:00
const usersPubkey = useAppSelector((state) => state.auth.usersPubkey)!
const nostrController = NostrController.getInstance()
const [userProfiles, setUserProfiles] = useState<{
[key: string]: NDKUserProfile
}>({})
const [drawnFiles, updateDrawnFiles] = useImmer<SigitFile[]>([])
const [parsingPdf, setIsParsing] = useState<boolean>(false)
2024-11-19 12:03:41 +01:00
const searchFieldRef = useRef<HTMLInputElement>(null)
2024-11-19 12:03:41 +01:00
const [selectedTool, setSelectedTool] = useState<DrawTool>()
const [foundUsers, setFoundUsers] = useState<FoundUser[]>([])
2024-11-19 12:03:41 +01:00
const [searchUsersLoading, setSearchUsersLoading] = useState<boolean>(false)
const [pastedUserNpubOrNip05, setPastedUserNpubOrNip05] = useState<
string | undefined
>()
2024-11-19 12:03:41 +01:00
/**
* Fired when user select
*/
const handleSearchUserChange = useCallback(
(_event: React.SyntheticEvent, value: string | FoundUser | null) => {
2024-11-19 12:03:41 +01:00
if (typeof value === 'object') {
const ndkEvent = value as FoundUser
if (ndkEvent?.pubkey) {
setUserInput(hexToNpub(ndkEvent.pubkey))
}
2024-11-19 12:03:41 +01:00
}
},
[setUserInput]
)
const handleSearchUserNip05 = async (
nip05: string
): Promise<string | null> => {
const { pubkey } = await queryNip05(nip05).catch((err) => {
console.error(err)
return { pubkey: null }
})
return pubkey
}
2024-11-19 12:03:41 +01:00
const handleSearchUsers = async (searchValue?: string) => {
const searchString = searchValue || userSearchInput || undefined
if (!searchString) return
setSearchUsersLoading(true)
const searchTerm = searchString.trim()
fetchEventsFromUserRelays(
{
kinds: [0],
search: searchTerm
},
usersPubkey,
UserRelaysType.Write
)
2024-11-19 12:03:41 +01:00
.then((events) => {
const nostrEvents = events.map((event) => event.rawEvent())
const fineFilteredEvents = nostrEvents
.filter((event) => {
const lowercaseContent = event.content.toLowerCase()
return (
lowercaseContent.includes(
`"name":"${searchTerm.toLowerCase()}"`
) ||
lowercaseContent.includes(
`"display_name":"${searchTerm.toLowerCase()}"`
) ||
lowercaseContent.includes(
`"username":"${searchTerm.toLowerCase()}"`
) ||
lowercaseContent.includes(`"nip05":"${searchTerm.toLowerCase()}"`)
)
})
.reduce((uniqueEvents, event) => {
if (!uniqueEvents.some((e) => e.pubkey === event.pubkey)) {
uniqueEvents.push({
...event,
npub: hexToNpub(event.pubkey)
})
}
return uniqueEvents
}, [] as FoundUser[])
console.info('fineFilteredEvents', fineFilteredEvents)
2024-11-19 12:03:41 +01:00
setFoundUsers(fineFilteredEvents)
if (!fineFilteredEvents.length)
toast.info('No user found with the provided search term')
2024-11-19 12:03:41 +01:00
})
.catch((error) => {
console.error(error)
})
.finally(() => {
setSearchUsersLoading(false)
})
}
useEffect(() => {
setTimeout(() => {
if (foundUsers.length) {
if (searchFieldRef.current) {
searchFieldRef.current.blur()
searchFieldRef.current.focus()
}
}
})
}, [foundUsers])
2024-11-19 12:03:41 +01:00
const handleInputKeyDown = async (
event: React.KeyboardEvent<HTMLDivElement>
) => {
2024-11-19 12:03:41 +01:00
if (
event.code === KeyboardCode.Enter ||
event.code === KeyboardCode.NumpadEnter
) {
event.preventDefault()
// If pasted user npub of nip05 is present, we just add the user to the counterparts list
if (pastedUserNpubOrNip05) {
setUserInput(pastedUserNpubOrNip05)
setPastedUserNpubOrNip05(undefined)
} else {
// Otherwize if search already provided some results, user must manually click the search button
if (!foundUsers.length) {
// If it's NIP05 (includes @ or is a valid domain) send request to .well-known
const domainRegex = /^[a-zA-Z0-9@.-]+\.[a-zA-Z]{2,}$/
if (domainRegex.test(userSearchInput)) {
setSearchUsersLoading(true)
const pubkey = await handleSearchUserNip05(userSearchInput)
setSearchUsersLoading(false)
if (pubkey) {
setUserInput(userSearchInput)
} else {
toast.error(`No user found with the NIP05: ${userSearchInput}`)
}
} else {
handleSearchUsers()
}
}
}
2024-11-19 12:03:41 +01:00
}
}
useEffect(() => {
if (selectedFiles) {
/**
2024-11-19 12:03:41 +01:00
* Reads the binary files and converts to an internal file type
* and sets to a state (adds images if it's a PDF)
*/
const parsePages = async () => {
const files = await settleAllFullfilfedPromises(
selectedFiles,
getSigitFile
)
updateDrawnFiles((draft) => {
// Existing files are untouched
// Handle removed files
// Remove in reverse to avoid index issues
for (let i = draft.length - 1; i >= 0; i--) {
if (
!files.some(
(f) => f.name === draft[i].name && f.size === draft[i].size
)
) {
draft.splice(i, 1)
}
}
// Add new files
files.forEach((f) => {
if (!draft.some((d) => d.name === f.name && d.size === f.size)) {
draft.push(f)
}
})
})
}
setIsParsing(true)
parsePages().finally(() => {
setIsParsing(false)
})
}
}, [selectedFiles, updateDrawnFiles])
2024-08-19 18:05:14 +02:00
/**
* 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 userProfiles)) {
findMetadata(user.pubkey)
.then((profile) => {
if (profile) {
setUserProfiles((prev) => ({
...prev,
[user.pubkey]: profile
}))
}
})
.catch((err) => {
console.error(
`error occurred in finding metadata for: ${user.pubkey}`,
err
)
})
}
})
}, [userProfiles, users, findMetadata])
2024-05-15 13:41:55 +05:00
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 = useCallback(async () => {
2024-05-14 14:27:05 +05:00
setError(undefined)
2024-05-14 14:27:05 +05:00
const addUser = (pubkey: string) => {
setUsers((prev) => {
2024-05-20 13:28:46 +05:00
const signers = prev.filter((user) => user.role === UserRole.signer)
const viewers = prev.filter((user) => user.role === UserRole.viewer)
2024-05-14 14:27:05 +05:00
const existingUserIndex = prev.findIndex(
(user) => user.pubkey === pubkey
)
2024-05-14 14:27:05 +05:00
// add new
2024-05-20 13:28:46 +05:00
if (existingUserIndex === -1) {
if (userRole === UserRole.signer) {
return [...signers, { pubkey, role: userRole }, ...viewers]
} else {
return [...signers, ...viewers, { pubkey, role: userRole }]
}
}
2024-05-14 14:27:05 +05:00
const existingUser = prev[existingUserIndex]
2024-05-14 14:27:05 +05:00
// return existing
if (existingUser.role === userRole) return prev
2024-05-14 14:27:05 +05:00
// change user role
const updatedUsers = [...prev]
const updatedUser = { ...updatedUsers[existingUserIndex] }
updatedUser.role = userRole
updatedUsers[existingUserIndex] = updatedUser
2024-05-20 13:28:46 +05:00
// 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)
]
2024-05-14 14:27:05 +05:00
})
}
const input = userInput.toLowerCase()
setUserSearchInput('')
if (input.startsWith('npub')) {
return handleAddNpubUser(input)
}
if (input.includes('@')) {
return await handleAddNip05User(input)
}
// If the user enters the domain (w/o @) assume it's the "root" and append _@
// https://github.com/nostr-protocol/nips/blob/master/05.md#showing-just-the-domain-as-an-identifier
if (input.includes('.')) {
return await handleAddNip05User(`_@${input}`)
}
setError('Invalid input! Make sure to provide correct npub or nip05.')
async function handleAddNip05User(input: string) {
2024-05-14 14:27:05 +05:00
setIsLoading(true)
setLoadingSpinnerDesc('Querying for nip05')
const nip05Profile = await queryNip05(input)
2024-05-14 14:27:05 +05:00
.catch((err) => {
console.error(`error occurred in querying nip05: ${input}`, err)
2024-05-14 14:27:05 +05:00
return null
})
.finally(() => {
setIsLoading(false)
setLoadingSpinnerDesc('')
})
if (nip05Profile && nip05Profile.pubkey) {
2024-05-14 14:27:05 +05:00
const pubkey = nip05Profile.pubkey
addUser(pubkey)
setUserInput('')
} else {
setError('Provided nip05 is not valid. Please enter correct nip05.')
}
return
}
function handleAddNpubUser(input: string) {
const pubkey = npubToHex(input)
if (pubkey) {
addUser(pubkey)
setUserInput('')
} else {
setError('Provided npub is not valid. Please enter correct npub.')
}
return
}
}, [
userInput,
userRole,
setError,
setUsers,
setUserSearchInput,
setIsLoading,
setLoadingSpinnerDesc,
setUserInput
])
useEffect(() => {
if (userInput?.length > 0) handleAddUser()
}, [handleAddUser, userInput])
2024-05-14 14:27:05 +05:00
const handleUserRoleChange = (role: UserRole, pubkey: string) => {
setUsers((prevUsers) =>
prevUsers.map((user) => {
if (user.pubkey === pubkey) {
return {
...user,
role
}
}
return user
})
)
2024-05-14 14:27:05 +05:00
}
const handleRemoveUser = (pubkey: string) => {
setUsers((prev) => prev.filter((user) => user.pubkey !== pubkey))
// Set counterpart to ''
const drawnFilesCopy = _.cloneDeep(drawnFiles)
drawnFilesCopy.forEach((s) => {
s.pages?.forEach((p) => {
p.drawnFields.forEach((d) => {
if (d.counterpart === hexToNpub(pubkey)) {
d.counterpart = ''
}
})
})
})
updateDrawnFiles(drawnFilesCopy)
2024-05-14 14:27:05 +05:00
}
2024-05-20 12:19:53 +05:00
/**
* 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<HTMLInputElement>) => {
if (event.target.files) {
// Get the uploaded files
const files = Array.from(event.target.files)
// Remove duplicates based on the file.name
setSelectedFiles((p) => {
const unique = [...p, ...files].filter(
(file, i, array) => i === array.findIndex((t) => t.name === file.name)
)
navigate('.', {
state: { uploadedFiles: unique },
replace: true
})
return unique
})
}
}
2024-05-14 14:27:05 +05:00
2024-08-19 14:55:26 +02:00
const handleFileClick = (id: string) => {
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth' })
2024-05-14 14:27:05 +05:00
}
const handleRemoveFile = (
event: React.MouseEvent<HTMLButtonElement, MouseEvent>,
fileToRemove: File
) => {
event.stopPropagation()
setSelectedFiles((prevFiles) => {
const files = prevFiles.filter((file) => file.name !== fileToRemove.name)
navigate('.', {
state: { uploadedFiles: files },
replace: true
})
return files
})
2024-05-14 14:27:05 +05:00
}
2024-04-22 16:24:50 +05:00
// Validate inputs before proceeding
const validateInputs = (): boolean => {
2024-06-03 22:59:51 +05:00
if (!title.trim()) {
toast.error('Title can not be empty')
return false
2024-06-03 22:59:51 +05:00
}
2024-08-30 13:27:10 +02:00
if (!users.some((u) => u.role === UserRole.signer)) {
toast.error('No signer is provided. At least add one signer.')
return false
2024-05-14 14:27:05 +05:00
}
2024-04-22 16:24:50 +05:00
2024-05-14 14:27:05 +05:00
if (selectedFiles.length === 0) {
toast.error('No file is selected. Select at least 1 file')
return false
2024-05-14 14:27:05 +05:00
}
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 } = {}
2024-05-14 14:27:05 +05:00
for (const file of selectedFiles) {
const arraybuffer = await file.arrayBuffer().catch(handleFileError(file))
if (!arraybuffer) return null
2024-05-14 14:27:05 +05:00
const hash = await getHash(arraybuffer)
if (!hash) {
return null
}
2024-05-14 14:27:05 +05:00
fileHashes[file.name] = hash
}
return fileHashes
}
2024-08-14 12:24:15 +03:00
const createMarks = (fileHashes: { [key: string]: string }): Mark[] => {
return drawnFiles
.flatMap((file) => {
const fileHash = fileHashes[file.name]
return (
file.pages?.flatMap((page, index) => {
return page.drawnFields.map((drawnField) => {
if (!drawnField.counterpart) {
throw new Error('Missing counterpart')
}
return {
type: drawnField.type,
location: {
page: index,
top: drawnField.top,
left: drawnField.left,
height: drawnField.height,
width: drawnField.width
},
npub: drawnField.counterpart,
2025-01-09 12:58:23 +02:00
fileName: file.name,
fileHash
}
})
}) || []
)
})
.map((mark, index) => {
2024-08-14 12:24:15 +03:00
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
}
2024-04-22 16:24:50 +05:00
// Generate the zip file
const generateZipFile = async (zip: JSZip): Promise<ArrayBuffer | null> => {
2024-05-14 14:27:05 +05:00
setLoadingSpinnerDesc('Generating zip file')
2024-04-22 16:24:50 +05:00
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<ArrayBuffer> => {
setLoadingSpinnerDesc('Encrypting zip file')
return encryptArrayBuffer(arraybuffer, encryptionKey)
}
2024-07-05 13:38:04 +05:00
// create final zip file for offline mode
const createFinalZipFile = async (
encryptedArrayBuffer: ArrayBuffer,
encryptionKey: string
): Promise<File | null> => {
// 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 userSet = new Set<string>()
const nostrController = NostrController.getInstance()
const pubkey = await nostrController.capturePublicKey()
userSet.add(pubkey)
signers.forEach((signer) => {
userSet.add(signer.pubkey)
})
viewers.forEach((viewer) => {
userSet.add(viewer.pubkey)
})
const keysFileContent = await generateKeysFile(
Array.from(userSet),
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
2024-08-20 17:37:32 +02:00
return new File([new Blob([arraybuffer])], `${unixNow()}.sigit.zip`, {
2024-08-14 12:24:15 +03:00
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
}
2024-06-12 19:44:06 +05:00
// Upload the file to the storage
2024-07-05 13:38:04 +05:00
const uploadFile = async (
arrayBuffer: ArrayBuffer
): Promise<string | null> => {
const blob = new Blob([arrayBuffer])
// Create a File object with the Blob data
const file = new File([blob], `compressed-${unixNow()}.sigit`, {
2024-07-05 13:38:04 +05:00
type: 'application/sigit'
})
return await uploadToFileStorage(file)
.then((url) => {
2024-07-05 13:38:04 +05:00
toast.success('files.zip uploaded to file storage')
return url
})
.catch(handleUploadError)
}
2024-05-14 14:27:05 +05:00
2024-07-05 13:38:04 +05:00
const generateFilesZip = async (): Promise<ArrayBuffer | null> => {
const zip = new JSZip()
selectedFiles.forEach((file) => {
zip.file(file.name, file)
})
return await zip
2024-07-05 13:38:04 +05:00
.generateAsync({
type: 'arraybuffer',
compression: 'DEFLATE',
compressionOptions: { level: 6 }
})
.catch(handleZipError)
}
const generateCreateSignature = async (
markConfig: Mark[],
2024-07-05 13:38:04 +05:00
fileHashes: {
[key: string]: string
},
zipUrl: string
2024-07-05 13:38:04 +05:00
) => {
const content: CreateSignatureEventContent = {
signers: signers.map((signer) => hexToNpub(signer.pubkey)),
viewers: viewers.map((viewer) => hexToNpub(viewer.pubkey)),
fileHashes,
markConfig,
2024-07-05 13:38:04 +05:00
zipUrl,
title
2024-07-05 13:38:04 +05:00
}
setLoadingSpinnerDesc('Preparing document(s) for signing')
2024-07-05 13:38:04 +05:00
2024-07-08 14:26:36 +05:00
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
})
2024-07-05 13:38:04 +05:00
2024-07-08 14:26:36 +05:00
if (!createSignature) return null
2024-07-05 13:38:04 +05:00
2024-07-08 14:26:36 +05:00
return JSON.stringify(createSignature, null, 2)
2024-07-05 13:38:04 +05:00
}
// Send notifications to signers and viewers
const sendNotifications = (notification: SigitNotification) => {
2024-07-05 13:38:04 +05:00
// 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, notification))
2024-07-05 13:38:04 +05:00
}
const extractNostrId = (stringifiedEvent: string): string => {
const e = JSON.parse(stringifiedEvent) as SignedEvent
return e.id
}
const initCreation = async () => {
try {
if (!validateInputs()) return
setIsLoading(true)
setLoadingSpinnerDesc('Generating file hashes')
const fileHashes = await generateFileHashes()
if (!fileHashes) return
setLoadingSpinnerDesc('Generating encryption key')
const encryptionKey = await generateEncryptionKey()
setLoadingSpinnerDesc('Creating marks')
const markConfig = createMarks(fileHashes)
return {
encryptionKey,
markConfig,
fileHashes
}
} catch (error) {
if (error instanceof Error) {
toast.error(error.message)
}
console.error(error)
setIsLoading(false)
}
}
const handleCreate = async () => {
try {
const result = await initCreation()
if (!result) return
const { encryptionKey, markConfig, fileHashes } = result
setLoadingSpinnerDesc('generating files.zip')
const arrayBuffer = await generateFilesZip()
if (!arrayBuffer) return
2024-07-05 13:38:04 +05:00
setLoadingSpinnerDesc('Encrypting files.zip')
const encryptedArrayBuffer = await encryptZipFile(
arrayBuffer,
encryptionKey
)
2024-07-05 13:38:04 +05:00
setLoadingSpinnerDesc('Uploading files.zip to file storage')
const fileUrl = await uploadFile(encryptedArrayBuffer)
if (!fileUrl) return
2024-07-05 13:38:04 +05:00
setLoadingSpinnerDesc('Generating create signature')
const createSignature = await generateCreateSignature(
markConfig,
fileHashes,
fileUrl
)
if (!createSignature) return
2024-07-05 13:38:04 +05:00
setLoadingSpinnerDesc('Generating keys for decryption')
2024-10-07 17:20:00 +02:00
// 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) return
2024-07-05 13:38:04 +05:00
setLoadingSpinnerDesc('Generating an open timestamp.')
const timestamp = await generateTimestamp(extractNostrId(createSignature))
const meta: Meta = {
createSignature,
keys,
modifiedAt: unixNow(),
docSignatures: {}
}
2024-07-05 13:38:04 +05:00
if (timestamp) {
meta.timestamps = [timestamp]
}
setLoadingSpinnerDesc('Updating user app data')
2024-07-05 13:38:04 +05:00
const event = await updateUsersAppData([meta])
if (!event) return
2024-07-05 13:38:04 +05:00
const metaUrl = await uploadMetaToFileStorage(meta, encryptionKey)
setLoadingSpinnerDesc('Sending notifications to counterparties')
const promises = sendNotifications({
metaUrl,
keys: meta.keys
})
2024-07-05 13:38:04 +05:00
await Promise.all(promises)
.then(() => {
toast.success('Notifications sent successfully')
})
.catch(() => {
toast.error('Failed to publish notifications')
})
2024-07-05 13:38:04 +05:00
const isFirstSigner = signers[0].pubkey === usersPubkey
if (isFirstSigner) {
navigate(appPrivateRoutes.sign, { state: { meta } })
} else {
const createSignatureJson = JSON.parse(createSignature)
navigate(`${appPublicRoutes.verify}/${createSignatureJson.id}`)
}
} catch (error) {
if (error instanceof Error) {
toast.error(error.message)
}
console.error(error)
} finally {
setIsLoading(false)
}
}
const handleCreateOffline = async () => {
try {
const result = await initCreation()
if (!result) return
const { encryptionKey, markConfig, fileHashes } = result
2024-07-05 13:38:04 +05:00
const zip = new JSZip()
selectedFiles.forEach((file) => {
zip.file(`files/${file.name}`, file)
})
setLoadingSpinnerDesc('Generating create signature')
const createSignature = await generateCreateSignature(
markConfig,
fileHashes,
''
)
if (!createSignature) 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) return
setLoadingSpinnerDesc('Encrypting zip file')
const encryptedArrayBuffer = await encryptZipFile(
arrayBuffer,
encryptionKey
)
const finalZipFile = await createFinalZipFile(
encryptedArrayBuffer,
encryptionKey
)
if (!finalZipFile) {
setIsLoading(false)
return
}
// If user is the next signer, we can navigate directly to sign page
const isFirstSigner = signers[0].pubkey === usersPubkey
if (isFirstSigner) {
navigate(appPrivateRoutes.sign, {
state: { arrayBuffer }
})
} else {
navigate(appPublicRoutes.verify, {
state: { uploadedZip: arrayBuffer }
})
}
} catch (error) {
if (error instanceof Error) {
toast.error(error.message)
}
console.error(error)
} finally {
setIsLoading(false)
}
}
/**
* Handles the user search textfield change
* If it's not valid npub or nip05, search will be automatically triggered
*/
const handleSearchAutocompleteTextfieldChange = async (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
const value = e.target.value
const disarmAddOnEnter = () => {
setPastedUserNpubOrNip05(undefined)
}
// Seems like it's npub format
if (value.startsWith('npub')) {
// We will try to convert npub to hex and if it's successfull that means
// npub is valid
const validHexPubkey = npubToHex(value)
if (validHexPubkey) {
// Arm the manual user npub add after enter is hit, we don't want to trigger search
setPastedUserNpubOrNip05(value)
} else {
disarmAddOnEnter()
}
} else {
// Disarm the add user on enter hit, and trigger search after 1 second
disarmAddOnEnter()
}
setUserSearchInput(value)
}
const parseContent = (event: NostrEvent) => {
2024-11-19 12:03:41 +01:00
try {
return JSON.parse(event.content)
} catch (e) {
return undefined
2024-11-19 12:03:41 +01:00
}
}
return (
<>
2024-08-06 12:46:42 +03:00
<Container className={styles.container}>
<StickySideColumns
left={
2024-08-16 11:08:03 +02:00
<div className={styles.flexWrap}>
<div className={styles.inputWrapper}>
<TextField
2024-09-05 13:24:34 +02:00
fullWidth
placeholder="Title"
size="small"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
<ol className={`${styles.paperGroup} ${styles.orderedFilesList}`}>
2024-08-16 11:08:03 +02:00
{selectedFiles.length > 0 &&
selectedFiles.map((file, index) => (
<li
key={index}
2024-08-19 14:55:26 +02:00
className={`${fileListStyles.fileItem} ${isActive(file) && fileListStyles.active}`}
onClick={() => {
handleFileClick('file-' + file.name)
setCurrentFile(file)
}}
>
<span className={styles.fileName}>{file.name}</span>
<Button
aria-label={`delete ${file.name}`}
variant="text"
onClick={(event) => handleRemoveFile(event, file)}
>
<FontAwesomeIcon icon={faTrash} />
</Button>
</li>
2024-08-16 11:08:03 +02:00
))}
</ol>
<Button variant="contained" onClick={handleUploadButtonClick}>
<FontAwesomeIcon icon={faUpload} />
<span className={styles.uploadFileText}>Upload new files</span>
</Button>
<input
ref={fileInputRef}
hidden={true}
multiple={true}
type="file"
aria-label="file-upload"
onChange={handleSelectFiles}
/>
2024-08-16 11:08:03 +02:00
</div>
}
right={
2024-08-16 11:08:03 +02:00
<div className={styles.flexWrap}>
<div className={`${styles.paperGroup} ${styles.users}`}>
<DisplayUser
users={users}
handleUserRoleChange={handleUserRoleChange}
handleRemoveUser={handleRemoveUser}
moveSigner={moveSigner}
/>
</div>
2024-11-19 12:03:41 +01:00
<div className={styles.addCounterpart}>
<div className={styles.inputWrapper}>
<Autocomplete
sx={{ width: 300 }}
options={foundUsers}
onChange={handleSearchUserChange}
inputValue={userSearchInput}
disableClearable
openOnFocus
2024-11-19 12:03:41 +01:00
autoHighlight
freeSolo
filterOptions={(x) => x}
getOptionLabel={(option) => {
let label: string = (option as FoundUser).npub
const contentJson = parseContent(option as FoundUser)
2024-11-19 12:03:41 +01:00
if (contentJson?.name) {
label = contentJson.name
} else {
label = option as string
}
2024-11-19 12:03:41 +01:00
return label
}}
renderOption={(props, option) => {
const { ...optionProps } = props
const contentJson = parseContent(option)
return (
<Box
component="li"
sx={{ '& > img': { mr: 2, flexShrink: 0 } }}
{...optionProps}
key={option.pubkey}
>
<AvatarIconButton
src={contentJson.picture || contentJson.image}
hexKey={option.pubkey}
color="inherit"
sx={{
padding: '0 10px 0 0'
}}
2024-11-19 12:03:41 +01:00
/>
<div>
{contentJson.name}{' '}
{usersPubkey === option.pubkey ? (
<span
style={{
color: '#4c82a3',
fontWeight: 'bold'
}}
>
Me
</span>
) : (
''
)}{' '}
({truncate(option.npub, { length: 16 })})
</div>
2024-11-19 12:03:41 +01:00
</Box>
)
}}
renderInput={(params) => (
<TextField
{...params}
key={params.id}
inputRef={searchFieldRef}
label="Add/Search counterpart"
onKeyDown={handleInputKeyDown}
onChange={handleSearchAutocompleteTextfieldChange}
2024-11-19 12:03:41 +01:00
/>
)}
/>
</div>
{!pastedUserNpubOrNip05 ? (
<Button
disabled={!userSearchInput || searchUsersLoading}
onClick={() => handleSearchUsers()}
variant="contained"
aria-label="Add"
className={styles.counterpartToggleButton}
>
{searchUsersLoading ? (
<CircularProgress size={14} />
) : (
<FontAwesomeIcon icon={faSearch} />
)}
</Button>
) : (
<Button
onClick={() => {
setUserInput(userSearchInput)
}}
variant="contained"
aria-label="Add"
className={styles.counterpartToggleButton}
>
<FontAwesomeIcon icon={faPlus} />
</Button>
)}
</div>
2024-08-19 18:05:14 +02:00
<div className={`${styles.paperGroup} ${styles.toolbox}`}>
2024-09-19 13:15:54 +02:00
{DEFAULT_TOOLBOX.filter((drawTool) => !drawTool.isHidden).map(
(drawTool: DrawTool, index: number) => {
return (
<div
key={index}
{...(!drawTool.isComingSoon && {
onClick: () => handleToolSelect(drawTool)
})}
className={`${styles.toolItem} ${selectedTool?.identifier === drawTool.identifier ? styles.selected : ''} ${drawTool.isComingSoon ? styles.comingSoon : ''}
2024-08-19 18:05:14 +02:00
`}
2024-09-19 13:15:54 +02:00
>
<FontAwesomeIcon
fontSize={'15px'}
icon={drawTool.icon}
/>
{drawTool.label}
{!drawTool.isComingSoon ? (
<FontAwesomeIcon
fontSize={'15px'}
icon={faEllipsis}
/>
) : (
<span className={styles.comingSoonPlaceholder}>
Coming soon
</span>
)}
</div>
)
}
)}
2024-08-19 18:05:14 +02:00
</div>
<Button onClick={handleCreate} variant="contained">
Publish
</Button>
<ButtonUnderline onClick={handleCreateOffline}>
<FontAwesomeIcon icon={faDownload} />
Create and export locally
</ButtonUnderline>
{!!error && (
<FormHelperText error={!!error}>{error}</FormHelperText>
)}
2024-08-16 11:08:03 +02:00
</div>
}
2024-09-04 14:05:36 +02:00
leftIcon={faFileCirclePlus}
centerIcon={faFile}
rightIcon={faToolbox}
>
<DrawPDFFields
users={users}
2024-12-27 15:33:43 +05:00
userProfiles={userProfiles}
selectedTool={selectedTool}
sigitFiles={drawnFiles}
updateSigitFiles={updateDrawnFiles}
/>
{parsingPdf && <LoadingSpinner variant="small" />}
</StickySideColumns>
2024-08-06 12:46:42 +03:00
</Container>
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
</>
)
}
2024-05-14 14:27:05 +05:00
type DisplayUsersProps = {
users: User[]
handleUserRoleChange: (role: UserRole, pubkey: string) => void
2024-05-14 14:27:05 +05:00
handleRemoveUser: (pubkey: string) => void
moveSigner: (dragIndex: number, hoverIndex: number) => void
2024-05-14 14:27:05 +05:00
}
const DisplayUser = ({
users,
handleUserRoleChange,
handleRemoveUser,
moveSigner
2024-05-14 14:27:05 +05:00
}: DisplayUsersProps) => {
return (
<>
2024-09-05 13:24:34 +02:00
<DndProvider backend={MultiBackend} options={HTML5toTouch}>
{users
.filter((user) => user.role === UserRole.signer)
.map((user, index) => (
<SignerCounterpart
key={`signer-${user.pubkey}`}
user={user}
index={index}
moveSigner={moveSigner}
handleUserRoleChange={handleUserRoleChange}
handleRemoveUser={handleRemoveUser}
/>
))}
</DndProvider>
{users
.filter((user) => user.role === UserRole.viewer)
.map((user) => {
return (
<div className={styles.user} key={`viewer-${user.pubkey}`}>
<Counterpart
user={user}
handleUserRoleChange={handleUserRoleChange}
handleRemoveUser={handleRemoveUser}
/>
</div>
)
})}
</>
2024-05-14 14:27:05 +05:00
)
}
interface DragItem {
index: number
id: string
type: string
}
type CounterpartProps = {
user: User
handleUserRoleChange: (role: UserRole, pubkey: string) => void
handleRemoveUser: (pubkey: string) => void
}
type SignerCounterpartProps = CounterpartProps & {
index: number
moveSigner: (dragIndex: number, hoverIndex: number) => void
}
const SignerCounterpart = ({
user,
index,
moveSigner,
handleUserRoleChange,
handleRemoveUser
}: SignerCounterpartProps) => {
const ref = useRef<HTMLTableRowElement>(null)
const [{ handlerId }, drop] = useDrop<
DragItem,
void,
{ handlerId: Identifier | null }
>({
accept: 'row',
collect(monitor) {
return {
handlerId: monitor.getHandlerId()
}
},
hover(item: DragItem, monitor) {
if (!ref.current) {
return
}
const dragIndex = item.index
const hoverIndex = index
// Don't replace items with themselves
if (dragIndex === hoverIndex) {
return
}
// Determine rectangle on screen
const hoverBoundingRect = ref.current?.getBoundingClientRect()
// Get vertical middle
const hoverMiddleY =
(hoverBoundingRect.bottom - hoverBoundingRect.top) / 2
// Determine mouse position
const clientOffset = monitor.getClientOffset()
// Get pixels to the top
const hoverClientY = (clientOffset as XYCoord).y - hoverBoundingRect.top
// Only perform the move when the mouse has crossed half of the items height
// When dragging downwards, only move when the cursor is below 50%
// When dragging upwards, only move when the cursor is above 50%
// Dragging downwards
if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) {
return
}
// Dragging upwards
if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) {
return
}
// Time to actually perform the action
moveSigner(dragIndex, hoverIndex)
// Note: we're mutating the monitor item here!
// Generally it's better to avoid mutations,
// but it's good here for the sake of performance
// to avoid expensive index searches.
item.index = hoverIndex
}
})
const [{ isDragging }, drag] = useDrag({
type: 'row',
item: () => {
return { id: user.pubkey, index }
},
collect: (monitor) => ({
isDragging: monitor.isDragging()
})
})
2024-09-05 09:28:04 +02:00
const opacity = isDragging ? 0.2 : 1
drag(drop(ref))
return (
<div
className={styles.user}
style={{ cursor: 'move', opacity }}
data-handler-id={handlerId}
ref={ref}
>
<FontAwesomeIcon width={'14px'} fontSize={'14px'} icon={faGripLines} />
<Counterpart
user={user}
handleRemoveUser={handleRemoveUser}
handleUserRoleChange={handleUserRoleChange}
/>
</div>
)
}
const Counterpart = ({
user,
handleUserRoleChange,
handleRemoveUser
}: CounterpartProps) => {
return (
<>
<div className={styles.avatar}>
<UserAvatar pubkey={user.pubkey} isNameVisible={true} />
</div>
<Tooltip title="Toggle User Role" arrow disableInteractive>
<Button
onClick={() =>
handleUserRoleChange(
user.role === UserRole.signer ? UserRole.viewer : UserRole.signer,
user.pubkey
)
}
2024-09-05 13:24:34 +02:00
className={styles.counterpartRowToggleButton}
data-variant="primary"
>
<FontAwesomeIcon
icon={user.role === UserRole.signer ? faPen : faEye}
/>
</Button>
</Tooltip>
<Tooltip title="Remove User" arrow disableInteractive>
<Button
onClick={() => handleRemoveUser(user.pubkey)}
2024-09-05 13:24:34 +02:00
className={styles.counterpartRowToggleButton}
data-variant="secondary"
>
2024-09-05 13:24:34 +02:00
<FontAwesomeIcon icon={faTrash} />
</Button>
</Tooltip>
</>
)
}