import styles from './style.module.scss' import { Box, Button, CircularProgress, FormHelperText, 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 { useCallback, useEffect, useRef, useState } from 'react' import { DndProvider, useDrag, useDrop } from 'react-dnd' import { MultiBackend } from 'react-dnd-multi-backend' import { HTML5toTouch } from 'rdndmb-html5-to-touch' import { useAppSelector } from '../../hooks/store' 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, RelayController } from '../../controllers' import { appPrivateRoutes } from '../../routes' import { CreateSignatureEventContent, KeyboardCode, Meta, ProfileMetadata, SigitNotification, SignedEvent, User, UserRole } from '../../types' import { encryptArrayBuffer, formatTimestamp, generateEncryptionKey, generateKeys, generateKeysFile, getHash, hexToNpub, isOnline, unixNow, npubToHex, queryNip05, sendNotification, signEventForMetaFile, updateUsersAppData, uploadToFileStorage, DEFAULT_TOOLBOX, settleAllFullfilfedPromises, DEFAULT_LOOK_UP_RELAY_LIST, uploadMetaToFileStorage } from '../../utils' import { Container } from '../../components/Container' 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 { faEllipsis, faEye, faFile, faFileCirclePlus, faGripLines, faPen, faPlus, faSearch, 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/lab' import _, { truncate } from 'lodash' import * as React from 'react' import { AvatarIconButton } from '../../components/UserAvatarIconButton' import { useImmer } from 'use-immer' type FoundUser = Event & { npub: string } 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 [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 [userSearchInput, setUserSearchInput] = useState('') const [userRole] = useState(UserRole.signer) const [error, setError] = useState() const [users, setUsers] = useState([]) const signers = users.filter((u) => u.role === UserRole.signer) const viewers = users.filter((u) => u.role === UserRole.viewer) const usersPubkey = useAppSelector((state) => state.auth.usersPubkey)! const nostrController = NostrController.getInstance() const [metadata, setMetadata] = useState<{ [key: string]: ProfileMetadata }>( {} ) const [drawnFiles, updateDrawnFiles] = useImmer([]) const [parsingPdf, setIsParsing] = useState(false) const searchFieldRef = useRef(null) const [selectedTool, setSelectedTool] = useState() const [foundUsers, setFoundUsers] = useState([]) const [searchUsersLoading, setSearchUsersLoading] = useState(false) const [pastedUserNpubOrNip05, setPastedUserNpubOrNip05] = useState< string | undefined >() /** * Fired when user select */ const handleSearchUserChange = useCallback( (_event: React.SyntheticEvent, value: string | FoundUser | null) => { if (typeof value === 'object') { const ndkEvent = value as FoundUser if (ndkEvent?.pubkey) { setUserInput(hexToNpub(ndkEvent.pubkey)) } } }, [setUserInput] ) const handleSearchUserNip05 = async ( nip05: string ): Promise => { const { pubkey } = await queryNip05(nip05).catch((err) => { console.error(err) return { pubkey: null } }) return pubkey } const handleSearchUsers = async (searchValue?: string) => { const searchString = searchValue || userSearchInput || undefined if (!searchString) return setSearchUsersLoading(true) const relayController = RelayController.getInstance() const metadataController = MetadataController.getInstance() const relaySet = await metadataController.findRelayListMetadata(usersPubkey) DEFAULT_LOOK_UP_RELAY_LIST.forEach((relay) => { if (!relaySet.write.includes(relay)) relaySet.write.push(relay) if (!relaySet.read.includes(relay)) relaySet.read.push(relay) }) const uniqueReadRelaySet = [...new Set(relaySet.read)] const searchTerm = searchString.trim() relayController .fetchEvents( { kinds: [0], search: searchTerm }, uniqueReadRelaySet ) .then((events) => { console.log('events', events) const fineFilteredEvents: FoundUser[] = events .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: FoundUser[], event: Event) => { if (!uniqueEvents.some((e: Event) => e.pubkey === event.pubkey)) { uniqueEvents.push({ ...event, npub: hexToNpub(event.pubkey) }) } return uniqueEvents }, []) console.info('fineFilteredEvents', fineFilteredEvents) setFoundUsers(fineFilteredEvents) if (!fineFilteredEvents.length) toast.info('No user found with the provided search term') }) .catch((error) => { console.error(error) }) .finally(() => { setSearchUsersLoading(false) }) } useEffect(() => { setTimeout(() => { if (foundUsers.length) { if (searchFieldRef.current) { searchFieldRef.current.blur() searchFieldRef.current.focus() } } }) }, [foundUsers]) const handleInputKeyDown = async ( event: React.KeyboardEvent ) => { 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() } } } } } useEffect(() => { if (selectedFiles) { /** * 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]) /** * 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 = MetadataController.getInstance() 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]) 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 = useCallback(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() 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) { 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 } 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]) 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)) // 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) } /** * 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) { // Get the uploaded files const files = Array.from(event.target.files) // Remove duplicates based on the file.name setSelectedFiles((p) => [...p, ...files].filter( (file, i, array) => i === array.findIndex((t) => t.name === file.name) ) ) } } 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.some((u) => u.role === UserRole.signer)) { toast.error('No signer is provided. At least add one signer.') 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 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, pdfFileHash: fileHash, fileName: 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`) // If user is the next signer, we can navigate directly to sign page if (signers[0].pubkey === usersPubkey) { navigate(appPrivateRoutes.sign, { state: { uploadedZip: finalZipFile } }) } 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 ( markConfig: Mark[], fileHashes: { [key: string]: string }, zipUrl: string ) => { const content: CreateSignatureEventContent = { signers: signers.map((signer) => hexToNpub(signer.pubkey)), viewers: viewers.map((viewer) => hexToNpub(viewer.pubkey)), fileHashes, markConfig, zipUrl, title } setLoadingSpinnerDesc('Preparing document(s) for signing') 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 = (notification: SigitNotification) => { // 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)) } const extractNostrId = (stringifiedEvent: string): string => { const e = JSON.parse(stringifiedEvent) as SignedEvent return e.id } const handleCreate = 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() if (await isOnline()) { setLoadingSpinnerDesc('generating files.zip') const arrayBuffer = await generateFilesZip() if (!arrayBuffer) return setLoadingSpinnerDesc('Encrypting files.zip') const encryptedArrayBuffer = await encryptZipFile( arrayBuffer, encryptionKey ) const markConfig = createMarks(fileHashes) setLoadingSpinnerDesc('Uploading files.zip to file storage') const fileUrl = await uploadFile(encryptedArrayBuffer) if (!fileUrl) return setLoadingSpinnerDesc('Generating create signature') const createSignature = await generateCreateSignature( markConfig, fileHashes, fileUrl ) if (!createSignature) 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) return setLoadingSpinnerDesc('Generating an open timestamp.') const timestamp = await generateTimestamp( extractNostrId(createSignature) ) const meta: Meta = { createSignature, keys, modifiedAt: unixNow(), docSignatures: {} } if (timestamp) { meta.timestamps = [timestamp] } setLoadingSpinnerDesc('Updating user app data') const event = await updateUsersAppData(meta) if (!event) return const metaUrl = await uploadMetaToFileStorage(meta, encryptionKey) setLoadingSpinnerDesc('Sending notifications to counterparties') const promises = sendNotifications({ metaUrl, keys: meta.keys }) await Promise.all(promises) .then(() => { toast.success('Notifications sent successfully') }) .catch(() => { toast.error('Failed to publish notifications') }) navigate(appPrivateRoutes.sign, { state: { meta } }) } else { const zip = new JSZip() selectedFiles.forEach((file) => { zip.file(`files/${file.name}`, file) }) const markConfig = createMarks(fileHashes) 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 ) await handleOfflineFlow(encryptedArrayBuffer, encryptionKey) } } 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 ) => { 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: Event) => { try { return JSON.parse(event.content) } catch (e) { console.error(e) return undefined } } return ( <>
setTitle(e.target.value)} />
    {selectedFiles.length > 0 && selectedFiles.map((file, index) => (
  1. { handleFileClick('file-' + file.name) setCurrentFile(file) }} > {file.name}
  2. ))}
} right={
x} getOptionLabel={(option) => { let label: string = (option as FoundUser).npub const contentJson = parseContent(option as FoundUser) if (contentJson?.name) { label = contentJson.name } else { label = option as string } return label }} renderOption={(props, option) => { const { ...optionProps } = props const contentJson = parseContent(option) return ( img': { mr: 2, flexShrink: 0 } }} {...optionProps} key={option.pubkey} >
{contentJson.name}{' '} {usersPubkey === option.pubkey ? ( Me ) : ( '' )}{' '} ({truncate(option.npub, { length: 16 })})
) }} renderInput={(params) => ( )} />
{!pastedUserNpubOrNip05 ? ( ) : ( )}
{DEFAULT_TOOLBOX.filter((drawTool) => !drawTool.isHidden).map( (drawTool: DrawTool, index: number) => { return (
handleToolSelect(drawTool) })} className={`${styles.toolItem} ${selectedTool?.identifier === drawTool.identifier ? styles.selected : ''} ${drawTool.isComingSoon ? styles.comingSoon : ''} `} > {drawTool.label} {!drawTool.isComingSoon ? ( ) : ( Coming soon )}
) } )}
{!!error && ( {error} )}
} leftIcon={faFileCirclePlus} centerIcon={faFile} rightIcon={faToolbox} > {parsingPdf && }
{isLoading && } ) } type DisplayUsersProps = { users: User[] handleUserRoleChange: (role: UserRole, pubkey: string) => void handleRemoveUser: (pubkey: string) => void moveSigner: (dragIndex: number, hoverIndex: number) => void } const DisplayUser = ({ users, handleUserRoleChange, handleRemoveUser, moveSigner }: DisplayUsersProps) => { return ( <> {users .filter((user) => user.role === UserRole.signer) .map((user, index) => ( ))} {users .filter((user) => user.role === UserRole.viewer) .map((user) => { return (
) })} ) } 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(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() }) }) const opacity = isDragging ? 0.2 : 1 drag(drop(ref)) return (
) } const Counterpart = ({ user, handleUserRoleChange, handleRemoveUser }: CounterpartProps) => { return ( <>
) }