chore: merge stating into use-ndk

This commit is contained in:
daniyal 2024-12-27 15:33:43 +05:00
commit 4e54d12175
31 changed files with 1329 additions and 766 deletions

62
package-lock.json generated
View File

@ -33,8 +33,9 @@
"idb": "8.0.0", "idb": "8.0.0",
"jszip": "3.10.1", "jszip": "3.10.1",
"lodash": "4.17.21", "lodash": "4.17.21",
"material-ui-popup-state": "^5.3.1",
"mui-file-input": "4.0.4", "mui-file-input": "4.0.4",
"nostr-login": "^1.6.6", "nostr-login": "1.6.14",
"nostr-tools": "2.7.0", "nostr-tools": "2.7.0",
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"pdfjs-dist": "^4.4.168", "pdfjs-dist": "^4.4.168",
@ -50,7 +51,8 @@
"react-toastify": "10.0.4", "react-toastify": "10.0.4",
"redux": "5.0.1", "redux": "5.0.1",
"signature_pad": "^5.0.4", "signature_pad": "^5.0.4",
"tseep": "1.2.1" "tseep": "1.2.1",
"use-immer": "^0.11.0"
}, },
"devDependencies": { "devDependencies": {
"@types/crypto-js": "^4.2.2", "@types/crypto-js": "^4.2.2",
@ -3379,6 +3381,12 @@
"safe-buffer": "^5.0.1" "safe-buffer": "^5.0.1"
} }
}, },
"node_modules/classnames": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
"license": "MIT"
},
"node_modules/cli-cursor": { "node_modules/cli-cursor": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz",
@ -3629,10 +3637,11 @@
"dev": true "dev": true
}, },
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.3", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"path-key": "^3.1.0", "path-key": "^3.1.0",
"shebang-command": "^2.0.0", "shebang-command": "^2.0.0",
@ -6030,6 +6039,26 @@
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
"dev": true "dev": true
}, },
"node_modules/material-ui-popup-state": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/material-ui-popup-state/-/material-ui-popup-state-5.3.1.tgz",
"integrity": "sha512-mmx1DsQwF/2cmcpHvS/QkUwOQG2oAM+cDEQU0DaZVYnvwKyTB3AFgu8l1/E+LQFausmzpSJoljwQSZXkNvt7eA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.20.6",
"@types/prop-types": "^15.7.3",
"@types/react": "^18.0.26",
"classnames": "^2.2.6",
"prop-types": "^15.7.2"
},
"engines": {
"node": ">=16"
},
"peerDependencies": {
"@mui/material": "^5.0.0 || ^6.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/md5.js": { "node_modules/md5.js": {
"version": "1.3.5", "version": "1.3.5",
"resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz",
@ -6281,9 +6310,9 @@
"optional": true "optional": true
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.7", "version": "3.3.8",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz",
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -6291,6 +6320,7 @@
"url": "https://github.com/sponsors/ai" "url": "https://github.com/sponsors/ai"
} }
], ],
"license": "MIT",
"bin": { "bin": {
"nanoid": "bin/nanoid.cjs" "nanoid": "bin/nanoid.cjs"
}, },
@ -6469,9 +6499,9 @@
} }
}, },
"node_modules/nostr-login": { "node_modules/nostr-login": {
"version": "1.6.6", "version": "1.6.14",
"resolved": "https://registry.npmjs.org/nostr-login/-/nostr-login-1.6.6.tgz", "resolved": "https://registry.npmjs.org/nostr-login/-/nostr-login-1.6.14.tgz",
"integrity": "sha512-XOpB9nG3Qgt7iea7gA1zn4TaTfUKCKGdCHKwErqLPtMk/q1Rhkzj5cq/66iU0WqC6mSiwENfTy1p4qaM7HzMtg==", "integrity": "sha512-pId1G79kjRW1B9qy6OrA8Not23JSfgmS2VegcKf7Qm9VMC7wYGXg1Ry3FMEAB8p11WoboQ8oJi2TqUGiOf61OQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@nostr-dev-kit/ndk": "^2.3.1", "@nostr-dev-kit/ndk": "^2.3.1",
@ -8616,6 +8646,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/use-immer": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/use-immer/-/use-immer-0.11.0.tgz",
"integrity": "sha512-RNAqi3GqsWJ4bcCd4LMBgdzvPmTABam24DUaFiKfX9s3MSorNRz9RDZYJkllJoMHUxVLMDetwAuCDeyWNrp1yA==",
"license": "MIT",
"peerDependencies": {
"immer": ">=8.0.0",
"react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/use-sync-external-store": { "node_modules/use-sync-external-store": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz",

View File

@ -43,8 +43,9 @@
"idb": "8.0.0", "idb": "8.0.0",
"jszip": "3.10.1", "jszip": "3.10.1",
"lodash": "4.17.21", "lodash": "4.17.21",
"material-ui-popup-state": "^5.3.1",
"mui-file-input": "4.0.4", "mui-file-input": "4.0.4",
"nostr-login": "^1.6.6", "nostr-login": "1.6.14",
"nostr-tools": "2.7.0", "nostr-tools": "2.7.0",
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"pdfjs-dist": "^4.4.168", "pdfjs-dist": "^4.4.168",
@ -60,7 +61,8 @@
"react-toastify": "10.0.4", "react-toastify": "10.0.4",
"redux": "5.0.1", "redux": "5.0.1",
"signature_pad": "^5.0.4", "signature_pad": "^5.0.4",
"tseep": "1.2.1" "tseep": "1.2.1",
"use-immer": "^0.11.0"
}, },
"devDependencies": { "devDependencies": {
"@types/crypto-js": "^4.2.2", "@types/crypto-js": "^4.2.2",

View File

@ -121,7 +121,17 @@ export const AppBar = () => {
<Container> <Container>
<Toolbar className={styles.toolbar} disableGutters={true}> <Toolbar className={styles.toolbar} disableGutters={true}>
<Box className={styles.logoWrapper}> <Box className={styles.logoWrapper}>
<img src="/logo.svg" alt="Logo" onClick={() => navigate('/')} /> <img
src="/logo.svg"
alt="Logo"
onClick={() => {
if (['', '#/'].includes(window.location.hash)) {
location.reload()
} else {
navigate('/')
}
}}
/>
</Box> </Box>
<Box className={styles.rightSideBox}> <Box className={styles.rightSideBox}>

View File

@ -1,5 +1,10 @@
import { Meta } from '../../types' import { Meta } from '../../types'
import { SigitCardDisplayInfo, SigitStatus, SignStatus } from '../../utils' import {
hexToNpub,
SigitCardDisplayInfo,
SigitStatus,
SignStatus
} from '../../utils'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { formatTimestamp, npubToHex } from '../../utils' import { formatTimestamp, npubToHex } from '../../utils'
import { appPublicRoutes, appPrivateRoutes } from '../../routes' import { appPublicRoutes, appPrivateRoutes } from '../../routes'
@ -20,6 +25,7 @@ import styles from './style.module.scss'
import { getExtensionIconLabel } from '../getExtensionIconLabel' import { getExtensionIconLabel } from '../getExtensionIconLabel'
import { useSigitMeta } from '../../hooks/useSigitMeta' import { useSigitMeta } from '../../hooks/useSigitMeta'
import { extractFileExtensions } from '../../utils/file' import { extractFileExtensions } from '../../utils/file'
import { useAppSelector } from '../../hooks'
type SigitProps = { type SigitProps = {
sigitCreateId: string sigitCreateId: string
@ -32,26 +38,32 @@ export const DisplaySigit = ({
parsedMeta, parsedMeta,
sigitCreateId: sigitCreateId sigitCreateId: sigitCreateId
}: SigitProps) => { }: SigitProps) => {
const { usersPubkey } = useAppSelector((state) => state.auth)
const { title, createdAt, submittedBy, signers, signedStatus, isValid } = const { title, createdAt, submittedBy, signers, signedStatus, isValid } =
parsedMeta parsedMeta
const { signersStatus, fileHashes } = useSigitMeta(meta) const { signersStatus, fileHashes } = useSigitMeta(meta)
const { extensions, isSame } = extractFileExtensions(Object.keys(fileHashes)) const { extensions, isSame } = extractFileExtensions(Object.keys(fileHashes))
const currentUserNpub: string = usersPubkey ? hexToNpub(usersPubkey) : ''
const currentUserNextSigner =
signersStatus[currentUserNpub as `npub1${string}`] === SignStatus.Awaiting
return ( return (
<div className={styles.itemWrapper}> <div className={styles.itemWrapper}>
{signedStatus === SigitStatus.Complete && ( {signedStatus === SigitStatus.Complete || !currentUserNextSigner ? (
<Link <Link
to={`${appPublicRoutes.verify}/${sigitCreateId}`} to={`${appPublicRoutes.verify}/${sigitCreateId}`}
className={styles.insetLink} className={styles.insetLink}
></Link> ></Link>
)} ) : (
{signedStatus !== SigitStatus.Complete && (
<Link <Link
to={`${appPrivateRoutes.sign}/${sigitCreateId}`} to={`${appPrivateRoutes.sign}/${sigitCreateId}`}
className={styles.insetLink} className={styles.insetLink}
></Link> ></Link>
)} )}
<p className={`line-clamp-2 ${styles.title}`}>{title}</p> <p className={`line-clamp-2 ${styles.title}`}>{title}</p>
<div className={styles.users}> <div className={styles.users}>
{submittedBy && ( {submittedBy && (

View File

@ -8,19 +8,25 @@ import {
Select Select
} from '@mui/material' } from '@mui/material'
import styles from './style.module.scss' import styles from './style.module.scss'
import React, { useEffect, useState } from 'react' import React, { useCallback, useEffect, useState } from 'react'
import { User, UserRole, KeyboardCode } from '../../types' import { User, UserRole, KeyboardCode } from '../../types'
import { MouseState, PdfPage, DrawnField, DrawTool } from '../../types/drawing' import { MouseState, DrawnField, DrawTool } from '../../types/drawing'
import { hexToNpub, npubToHex, getProfileUsername } from '../../utils' import { hexToNpub, npubToHex, getProfileUsername } from '../../utils'
import { SigitFile } from '../../utils/file' import { SigitFile } from '../../utils/file'
import { getToolboxLabelByMarkType } from '../../utils/mark' import { getToolboxLabelByMarkType } from '../../utils/mark'
import { FileDivider } from '../FileDivider'
import { ExtensionFileBox } from '../ExtensionFileBox'
import { FONT_SIZE, FONT_TYPE, inPx } from '../../utils/pdf' import { FONT_SIZE, FONT_TYPE, inPx } from '../../utils/pdf'
import { useScale } from '../../hooks/useScale' import { useScale } from '../../hooks/useScale'
import { AvatarIconButton } from '../UserAvatarIconButton' import { AvatarIconButton } from '../UserAvatarIconButton'
import { UserAvatar } from '../UserAvatar' import { UserAvatar } from '../UserAvatar'
import _ from 'lodash' import { Updater } from 'use-immer'
import { FileItem } from './internal/FileItem'
import { FileDivider } from '../FileDivider'
import { Counterpart } from './internal/Counterpart'
const MINIMUM_RECT_SIZE = {
width: 10,
height: 10
} as const
import { NDKUserProfile } from '@nostr-dev-kit/ndk' import { NDKUserProfile } from '@nostr-dev-kit/ndk'
const DEFAULT_START_SIZE = { const DEFAULT_START_SIZE = {
@ -32,16 +38,25 @@ interface HideSignersForDrawnField {
[key: number]: boolean [key: number]: boolean
} }
interface Props { type PageIndexer = [file: number, page: number]
type FieldIndexer = [...PageIndexer, field: number]
interface DrawPdfFieldsProps {
users: User[] users: User[]
userProfiles: { [key: string]: NDKUserProfile } userProfiles: { [key: string]: NDKUserProfile }
sigitFiles: SigitFile[] sigitFiles: SigitFile[]
setSigitFiles: React.Dispatch<React.SetStateAction<SigitFile[]>> updateSigitFiles: Updater<SigitFile[]>
selectedTool?: DrawTool selectedTool?: DrawTool
} }
export const DrawPDFFields = (props: Props) => { export const DrawPDFFields = ({
const { selectedTool, sigitFiles, setSigitFiles, users } = props selectedTool,
userProfiles,
sigitFiles,
updateSigitFiles,
users
}: DrawPdfFieldsProps) => {
const { to, from } = useScale()
const signers = users.filter((u) => u.role === UserRole.signer) const signers = users.filter((u) => u.role === UserRole.signer)
const defaultSignerNpub = signers.length ? hexToNpub(signers[0].pubkey) : '' const defaultSignerNpub = signers.length ? hexToNpub(signers[0].pubkey) : ''
@ -54,144 +69,124 @@ export const DrawPDFFields = (props: Props) => {
* @param pubkeys * @param pubkeys
* @returns available pubkey or empty string * @returns available pubkey or empty string
*/ */
const getAvailableSigner = (...pubkeys: string[]) => { const getAvailableSigner = useCallback(
(...pubkeys: string[]) => {
const availableSigner: string | undefined = pubkeys.find((pubkey) => const availableSigner: string | undefined = pubkeys.find((pubkey) =>
signers.some((s) => s.pubkey === npubToHex(pubkey)) signers.some((s) => s.pubkey === npubToHex(pubkey))
) )
return availableSigner || '' return availableSigner || ''
},
[signers]
)
const [mouseState, setMouseState] = useState<MouseState>({})
const [indexer, setIndexer] = useState<PageIndexer | FieldIndexer>()
const [field, setField] = useState<
DrawnField & {
x: number
y: number
} }
>()
const { to, from } = useScale() const [lastIndexer, setLastIndexer] = useState<FieldIndexer>()
const [mouseState, setMouseState] = useState<MouseState>({
clicked: false
})
const [activeDrawnField, setActiveDrawnField] = useState<{
fileIndex: number
pageIndex: number
drawnFieldIndex: number
}>()
const isActiveDrawnField = ( const isActiveDrawnField = (
fileIndex: number, fileIndex: number,
pageIndex: number, pageIndex: number,
drawnFieldIndex: number drawnFieldIndex: number
) => ) =>
activeDrawnField?.fileIndex === fileIndex && lastIndexer &&
activeDrawnField?.pageIndex === pageIndex && lastIndexer[0] === fileIndex &&
activeDrawnField?.drawnFieldIndex === drawnFieldIndex lastIndexer[1] === pageIndex &&
lastIndexer[2] === drawnFieldIndex
/** /**
* Drawing events * Gets the pointer coordinates relative to a element in the `event` param
* @param event PointerEvent
* @param customTarget coordinates relative to this element, if not provided
* event.target will be used
*/ */
useEffect(() => { const getPointerCoordinates = (
window.addEventListener('pointerup', handlePointerUp) event: React.PointerEvent,
window.addEventListener('pointercancel', handlePointerUp) customTarget?: HTMLElement | null
) => {
const target = customTarget ? customTarget : event.currentTarget
const rect = target.getBoundingClientRect()
return () => { // Clamp X Y within the target
window.removeEventListener('pointerup', handlePointerUp) const x = Math.max(0, Math.min(event.clientX, rect.right) - rect.left) //x position within the element.
window.removeEventListener('pointercancel', handlePointerUp) const y = Math.max(0, Math.min(event.clientY, rect.bottom) - rect.top) //y position within the element.
return {
x,
y,
rect
} }
}, [])
const refreshPdfFiles = () => {
setSigitFiles([...sigitFiles])
} }
/** /**
* Fired only on when left (primary pointer interaction) clicking page image * Fired only on when left (primary pointer interaction) clicking page image
* Creates new drawnElement and pushes in the array * Creates new drawnElement
* It is re rendered and visible right away
* *
* @param event Pointer event * @param event Pointer event
* @param page PdfPage where press happened * @param pageIndexer File and page index
* @param pageWidth pdf value used to scale pointer coordinates
*/ */
const handlePointerDown = ( const handlePointerDown = useCallback(
(
event: React.PointerEvent, event: React.PointerEvent,
page: PdfPage, pageIndexer: PageIndexer,
fileIndex: number, pageWidth: number
pageIndex: number
) => { ) => {
// Proceed only if left click // Proceed only if left click
if (event.button !== 0) return if (event.button !== 0) return
if (!selectedTool) return
if (!selectedTool) { event.currentTarget.setPointerCapture(event.pointerId)
return const counterpart = getAvailableSigner(lastSigner, defaultSignerNpub)
}
const { x, y } = getPointerCoordinates(event) const { x, y } = getPointerCoordinates(event)
const newField: DrawnField = { setIndexer(pageIndexer)
left: to(page.width, x), setField({
top: to(page.width, y), x: to(pageWidth, x),
y: to(pageWidth, y),
left: to(pageWidth, x),
top: to(pageWidth, y),
width: event.pointerType === 'mouse' ? 0 : DEFAULT_START_SIZE.width, width: event.pointerType === 'mouse' ? 0 : DEFAULT_START_SIZE.width,
height: event.pointerType === 'mouse' ? 0 : DEFAULT_START_SIZE.height, height: event.pointerType === 'mouse' ? 0 : DEFAULT_START_SIZE.height,
counterpart: getAvailableSigner(lastSigner, defaultSignerNpub), type: selectedTool.identifier,
type: selectedTool.identifier counterpart
}
page.drawnFields.push(newField)
setActiveDrawnField({
fileIndex,
pageIndex,
drawnFieldIndex: page.drawnFields.length - 1
}) })
setMouseState((prev) => { setMouseState({
return {
...prev,
clicked: true clicked: true
}
}) })
} },
[defaultSignerNpub, getAvailableSigner, lastSigner, selectedTool, to]
)
/** /**
* Drawing is finished, resets all the variables used to draw * After {@link handlePointerDown} creates an drawing element, this function
* alters the newly created drawing element, resizing it while pointer moves
* @param event Pointer event * @param event Pointer event
* @param pageWidth pdf value used to scale pointer coordinates
*/ */
const handlePointerUp = () => { const handlePointerMove = useCallback(
setMouseState((prev) => { (event: React.PointerEvent, pageWidth: number) => {
return {
...prev,
clicked: false,
dragging: false,
resizing: false
}
})
}
/**
* After {@link handlePointerDown} create an drawing element, this function gets called on every pixel moved
* which alters the newly created drawing element, resizing it while pointer moves
* @param event Pointer event
* @param page PdfPage where moving is happening
*/
const handlePointerMove = (event: React.PointerEvent, page: PdfPage) => {
if (mouseState.clicked && selectedTool && event.pointerType === 'mouse') { if (mouseState.clicked && selectedTool && event.pointerType === 'mouse') {
const lastElementIndex = page.drawnFields.length - 1
const lastDrawnField = page.drawnFields[lastElementIndex]
// Return early if we don't have lastDrawnField
// Issue noticed in the console when dragging out of bounds
// to the page below (without releaseing mouse click)
if (!lastDrawnField) return
const { x, y } = getPointerCoordinates(event) const { x, y } = getPointerCoordinates(event)
const pageX = to(pageWidth, x)
const pageY = to(pageWidth, y)
const width = to(page.width, x) - lastDrawnField.left // Calculate left, top, width, and height based on direction
const height = to(page.width, y) - lastDrawnField.top setField((prev) => {
const left = pageX < prev!.x ? pageX : prev!.x
const top = pageY < prev!.y ? pageY : prev!.y
lastDrawnField.width = width const width = Math.abs(pageX - prev!.x)
lastDrawnField.height = height const height = Math.abs(pageY - prev!.y)
return { ...prev!, left, top, width, height }
const currentDrawnFields = page.drawnFields })
currentDrawnFields[lastElementIndex] = lastDrawnField
refreshPdfFiles()
}
} }
},
[mouseState.clicked, selectedTool, to]
)
/** /**
* Fired when event happens on the drawn element which will be moved * Fired when event happens on the drawn element which will be moved
@ -201,22 +196,30 @@ export const DrawPDFFields = (props: Props) => {
* y - offsetY * y - offsetY
* *
* @param event Pointer event * @param event Pointer event
* @param drawnFieldIndex Which we are moving * @param fieldIndexer Which field we are moving
*/ */
const handleDrawnFieldPointerDown = ( const handleDrawnFieldPointerDown = (
event: React.PointerEvent, event: React.PointerEvent,
fileIndex: number, fieldIndexer: FieldIndexer
pageIndex: number,
drawnFieldIndex: number
) => { ) => {
event.stopPropagation() event.stopPropagation()
// Proceed only if left click // Proceed only if left click
if (event.button !== 0) return if (event.button !== 0) return
event.currentTarget.setPointerCapture(event.pointerId)
const drawingRectangleCoords = getPointerCoordinates(event) const drawingRectangleCoords = getPointerCoordinates(event)
const [fileIndex, pageIndex, drawnFieldIndex] = fieldIndexer
const page = sigitFiles[fileIndex].pages![pageIndex]
const drawnField = page.drawnFields[drawnFieldIndex]
setActiveDrawnField({ fileIndex, pageIndex, drawnFieldIndex }) setIndexer(fieldIndexer)
setField({
...drawnField,
x: to(page.width, drawingRectangleCoords.x),
y: to(page.width, drawingRectangleCoords.y)
})
setLastIndexer(fieldIndexer)
setMouseState({ setMouseState({
dragging: true, dragging: true,
clicked: false, clicked: false,
@ -226,7 +229,7 @@ export const DrawPDFFields = (props: Props) => {
} }
}) })
// make signers dropdown visible // Make signers dropdown visible
setHideSignersForDrawnField((prev) => ({ setHideSignersForDrawnField((prev) => ({
...prev, ...prev,
[drawnFieldIndex]: false [drawnFieldIndex]: false
@ -236,12 +239,10 @@ export const DrawPDFFields = (props: Props) => {
/** /**
* Moves the drawnElement by the pointer position (pointer can grab anywhere on the drawn element) * Moves the drawnElement by the pointer position (pointer can grab anywhere on the drawn element)
* @param event Pointer event * @param event Pointer event
* @param drawnField which we are moving * @param pageWidth pdf value used to scale pointer coordinates
* @param pageWidth pdf value which is used to calculate scaled offset
*/ */
const handleDrawnFieldPointerMove = ( const handleDrawnFieldPointerMove = (
event: React.PointerEvent, event: React.PointerEvent,
drawnField: DrawnField,
pageWidth: number pageWidth: number
) => { ) => {
if (mouseState.dragging) { if (mouseState.dragging) {
@ -255,18 +256,21 @@ export const DrawPDFFields = (props: Props) => {
let left = to(pageWidth, x - coordsOffset.x) let left = to(pageWidth, x - coordsOffset.x)
let top = to(pageWidth, y - coordsOffset.y) let top = to(pageWidth, y - coordsOffset.y)
const rightLimit = to(pageWidth, rect.width) - drawnField.width setField((prev) => {
const bottomLimit = to(pageWidth, rect.height) - drawnField.height const rightLimit = to(pageWidth, rect.width) - prev!.width
const bottomLimit = to(pageWidth, rect.height) - prev!.height
if (left < 0) left = 0 if (left < 0) left = 0
if (top < 0) top = 0 if (top < 0) top = 0
if (left > rightLimit) left = rightLimit if (left > rightLimit) left = rightLimit
if (top > bottomLimit) top = bottomLimit if (top > bottomLimit) top = bottomLimit
drawnField.left = left return {
drawnField.top = top ...prev!,
left,
refreshPdfFiles() top
}
})
} }
} }
} }
@ -274,73 +278,85 @@ export const DrawPDFFields = (props: Props) => {
/** /**
* Fired when clicked on the resize handle, sets the state for a resize action * Fired when clicked on the resize handle, sets the state for a resize action
* @param event Pointer event * @param event Pointer event
* @param drawnFieldIndex which we are resizing * @param fieldIndexer which field we are resizing
*/ */
const handleResizePointerDown = ( const handleResizePointerDown = useCallback(
event: React.PointerEvent, (event: React.PointerEvent, fieldIndexer: FieldIndexer) => {
fileIndex: number,
pageIndex: number,
drawnFieldIndex: number
) => {
// Proceed only if left click // Proceed only if left click
if (event.button !== 0) return if (event.button !== 0) return
event.stopPropagation() event.stopPropagation()
setActiveDrawnField({ fileIndex, pageIndex, drawnFieldIndex }) event.currentTarget.setPointerCapture(event.pointerId)
const [fileIndex, pageIndex, drawnFieldIndex] = fieldIndexer
const page = sigitFiles[fileIndex].pages![pageIndex]
const drawnField = page.drawnFields[drawnFieldIndex]
setIndexer(fieldIndexer)
setField({
...drawnField,
x: drawnField.left,
y: drawnField.top
})
setLastIndexer(fieldIndexer)
setMouseState({ setMouseState({
resizing: true resizing: true
}) })
} },
[sigitFiles]
)
/** /**
* Resizes the drawn element by the mouse position * Resizes the drawn element by the mouse position
* @param event Pointer event * @param event Pointer event
* @param drawnField which we are resizing * @param pageWidth pdf value used to scale pointer coordinates
* @param pageWidth pdf value which is used to calculate scaled offset
*/ */
const handleResizePointerMove = ( const handleResizePointerMove = useCallback(
event: React.PointerEvent, (event: React.PointerEvent, pageWidth: number) => {
drawnField: DrawnField,
pageWidth: number
) => {
if (mouseState.resizing) { if (mouseState.resizing) {
const { x, y } = getPointerCoordinates(
event,
// currentTarget = span handle // currentTarget = span handle
// 1st parent = drawnField // 1st parent = drawnField
// 2nd parent = img // 2nd parent = img
const { x, y } = getPointerCoordinates(
event,
event.currentTarget.parentElement?.parentElement event.currentTarget.parentElement?.parentElement
) )
const width = to(pageWidth, x) - drawnField.left const pageX = to(pageWidth, x)
const height = to(pageWidth, y) - drawnField.top const pageY = to(pageWidth, y)
drawnField.width = width setField((prev) => {
drawnField.height = height const left = pageX < prev!.x ? pageX : prev!.x
const top = pageY < prev!.y ? pageY : prev!.y
refreshPdfFiles() const width = Math.abs(pageX - prev!.x)
} const height = Math.abs(pageY - prev!.y)
return { ...prev!, left, top, width, height }
})
} }
},
[mouseState.resizing, to]
)
const handlePointerUpReleaseCapture = useCallback(
(event: React.PointerEvent) => {
event.currentTarget.releasePointerCapture(event.pointerId)
},
[]
)
/** /**
* Removes the drawn element using the indexes in the params * Removes the drawn element using the indexes in the params
* @param event Pointer event * @param event Pointer event
* @param pdfFileIndex pdf file index * @param fieldIIndexer [file index, page index, field index]
* @param pdfPageIndex pdf page index
* @param drawnFileIndex drawn file index
*/ */
const handleRemovePointerDown = ( const handleRemovePointerDown = (
event: React.PointerEvent, event: React.PointerEvent,
pdfFileIndex: number, [fileIndex, pageIndex, fieldIndex]: FieldIndexer
pdfPageIndex: number,
drawnFileIndex: number
) => { ) => {
event.stopPropagation() event.stopPropagation()
const pages = sigitFiles[pdfFileIndex]?.pages updateSigitFiles((draft) => {
if (pages) { draft[fileIndex]?.pages![pageIndex]?.drawnFields?.splice(fieldIndex, 1)
pages[pdfPageIndex]?.drawnFields?.splice(drawnFileIndex, 1) })
}
} }
/** /**
@ -379,28 +395,72 @@ export const DrawPDFFields = (props: Props) => {
} }
/** /**
* Gets the pointer coordinates relative to a element in the `event` param * Drawing is finished, resets all the variables used to draw
* @param event PointerEvent
* @param customTarget coordinates relative to this element, if not provided
* event.target will be used
*/ */
const getPointerCoordinates = ( const handlePointerUp = useCallback(() => {
event: React.PointerEvent, // Proceed if we have selected something
customTarget?: HTMLElement | null if (indexer) {
) => { // Check if we have the "preview" field state
const target = customTarget ? customTarget : event.currentTarget if (field) {
const rect = target.getBoundingClientRect() // Cancel update if preview field is below the MINIMUM_RECT_SIZE threshhold
if (
field.width < MINIMUM_RECT_SIZE.width ||
field.height < MINIMUM_RECT_SIZE.height
) {
setIndexer(undefined)
setMouseState({})
return
}
// Clamp X Y within the target const [fileIndex, pageIndex, fieldIndex] = indexer
const x = Math.min(event.clientX, rect.right) - rect.left //x position within the element.
const y = Math.min(event.clientY, rect.bottom) - rect.top //y position within the element.
return { // Add new drawn field to the files
x, if (mouseState.clicked) {
y, updateSigitFiles((draft) => {
rect draft[fileIndex].pages![pageIndex].drawnFields.push(field)
})
}
// Move
if (mouseState.dragging) {
updateSigitFiles((draft) => {
draft[fileIndex].pages![pageIndex].drawnFields[fieldIndex!] = field
})
}
// Resize
if (mouseState.resizing) {
updateSigitFiles((draft) => {
draft[fileIndex].pages![pageIndex].drawnFields[fieldIndex!] = field
})
}
// Clear indexer after applying the update
setIndexer(undefined)
} }
} }
setMouseState({})
}, [
field,
indexer,
mouseState.clicked,
mouseState.dragging,
mouseState.resizing,
updateSigitFiles
])
/**
* Drawing events
*/
useEffect(() => {
window.addEventListener('pointerup', handlePointerUp)
window.addEventListener('pointercancel', handlePointerUp)
return () => {
window.removeEventListener('pointerup', handlePointerUp)
window.removeEventListener('pointercancel', handlePointerUp)
}
}, [handlePointerUp])
/** /**
* Renders the pdf pages and drawing elements * Renders the pdf pages and drawing elements
@ -412,6 +472,13 @@ export const DrawPDFFields = (props: Props) => {
return ( return (
<> <>
{file.pages?.map((page, pageIndex: number) => { {file.pages?.map((page, pageIndex: number) => {
let isPageIndexerActive = false
if (indexer) {
const [fi, pi, di] = indexer
isPageIndexerActive =
fi === fileIndex && pi === pageIndex && typeof di === 'undefined'
}
return ( return (
<div <div
key={pageIndex} key={pageIndex}
@ -420,32 +487,68 @@ export const DrawPDFFields = (props: Props) => {
onKeyDown={(event) => handleEscapeButtonDown(event)} onKeyDown={(event) => handleEscapeButtonDown(event)}
> >
<img <img
onPointerMove={(event) => {
handlePointerMove(event, page)
}}
onPointerDown={(event) => { onPointerDown={(event) => {
handlePointerDown(event, page, fileIndex, pageIndex) handlePointerDown(event, [fileIndex, pageIndex], page.width)
}} }}
onPointerMove={(event) => {
handlePointerMove(event, page.width)
}}
onPointerUp={handlePointerUpReleaseCapture}
draggable="false" draggable="false"
src={page.image} src={page.image}
alt={`page ${pageIndex + 1} of ${file.name}`} alt={`page ${pageIndex + 1} of ${file.name}`}
/> />
{isPageIndexerActive && field && (
<div
className={styles.drawingRectanglePreview}
style={{
backgroundColor: field.counterpart
? `#${npubToHex(field.counterpart)?.substring(0, 6)}4b`
: undefined,
outlineColor: field.counterpart
? `#${npubToHex(field.counterpart)?.substring(0, 6)}`
: undefined,
left: inPx(from(page.width, field.left)),
top: inPx(from(page.width, field.top)),
width: inPx(from(page.width, field.width)),
height: inPx(from(page.width, field.height))
}}
>
<div
className={`file-mark ${styles.placeholder}`}
style={{
fontFamily: FONT_TYPE,
fontSize: inPx(from(page.width, FONT_SIZE))
}}
>
{getToolboxLabelByMarkType(field.type) || 'placeholder'}
</div>
</div>
)}
{page.drawnFields.map((drawnField, drawnFieldIndex: number) => { {page.drawnFields.map((drawnField, drawnFieldIndex: number) => {
let isFieldIndexerActive = false
if (indexer) {
const [fi, pi, di] = indexer
isFieldIndexerActive =
fi === fileIndex &&
pi === pageIndex &&
di === drawnFieldIndex
}
return ( return (
<div <div
key={drawnFieldIndex} key={drawnFieldIndex}
onPointerDown={(event) => onPointerDown={(event) =>
handleDrawnFieldPointerDown( handleDrawnFieldPointerDown(event, [
event,
fileIndex, fileIndex,
pageIndex, pageIndex,
drawnFieldIndex drawnFieldIndex
) ])
} }
onPointerMove={(event) => { onPointerMove={(event) => {
handleDrawnFieldPointerMove(event, drawnField, page.width) handleDrawnFieldPointerMove(event, page.width)
}} }}
onPointerUp={handlePointerUpReleaseCapture}
className={styles.drawingRectangle} className={styles.drawingRectangle}
style={{ style={{
backgroundColor: drawnField.counterpart backgroundColor: drawnField.counterpart
@ -454,12 +557,29 @@ export const DrawPDFFields = (props: Props) => {
outlineColor: drawnField.counterpart outlineColor: drawnField.counterpart
? `#${npubToHex(drawnField.counterpart)?.substring(0, 6)}` ? `#${npubToHex(drawnField.counterpart)?.substring(0, 6)}`
: undefined, : undefined,
...(isFieldIndexerActive && field
? {
left: inPx(from(page.width, field.left)),
top: inPx(from(page.width, field.top)),
width: inPx(from(page.width, field.width)),
height: inPx(from(page.width, field.height))
}
: {
left: inPx(from(page.width, drawnField.left)), left: inPx(from(page.width, drawnField.left)),
top: inPx(from(page.width, drawnField.top)), top: inPx(from(page.width, drawnField.top)),
width: inPx(from(page.width, drawnField.width)), width: inPx(from(page.width, drawnField.width)),
height: inPx(from(page.width, drawnField.height)), height: inPx(from(page.width, drawnField.height))
}),
pointerEvents: mouseState.clicked ? 'none' : 'all', pointerEvents: mouseState.clicked ? 'none' : 'all',
touchAction: 'none', touchAction: 'none',
zIndex: isActiveDrawnField(
fileIndex,
pageIndex,
drawnFieldIndex
)
? 60
: undefined,
opacity: opacity:
mouseState.dragging && mouseState.dragging &&
isActiveDrawnField( isActiveDrawnField(
@ -483,37 +603,36 @@ export const DrawPDFFields = (props: Props) => {
</div> </div>
<span <span
onPointerDown={(event) => onPointerDown={(event) =>
handleResizePointerDown( handleResizePointerDown(event, [
event,
fileIndex, fileIndex,
pageIndex, pageIndex,
drawnFieldIndex drawnFieldIndex
) ])
} }
onPointerMove={(event) => { onPointerMove={(event) => {
handleResizePointerMove(event, drawnField, page.width) handleResizePointerMove(event, page.width)
}} }}
onPointerUp={handlePointerUpReleaseCapture}
className={styles.resizeHandle} className={styles.resizeHandle}
style={{ style={{
background: ...(mouseState.resizing &&
mouseState.resizing &&
isActiveDrawnField( isActiveDrawnField(
fileIndex, fileIndex,
pageIndex, pageIndex,
drawnFieldIndex drawnFieldIndex
) ) && {
? 'var(--primary-main)' cursor: 'grabbing',
: undefined opacity: 0.1
})
}} }}
></span> ></span>
<span <span
onPointerDown={(event) => { onPointerDown={(event) => {
handleRemovePointerDown( handleRemovePointerDown(event, [
event,
fileIndex, fileIndex,
pageIndex, pageIndex,
drawnFieldIndex drawnFieldIndex
) ])
}} }}
className={styles.removeHandle} className={styles.removeHandle}
> >
@ -551,21 +670,23 @@ export const DrawPDFFields = (props: Props) => {
onChange={(event) => { onChange={(event) => {
drawnField.counterpart = event.target.value drawnField.counterpart = event.target.value
setLastSigner(event.target.value) setLastSigner(event.target.value)
refreshPdfFiles()
}} }}
labelId="counterparts" labelId="counterparts"
label="Counterparts" label="Counterparts"
sx={{ sx={{
background: 'white' background: 'white'
}} }}
renderValue={(value) => renderValue={(value) => (
renderCounterpartValue(value) <Counterpart
} npub={value}
metadata={userProfiles}
signers={signers}
/>
)}
> >
{signers.map((signer, index) => { {signers.map((signer, index) => {
const npub = hexToNpub(signer.pubkey) const npub = hexToNpub(signer.pubkey)
const profile = const profile = userProfiles[signer.pubkey]
props.userProfiles[signer.pubkey]
const displayValue = getProfileUsername( const displayValue = getProfileUsername(
npub, npub,
profile profile
@ -618,58 +739,24 @@ export const DrawPDFFields = (props: Props) => {
) )
} }
const renderCounterpartValue = (npub: string) => {
let displayValue = _.truncate(npub, { length: 16 })
const signer = signers.find((u) => u.pubkey === npubToHex(npub))
if (signer) {
const profile = props.userProfiles[signer.pubkey]
displayValue = getProfileUsername(npub, profile)
return (
<div className={styles.counterpartSelectValue}>
<AvatarIconButton
src={profile?.image}
hexKey={signer.pubkey || undefined}
sx={{
padding: 0,
marginRight: '6px',
'> img': {
width: '21px',
height: '21px'
}
}}
/>
{displayValue}
</div>
)
}
return displayValue
}
return ( return (
<div className="files-wrapper"> <div className="files-wrapper">
{sigitFiles.map((file, i) => { {sigitFiles.length > 0 &&
return ( sigitFiles
.map<React.ReactNode>((file, i) =>
file.isPdf ? (
<React.Fragment key={file.name}> <React.Fragment key={file.name}>
<div className="file-wrapper" id={`file-${file.name}`}> {getPdfPages(file, i)}
{file.isPdf && getPdfPages(file, i)}
{file.isImage && (
<img
className="file-image"
src={file.objectUrl}
alt={file.name}
/>
)}
{!(file.isPdf || file.isImage) && (
<ExtensionFileBox extension={file.extension} />
)}
</div>
{i < sigitFiles.length - 1 && <FileDivider />}
</React.Fragment> </React.Fragment>
) : (
<FileItem key={file.name} file={file} />
) )
})} )
.reduce((prev, curr, i) => [
prev,
<FileDivider key={`separator-${i}`} />,
curr
])}
</div> </div>
) )
} }

View File

@ -0,0 +1,3 @@
.counterpartSelectValue {
display: flex;
}

View File

@ -0,0 +1,46 @@
import React from 'react'
import { ProfileMetadata, User } from '../../../types'
import _ from 'lodash'
import { npubToHex, getProfileUsername } from '../../../utils'
import { AvatarIconButton } from '../../UserAvatarIconButton'
import styles from './Counterpart.module.scss'
interface CounterpartProps {
npub: string
metadata: {
[key: string]: ProfileMetadata
}
signers: User[]
}
export const Counterpart = React.memo(
({ npub, metadata, signers }: CounterpartProps) => {
let displayValue = _.truncate(npub, { length: 16 })
const signer = signers.find((u) => u.pubkey === npubToHex(npub))
if (signer) {
const signerMetadata = metadata[signer.pubkey]
displayValue = getProfileUsername(npub, signerMetadata)
return (
<div className={styles.counterpartSelectValue}>
<AvatarIconButton
src={signerMetadata.picture}
hexKey={signer.pubkey || undefined}
sx={{
padding: 0,
marginRight: '6px',
'> img': {
width: '21px',
height: '21px'
}
}}
/>
{displayValue}
</div>
)
}
return displayValue
}
)

View File

@ -0,0 +1,19 @@
import React from 'react'
import { SigitFile } from '../../../utils/file'
import { ExtensionFileBox } from '../../ExtensionFileBox'
import { ImageItem } from './ImageItem'
interface FileItemProps {
file: SigitFile
}
export const FileItem = React.memo(({ file }: FileItemProps) => {
const content = <ExtensionFileBox extension={file.extension} />
if (file.isImage) return <ImageItem file={file} />
return (
<div key={file.name} className="file-wrapper" id={`file-${file.name}`}>
{content}
</div>
)
})

View File

@ -0,0 +1,10 @@
import React from 'react'
import { SigitFile } from '../../../utils/file'
interface ImageItemProps {
file: SigitFile
}
export const ImageItem = React.memo(({ file }: ImageItemProps) => {
return <img className="file-image" src={file.objectUrl} alt={file.name} />
})

View File

@ -38,10 +38,6 @@
visibility: hidden; visibility: hidden;
} }
&.edited {
outline: 1px dotted #01aaad;
}
.resizeHandle { .resizeHandle {
position: absolute; position: absolute;
right: -5px; right: -5px;
@ -51,7 +47,7 @@
background-color: #fff; background-color: #fff;
border: 1px solid rgb(160, 160, 160); border: 1px solid rgb(160, 160, 160);
border-radius: 50%; border-radius: 50%;
cursor: nwse-resize; cursor: grab;
// Increase the area a bit so it's easier to click // Increase the area a bit so it's easier to click
&::after { &::after {
@ -89,13 +85,34 @@
} }
} }
.counterpartSelectValue {
display: flex;
}
.counterpartAvatar { .counterpartAvatar {
img { img {
width: 21px; width: 21px;
height: 21px; height: 21px;
} }
} }
.signingRectangle {
position: absolute;
outline: 1px solid #01aaad;
z-index: 40;
background-color: #01aaad4b;
cursor: pointer;
&.edited {
outline: 1px dotted #01aaad;
}
}
.drawingRectanglePreview {
position: absolute;
outline: 1px solid;
z-index: 50;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
pointer-events: none;
touch-action: none;
opacity: 0.8;
}

View File

@ -1,23 +1,25 @@
import { CurrentUserFile } from '../../types/file.ts' import { CurrentUserFile } from '../../types/file.ts'
import styles from './style.module.scss' import styles from './style.module.scss'
import { Button } from '@mui/material' import { Button, Menu, MenuItem } from '@mui/material'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faCheck } from '@fortawesome/free-solid-svg-icons' import { faCheck } from '@fortawesome/free-solid-svg-icons'
import PopupState, { bindTrigger, bindMenu } from 'material-ui-popup-state'
import React from 'react'
interface FileListProps { interface FileListProps {
files: CurrentUserFile[] files: CurrentUserFile[]
currentFile: CurrentUserFile currentFile: CurrentUserFile
setCurrentFile: (file: CurrentUserFile) => void setCurrentFile: (file: CurrentUserFile) => void
handleDownload: () => void handleExport: () => void
downloadLabel?: string handleEncryptedExport?: () => void
} }
const FileList = ({ const FileList = ({
files, files,
currentFile, currentFile,
setCurrentFile, setCurrentFile,
handleDownload, handleExport,
downloadLabel handleEncryptedExport
}: FileListProps) => { }: FileListProps) => {
const isActive = (file: CurrentUserFile) => file.id === currentFile.id const isActive = (file: CurrentUserFile) => file.id === currentFile.id
return ( return (
@ -42,9 +44,35 @@ const FileList = ({
</li> </li>
))} ))}
</ul> </ul>
<Button variant="contained" fullWidth onClick={handleDownload}>
{downloadLabel || 'Download Files'} <PopupState variant="popover" popupId="download-popup-menu">
{(popupState) => (
<React.Fragment>
<Button variant="contained" {...bindTrigger(popupState)}>
Export files
</Button> </Button>
<Menu {...bindMenu(popupState)}>
<MenuItem
onClick={() => {
popupState.close
handleExport()
}}
>
Export Files
</MenuItem>
<MenuItem
onClick={() => {
popupState.close
typeof handleEncryptedExport === 'function' &&
handleEncryptedExport()
}}
>
Export Encrypted Files
</MenuItem>
</Menu>
</React.Fragment>
)}
</PopupState>
</div> </div>
) )
} }

View File

@ -68,6 +68,12 @@ export const Footer = () =>
}} }}
component={Link} component={Link}
to={'/'} to={'/'}
onClick={(event) => {
if (['', '#/'].includes(window.location.hash)) {
event.preventDefault()
window.scrollTo(0, 0)
}
}}
variant={'text'} variant={'text'}
> >
Home Home

View File

@ -7,7 +7,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background-color: rgba(0, 0, 0, 0.5); background-color: rgba(0, 0, 0, 0.5);
z-index: 50; z-index: 70;
-webkit-backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
} }

View File

@ -1,5 +1,4 @@
import { CurrentUserMark } from '../../types/mark.ts' import { CurrentUserMark } from '../../types/mark.ts'
import styles from './style.module.scss'
import { import {
findNextIncompleteCurrentUserMark, findNextIncompleteCurrentUserMark,
getToolboxLabelByMarkType, getToolboxLabelByMarkType,
@ -8,12 +7,16 @@ import {
} from '../../utils' } from '../../utils'
import React, { useState } from 'react' import React, { useState } from 'react'
import { MarkInput } from '../MarkTypeStrategy/MarkInput.tsx' import { MarkInput } from '../MarkTypeStrategy/MarkInput.tsx'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faCheck } from '@fortawesome/free-solid-svg-icons'
import { Button } from '@mui/material'
import styles from './style.module.scss'
interface MarkFormFieldProps { interface MarkFormFieldProps {
currentUserMarks: CurrentUserMark[] currentUserMarks: CurrentUserMark[]
handleCurrentUserMarkChange: (mark: CurrentUserMark) => void handleCurrentUserMarkChange: (mark: CurrentUserMark) => void
handleSelectedMarkValueChange: (value: string) => void handleSelectedMarkValueChange: (value: string) => void
handleSubmit: (event: React.FormEvent<HTMLFormElement>) => void handleSubmit: (event: React.MouseEvent<HTMLButtonElement>) => void
selectedMark: CurrentUserMark selectedMark: CurrentUserMark
selectedMarkValue: string selectedMarkValue: string
} }
@ -30,11 +33,13 @@ const MarkFormField = ({
handleCurrentUserMarkChange handleCurrentUserMarkChange
}: MarkFormFieldProps) => { }: MarkFormFieldProps) => {
const [displayActions, setDisplayActions] = useState(true) const [displayActions, setDisplayActions] = useState(true)
const [complete, setComplete] = useState(false)
const isReadyToSign = () => const isReadyToSign = () =>
isCurrentUserMarksComplete(currentUserMarks) || isCurrentUserMarksComplete(currentUserMarks) ||
isCurrentValueLast(currentUserMarks, selectedMark, selectedMarkValue) isCurrentValueLast(currentUserMarks, selectedMark, selectedMarkValue)
const isCurrent = (currentMark: CurrentUserMark) => const isCurrent = (currentMark: CurrentUserMark) =>
currentMark.id === selectedMark.id currentMark.id === selectedMark.id && !complete
const isDone = (currentMark: CurrentUserMark) => const isDone = (currentMark: CurrentUserMark) =>
isCurrent(currentMark) ? !!selectedMarkValue : currentMark.isCompleted isCurrent(currentMark) ? !!selectedMarkValue : currentMark.isCompleted
const findNext = () => { const findNext = () => {
@ -46,13 +51,36 @@ const MarkFormField = ({
const handleFormSubmit = (event: React.FormEvent<HTMLFormElement>) => { const handleFormSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault() event.preventDefault()
console.log('handle form submit runs...') console.log('handle form submit runs...')
return isReadyToSign()
? handleSubmit(event) // Without this line, we lose mark values when switching
handleCurrentUserMarkChange(selectedMark)
if (!complete) {
isReadyToSign()
? setComplete(true)
: handleCurrentUserMarkChange(findNext()!) : handleCurrentUserMarkChange(findNext()!)
} }
}
const toggleActions = () => setDisplayActions(!displayActions) const toggleActions = () => setDisplayActions(!displayActions)
const markLabel = getToolboxLabelByMarkType(selectedMark.mark.type) const markLabel = getToolboxLabelByMarkType(selectedMark.mark.type)
const handleCurrentUserMarkClick = (mark: CurrentUserMark) => {
setComplete(false)
handleCurrentUserMarkChange(mark)
}
const handleSelectCompleteMark = () => {
handleCurrentUserMarkChange(selectedMark)
setComplete(true)
}
const handleSignAndComplete = (
event: React.MouseEvent<HTMLButtonElement>
) => {
handleSubmit(event)
}
return ( return (
<div className={styles.container}> <div className={styles.container}>
<div className={styles.trigger}> <div className={styles.trigger}>
@ -78,10 +106,14 @@ const MarkFormField = ({
<div className={styles.actionsWrapper}> <div className={styles.actionsWrapper}>
<div className={styles.actionsTop}> <div className={styles.actionsTop}>
<div className={styles.actionsTopInfo}> <div className={styles.actionsTopInfo}>
{!complete && (
<p className={styles.actionsTopInfoText}>Add {markLabel}</p> <p className={styles.actionsTopInfoText}>Add {markLabel}</p>
)}
{complete && <p className={styles.actionsTopInfoText}>Finish</p>}
</div> </div>
</div> </div>
<div className={styles.inputWrapper}> <div className={styles.inputWrapper}>
{!complete && (
<form onSubmit={(e) => handleFormSubmit(e)}> <form onSubmit={(e) => handleFormSubmit(e)}>
<MarkInput <MarkInput
markType={selectedMark.mark.type} markType={selectedMark.mark.type}
@ -92,19 +124,37 @@ const MarkFormField = ({
userMark={selectedMark} userMark={selectedMark}
/> />
<div className={styles.actionsBottom}> <div className={styles.actionsBottom}>
<button type="submit" className={styles.submitButton}> <Button type="submit" className={styles.submitButton}>
NEXT NEXT
</button> </Button>
</div> </div>
</form> </form>
)}
{complete && (
<div className={styles.actionsBottom}>
<Button
onClick={handleSignAndComplete}
className={[styles.submitButton, styles.completeButton].join(
' '
)}
disabled={!isReadyToSign()}
autoFocus
>
SIGN AND COMPLETE
</Button>
</div>
)}
<div className={styles.footerContainer}> <div className={styles.footerContainer}>
<div className={styles.footer}> <div className={styles.footer}>
{currentUserMarks.map((mark, index) => { {currentUserMarks.map((mark, index) => {
return ( return (
<div className={styles.pagination} key={index}> <div className={styles.pagination} key={index}>
<button <button
type="button"
className={`${styles.paginationButton} ${isDone(mark) ? styles.paginationButtonDone : ''}`} className={`${styles.paginationButton} ${isDone(mark) ? styles.paginationButtonDone : ''}`}
onClick={() => handleCurrentUserMarkChange(mark)} onClick={() => handleCurrentUserMarkClick(mark)}
> >
{mark.id} {mark.id}
</button> </button>
@ -114,6 +164,22 @@ const MarkFormField = ({
</div> </div>
) )
})} })}
<div className={styles.pagination}>
<button
type="button"
className={`${styles.paginationButton} ${isReadyToSign() ? styles.paginationButtonDone : ''}`}
onClick={handleSelectCompleteMark}
title="Complete"
>
<FontAwesomeIcon
className={styles.finishPage}
icon={faCheck}
/>
</button>
{complete && (
<div className={styles.paginationButtonCurrent}></div>
)}
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -70,6 +70,11 @@
margin-top: 10px; margin-top: 10px;
} }
.completeButton {
font-size: 18px;
padding: 10px 20px;
}
.paginationButton { .paginationButton {
font-size: 12px; font-size: 12px;
padding: 5px 10px; padding: 5px 10px;
@ -78,7 +83,8 @@
color: rgba(0, 0, 0, 0.5); color: rgba(0, 0, 0, 0.5);
} }
.paginationButton:hover { .paginationButton:hover,
.paginationButton:focus {
background: #447592; background: #447592;
color: rgba(255, 255, 255, 0.5); color: rgba(255, 255, 255, 0.5);
} }
@ -122,7 +128,7 @@
align-items: center; align-items: center;
grid-gap: 15px; grid-gap: 15px;
box-shadow: 0 -2px 4px 0 rgb(0, 0, 0, 0.1); box-shadow: 0 -2px 4px 0 rgb(0, 0, 0, 0.1);
max-width: 750px; max-width: 450px;
&.expanded { &.expanded {
display: flex; display: flex;
@ -216,3 +222,7 @@
flex-direction: column; flex-direction: column;
grid-gap: 5px; grid-gap: 5px;
} }
.finishPage {
padding: 1px 0;
}

View File

@ -32,7 +32,7 @@ const PdfMarkItem = forwardRef<HTMLDivElement, PdfMarkItemProps>(
<div <div
ref={ref} ref={ref}
onClick={handleClick} onClick={handleClick}
className={`file-mark ${styles.drawingRectangle} ${isEdited() && styles.edited}`} className={`file-mark ${styles.signingRectangle} ${isEdited() && styles.edited}`}
style={{ style={{
backgroundColor: selectedMark?.mark.npub backgroundColor: selectedMark?.mark.npub
? `#${npubToHex(selectedMark?.mark.npub)?.substring(0, 6)}4b` ? `#${npubToHex(selectedMark?.mark.npub)?.substring(0, 6)}4b`

View File

@ -24,11 +24,12 @@ import {
interface PdfMarkingProps { interface PdfMarkingProps {
currentUserMarks: CurrentUserMark[] currentUserMarks: CurrentUserMark[]
files: CurrentUserFile[] files: CurrentUserFile[]
handleDownload: () => void handleExport: () => void
handleEncryptedExport: () => void
handleSign: () => void
meta: Meta | null meta: Meta | null
otherUserMarks: Mark[] otherUserMarks: Mark[]
setCurrentUserMarks: (currentUserMarks: CurrentUserMark[]) => void setCurrentUserMarks: (currentUserMarks: CurrentUserMark[]) => void
setIsMarksCompleted: (isMarksCompleted: boolean) => void
setUpdatedMarks: (markToUpdate: Mark) => void setUpdatedMarks: (markToUpdate: Mark) => void
} }
@ -42,10 +43,11 @@ const PdfMarking = (props: PdfMarkingProps) => {
const { const {
files, files,
currentUserMarks, currentUserMarks,
setIsMarksCompleted,
setCurrentUserMarks, setCurrentUserMarks,
setUpdatedMarks, setUpdatedMarks,
handleDownload, handleExport,
handleEncryptedExport,
handleSign,
meta, meta,
otherUserMarks otherUserMarks
} = props } = props
@ -70,8 +72,8 @@ const PdfMarking = (props: PdfMarkingProps) => {
const handleMarkClick = (id: number) => { const handleMarkClick = (id: number) => {
const nextMark = currentUserMarks.find((mark) => mark.mark.id === id) const nextMark = currentUserMarks.find((mark) => mark.mark.id === id)
setSelectedMark(nextMark!)
setSelectedMarkValue(nextMark?.mark.value ?? EMPTY) if (nextMark) handleCurrentUserMarkChange(nextMark)
} }
const handleCurrentUserMarkChange = (mark: CurrentUserMark) => { const handleCurrentUserMarkChange = (mark: CurrentUserMark) => {
@ -86,11 +88,18 @@ const PdfMarking = (props: PdfMarkingProps) => {
updatedSelectedMark updatedSelectedMark
) )
setCurrentUserMarks(updatedCurrentUserMarks) setCurrentUserMarks(updatedCurrentUserMarks)
// If clicking on the same mark, don't update the value, otherwise do update
if (mark.id !== selectedMark.id) {
setSelectedMarkValue(mark.currentValue ?? EMPTY) setSelectedMarkValue(mark.currentValue ?? EMPTY)
setSelectedMark(mark) setSelectedMark(mark)
} }
}
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => { /**
* Sign and Complete
*/
const handleSubmit = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault() event.preventDefault()
if (!selectedMarkValue || !selectedMark) return if (!selectedMarkValue || !selectedMark) return
@ -106,8 +115,8 @@ const PdfMarking = (props: PdfMarkingProps) => {
) )
setCurrentUserMarks(updatedCurrentUserMarks) setCurrentUserMarks(updatedCurrentUserMarks)
setSelectedMark(null) setSelectedMark(null)
setIsMarksCompleted(true)
setUpdatedMarks(updatedMark.mark) setUpdatedMarks(updatedMark.mark)
handleSign()
} }
// const updateCurrentUserMarkValues = () => { // const updateCurrentUserMarkValues = () => {
@ -132,7 +141,8 @@ const PdfMarking = (props: PdfMarkingProps) => {
files={files} files={files}
currentFile={currentFile} currentFile={currentFile}
setCurrentFile={setCurrentFile} setCurrentFile={setCurrentFile}
handleDownload={handleDownload} handleExport={handleExport}
handleEncryptedExport={handleEncryptedExport}
/> />
)} )}
</div> </div>

View File

@ -45,18 +45,17 @@ const PdfView = ({
const filterMarksByFile = (marks: Mark[], hash: string): Mark[] => { const filterMarksByFile = (marks: Mark[], hash: string): Mark[] => {
return marks.filter((mark) => mark.pdfFileHash === hash) return marks.filter((mark) => mark.pdfFileHash === hash)
} }
const isNotLastPdfFile = (index: number, files: CurrentUserFile[]): boolean =>
index !== files.length - 1
return ( return (
<div className="files-wrapper"> <div className="files-wrapper">
{files.length > 0 ? ( {files.length > 0 ? (
files.map((currentUserFile, index, arr) => { files
.map<React.ReactNode>((currentUserFile) => {
const { hash, file, id } = currentUserFile const { hash, file, id } = currentUserFile
if (!hash) return if (!hash) return
return ( return (
<React.Fragment key={index}>
<div <div
key={`file-${file.name}`}
id={file.name} id={file.name}
className="file-wrapper" className="file-wrapper"
ref={(el) => (pdfRefs.current[id] = el)} ref={(el) => (pdfRefs.current[id] = el)}
@ -70,10 +69,13 @@ const PdfView = ({
otherUserMarks={filterMarksByFile(otherUserMarks, hash)} otherUserMarks={filterMarksByFile(otherUserMarks, hash)}
/> />
</div> </div>
{isNotLastPdfFile(index, arr) && <FileDivider />}
</React.Fragment>
) )
}) })
.reduce((prev, curr, i) => [
prev,
<FileDivider key={`separator-${i}`} />,
curr
])
) : ( ) : (
<LoadingSpinner variant="small" /> <LoadingSpinner variant="small" />
)} )}

View File

@ -19,11 +19,18 @@ import {
updateUserAppData as updateUserAppDataAction updateUserAppData as updateUserAppDataAction
} from '../store/actions' } from '../store/actions'
import { Keys } from '../store/auth/types' import { Keys } from '../store/auth/types'
import { Meta, UserAppData, UserRelaysType } from '../types' import {
isSigitNotification,
Meta,
SigitNotification,
UserAppData,
UserRelaysType
} from '../types'
import { import {
countLeadingZeroes, countLeadingZeroes,
createWrap, createWrap,
deleteBlossomFile, deleteBlossomFile,
fetchMetaFromFileStorage,
getDTagForUserAppData, getDTagForUserAppData,
getUserAppDataFromBlossom, getUserAppDataFromBlossom,
hexToNpub, hexToNpub,
@ -337,21 +344,63 @@ export const useNDK = () => {
if (!internalUnsignedEvent || internalUnsignedEvent.kind !== 938) return if (!internalUnsignedEvent || internalUnsignedEvent.kind !== 938) return
const meta = await parseJson<Meta>(internalUnsignedEvent.content).catch( const parsedContent = await parseJson<Meta | SigitNotification>(
(err) => { internalUnsignedEvent.content
).catch((err) => {
console.log( console.log(
'An error occurred in parsing the internal unsigned event', 'An error occurred in parsing the internal unsigned event',
err err
) )
return null return null
} })
)
if (!meta) return if (!parsedContent) return
let meta: Meta
if (isSigitNotification(parsedContent)) {
const notification = parsedContent
if (!notification.keys || !usersPubkey) return
let encryptionKey: string | undefined
const { sender, keys } = notification.keys
// Retrieve the user's public key from the state
const usersNpub = hexToNpub(usersPubkey)
// Check if the user's public key is in the keys object
if (usersNpub in keys) {
// Instantiate the NostrController to decrypt the encryption key
const nostrController = NostrController.getInstance()
const decrypted = await nostrController
.nip04Decrypt(sender, keys[usersNpub])
.catch((err) => {
console.log('An error occurred in decrypting encryption key', err)
return undefined
})
encryptionKey = decrypted
}
try {
meta = await fetchMetaFromFileStorage(
notification.metaUrl,
encryptionKey
)
} catch (error) {
console.error(
`An error occured fetching meta file from storage`,
error
)
return
}
} else {
meta = parsedContent
}
await updateUsersAppData(meta) await updateUsersAppData(meta)
}, },
[dispatch, processedEvents, updateUsersAppData] [dispatch, processedEvents, updateUsersAppData, usersPubkey]
) )
const subscribeForSigits = useCallback( const subscribeForSigits = useCallback(
@ -376,15 +425,20 @@ export const useNDK = () => {
[fetchEventsFromUserRelays, processReceivedEvent] [fetchEventsFromUserRelays, processReceivedEvent]
) )
/**
* Function to send a notification to a specified receiver.
* @param receiver - The recipient's public key.
* @param notification - Url pointing to metadata associated with the notification on blossom and keys to decrypt.
*/
const sendNotification = useCallback( const sendNotification = useCallback(
async (receiver: string, meta: Meta) => { async (receiver: string, notification: SigitNotification) => {
if (!usersPubkey) return if (!usersPubkey) return
// Create an unsigned event object with the provided metadata // Create an unsigned event object with the provided metadata
const unsignedEvent: UnsignedEvent = { const unsignedEvent: UnsignedEvent = {
kind: 938, kind: 938,
pubkey: usersPubkey, pubkey: usersPubkey,
content: JSON.stringify(meta), content: JSON.stringify(notification),
tags: [], tags: [],
created_at: unixNow() created_at: unixNow()
} }

View File

@ -45,7 +45,7 @@ export interface FlatMeta
isValid: boolean isValid: boolean
// Decryption // Decryption
encryptionKey: string | null encryptionKey: string | undefined
// Parsed Document Signatures // Parsed Document Signatures
parsedSignatureEvents: { parsedSignatureEvents: {
@ -101,7 +101,7 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
[signer: `npub1${string}`]: SignStatus [signer: `npub1${string}`]: SignStatus
}>({}) }>({})
const [encryptionKey, setEncryptionKey] = useState<string | null>(null) const [encryptionKey, setEncryptionKey] = useState<string | undefined>()
useEffect(() => { useEffect(() => {
if (!meta) return if (!meta) return
@ -143,7 +143,7 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
setMarkConfig(markConfig) setMarkConfig(markConfig)
setZipUrl(zipUrl) setZipUrl(zipUrl)
let encryptionKey: string | null = null let encryptionKey: string | undefined
if (meta.keys) { if (meta.keys) {
const { sender, keys } = meta.keys const { sender, keys } = meta.keys
// Retrieve the user's public key from the state // Retrieve the user's public key from the state
@ -161,7 +161,7 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
'An error occurred in decrypting encryption key', 'An error occurred in decrypting encryption key',
err err
) )
return null return undefined
}) })
encryptionKey = decrypted encryptionKey = decrypted

View File

@ -138,7 +138,15 @@ export const MainLayout = () => {
initNostrLogin({ initNostrLogin({
methods: ['connect', 'extension', 'local'], methods: ['connect', 'extension', 'local'],
noBanner: true, noBanner: true,
onAuth: handleNostrAuth onAuth: handleNostrAuth,
outboxRelays: [
'wss://purplepag.es',
'wss://relay.nos.social',
'wss://user.kindpag.es',
'wss://relay.damus.io',
'wss://nos.lol',
'wss://relay.sigit.io'
]
}).catch((error) => { }).catch((error) => {
console.error('Failed to initialize Nostr-Login', error) console.error('Failed to initialize Nostr-Login', error)
}) })

View File

@ -20,11 +20,12 @@ import { toast } from 'react-toastify'
import { LoadingSpinner } from '../../components/LoadingSpinner' import { LoadingSpinner } from '../../components/LoadingSpinner'
import { UserAvatar } from '../../components/UserAvatar' import { UserAvatar } from '../../components/UserAvatar'
import { NostrController } from '../../controllers' import { NostrController } from '../../controllers'
import { appPrivateRoutes } from '../../routes' import { appPrivateRoutes, appPublicRoutes } from '../../routes'
import { import {
CreateSignatureEventContent, CreateSignatureEventContent,
KeyboardCode, KeyboardCode,
Meta, Meta,
SigitNotification,
SignedEvent, SignedEvent,
User, User,
UserRelaysType, UserRelaysType,
@ -45,7 +46,8 @@ import {
signEventForMetaFile, signEventForMetaFile,
uploadToFileStorage, uploadToFileStorage,
DEFAULT_TOOLBOX, DEFAULT_TOOLBOX,
settleAllFullfilfedPromises settleAllFullfilfedPromises,
uploadMetaToFileStorage
} from '../../utils' } from '../../utils'
import { Container } from '../../components/Container' import { Container } from '../../components/Container'
import fileListStyles from '../../components/FileList/style.module.scss' import fileListStyles from '../../components/FileList/style.module.scss'
@ -69,13 +71,14 @@ import {
} from '@fortawesome/free-solid-svg-icons' } from '@fortawesome/free-solid-svg-icons'
import { getSigitFile, SigitFile } from '../../utils/file.ts' import { getSigitFile, SigitFile } from '../../utils/file.ts'
import { generateTimestamp } from '../../utils/opentimestamps.ts' import { generateTimestamp } from '../../utils/opentimestamps.ts'
import { Autocomplete } from '@mui/lab' import { Autocomplete } from '@mui/material'
import _, { truncate } from 'lodash' import _, { truncate } from 'lodash'
import * as React from 'react' import * as React from 'react'
import { AvatarIconButton } from '../../components/UserAvatarIconButton' import { AvatarIconButton } from '../../components/UserAvatarIconButton'
import { NDKUserProfile, NostrEvent } from '@nostr-dev-kit/ndk' import { NDKUserProfile, NostrEvent } from '@nostr-dev-kit/ndk'
import { useNDKContext } from '../../hooks/useNDKContext.ts' import { useNDKContext } from '../../hooks/useNDKContext.ts'
import { useNDK } from '../../hooks/useNDK.ts' import { useNDK } from '../../hooks/useNDK.ts'
import { useImmer } from 'use-immer'
type FoundUser = NostrEvent & { npub: string } type FoundUser = NostrEvent & { npub: string }
@ -94,7 +97,7 @@ export const CreatePage = () => {
const [title, setTitle] = useState(`sigit_${formatTimestamp(Date.now())}`) const [title, setTitle] = useState(`sigit_${formatTimestamp(Date.now())}`)
const [selectedFiles, setSelectedFiles] = useState<File[]>([]) const [selectedFiles, setSelectedFiles] = useState<File[]>([...uploadedFiles])
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const handleUploadButtonClick = () => { const handleUploadButtonClick = () => {
if (fileInputRef.current) { if (fileInputRef.current) {
@ -120,7 +123,7 @@ export const CreatePage = () => {
[key: string]: NDKUserProfile [key: string]: NDKUserProfile
}>({}) }>({})
const [drawnFiles, setDrawnFiles] = useState<SigitFile[]>([]) const [drawnFiles, updateDrawnFiles] = useImmer<SigitFile[]>([])
const [parsingPdf, setIsParsing] = useState<boolean>(false) const [parsingPdf, setIsParsing] = useState<boolean>(false)
const searchFieldRef = useRef<HTMLInputElement>(null) const searchFieldRef = useRef<HTMLInputElement>(null)
@ -148,6 +151,17 @@ export const CreatePage = () => {
[setUserInput] [setUserInput]
) )
const handleSearchUserNip05 = async (
nip05: string
): Promise<string | null> => {
const { pubkey } = await queryNip05(nip05).catch((err) => {
console.error(err)
return { pubkey: null }
})
return pubkey
}
const handleSearchUsers = async (searchValue?: string) => { const handleSearchUsers = async (searchValue?: string) => {
const searchString = searchValue || userSearchInput || undefined const searchString = searchValue || userSearchInput || undefined
@ -195,8 +209,11 @@ export const CreatePage = () => {
return uniqueEvents return uniqueEvents
}, [] as FoundUser[]) }, [] as FoundUser[])
console.log('fineFilteredEvents', fineFilteredEvents) console.info('fineFilteredEvents', fineFilteredEvents)
setFoundUsers(fineFilteredEvents) setFoundUsers(fineFilteredEvents)
if (!fineFilteredEvents.length)
toast.info('No user found with the provided search term')
}) })
.catch((error) => { .catch((error) => {
console.error(error) console.error(error)
@ -217,7 +234,9 @@ export const CreatePage = () => {
}) })
}, [foundUsers]) }, [foundUsers])
const handleInputKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => { const handleInputKeyDown = async (
event: React.KeyboardEvent<HTMLDivElement>
) => {
if ( if (
event.code === KeyboardCode.Enter || event.code === KeyboardCode.Enter ||
event.code === KeyboardCode.NumpadEnter event.code === KeyboardCode.NumpadEnter
@ -231,11 +250,27 @@ export const CreatePage = () => {
} else { } else {
// Otherwize if search already provided some results, user must manually click the search button // Otherwize if search already provided some results, user must manually click the search button
if (!foundUsers.length) { 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() handleSearchUsers()
} }
} }
} }
} }
}
useEffect(() => { useEffect(() => {
if (selectedFiles) { if (selectedFiles) {
@ -248,8 +283,28 @@ export const CreatePage = () => {
selectedFiles, selectedFiles,
getSigitFile getSigitFile
) )
updateDrawnFiles((draft) => {
// Existing files are untouched
setDrawnFiles(files) // 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) setIsParsing(true)
@ -258,7 +313,7 @@ export const CreatePage = () => {
setIsParsing(false) setIsParsing(false)
}) })
} }
}, [selectedFiles]) }, [selectedFiles, updateDrawnFiles])
/** /**
* Changes the drawing tool * Changes the drawing tool
@ -296,12 +351,6 @@ export const CreatePage = () => {
}) })
}, [userProfiles, users, findMetadata]) }, [userProfiles, users, findMetadata])
useEffect(() => {
if (uploadedFiles) {
setSelectedFiles([...uploadedFiles])
}
}, [uploadedFiles])
useEffect(() => { useEffect(() => {
if (usersPubkey) { if (usersPubkey) {
setUsers((prev) => { setUsers((prev) => {
@ -455,7 +504,7 @@ export const CreatePage = () => {
}) })
}) })
}) })
setDrawnFiles(drawnFilesCopy) updateDrawnFiles(drawnFilesCopy)
} }
/** /**
@ -479,11 +528,16 @@ export const CreatePage = () => {
const files = Array.from(event.target.files) const files = Array.from(event.target.files)
// Remove duplicates based on the file.name // Remove duplicates based on the file.name
setSelectedFiles((p) => setSelectedFiles((p) => {
[...p, ...files].filter( const unique = [...p, ...files].filter(
(file, i, array) => i === array.findIndex((t) => t.name === file.name) (file, i, array) => i === array.findIndex((t) => t.name === file.name)
) )
) navigate('.', {
state: { uploadedFiles: unique },
replace: true
})
return unique
})
} }
} }
@ -497,9 +551,14 @@ export const CreatePage = () => {
) => { ) => {
event.stopPropagation() event.stopPropagation()
setSelectedFiles((prevFiles) => setSelectedFiles((prevFiles) => {
prevFiles.filter((file) => file.name !== fileToRemove.name) const files = prevFiles.filter((file) => file.name !== fileToRemove.name)
) navigate('.', {
state: { uploadedFiles: files },
replace: true
})
return files
})
} }
// Validate inputs before proceeding // Validate inputs before proceeding
@ -743,7 +802,7 @@ export const CreatePage = () => {
title title
} }
setLoadingSpinnerDesc('Signing nostr event for create signature') setLoadingSpinnerDesc('Preparing document(s) for signing')
const createSignature = await signEventForMetaFile( const createSignature = await signEventForMetaFile(
JSON.stringify(content), JSON.stringify(content),
@ -761,7 +820,7 @@ export const CreatePage = () => {
} }
// Send notifications to signers and viewers // Send notifications to signers and viewers
const sendNotifications = (meta: Meta) => { const sendNotifications = (notification: SigitNotification) => {
// no need to send notification to self so remove it from the list // no need to send notification to self so remove it from the list
const receivers = ( const receivers = (
signers.length > 0 signers.length > 0
@ -769,7 +828,7 @@ export const CreatePage = () => {
: viewers.map((viewer) => viewer.pubkey) : viewers.map((viewer) => viewer.pubkey)
).filter((receiver) => receiver !== usersPubkey) ).filter((receiver) => receiver !== usersPubkey)
return receivers.map((receiver) => sendNotification(receiver, meta)) return receivers.map((receiver) => sendNotification(receiver, notification))
} }
const extractNostrId = (stringifiedEvent: string): string => { const extractNostrId = (stringifiedEvent: string): string => {
@ -844,11 +903,17 @@ export const CreatePage = () => {
} }
setLoadingSpinnerDesc('Updating user app data') setLoadingSpinnerDesc('Updating user app data')
const event = await updateUsersAppData(meta) const event = await updateUsersAppData(meta)
if (!event) return if (!event) return
const metaUrl = await uploadMetaToFileStorage(meta, encryptionKey)
setLoadingSpinnerDesc('Sending notifications to counterparties') setLoadingSpinnerDesc('Sending notifications to counterparties')
const promises = sendNotifications(meta) const promises = sendNotifications({
metaUrl,
keys: meta.keys
})
await Promise.all(promises) await Promise.all(promises)
.then(() => { .then(() => {
@ -858,7 +923,14 @@ export const CreatePage = () => {
toast.error('Failed to publish notifications') toast.error('Failed to publish notifications')
}) })
const isFirstSigner = signers[0].pubkey === usersPubkey
if (isFirstSigner) {
navigate(appPrivateRoutes.sign, { state: { meta } }) navigate(appPrivateRoutes.sign, { state: { meta } })
} else {
const createSignatureJson = JSON.parse(createSignature)
navigate(`${appPublicRoutes.verify}/${createSignatureJson.id}`)
}
} else { } else {
const zip = new JSZip() const zip = new JSZip()
@ -938,19 +1010,6 @@ export const CreatePage = () => {
} else { } else {
disarmAddOnEnter() disarmAddOnEnter()
} }
} else if (value.includes('@')) {
// Seems like it's nip05 format
const { pubkey } = await queryNip05(value).catch((err) => {
console.error(err)
return { pubkey: null }
})
if (pubkey) {
// Arm the manual user npub add after enter is hit, we don't want to trigger search
setPastedUserNpubOrNip05(hexToNpub(pubkey))
} else {
disarmAddOnEnter()
}
} else { } else {
// Disarm the add user on enter hit, and trigger search after 1 second // Disarm the add user on enter hit, and trigger search after 1 second
disarmAddOnEnter() disarmAddOnEnter()
@ -969,7 +1028,6 @@ export const CreatePage = () => {
return ( return (
<> <>
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
<Container className={styles.container}> <Container className={styles.container}>
<StickySideColumns <StickySideColumns
left={ left={
@ -1124,7 +1182,9 @@ export const CreatePage = () => {
</Button> </Button>
) : ( ) : (
<Button <Button
onClick={handleAddUser} onClick={() => {
setUserInput(userSearchInput)
}}
variant="contained" variant="contained"
aria-label="Add" aria-label="Add"
className={styles.counterpartToggleButton} className={styles.counterpartToggleButton}
@ -1180,19 +1240,17 @@ export const CreatePage = () => {
centerIcon={faFile} centerIcon={faFile}
rightIcon={faToolbox} rightIcon={faToolbox}
> >
{parsingPdf ? (
<LoadingSpinner variant="small" />
) : (
<DrawPDFFields <DrawPDFFields
users={users} users={users}
userProfiles={userProfiles} userProfiles={userProfiles}
selectedTool={selectedTool} selectedTool={selectedTool}
sigitFiles={drawnFiles} sigitFiles={drawnFiles}
setSigitFiles={setDrawnFiles} updateSigitFiles={updateDrawnFiles}
/> />
)} {parsingPdf && <LoadingSpinner variant="small" />}
</StickySideColumns> </StickySideColumns>
</Container> </Container>
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
</> </>
) )
} }

View File

@ -135,6 +135,46 @@ export const HomePage = () => {
const [filter, setFilter] = useState<Filter>('Show all') const [filter, setFilter] = useState<Filter>('Show all')
const [sort, setSort] = useState<Sort>('desc') const [sort, setSort] = useState<Sort>('desc')
const renderSubmissions = () => {
const submissions = Object.keys(parsedSigits)
.filter((s) => {
const { title, signedStatus } = parsedSigits[s]
const isMatch = title?.toLowerCase().includes(q.toLowerCase())
switch (filter) {
case 'Completed':
return signedStatus === SigitStatus.Complete && isMatch
case 'In-progress':
return signedStatus === SigitStatus.Partial && isMatch
case 'Show all':
return isMatch
default:
console.error('Filter case not handled.')
}
})
.sort((a, b) => {
const x = parsedSigits[a].createdAt ?? 0
const y = parsedSigits[b].createdAt ?? 0
return sort === 'desc' ? y - x : x - y
})
if (submissions.length) {
return submissions.map((key) => (
<DisplaySigit
key={`sigit-${key}`}
sigitCreateId={key}
parsedMeta={parsedSigits[key]}
meta={sigits[key]}
/>
))
} else {
return (
<div className={styles.noResults}>
<p>No results</p>
</div>
)
}
}
return ( return (
<div {...getRootProps()} tabIndex={-1}> <div {...getRootProps()} tabIndex={-1}>
<Container className={styles.container}> <Container className={styles.container}>
@ -233,36 +273,8 @@ export const HomePage = () => {
<label htmlFor="file-upload">Click or drag files to upload!</label> <label htmlFor="file-upload">Click or drag files to upload!</label>
)} )}
</button> </button>
<div className={styles.submissions}>
{Object.keys(parsedSigits) <div className={styles.submissions}>{renderSubmissions()}</div>
.filter((s) => {
const { title, signedStatus } = parsedSigits[s]
const isMatch = title?.toLowerCase().includes(q.toLowerCase())
switch (filter) {
case 'Completed':
return signedStatus === SigitStatus.Complete && isMatch
case 'In-progress':
return signedStatus === SigitStatus.Partial && isMatch
case 'Show all':
return isMatch
default:
console.error('Filter case not handled.')
}
})
.sort((a, b) => {
const x = parsedSigits[a].createdAt ?? 0
const y = parsedSigits[b].createdAt ?? 0
return sort === 'desc' ? y - x : x - y
})
.map((key) => (
<DisplaySigit
key={`sigit-${key}`}
sigitCreateId={key}
parsedMeta={parsedSigits[key]}
meta={sigits[key]}
/>
))}
</div>
</Container> </Container>
<Footer /> <Footer />
</div> </div>

View File

@ -99,3 +99,10 @@
gap: 25px; gap: 25px;
grid-template-columns: repeat(auto-fit, minmax(365px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(365px, 1fr));
} }
.noResults {
display: flex;
justify-content: center;
font-weight: normal;
color: #a1a1a1;
}

View File

@ -1,66 +1,54 @@
import { Box, Button, Typography } from '@mui/material'
import axios from 'axios' import axios from 'axios'
import saveAs from 'file-saver' import saveAs from 'file-saver'
import JSZip from 'jszip' import JSZip from 'jszip'
import _ from 'lodash' import _ from 'lodash'
import { MuiFileInput } from 'mui-file-input'
import { Event, verifyEvent } from 'nostr-tools' import { Event, verifyEvent } from 'nostr-tools'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { useAppSelector } from '../../hooks/store' import { useAppSelector } from '../../hooks'
import { useLocation, useNavigate, useParams } from 'react-router-dom' import { useLocation, useNavigate, useParams } from 'react-router-dom'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { LoadingSpinner } from '../../components/LoadingSpinner' import { LoadingSpinner } from '../../components/LoadingSpinner'
import { NostrController } from '../../controllers' import { NostrController } from '../../controllers'
import { appPublicRoutes } from '../../routes' import { appPrivateRoutes, appPublicRoutes } from '../../routes'
import { CreateSignatureEventContent, Meta, SignedEvent } from '../../types' import { CreateSignatureEventContent, Meta, SignedEvent } from '../../types'
import { import {
ARRAY_BUFFER,
decryptArrayBuffer, decryptArrayBuffer,
DEFLATE,
encryptArrayBuffer, encryptArrayBuffer,
extractMarksFromSignedMeta, extractMarksFromSignedMeta,
extractZipUrlAndEncryptionKey, extractZipUrlAndEncryptionKey,
filterMarksByPubkey,
findOtherUserMarks,
generateEncryptionKey, generateEncryptionKey,
generateKeysFile, generateKeysFile,
getCurrentUserFiles, getCurrentUserFiles,
getCurrentUserMarks,
getHash, getHash,
hexToNpub, hexToNpub,
isOnline, isOnline,
loadZip, loadZip,
unixNow,
npubToHex, npubToHex,
parseJson, parseJson,
processMarks,
readContentOfZipEntry, readContentOfZipEntry,
signEventForMetaFile, signEventForMetaFile,
findOtherUserMarks,
timeout, timeout,
processMarks unixNow,
updateMarks,
uploadMetaToFileStorage
} from '../../utils' } from '../../utils'
import { Container } from '../../components/Container'
import { DisplayMeta } from './internal/displayMeta'
import styles from './style.module.scss'
import { CurrentUserMark, Mark } from '../../types/mark.ts' import { CurrentUserMark, Mark } from '../../types/mark.ts'
import { getLastSignersSig, isFullySigned } from '../../utils/sign.ts'
import {
filterMarksByPubkey,
getCurrentUserMarks,
isCurrentUserMarksComplete,
updateMarks
} from '../../utils'
import PdfMarking from '../../components/PDFView/PdfMarking.tsx' import PdfMarking from '../../components/PDFView/PdfMarking.tsx'
import { import {
convertToSigitFile, convertToSigitFile,
getZipWithFiles, getZipWithFiles,
SigitFile SigitFile
} from '../../utils/file.ts' } from '../../utils/file.ts'
import { ARRAY_BUFFER, DEFLATE } from '../../utils/const.ts'
import { generateTimestamp } from '../../utils/opentimestamps.ts' import { generateTimestamp } from '../../utils/opentimestamps.ts'
import { MARK_TYPE_CONFIG } from '../../components/MarkTypeStrategy/MarkStrategy.tsx' import { MARK_TYPE_CONFIG } from '../../components/MarkTypeStrategy/MarkStrategy.tsx'
import { useNDK } from '../../hooks/useNDK.ts' import { useNDK } from '../../hooks/useNDK.ts'
import { getLastSignersSig } from '../../utils/sign.ts'
enum SignedStatus {
Fully_Signed,
User_Is_Next_Signer,
User_Is_Not_Next_Signer
}
export const SignPage = () => { export const SignPage = () => {
const navigate = useNavigate() const navigate = useNavigate()
@ -100,17 +88,12 @@ export const SignPage = () => {
} }
} }
const [displayInput, setDisplayInput] = useState(false)
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [files, setFiles] = useState<{ [filename: string]: SigitFile }>({}) const [files, setFiles] = useState<{ [filename: string]: SigitFile }>({})
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('') const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
const [meta, setMeta] = useState<Meta | null>(null) const [meta, setMeta] = useState<Meta | null>(null)
const [signedStatus, setSignedStatus] = useState<SignedStatus>()
const [submittedBy, setSubmittedBy] = useState<string>() const [submittedBy, setSubmittedBy] = useState<string>()
@ -124,66 +107,14 @@ export const SignPage = () => {
[key: string]: string | null [key: string]: string | null
}>({}) }>({})
const [signedBy, setSignedBy] = useState<`npub1${string}`[]>([])
const [nextSinger, setNextSinger] = useState<string>()
// This state variable indicates whether the logged-in user is a signer, a creator, or neither.
const [isSignerOrCreator, setIsSignerOrCreator] = useState(false)
const usersPubkey = useAppSelector((state) => state.auth.usersPubkey) const usersPubkey = useAppSelector((state) => state.auth.usersPubkey)
const nostrController = NostrController.getInstance() const nostrController = NostrController.getInstance()
const [currentUserMarks, setCurrentUserMarks] = useState<CurrentUserMark[]>( const [currentUserMarks, setCurrentUserMarks] = useState<CurrentUserMark[]>(
[] []
) )
const [isMarksCompleted, setIsMarksCompleted] = useState(false)
const [otherUserMarks, setOtherUserMarks] = useState<Mark[]>([]) const [otherUserMarks, setOtherUserMarks] = useState<Mark[]>([])
useEffect(() => {
if (signers.length > 0) {
// check if all signers have signed then its fully signed
if (isFullySigned(signers, signedBy)) {
setSignedStatus(SignedStatus.Fully_Signed)
} else {
for (const signer of signers) {
if (!signedBy.includes(signer)) {
// signers in meta.json are in npub1 format
// so, convert it to hex before setting to nextSigner
setNextSinger(npubToHex(signer)!)
const usersNpub = hexToNpub(usersPubkey!)
if (signer === usersNpub) {
// logged in user is the next signer
setSignedStatus(SignedStatus.User_Is_Next_Signer)
} else {
setSignedStatus(SignedStatus.User_Is_Not_Next_Signer)
}
break
}
}
}
} else {
// there's no signer just viewers. So its fully signed
setSignedStatus(SignedStatus.Fully_Signed)
}
// Determine and set the status of the user
if (submittedBy && usersPubkey && submittedBy === usersPubkey) {
// If the submission was made by the user, set the status to true
setIsSignerOrCreator(true)
} else if (usersPubkey) {
// Convert the user's public key from hex to npub format
const usersNpub = hexToNpub(usersPubkey)
if (signers.includes(usersNpub)) {
// If the user's npub is in the list of signers, set the status to true
setIsSignerOrCreator(true)
}
}
}, [signers, signedBy, usersPubkey, submittedBy])
useEffect(() => { useEffect(() => {
const handleUpdatedMeta = async (meta: Meta) => { const handleUpdatedMeta = async (meta: Meta) => {
const createSignatureEvent = await parseJson<Event>( const createSignatureEvent = await parseJson<Event>(
@ -263,11 +194,10 @@ export const SignPage = () => {
m.value && m.value &&
encryptionKey encryptionKey
) { ) {
const decrypted = await fetchAndDecrypt( otherUserMarks[i].value = await fetchAndDecrypt(
m.value, m.value,
encryptionKey encryptionKey
) )
otherUserMarks[i].value = decrypted
} }
} catch (error) { } catch (error) {
console.error(`Error during mark fetchAndDecrypt phase`, error) console.error(`Error during mark fetchAndDecrypt phase`, error)
@ -278,10 +208,7 @@ export const SignPage = () => {
setOtherUserMarks(otherUserMarks) setOtherUserMarks(otherUserMarks)
setCurrentUserMarks(currentUserMarks) setCurrentUserMarks(currentUserMarks)
setIsMarksCompleted(isCurrentUserMarksComplete(currentUserMarks))
} }
setSignedBy(Object.keys(meta.docSignatures) as `npub1${string}`[])
} }
if (meta) { if (meta) {
@ -290,29 +217,6 @@ export const SignPage = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [meta, usersPubkey]) }, [meta, usersPubkey])
const handleDownload = async () => {
if (Object.entries(files).length === 0 || !meta || !usersPubkey) return
setLoadingSpinnerDesc('Generating file')
try {
const zip = await getZipWithFiles(meta, files)
const arrayBuffer = await zip.generateAsync({
type: ARRAY_BUFFER,
compression: DEFLATE,
compressionOptions: {
level: 6
}
})
if (!arrayBuffer) return
const blob = new Blob([arrayBuffer])
saveAs(blob, `exported-${unixNow()}.sigit.zip`)
} catch (error) {
console.log('error in zip:>> ', error)
if (error instanceof Error) {
toast.error(error.message || 'Error occurred in generating zip file')
}
}
}
const decrypt = useCallback( const decrypt = useCallback(
async (file: File) => { async (file: File) => {
setLoadingSpinnerDesc('Decrypting file') setLoadingSpinnerDesc('Decrypting file')
@ -424,7 +328,6 @@ export const SignPage = () => {
}) })
} else { } else {
setIsLoading(false) setIsLoading(false)
setDisplayInput(true)
} }
}, [decryptedArrayBuffer, uploadedZip, metaInNavState, decrypt]) }, [decryptedArrayBuffer, uploadedZip, metaInNavState, decrypt])
@ -541,9 +444,6 @@ export const SignPage = () => {
setFiles(files) setFiles(files)
setCurrentFileHashes(fileHashes) setCurrentFileHashes(fileHashes)
setDisplayInput(false)
setLoadingSpinnerDesc('Parsing meta.json') setLoadingSpinnerDesc('Parsing meta.json')
const metaFileContent = await readContentOfZipEntry( const metaFileContent = await readContentOfZipEntry(
@ -571,21 +471,6 @@ export const SignPage = () => {
setMeta(parsedMetaJson) setMeta(parsedMetaJson)
} }
const handleDecrypt = async () => {
if (!selectedFile) return
setIsLoading(true)
const arrayBuffer = await decrypt(selectedFile)
if (!arrayBuffer) {
setIsLoading(false)
toast.error('Error decrypting file')
return
}
handleDecryptedArrayBuffer(arrayBuffer)
}
const handleSign = async () => { const handleSign = async () => {
if (Object.entries(files).length === 0 || !meta) return if (Object.entries(files).length === 0 || !meta) return
@ -635,11 +520,18 @@ export const SignPage = () => {
} }
if (await isOnline()) { if (await isOnline()) {
await handleOnlineFlow(updatedMeta) await handleOnlineFlow(updatedMeta, encryptionKey)
} else { } else {
setMeta(updatedMeta) setMeta(updatedMeta)
setIsLoading(false) setIsLoading(false)
} }
if (metaInNavState) {
const createSignature = JSON.parse(metaInNavState.createSignature)
navigate(`${appPublicRoutes.verify}/${createSignature.id}`)
} else {
navigate(appPrivateRoutes.homePage)
}
} }
// Sign the event for the meta file // Sign the event for the meta file
@ -730,6 +622,14 @@ export const SignPage = () => {
}) })
} }
// Check if the current user is the last signer
const checkIsLastSigner = (signers: string[]): boolean => {
const usersNpub = hexToNpub(usersPubkey!)
const lastSignerIndex = signers.length - 1
const signerIndex = signers.indexOf(usersNpub)
return signerIndex === lastSignerIndex
}
// Handle errors during zip file generation // Handle errors during zip file generation
const handleZipError = (err: unknown) => { const handleZipError = (err: unknown) => {
console.log('Error in zip:>> ', err) console.log('Error in zip:>> ', err)
@ -741,7 +641,10 @@ export const SignPage = () => {
} }
// Handle the online flow: update users app data and send notifications // Handle the online flow: update users app data and send notifications
const handleOnlineFlow = async (meta: Meta) => { const handleOnlineFlow = async (
meta: Meta,
encryptionKey: string | undefined
) => {
setLoadingSpinnerDesc('Updating users app data') setLoadingSpinnerDesc('Updating users app data')
const updatedEvent = await updateUsersAppData(meta) const updatedEvent = await updateUsersAppData(meta)
if (!updatedEvent) { if (!updatedEvent) {
@ -749,6 +652,18 @@ export const SignPage = () => {
return return
} }
let metaUrl: string
try {
metaUrl = await uploadMetaToFileStorage(meta, encryptionKey)
} catch (error) {
if (error instanceof Error) {
toast.error(error.message)
}
console.error(error)
setIsLoading(false)
return
}
const userSet = new Set<`npub1${string}`>() const userSet = new Set<`npub1${string}`>()
if (submittedBy && submittedBy !== usersPubkey) { if (submittedBy && submittedBy !== usersPubkey) {
userSet.add(hexToNpub(submittedBy)) userSet.add(hexToNpub(submittedBy))
@ -781,7 +696,7 @@ export const SignPage = () => {
setLoadingSpinnerDesc('Sending notifications') setLoadingSpinnerDesc('Sending notifications')
const users = Array.from(userSet) const users = Array.from(userSet)
const promises = users.map((user) => const promises = users.map((user) =>
sendNotification(npubToHex(user)!, meta) sendNotification(npubToHex(user)!, { metaUrl, keys: meta.keys })
) )
await Promise.all(promises) await Promise.all(promises)
.then(() => { .then(() => {
@ -795,16 +710,38 @@ export const SignPage = () => {
setIsLoading(false) setIsLoading(false)
} }
// Check if the current user is the last signer const handleExport = async () => {
const checkIsLastSigner = (signers: string[]): boolean => { const arrayBuffer = await prepareZipExport()
const usersNpub = hexToNpub(usersPubkey!) if (!arrayBuffer) return
const lastSignerIndex = signers.length - 1
const signerIndex = signers.indexOf(usersNpub) const blob = new Blob([arrayBuffer])
return signerIndex === lastSignerIndex saveAs(blob, `exported-${unixNow()}.sigit.zip`)
setIsLoading(false)
navigate(appPublicRoutes.verify)
} }
const handleExport = async () => { const handleEncryptedExport = async () => {
if (Object.entries(files).length === 0 || !meta || !usersPubkey) return const arrayBuffer = await prepareZipExport()
if (!arrayBuffer) return
const key = await generateEncryptionKey()
setLoadingSpinnerDesc('Encrypting zip file')
const encryptedArrayBuffer = await encryptArrayBuffer(arrayBuffer, key)
const finalZipFile = await createFinalZipFile(encryptedArrayBuffer, key)
if (!finalZipFile) return
saveAs(finalZipFile, `exported-${unixNow()}.sigit.zip`)
setIsLoading(false)
}
const prepareZipExport = async (): Promise<ArrayBuffer | null> => {
if (Object.entries(files).length === 0 || !meta || !usersPubkey)
return Promise.resolve(null)
const usersNpub = hexToNpub(usersPubkey) const usersNpub = hexToNpub(usersPubkey)
if ( if (
@ -812,15 +749,15 @@ export const SignPage = () => {
!viewers.includes(usersNpub) && !viewers.includes(usersNpub) &&
submittedBy !== usersNpub submittedBy !== usersNpub
) )
return return Promise.resolve(null)
setIsLoading(true) setIsLoading(true)
setLoadingSpinnerDesc('Signing nostr event') setLoadingSpinnerDesc('Signing nostr event')
if (!meta) return if (!meta) return Promise.resolve(null)
const prevSig = getLastSignersSig(meta, signers) const prevSig = getLastSignersSig(meta, signers)
if (!prevSig) return if (!prevSig) return Promise.resolve(null)
const signedEvent = await signEventForMetaFile( const signedEvent = await signEventForMetaFile(
JSON.stringify({ JSON.stringify({
@ -830,7 +767,7 @@ export const SignPage = () => {
setIsLoading setIsLoading
) )
if (!signedEvent) return if (!signedEvent) return Promise.resolve(null)
const exportSignature = JSON.stringify(signedEvent, null, 2) const exportSignature = JSON.stringify(signedEvent, null, 2)
@ -848,8 +785,8 @@ export const SignPage = () => {
const arrayBuffer = await zip const arrayBuffer = await zip
.generateAsync({ .generateAsync({
type: 'arraybuffer', type: ARRAY_BUFFER,
compression: 'DEFLATE', compression: DEFLATE,
compressionOptions: { compressionOptions: {
level: 6 level: 6
} }
@ -861,50 +798,9 @@ export const SignPage = () => {
return null return null
}) })
if (!arrayBuffer) return if (!arrayBuffer) return Promise.resolve(null)
const blob = new Blob([arrayBuffer]) return Promise.resolve(arrayBuffer)
saveAs(blob, `exported-${unixNow()}.sigit.zip`)
setIsLoading(false)
navigate(appPublicRoutes.verify)
}
const handleEncryptedExport = async () => {
if (Object.entries(files).length === 0 || !meta) return
const stringifiedMeta = JSON.stringify(meta, null, 2)
const zip = await getZipWithFiles(meta, files)
zip.file('meta.json', stringifiedMeta)
const arrayBuffer = await zip
.generateAsync({
type: 'arraybuffer',
compression: 'DEFLATE',
compressionOptions: {
level: 6
}
})
.catch((err) => {
console.log('err in zip:>> ', err)
setIsLoading(false)
toast.error(err.message || 'Error occurred in generating zip file')
return null
})
if (!arrayBuffer) return
const key = await generateEncryptionKey()
setLoadingSpinnerDesc('Encrypting zip file')
const encryptedArrayBuffer = await encryptArrayBuffer(arrayBuffer, key)
const finalZipFile = await createFinalZipFile(encryptedArrayBuffer, key)
if (!finalZipFile) return
saveAs(finalZipFile, `exported-${unixNow()}.sigit.zip`)
} }
/** /**
@ -944,90 +840,17 @@ export const SignPage = () => {
return <LoadingSpinner desc={loadingSpinnerDesc} /> return <LoadingSpinner desc={loadingSpinnerDesc} />
} }
if (!isMarksCompleted && signedStatus === SignedStatus.User_Is_Next_Signer) {
return ( return (
<PdfMarking <PdfMarking
files={getCurrentUserFiles(files, currentFileHashes, creatorFileHashes)} files={getCurrentUserFiles(files, currentFileHashes, creatorFileHashes)}
currentUserMarks={currentUserMarks} currentUserMarks={currentUserMarks}
setIsMarksCompleted={setIsMarksCompleted}
setCurrentUserMarks={setCurrentUserMarks} setCurrentUserMarks={setCurrentUserMarks}
setUpdatedMarks={setUpdatedMarks} setUpdatedMarks={setUpdatedMarks}
handleDownload={handleDownload} handleSign={handleSign}
handleExport={handleExport}
handleEncryptedExport={handleEncryptedExport}
otherUserMarks={otherUserMarks} otherUserMarks={otherUserMarks}
meta={meta} meta={meta}
/> />
) )
} }
return (
<>
<Container className={styles.container}>
{displayInput && (
<>
<Typography component="label" variant="h6">
Select sigit file
</Typography>
<Box className={styles.inputBlock}>
<MuiFileInput
placeholder="Select file"
inputProps={{ accept: '.sigit.zip' }}
value={selectedFile}
onChange={(value) => setSelectedFile(value)}
/>
</Box>
{selectedFile && (
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'center' }}>
<Button onClick={handleDecrypt} variant="contained">
Decrypt
</Button>
</Box>
)}
</>
)}
{submittedBy && Object.entries(files).length > 0 && meta && (
<>
<DisplayMeta
meta={meta}
files={files}
submittedBy={submittedBy}
signers={signers}
viewers={viewers}
creatorFileHashes={creatorFileHashes}
currentFileHashes={currentFileHashes}
signedBy={signedBy}
nextSigner={nextSinger}
getPrevSignersSig={getPrevSignersSig}
/>
{signedStatus === SignedStatus.Fully_Signed && (
<Box sx={{ mt: 1, display: 'flex', justifyContent: 'center' }}>
<Button onClick={handleExport} variant="contained">
Export Sigit
</Button>
</Box>
)}
{signedStatus === SignedStatus.User_Is_Next_Signer && (
<Box sx={{ mt: 1, display: 'flex', justifyContent: 'center' }}>
<Button onClick={handleSign} variant="contained">
Sign
</Button>
</Box>
)}
{isSignerOrCreator && (
<Box sx={{ mt: 1, display: 'flex', justifyContent: 'center' }}>
<Button onClick={handleEncryptedExport} variant="contained">
Export Encrypted Sigit
</Button>
</Box>
)}
</>
)}
</Container>
</>
)
}

View File

@ -21,7 +21,13 @@ import {
readContentOfZipEntry, readContentOfZipEntry,
signEventForMetaFile, signEventForMetaFile,
getCurrentUserFiles, getCurrentUserFiles,
npubToHex npubToHex,
generateEncryptionKey,
encryptArrayBuffer,
generateKeysFile,
ARRAY_BUFFER,
DEFLATE,
uploadMetaToFileStorage
} from '../../utils' } from '../../utils'
import styles from './style.module.scss' import styles from './style.module.scss'
import { useLocation, useParams } from 'react-router-dom' import { useLocation, useParams } from 'react-router-dom'
@ -350,6 +356,11 @@ export const VerifyPage = () => {
const updatedEvent = await updateUsersAppData(updatedMeta) const updatedEvent = await updateUsersAppData(updatedMeta)
if (!updatedEvent) return if (!updatedEvent) return
const metaUrl = await uploadMetaToFileStorage(
updatedMeta,
encryptionKey
)
const userSet = new Set<`npub1${string}`>() const userSet = new Set<`npub1${string}`>()
signers.forEach((signer) => { signers.forEach((signer) => {
if (signer !== usersPubkey) { if (signer !== usersPubkey) {
@ -363,7 +374,10 @@ export const VerifyPage = () => {
const users = Array.from(userSet) const users = Array.from(userSet)
const promises = users.map((user) => const promises = users.map((user) =>
sendNotification(npubToHex(user)!, updatedMeta) sendNotification(npubToHex(user)!, {
metaUrl,
keys: meta.keys!
})
) )
await Promise.all(promises) await Promise.all(promises)
@ -540,8 +554,114 @@ export const VerifyPage = () => {
setIsLoading(false) setIsLoading(false)
} }
const handleMarkedExport = async () => { // Handle errors during zip file generation
if (Object.entries(files).length === 0 || !meta || !usersPubkey) return 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
}
// Check if the current user is the last signer
const checkIsLastSigner = (signers: string[]): boolean => {
const usersNpub = hexToNpub(usersPubkey!)
const lastSignerIndex = signers.length - 1
const signerIndex = signers.indexOf(usersNpub)
return signerIndex === lastSignerIndex
}
// create final zip file
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 isLastSigner = checkIsLastSigner(signers)
const userSet = new Set<string>()
if (isLastSigner) {
if (submittedBy) {
userSet.add(submittedBy)
}
signers.forEach((signer) => {
userSet.add(npubToHex(signer)!)
})
viewers.forEach((viewer) => {
userSet.add(npubToHex(viewer)!)
})
} else {
const usersNpub = hexToNpub(usersPubkey!)
const signerIndex = signers.indexOf(usersNpub)
const nextSigner = signers[signerIndex + 1]
userSet.add(npubToHex(nextSigner)!)
}
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
return new File([new Blob([arraybuffer])], `${unixNow()}.sigit.zip`, {
type: 'application/zip'
})
}
const handleExport = async () => {
const arrayBuffer = await prepareZipExport()
if (!arrayBuffer) return
const blob = new Blob([arrayBuffer])
saveAs(blob, `exported-${unixNow()}.sigit.zip`)
setIsLoading(false)
}
const handleEncryptedExport = async () => {
const arrayBuffer = await prepareZipExport()
if (!arrayBuffer) return
const key = await generateEncryptionKey()
setLoadingSpinnerDesc('Encrypting zip file')
const encryptedArrayBuffer = await encryptArrayBuffer(arrayBuffer, key)
const finalZipFile = await createFinalZipFile(encryptedArrayBuffer, key)
if (!finalZipFile) return
saveAs(finalZipFile, `exported-${unixNow()}.sigit.zip`)
setIsLoading(false)
}
const prepareZipExport = async (): Promise<ArrayBuffer | null> => {
if (Object.entries(files).length === 0 || !meta || !usersPubkey)
return Promise.resolve(null)
const usersNpub = hexToNpub(usersPubkey) const usersNpub = hexToNpub(usersPubkey)
if ( if (
@ -549,14 +669,14 @@ export const VerifyPage = () => {
!viewers.includes(usersNpub) && !viewers.includes(usersNpub) &&
submittedBy !== usersNpub submittedBy !== usersNpub
) { ) {
return return Promise.resolve(null)
} }
setIsLoading(true) setIsLoading(true)
setLoadingSpinnerDesc('Signing nostr event') setLoadingSpinnerDesc('Signing nostr event')
const prevSig = getLastSignersSig(meta, signers) const prevSig = getLastSignersSig(meta, signers)
if (!prevSig) return if (!prevSig) return Promise.resolve(null)
const signedEvent = await signEventForMetaFile( const signedEvent = await signEventForMetaFile(
JSON.stringify({ prevSig }), JSON.stringify({ prevSig }),
@ -564,7 +684,7 @@ export const VerifyPage = () => {
setIsLoading setIsLoading
) )
if (!signedEvent) return if (!signedEvent) return Promise.resolve(null)
const exportSignature = JSON.stringify(signedEvent, null, 2) const exportSignature = JSON.stringify(signedEvent, null, 2)
const updatedMeta = { ...meta, exportSignature } const updatedMeta = { ...meta, exportSignature }
@ -575,8 +695,8 @@ export const VerifyPage = () => {
const arrayBuffer = await zip const arrayBuffer = await zip
.generateAsync({ .generateAsync({
type: 'arraybuffer', type: ARRAY_BUFFER,
compression: 'DEFLATE', compression: DEFLATE,
compressionOptions: { compressionOptions: {
level: 6 level: 6
} }
@ -588,12 +708,9 @@ export const VerifyPage = () => {
return null return null
}) })
if (!arrayBuffer) return if (!arrayBuffer) return Promise.resolve(null)
const blob = new Blob([arrayBuffer]) return Promise.resolve(arrayBuffer)
saveAs(blob, `exported-${unixNow()}.sigit.zip`)
setIsLoading(false)
} }
return ( return (
@ -639,8 +756,8 @@ export const VerifyPage = () => {
)} )}
currentFile={currentFile} currentFile={currentFile}
setCurrentFile={setCurrentFile} setCurrentFile={setCurrentFile}
handleDownload={handleMarkedExport} handleExport={handleExport}
downloadLabel="Download Sigit" handleEncryptedExport={handleEncryptedExport}
/> />
) )
} }

View File

@ -53,10 +53,6 @@
.mark { .mark {
position: absolute; position: absolute;
display: flex;
justify-content: center;
align-items: center;
} }
[data-dev='true'] { [data-dev='true'] {

View File

@ -83,3 +83,12 @@ export interface UserAppData {
export interface DocSignatureEvent extends Event { export interface DocSignatureEvent extends Event {
parsedContent?: SignedEventContent parsedContent?: SignedEventContent
} }
export interface SigitNotification {
metaUrl: string
keys?: { sender: string; keys: { [user: `npub1${string}`]: string } }
}
export function isSigitNotification(obj: unknown): obj is SigitNotification {
return typeof (obj as SigitNotification).metaUrl === 'string'
}

View File

@ -0,0 +1,26 @@
import { Jsonable } from '.'
export enum MetaStorageErrorType {
'ENCRYPTION_KEY_REQUIRED' = 'Encryption key is required.',
'HASHING_FAILED' = "Can't get encrypted file hash.",
'FETCH_FAILED' = 'Fetching meta.json requires an encryption key.',
'HASH_VERIFICATION_FAILED' = 'Unable to verify meta.json.',
'DECRYPTION_FAILED' = 'Error decryping meta.json.',
'UNHANDLED_FETCH_ERROR' = 'Unable to fetch meta.json. Something went wrong.'
}
export class MetaStorageError extends Error {
public readonly context?: Jsonable
constructor(
message: MetaStorageErrorType,
options: { cause?: Error; context?: Jsonable } = {}
) {
const { cause, context } = options
super(message, { cause })
this.name = this.constructor.name
this.context = context
}
}

View File

@ -119,6 +119,6 @@ export const SIGNATURE_PAD_OPTIONS = {
} as const } as const
export const SIGNATURE_PAD_SIZE = { export const SIGNATURE_PAD_SIZE = {
width: 600, width: 300,
height: 300 height: 150
} }

View File

@ -1,5 +1,12 @@
import { CreateSignatureEventContent, Meta } from '../types' import { CreateSignatureEventContent, Meta } from '../types'
import { fromUnixTimestamp, parseJson } from '.' import {
decryptArrayBuffer,
encryptArrayBuffer,
fromUnixTimestamp,
getHash,
parseJson,
uploadToFileStorage
} from '.'
import { Event, verifyEvent } from 'nostr-tools' import { Event, verifyEvent } from 'nostr-tools'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { extractFileExtensions } from './file' import { extractFileExtensions } from './file'
@ -8,6 +15,11 @@ import {
MetaParseError, MetaParseError,
MetaParseErrorType MetaParseErrorType
} from '../types/errors/MetaParseError' } from '../types/errors/MetaParseError'
import axios from 'axios'
import {
MetaStorageError,
MetaStorageErrorType
} from '../types/errors/MetaStorageError'
export enum SignStatus { export enum SignStatus {
Signed = 'Signed', Signed = 'Signed',
@ -126,3 +138,76 @@ export const extractSigitCardDisplayInfo = async (meta: Meta) => {
} }
} }
} }
export const uploadMetaToFileStorage = async (
meta: Meta,
encryptionKey: string | undefined
) => {
// Value is the stringified meta object
const value = JSON.stringify(meta)
const encoder = new TextEncoder()
// Encode it to the arrayBuffer
const uint8Array = encoder.encode(value)
if (!encryptionKey) {
throw new MetaStorageError(MetaStorageErrorType.ENCRYPTION_KEY_REQUIRED)
}
// Encrypt the file contents with the same encryption key from the create signature
const encryptedArrayBuffer = await encryptArrayBuffer(
uint8Array,
encryptionKey
)
const hash = await getHash(encryptedArrayBuffer)
if (!hash) {
throw new MetaStorageError(MetaStorageErrorType.HASHING_FAILED)
}
// Create the encrypted json file from array buffer and hash
const file = new File([encryptedArrayBuffer], `${hash}.json`)
const url = await uploadToFileStorage(file)
return url
}
export const fetchMetaFromFileStorage = async (
url: string,
encryptionKey: string | undefined
) => {
if (!encryptionKey) {
throw new MetaStorageError(MetaStorageErrorType.ENCRYPTION_KEY_REQUIRED)
}
const encryptedArrayBuffer = await axios.get(url, {
responseType: 'arraybuffer'
})
// Verify hash
const parts = url.split('/')
const urlHash = parts[parts.length - 1]
const hash = await getHash(encryptedArrayBuffer.data)
if (hash !== urlHash) {
throw new MetaStorageError(MetaStorageErrorType.HASH_VERIFICATION_FAILED)
}
const arrayBuffer = await decryptArrayBuffer(
encryptedArrayBuffer.data,
encryptionKey
).catch((err) => {
throw new MetaStorageError(MetaStorageErrorType.DECRYPTION_FAILED, {
cause: err
})
})
if (arrayBuffer) {
// Decode meta.json and parse
const decoder = new TextDecoder()
const json = decoder.decode(arrayBuffer)
const meta = await parseJson<Meta>(json)
return meta
}
throw new MetaStorageError(MetaStorageErrorType.UNHANDLED_FETCH_ERROR)
}