diff --git a/package-lock.json b/package-lock.json index 21aadbc..65fb09a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,8 +33,9 @@ "idb": "8.0.0", "jszip": "3.10.1", "lodash": "4.17.21", + "material-ui-popup-state": "^5.3.1", "mui-file-input": "4.0.4", - "nostr-login": "^1.6.6", + "nostr-login": "1.6.14", "nostr-tools": "2.7.0", "pdf-lib": "^1.17.1", "pdfjs-dist": "^4.4.168", @@ -50,7 +51,8 @@ "react-toastify": "10.0.4", "redux": "5.0.1", "signature_pad": "^5.0.4", - "tseep": "1.2.1" + "tseep": "1.2.1", + "use-immer": "^0.11.0" }, "devDependencies": { "@types/crypto-js": "^4.2.2", @@ -3379,6 +3381,12 @@ "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": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", @@ -3629,10 +3637,11 @@ "dev": true }, "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, + "license": "MIT", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -6030,6 +6039,26 @@ "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", "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": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", @@ -6281,9 +6310,9 @@ "optional": true }, "node_modules/nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", + "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", "dev": true, "funding": [ { @@ -6291,6 +6320,7 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -6469,9 +6499,9 @@ } }, "node_modules/nostr-login": { - "version": "1.6.6", - "resolved": "https://registry.npmjs.org/nostr-login/-/nostr-login-1.6.6.tgz", - "integrity": "sha512-XOpB9nG3Qgt7iea7gA1zn4TaTfUKCKGdCHKwErqLPtMk/q1Rhkzj5cq/66iU0WqC6mSiwENfTy1p4qaM7HzMtg==", + "version": "1.6.14", + "resolved": "https://registry.npmjs.org/nostr-login/-/nostr-login-1.6.14.tgz", + "integrity": "sha512-pId1G79kjRW1B9qy6OrA8Not23JSfgmS2VegcKf7Qm9VMC7wYGXg1Ry3FMEAB8p11WoboQ8oJi2TqUGiOf61OQ==", "license": "MIT", "dependencies": { "@nostr-dev-kit/ndk": "^2.3.1", @@ -8616,6 +8646,16 @@ "dev": true, "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": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", diff --git a/package.json b/package.json index 983145b..98bc510 100644 --- a/package.json +++ b/package.json @@ -43,8 +43,9 @@ "idb": "8.0.0", "jszip": "3.10.1", "lodash": "4.17.21", + "material-ui-popup-state": "^5.3.1", "mui-file-input": "4.0.4", - "nostr-login": "^1.6.6", + "nostr-login": "1.6.14", "nostr-tools": "2.7.0", "pdf-lib": "^1.17.1", "pdfjs-dist": "^4.4.168", @@ -60,7 +61,8 @@ "react-toastify": "10.0.4", "redux": "5.0.1", "signature_pad": "^5.0.4", - "tseep": "1.2.1" + "tseep": "1.2.1", + "use-immer": "^0.11.0" }, "devDependencies": { "@types/crypto-js": "^4.2.2", diff --git a/src/components/AppBar/AppBar.tsx b/src/components/AppBar/AppBar.tsx index 6a5862d..e7f5d95 100644 --- a/src/components/AppBar/AppBar.tsx +++ b/src/components/AppBar/AppBar.tsx @@ -121,7 +121,17 @@ export const AppBar = () => { - Logo navigate('/')} /> + Logo { + if (['', '#/'].includes(window.location.hash)) { + location.reload() + } else { + navigate('/') + } + }} + /> diff --git a/src/components/DisplaySigit/index.tsx b/src/components/DisplaySigit/index.tsx index f8e890d..5147b45 100644 --- a/src/components/DisplaySigit/index.tsx +++ b/src/components/DisplaySigit/index.tsx @@ -1,5 +1,10 @@ import { Meta } from '../../types' -import { SigitCardDisplayInfo, SigitStatus, SignStatus } from '../../utils' +import { + hexToNpub, + SigitCardDisplayInfo, + SigitStatus, + SignStatus +} from '../../utils' import { Link } from 'react-router-dom' import { formatTimestamp, npubToHex } from '../../utils' import { appPublicRoutes, appPrivateRoutes } from '../../routes' @@ -20,6 +25,7 @@ import styles from './style.module.scss' import { getExtensionIconLabel } from '../getExtensionIconLabel' import { useSigitMeta } from '../../hooks/useSigitMeta' import { extractFileExtensions } from '../../utils/file' +import { useAppSelector } from '../../hooks' type SigitProps = { sigitCreateId: string @@ -32,26 +38,32 @@ export const DisplaySigit = ({ parsedMeta, sigitCreateId: sigitCreateId }: SigitProps) => { + const { usersPubkey } = useAppSelector((state) => state.auth) + const { title, createdAt, submittedBy, signers, signedStatus, isValid } = parsedMeta const { signersStatus, fileHashes } = useSigitMeta(meta) const { extensions, isSame } = extractFileExtensions(Object.keys(fileHashes)) + const currentUserNpub: string = usersPubkey ? hexToNpub(usersPubkey) : '' + const currentUserNextSigner = + signersStatus[currentUserNpub as `npub1${string}`] === SignStatus.Awaiting + return (
- {signedStatus === SigitStatus.Complete && ( + {signedStatus === SigitStatus.Complete || !currentUserNextSigner ? ( - )} - {signedStatus !== SigitStatus.Complete && ( + ) : ( )} +

{title}

{submittedBy && ( diff --git a/src/components/DrawPDFFields/index.tsx b/src/components/DrawPDFFields/index.tsx index 73384c5..156922f 100644 --- a/src/components/DrawPDFFields/index.tsx +++ b/src/components/DrawPDFFields/index.tsx @@ -8,19 +8,25 @@ import { Select } from '@mui/material' 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 { MouseState, PdfPage, DrawnField, DrawTool } from '../../types/drawing' +import { MouseState, DrawnField, DrawTool } from '../../types/drawing' import { hexToNpub, npubToHex, getProfileUsername } from '../../utils' import { SigitFile } from '../../utils/file' import { getToolboxLabelByMarkType } from '../../utils/mark' -import { FileDivider } from '../FileDivider' -import { ExtensionFileBox } from '../ExtensionFileBox' import { FONT_SIZE, FONT_TYPE, inPx } from '../../utils/pdf' import { useScale } from '../../hooks/useScale' import { AvatarIconButton } from '../UserAvatarIconButton' 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' const DEFAULT_START_SIZE = { @@ -32,16 +38,25 @@ interface HideSignersForDrawnField { [key: number]: boolean } -interface Props { +type PageIndexer = [file: number, page: number] +type FieldIndexer = [...PageIndexer, field: number] + +interface DrawPdfFieldsProps { users: User[] userProfiles: { [key: string]: NDKUserProfile } sigitFiles: SigitFile[] - setSigitFiles: React.Dispatch> + updateSigitFiles: Updater selectedTool?: DrawTool } -export const DrawPDFFields = (props: Props) => { - const { selectedTool, sigitFiles, setSigitFiles, users } = props +export const DrawPDFFields = ({ + selectedTool, + userProfiles, + sigitFiles, + updateSigitFiles, + users +}: DrawPdfFieldsProps) => { + const { to, from } = useScale() const signers = users.filter((u) => u.role === UserRole.signer) const defaultSignerNpub = signers.length ? hexToNpub(signers[0].pubkey) : '' @@ -54,144 +69,124 @@ export const DrawPDFFields = (props: Props) => { * @param pubkeys * @returns available pubkey or empty string */ - const getAvailableSigner = (...pubkeys: string[]) => { - const availableSigner: string | undefined = pubkeys.find((pubkey) => - signers.some((s) => s.pubkey === npubToHex(pubkey)) - ) - return availableSigner || '' - } + const getAvailableSigner = useCallback( + (...pubkeys: string[]) => { + const availableSigner: string | undefined = pubkeys.find((pubkey) => + signers.some((s) => s.pubkey === npubToHex(pubkey)) + ) + return availableSigner || '' + }, + [signers] + ) - const { to, from } = useScale() - - const [mouseState, setMouseState] = useState({ - clicked: false - }) - - const [activeDrawnField, setActiveDrawnField] = useState<{ - fileIndex: number - pageIndex: number - drawnFieldIndex: number - }>() + const [mouseState, setMouseState] = useState({}) + const [indexer, setIndexer] = useState() + const [field, setField] = useState< + DrawnField & { + x: number + y: number + } + >() + const [lastIndexer, setLastIndexer] = useState() const isActiveDrawnField = ( fileIndex: number, pageIndex: number, drawnFieldIndex: number ) => - activeDrawnField?.fileIndex === fileIndex && - activeDrawnField?.pageIndex === pageIndex && - activeDrawnField?.drawnFieldIndex === drawnFieldIndex + lastIndexer && + lastIndexer[0] === fileIndex && + 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(() => { - window.addEventListener('pointerup', handlePointerUp) - window.addEventListener('pointercancel', handlePointerUp) + const getPointerCoordinates = ( + event: React.PointerEvent, + customTarget?: HTMLElement | null + ) => { + const target = customTarget ? customTarget : event.currentTarget + const rect = target.getBoundingClientRect() - return () => { - window.removeEventListener('pointerup', handlePointerUp) - window.removeEventListener('pointercancel', handlePointerUp) + // Clamp X Y within the target + const x = Math.max(0, Math.min(event.clientX, rect.right) - rect.left) //x position within the element. + 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 - * Creates new drawnElement and pushes in the array - * It is re rendered and visible right away + * Creates new drawnElement * * @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 = ( - event: React.PointerEvent, - page: PdfPage, - fileIndex: number, - pageIndex: number - ) => { - // Proceed only if left click - if (event.button !== 0) return - - if (!selectedTool) { - return - } - - const { x, y } = getPointerCoordinates(event) - - const newField: DrawnField = { - left: to(page.width, x), - top: to(page.width, y), - width: event.pointerType === 'mouse' ? 0 : DEFAULT_START_SIZE.width, - height: event.pointerType === 'mouse' ? 0 : DEFAULT_START_SIZE.height, - counterpart: getAvailableSigner(lastSigner, defaultSignerNpub), - type: selectedTool.identifier - } - - page.drawnFields.push(newField) - - setActiveDrawnField({ - fileIndex, - pageIndex, - drawnFieldIndex: page.drawnFields.length - 1 - }) - setMouseState((prev) => { - return { - ...prev, - clicked: true - } - }) - } - - /** - * Drawing is finished, resets all the variables used to draw - * @param event Pointer event - */ - const handlePointerUp = () => { - setMouseState((prev) => { - 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') { - 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 handlePointerDown = useCallback( + ( + event: React.PointerEvent, + pageIndexer: PageIndexer, + pageWidth: number + ) => { + // Proceed only if left click + if (event.button !== 0) return + if (!selectedTool) return + event.currentTarget.setPointerCapture(event.pointerId) + const counterpart = getAvailableSigner(lastSigner, defaultSignerNpub) const { x, y } = getPointerCoordinates(event) - const width = to(page.width, x) - lastDrawnField.left - const height = to(page.width, y) - lastDrawnField.top + setIndexer(pageIndexer) + setField({ + 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, + height: event.pointerType === 'mouse' ? 0 : DEFAULT_START_SIZE.height, + type: selectedTool.identifier, + counterpart + }) + setMouseState({ + clicked: true + }) + }, + [defaultSignerNpub, getAvailableSigner, lastSigner, selectedTool, to] + ) - lastDrawnField.width = width - lastDrawnField.height = height + /** + * 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 pageWidth pdf value used to scale pointer coordinates + */ + const handlePointerMove = useCallback( + (event: React.PointerEvent, pageWidth: number) => { + if (mouseState.clicked && selectedTool && event.pointerType === 'mouse') { + const { x, y } = getPointerCoordinates(event) + const pageX = to(pageWidth, x) + const pageY = to(pageWidth, y) - const currentDrawnFields = page.drawnFields + // Calculate left, top, width, and height based on direction + setField((prev) => { + const left = pageX < prev!.x ? pageX : prev!.x + const top = pageY < prev!.y ? pageY : prev!.y - currentDrawnFields[lastElementIndex] = lastDrawnField - - refreshPdfFiles() - } - } + const width = Math.abs(pageX - prev!.x) + const height = Math.abs(pageY - prev!.y) + return { ...prev!, left, top, width, height } + }) + } + }, + [mouseState.clicked, selectedTool, to] + ) /** * Fired when event happens on the drawn element which will be moved @@ -201,22 +196,30 @@ export const DrawPDFFields = (props: Props) => { * y - offsetY * * @param event Pointer event - * @param drawnFieldIndex Which we are moving + * @param fieldIndexer Which field we are moving */ const handleDrawnFieldPointerDown = ( event: React.PointerEvent, - fileIndex: number, - pageIndex: number, - drawnFieldIndex: number + fieldIndexer: FieldIndexer ) => { event.stopPropagation() // Proceed only if left click if (event.button !== 0) return + event.currentTarget.setPointerCapture(event.pointerId) 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({ dragging: true, clicked: false, @@ -226,7 +229,7 @@ export const DrawPDFFields = (props: Props) => { } }) - // make signers dropdown visible + // Make signers dropdown visible setHideSignersForDrawnField((prev) => ({ ...prev, [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) * @param event Pointer event - * @param drawnField which we are moving - * @param pageWidth pdf value which is used to calculate scaled offset + * @param pageWidth pdf value used to scale pointer coordinates */ const handleDrawnFieldPointerMove = ( event: React.PointerEvent, - drawnField: DrawnField, pageWidth: number ) => { if (mouseState.dragging) { @@ -255,18 +256,21 @@ export const DrawPDFFields = (props: Props) => { let left = to(pageWidth, x - coordsOffset.x) let top = to(pageWidth, y - coordsOffset.y) - const rightLimit = to(pageWidth, rect.width) - drawnField.width - const bottomLimit = to(pageWidth, rect.height) - drawnField.height + setField((prev) => { + const rightLimit = to(pageWidth, rect.width) - prev!.width + const bottomLimit = to(pageWidth, rect.height) - prev!.height - if (left < 0) left = 0 - if (top < 0) top = 0 - if (left > rightLimit) left = rightLimit - if (top > bottomLimit) top = bottomLimit + if (left < 0) left = 0 + if (top < 0) top = 0 + if (left > rightLimit) left = rightLimit + if (top > bottomLimit) top = bottomLimit - drawnField.left = left - drawnField.top = top - - refreshPdfFiles() + return { + ...prev!, + left, + top + } + }) } } } @@ -274,73 +278,85 @@ export const DrawPDFFields = (props: Props) => { /** * Fired when clicked on the resize handle, sets the state for a resize action * @param event Pointer event - * @param drawnFieldIndex which we are resizing + * @param fieldIndexer which field we are resizing */ - const handleResizePointerDown = ( - event: React.PointerEvent, - fileIndex: number, - pageIndex: number, - drawnFieldIndex: number - ) => { - // Proceed only if left click - if (event.button !== 0) return - event.stopPropagation() + const handleResizePointerDown = useCallback( + (event: React.PointerEvent, fieldIndexer: FieldIndexer) => { + // Proceed only if left click + if (event.button !== 0) return + event.stopPropagation() - setActiveDrawnField({ fileIndex, pageIndex, drawnFieldIndex }) - setMouseState({ - resizing: true - }) - } + 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({ + resizing: true + }) + }, + [sigitFiles] + ) /** * Resizes the drawn element by the mouse position * @param event Pointer event - * @param drawnField which we are resizing - * @param pageWidth pdf value which is used to calculate scaled offset + * @param pageWidth pdf value used to scale pointer coordinates */ - const handleResizePointerMove = ( - event: React.PointerEvent, - drawnField: DrawnField, - pageWidth: number - ) => { - if (mouseState.resizing) { - const { x, y } = getPointerCoordinates( - event, + const handleResizePointerMove = useCallback( + (event: React.PointerEvent, pageWidth: number) => { + if (mouseState.resizing) { // currentTarget = span handle // 1st parent = drawnField // 2nd parent = img - event.currentTarget.parentElement?.parentElement - ) + const { x, y } = getPointerCoordinates( + event, + event.currentTarget.parentElement?.parentElement + ) - const width = to(pageWidth, x) - drawnField.left - const height = to(pageWidth, y) - drawnField.top + const pageX = to(pageWidth, x) + const pageY = to(pageWidth, y) - drawnField.width = width - drawnField.height = height + setField((prev) => { + 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 * @param event Pointer event - * @param pdfFileIndex pdf file index - * @param pdfPageIndex pdf page index - * @param drawnFileIndex drawn file index + * @param fieldIIndexer [file index, page index, field index] */ const handleRemovePointerDown = ( event: React.PointerEvent, - pdfFileIndex: number, - pdfPageIndex: number, - drawnFileIndex: number + [fileIndex, pageIndex, fieldIndex]: FieldIndexer ) => { event.stopPropagation() - const pages = sigitFiles[pdfFileIndex]?.pages - if (pages) { - pages[pdfPageIndex]?.drawnFields?.splice(drawnFileIndex, 1) - } + updateSigitFiles((draft) => { + draft[fileIndex]?.pages![pageIndex]?.drawnFields?.splice(fieldIndex, 1) + }) } /** @@ -379,28 +395,72 @@ export const DrawPDFFields = (props: Props) => { } /** - * 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 + * Drawing is finished, resets all the variables used to draw */ - const getPointerCoordinates = ( - event: React.PointerEvent, - customTarget?: HTMLElement | null - ) => { - const target = customTarget ? customTarget : event.currentTarget - const rect = target.getBoundingClientRect() + const handlePointerUp = useCallback(() => { + // Proceed if we have selected something + if (indexer) { + // Check if we have the "preview" field state + if (field) { + // 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 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. + const [fileIndex, pageIndex, fieldIndex] = indexer - return { - x, - y, - rect + // Add new drawn field to the files + if (mouseState.clicked) { + updateSigitFiles((draft) => { + 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 @@ -412,6 +472,13 @@ export const DrawPDFFields = (props: Props) => { return ( <> {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 (
{ onKeyDown={(event) => handleEscapeButtonDown(event)} > { - handlePointerMove(event, page) - }} onPointerDown={(event) => { - handlePointerDown(event, page, fileIndex, pageIndex) + handlePointerDown(event, [fileIndex, pageIndex], page.width) }} + onPointerMove={(event) => { + handlePointerMove(event, page.width) + }} + onPointerUp={handlePointerUpReleaseCapture} draggable="false" src={page.image} alt={`page ${pageIndex + 1} of ${file.name}`} /> - + {isPageIndexerActive && field && ( +
+
+ {getToolboxLabelByMarkType(field.type) || 'placeholder'} +
+
+ )} {page.drawnFields.map((drawnField, drawnFieldIndex: number) => { + let isFieldIndexerActive = false + if (indexer) { + const [fi, pi, di] = indexer + isFieldIndexerActive = + fi === fileIndex && + pi === pageIndex && + di === drawnFieldIndex + } + return (
- handleDrawnFieldPointerDown( - event, + handleDrawnFieldPointerDown(event, [ fileIndex, pageIndex, drawnFieldIndex - ) + ]) } onPointerMove={(event) => { - handleDrawnFieldPointerMove(event, drawnField, page.width) + handleDrawnFieldPointerMove(event, page.width) }} + onPointerUp={handlePointerUpReleaseCapture} className={styles.drawingRectangle} style={{ backgroundColor: drawnField.counterpart @@ -454,12 +557,29 @@ export const DrawPDFFields = (props: Props) => { outlineColor: drawnField.counterpart ? `#${npubToHex(drawnField.counterpart)?.substring(0, 6)}` : undefined, - left: inPx(from(page.width, drawnField.left)), - top: inPx(from(page.width, drawnField.top)), - width: inPx(from(page.width, drawnField.width)), - height: inPx(from(page.width, drawnField.height)), + ...(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)), + top: inPx(from(page.width, drawnField.top)), + width: inPx(from(page.width, drawnField.width)), + height: inPx(from(page.width, drawnField.height)) + }), + pointerEvents: mouseState.clicked ? 'none' : 'all', touchAction: 'none', + zIndex: isActiveDrawnField( + fileIndex, + pageIndex, + drawnFieldIndex + ) + ? 60 + : undefined, opacity: mouseState.dragging && isActiveDrawnField( @@ -483,37 +603,36 @@ export const DrawPDFFields = (props: Props) => {
- handleResizePointerDown( - event, + handleResizePointerDown(event, [ fileIndex, pageIndex, drawnFieldIndex - ) + ]) } onPointerMove={(event) => { - handleResizePointerMove(event, drawnField, page.width) + handleResizePointerMove(event, page.width) }} + onPointerUp={handlePointerUpReleaseCapture} className={styles.resizeHandle} style={{ - background: - mouseState.resizing && + ...(mouseState.resizing && isActiveDrawnField( fileIndex, pageIndex, drawnFieldIndex - ) - ? 'var(--primary-main)' - : undefined + ) && { + cursor: 'grabbing', + opacity: 0.1 + }) }} > { - handleRemovePointerDown( - event, + handleRemovePointerDown(event, [ fileIndex, pageIndex, drawnFieldIndex - ) + ]) }} className={styles.removeHandle} > @@ -551,21 +670,23 @@ export const DrawPDFFields = (props: Props) => { onChange={(event) => { drawnField.counterpart = event.target.value setLastSigner(event.target.value) - refreshPdfFiles() }} labelId="counterparts" label="Counterparts" sx={{ background: 'white' }} - renderValue={(value) => - renderCounterpartValue(value) - } + renderValue={(value) => ( + + )} > {signers.map((signer, index) => { const npub = hexToNpub(signer.pubkey) - const profile = - props.userProfiles[signer.pubkey] + const profile = userProfiles[signer.pubkey] const displayValue = getProfileUsername( npub, 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 ( -
- img': { - width: '21px', - height: '21px' - } - }} - /> - {displayValue} -
- ) - } - - return displayValue - } - return (
- {sigitFiles.map((file, i) => { - return ( - -
- {file.isPdf && getPdfPages(file, i)} - {file.isImage && ( - {file.name} - )} - {!(file.isPdf || file.isImage) && ( - - )} -
- {i < sigitFiles.length - 1 && } -
- ) - })} + {sigitFiles.length > 0 && + sigitFiles + .map((file, i) => + file.isPdf ? ( + + {getPdfPages(file, i)} + + ) : ( + + ) + ) + .reduce((prev, curr, i) => [ + prev, + , + curr + ])}
) } diff --git a/src/components/DrawPDFFields/internal/Counterpart.module.scss b/src/components/DrawPDFFields/internal/Counterpart.module.scss new file mode 100644 index 0000000..933913e --- /dev/null +++ b/src/components/DrawPDFFields/internal/Counterpart.module.scss @@ -0,0 +1,3 @@ +.counterpartSelectValue { + display: flex; +} diff --git a/src/components/DrawPDFFields/internal/Counterpart.tsx b/src/components/DrawPDFFields/internal/Counterpart.tsx new file mode 100644 index 0000000..1e2f61b --- /dev/null +++ b/src/components/DrawPDFFields/internal/Counterpart.tsx @@ -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 ( +
+ img': { + width: '21px', + height: '21px' + } + }} + /> + {displayValue} +
+ ) + } + + return displayValue + } +) diff --git a/src/components/DrawPDFFields/internal/FileItem.tsx b/src/components/DrawPDFFields/internal/FileItem.tsx new file mode 100644 index 0000000..a1f13ea --- /dev/null +++ b/src/components/DrawPDFFields/internal/FileItem.tsx @@ -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 = + if (file.isImage) return + + return ( +
+ {content} +
+ ) +}) diff --git a/src/components/DrawPDFFields/internal/ImageItem.tsx b/src/components/DrawPDFFields/internal/ImageItem.tsx new file mode 100644 index 0000000..8cba790 --- /dev/null +++ b/src/components/DrawPDFFields/internal/ImageItem.tsx @@ -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 {file.name} +}) diff --git a/src/components/DrawPDFFields/style.module.scss b/src/components/DrawPDFFields/style.module.scss index 0a89fbf..5eb5307 100644 --- a/src/components/DrawPDFFields/style.module.scss +++ b/src/components/DrawPDFFields/style.module.scss @@ -38,10 +38,6 @@ visibility: hidden; } - &.edited { - outline: 1px dotted #01aaad; - } - .resizeHandle { position: absolute; right: -5px; @@ -51,7 +47,7 @@ background-color: #fff; border: 1px solid rgb(160, 160, 160); border-radius: 50%; - cursor: nwse-resize; + cursor: grab; // Increase the area a bit so it's easier to click &::after { @@ -89,13 +85,34 @@ } } -.counterpartSelectValue { - display: flex; -} - .counterpartAvatar { img { width: 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; +} diff --git a/src/components/FileList/index.tsx b/src/components/FileList/index.tsx index a47ace7..a073ca4 100644 --- a/src/components/FileList/index.tsx +++ b/src/components/FileList/index.tsx @@ -1,23 +1,25 @@ import { CurrentUserFile } from '../../types/file.ts' 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 { faCheck } from '@fortawesome/free-solid-svg-icons' +import PopupState, { bindTrigger, bindMenu } from 'material-ui-popup-state' +import React from 'react' interface FileListProps { files: CurrentUserFile[] currentFile: CurrentUserFile setCurrentFile: (file: CurrentUserFile) => void - handleDownload: () => void - downloadLabel?: string + handleExport: () => void + handleEncryptedExport?: () => void } const FileList = ({ files, currentFile, setCurrentFile, - handleDownload, - downloadLabel + handleExport, + handleEncryptedExport }: FileListProps) => { const isActive = (file: CurrentUserFile) => file.id === currentFile.id return ( @@ -42,9 +44,35 @@ const FileList = ({ ))} - + + + {(popupState) => ( + + + + { + popupState.close + handleExport() + }} + > + Export Files + + { + popupState.close + typeof handleEncryptedExport === 'function' && + handleEncryptedExport() + }} + > + Export Encrypted Files + + + + )} +
) } diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx index ead7cec..c7459e3 100644 --- a/src/components/Footer/Footer.tsx +++ b/src/components/Footer/Footer.tsx @@ -68,6 +68,12 @@ export const Footer = () => }} component={Link} to={'/'} + onClick={(event) => { + if (['', '#/'].includes(window.location.hash)) { + event.preventDefault() + window.scrollTo(0, 0) + } + }} variant={'text'} > Home diff --git a/src/components/LoadingSpinner/style.module.scss b/src/components/LoadingSpinner/style.module.scss index 088f389..2ed8093 100644 --- a/src/components/LoadingSpinner/style.module.scss +++ b/src/components/LoadingSpinner/style.module.scss @@ -7,7 +7,7 @@ align-items: center; justify-content: center; background-color: rgba(0, 0, 0, 0.5); - z-index: 50; + z-index: 70; -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px); } diff --git a/src/components/MarkFormField/index.tsx b/src/components/MarkFormField/index.tsx index 18bbf31..99b9b7f 100644 --- a/src/components/MarkFormField/index.tsx +++ b/src/components/MarkFormField/index.tsx @@ -1,5 +1,4 @@ import { CurrentUserMark } from '../../types/mark.ts' -import styles from './style.module.scss' import { findNextIncompleteCurrentUserMark, getToolboxLabelByMarkType, @@ -8,12 +7,16 @@ import { } from '../../utils' import React, { useState } from 'react' 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 { currentUserMarks: CurrentUserMark[] handleCurrentUserMarkChange: (mark: CurrentUserMark) => void handleSelectedMarkValueChange: (value: string) => void - handleSubmit: (event: React.FormEvent) => void + handleSubmit: (event: React.MouseEvent) => void selectedMark: CurrentUserMark selectedMarkValue: string } @@ -30,11 +33,13 @@ const MarkFormField = ({ handleCurrentUserMarkChange }: MarkFormFieldProps) => { const [displayActions, setDisplayActions] = useState(true) + const [complete, setComplete] = useState(false) + const isReadyToSign = () => isCurrentUserMarksComplete(currentUserMarks) || isCurrentValueLast(currentUserMarks, selectedMark, selectedMarkValue) const isCurrent = (currentMark: CurrentUserMark) => - currentMark.id === selectedMark.id + currentMark.id === selectedMark.id && !complete const isDone = (currentMark: CurrentUserMark) => isCurrent(currentMark) ? !!selectedMarkValue : currentMark.isCompleted const findNext = () => { @@ -46,13 +51,36 @@ const MarkFormField = ({ const handleFormSubmit = (event: React.FormEvent) => { event.preventDefault() console.log('handle form submit runs...') - return isReadyToSign() - ? handleSubmit(event) - : handleCurrentUserMarkChange(findNext()!) + + // Without this line, we lose mark values when switching + handleCurrentUserMarkChange(selectedMark) + + if (!complete) { + isReadyToSign() + ? setComplete(true) + : handleCurrentUserMarkChange(findNext()!) + } } + const toggleActions = () => setDisplayActions(!displayActions) 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 + ) => { + handleSubmit(event) + } + return (
@@ -78,33 +106,55 @@ const MarkFormField = ({
-

Add {markLabel}

+ {!complete && ( +

Add {markLabel}

+ )} + {complete &&

Finish

}
-
handleFormSubmit(e)}> - + {!complete && ( + handleFormSubmit(e)}> + +
+ +
+ + )} + + {complete && (
- +
- + )} +
{currentUserMarks.map((mark, index) => { return (
@@ -114,6 +164,22 @@ const MarkFormField = ({
) })} +
+ + {complete && ( +
+ )} +
diff --git a/src/components/MarkFormField/style.module.scss b/src/components/MarkFormField/style.module.scss index 0125140..e7a3e3c 100644 --- a/src/components/MarkFormField/style.module.scss +++ b/src/components/MarkFormField/style.module.scss @@ -70,6 +70,11 @@ margin-top: 10px; } + .completeButton { + font-size: 18px; + padding: 10px 20px; + } + .paginationButton { font-size: 12px; padding: 5px 10px; @@ -78,7 +83,8 @@ color: rgba(0, 0, 0, 0.5); } - .paginationButton:hover { + .paginationButton:hover, + .paginationButton:focus { background: #447592; color: rgba(255, 255, 255, 0.5); } @@ -122,7 +128,7 @@ align-items: center; grid-gap: 15px; box-shadow: 0 -2px 4px 0 rgb(0, 0, 0, 0.1); - max-width: 750px; + max-width: 450px; &.expanded { display: flex; @@ -216,3 +222,7 @@ flex-direction: column; grid-gap: 5px; } + +.finishPage { + padding: 1px 0; +} diff --git a/src/components/PDFView/PdfMarkItem.tsx b/src/components/PDFView/PdfMarkItem.tsx index 0eaa49d..a1dddb3 100644 --- a/src/components/PDFView/PdfMarkItem.tsx +++ b/src/components/PDFView/PdfMarkItem.tsx @@ -32,7 +32,7 @@ const PdfMarkItem = forwardRef(
void + handleExport: () => void + handleEncryptedExport: () => void + handleSign: () => void meta: Meta | null otherUserMarks: Mark[] setCurrentUserMarks: (currentUserMarks: CurrentUserMark[]) => void - setIsMarksCompleted: (isMarksCompleted: boolean) => void setUpdatedMarks: (markToUpdate: Mark) => void } @@ -42,10 +43,11 @@ const PdfMarking = (props: PdfMarkingProps) => { const { files, currentUserMarks, - setIsMarksCompleted, setCurrentUserMarks, setUpdatedMarks, - handleDownload, + handleExport, + handleEncryptedExport, + handleSign, meta, otherUserMarks } = props @@ -70,8 +72,8 @@ const PdfMarking = (props: PdfMarkingProps) => { const handleMarkClick = (id: number) => { const nextMark = currentUserMarks.find((mark) => mark.mark.id === id) - setSelectedMark(nextMark!) - setSelectedMarkValue(nextMark?.mark.value ?? EMPTY) + + if (nextMark) handleCurrentUserMarkChange(nextMark) } const handleCurrentUserMarkChange = (mark: CurrentUserMark) => { @@ -86,11 +88,18 @@ const PdfMarking = (props: PdfMarkingProps) => { updatedSelectedMark ) setCurrentUserMarks(updatedCurrentUserMarks) - setSelectedMarkValue(mark.currentValue ?? EMPTY) - setSelectedMark(mark) + + // If clicking on the same mark, don't update the value, otherwise do update + if (mark.id !== selectedMark.id) { + setSelectedMarkValue(mark.currentValue ?? EMPTY) + setSelectedMark(mark) + } } - const handleSubmit = (event: React.FormEvent) => { + /** + * Sign and Complete + */ + const handleSubmit = (event: React.MouseEvent) => { event.preventDefault() if (!selectedMarkValue || !selectedMark) return @@ -106,8 +115,8 @@ const PdfMarking = (props: PdfMarkingProps) => { ) setCurrentUserMarks(updatedCurrentUserMarks) setSelectedMark(null) - setIsMarksCompleted(true) setUpdatedMarks(updatedMark.mark) + handleSign() } // const updateCurrentUserMarkValues = () => { @@ -132,7 +141,8 @@ const PdfMarking = (props: PdfMarkingProps) => { files={files} currentFile={currentFile} setCurrentFile={setCurrentFile} - handleDownload={handleDownload} + handleExport={handleExport} + handleEncryptedExport={handleEncryptedExport} /> )}
diff --git a/src/components/PDFView/index.tsx b/src/components/PDFView/index.tsx index acd1874..95d577e 100644 --- a/src/components/PDFView/index.tsx +++ b/src/components/PDFView/index.tsx @@ -45,18 +45,17 @@ const PdfView = ({ const filterMarksByFile = (marks: Mark[], hash: string): Mark[] => { return marks.filter((mark) => mark.pdfFileHash === hash) } - const isNotLastPdfFile = (index: number, files: CurrentUserFile[]): boolean => - index !== files.length - 1 return (
{files.length > 0 ? ( - files.map((currentUserFile, index, arr) => { - const { hash, file, id } = currentUserFile + files + .map((currentUserFile) => { + const { hash, file, id } = currentUserFile - if (!hash) return - return ( - + if (!hash) return + return (
(pdfRefs.current[id] = el)} @@ -70,10 +69,13 @@ const PdfView = ({ otherUserMarks={filterMarksByFile(otherUserMarks, hash)} />
- {isNotLastPdfFile(index, arr) && } -
- ) - }) + ) + }) + .reduce((prev, curr, i) => [ + prev, + , + curr + ]) ) : ( )} diff --git a/src/hooks/useNDK.ts b/src/hooks/useNDK.ts index 5e24352..fbd8d23 100644 --- a/src/hooks/useNDK.ts +++ b/src/hooks/useNDK.ts @@ -19,11 +19,18 @@ import { updateUserAppData as updateUserAppDataAction } from '../store/actions' import { Keys } from '../store/auth/types' -import { Meta, UserAppData, UserRelaysType } from '../types' +import { + isSigitNotification, + Meta, + SigitNotification, + UserAppData, + UserRelaysType +} from '../types' import { countLeadingZeroes, createWrap, deleteBlossomFile, + fetchMetaFromFileStorage, getDTagForUserAppData, getUserAppDataFromBlossom, hexToNpub, @@ -337,21 +344,63 @@ export const useNDK = () => { if (!internalUnsignedEvent || internalUnsignedEvent.kind !== 938) return - const meta = await parseJson(internalUnsignedEvent.content).catch( - (err) => { - console.log( - 'An error occurred in parsing the internal unsigned event', - err - ) - return null - } - ) + const parsedContent = await parseJson( + internalUnsignedEvent.content + ).catch((err) => { + console.log( + 'An error occurred in parsing the internal unsigned event', + err + ) + 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) }, - [dispatch, processedEvents, updateUsersAppData] + [dispatch, processedEvents, updateUsersAppData, usersPubkey] ) const subscribeForSigits = useCallback( @@ -376,15 +425,20 @@ export const useNDK = () => { [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( - async (receiver: string, meta: Meta) => { + async (receiver: string, notification: SigitNotification) => { if (!usersPubkey) return // Create an unsigned event object with the provided metadata const unsignedEvent: UnsignedEvent = { kind: 938, pubkey: usersPubkey, - content: JSON.stringify(meta), + content: JSON.stringify(notification), tags: [], created_at: unixNow() } diff --git a/src/hooks/useSigitMeta.tsx b/src/hooks/useSigitMeta.tsx index 85841f2..5c1159e 100644 --- a/src/hooks/useSigitMeta.tsx +++ b/src/hooks/useSigitMeta.tsx @@ -45,7 +45,7 @@ export interface FlatMeta isValid: boolean // Decryption - encryptionKey: string | null + encryptionKey: string | undefined // Parsed Document Signatures parsedSignatureEvents: { @@ -101,7 +101,7 @@ export const useSigitMeta = (meta: Meta): FlatMeta => { [signer: `npub1${string}`]: SignStatus }>({}) - const [encryptionKey, setEncryptionKey] = useState(null) + const [encryptionKey, setEncryptionKey] = useState() useEffect(() => { if (!meta) return @@ -143,7 +143,7 @@ export const useSigitMeta = (meta: Meta): FlatMeta => { setMarkConfig(markConfig) setZipUrl(zipUrl) - let encryptionKey: string | null = null + let encryptionKey: string | undefined if (meta.keys) { const { sender, keys } = meta.keys // 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', err ) - return null + return undefined }) encryptionKey = decrypted diff --git a/src/layouts/Main.tsx b/src/layouts/Main.tsx index 6d27ae8..e4bb7a6 100644 --- a/src/layouts/Main.tsx +++ b/src/layouts/Main.tsx @@ -138,7 +138,15 @@ export const MainLayout = () => { initNostrLogin({ methods: ['connect', 'extension', 'local'], 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) => { console.error('Failed to initialize Nostr-Login', error) }) diff --git a/src/pages/create/index.tsx b/src/pages/create/index.tsx index f593cbd..f870849 100644 --- a/src/pages/create/index.tsx +++ b/src/pages/create/index.tsx @@ -20,11 +20,12 @@ import { toast } from 'react-toastify' import { LoadingSpinner } from '../../components/LoadingSpinner' import { UserAvatar } from '../../components/UserAvatar' import { NostrController } from '../../controllers' -import { appPrivateRoutes } from '../../routes' +import { appPrivateRoutes, appPublicRoutes } from '../../routes' import { CreateSignatureEventContent, KeyboardCode, Meta, + SigitNotification, SignedEvent, User, UserRelaysType, @@ -45,7 +46,8 @@ import { signEventForMetaFile, uploadToFileStorage, DEFAULT_TOOLBOX, - settleAllFullfilfedPromises + settleAllFullfilfedPromises, + uploadMetaToFileStorage } from '../../utils' import { Container } from '../../components/Container' import fileListStyles from '../../components/FileList/style.module.scss' @@ -69,13 +71,14 @@ import { } from '@fortawesome/free-solid-svg-icons' import { getSigitFile, SigitFile } from '../../utils/file.ts' import { generateTimestamp } from '../../utils/opentimestamps.ts' -import { Autocomplete } from '@mui/lab' +import { Autocomplete } from '@mui/material' import _, { truncate } from 'lodash' import * as React from 'react' import { AvatarIconButton } from '../../components/UserAvatarIconButton' import { NDKUserProfile, NostrEvent } from '@nostr-dev-kit/ndk' import { useNDKContext } from '../../hooks/useNDKContext.ts' import { useNDK } from '../../hooks/useNDK.ts' +import { useImmer } from 'use-immer' type FoundUser = NostrEvent & { npub: string } @@ -94,7 +97,7 @@ export const CreatePage = () => { const [title, setTitle] = useState(`sigit_${formatTimestamp(Date.now())}`) - const [selectedFiles, setSelectedFiles] = useState([]) + const [selectedFiles, setSelectedFiles] = useState([...uploadedFiles]) const fileInputRef = useRef(null) const handleUploadButtonClick = () => { if (fileInputRef.current) { @@ -120,7 +123,7 @@ export const CreatePage = () => { [key: string]: NDKUserProfile }>({}) - const [drawnFiles, setDrawnFiles] = useState([]) + const [drawnFiles, updateDrawnFiles] = useImmer([]) const [parsingPdf, setIsParsing] = useState(false) const searchFieldRef = useRef(null) @@ -148,6 +151,17 @@ export const CreatePage = () => { [setUserInput] ) + const handleSearchUserNip05 = async ( + nip05: string + ): Promise => { + const { pubkey } = await queryNip05(nip05).catch((err) => { + console.error(err) + return { pubkey: null } + }) + + return pubkey + } + const handleSearchUsers = async (searchValue?: string) => { const searchString = searchValue || userSearchInput || undefined @@ -195,8 +209,11 @@ export const CreatePage = () => { return uniqueEvents }, [] as FoundUser[]) - console.log('fineFilteredEvents', fineFilteredEvents) + console.info('fineFilteredEvents', fineFilteredEvents) setFoundUsers(fineFilteredEvents) + + if (!fineFilteredEvents.length) + toast.info('No user found with the provided search term') }) .catch((error) => { console.error(error) @@ -217,7 +234,9 @@ export const CreatePage = () => { }) }, [foundUsers]) - const handleInputKeyDown = (event: React.KeyboardEvent) => { + const handleInputKeyDown = async ( + event: React.KeyboardEvent + ) => { if ( event.code === KeyboardCode.Enter || event.code === KeyboardCode.NumpadEnter @@ -231,7 +250,23 @@ export const CreatePage = () => { } else { // Otherwize if search already provided some results, user must manually click the search button if (!foundUsers.length) { - handleSearchUsers() + // 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() + } } } } @@ -248,8 +283,28 @@ export const CreatePage = () => { selectedFiles, 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) @@ -258,7 +313,7 @@ export const CreatePage = () => { setIsParsing(false) }) } - }, [selectedFiles]) + }, [selectedFiles, updateDrawnFiles]) /** * Changes the drawing tool @@ -296,12 +351,6 @@ export const CreatePage = () => { }) }, [userProfiles, users, findMetadata]) - useEffect(() => { - if (uploadedFiles) { - setSelectedFiles([...uploadedFiles]) - } - }, [uploadedFiles]) - useEffect(() => { if (usersPubkey) { 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) // Remove duplicates based on the file.name - setSelectedFiles((p) => - [...p, ...files].filter( + setSelectedFiles((p) => { + const unique = [...p, ...files].filter( (file, i, array) => i === array.findIndex((t) => t.name === file.name) ) - ) + navigate('.', { + state: { uploadedFiles: unique }, + replace: true + }) + return unique + }) } } @@ -497,9 +551,14 @@ export const CreatePage = () => { ) => { event.stopPropagation() - setSelectedFiles((prevFiles) => - prevFiles.filter((file) => file.name !== fileToRemove.name) - ) + setSelectedFiles((prevFiles) => { + const files = prevFiles.filter((file) => file.name !== fileToRemove.name) + navigate('.', { + state: { uploadedFiles: files }, + replace: true + }) + return files + }) } // Validate inputs before proceeding @@ -743,7 +802,7 @@ export const CreatePage = () => { title } - setLoadingSpinnerDesc('Signing nostr event for create signature') + setLoadingSpinnerDesc('Preparing document(s) for signing') const createSignature = await signEventForMetaFile( JSON.stringify(content), @@ -761,7 +820,7 @@ export const CreatePage = () => { } // 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 const receivers = ( signers.length > 0 @@ -769,7 +828,7 @@ export const CreatePage = () => { : viewers.map((viewer) => viewer.pubkey) ).filter((receiver) => receiver !== usersPubkey) - return receivers.map((receiver) => sendNotification(receiver, meta)) + return receivers.map((receiver) => sendNotification(receiver, notification)) } const extractNostrId = (stringifiedEvent: string): string => { @@ -844,11 +903,17 @@ export const CreatePage = () => { } setLoadingSpinnerDesc('Updating user app data') + const event = await updateUsersAppData(meta) if (!event) return + const metaUrl = await uploadMetaToFileStorage(meta, encryptionKey) + setLoadingSpinnerDesc('Sending notifications to counterparties') - const promises = sendNotifications(meta) + const promises = sendNotifications({ + metaUrl, + keys: meta.keys + }) await Promise.all(promises) .then(() => { @@ -858,7 +923,14 @@ export const CreatePage = () => { toast.error('Failed to publish notifications') }) - navigate(appPrivateRoutes.sign, { state: { meta } }) + const isFirstSigner = signers[0].pubkey === usersPubkey + + if (isFirstSigner) { + navigate(appPrivateRoutes.sign, { state: { meta } }) + } else { + const createSignatureJson = JSON.parse(createSignature) + navigate(`${appPublicRoutes.verify}/${createSignatureJson.id}`) + } } else { const zip = new JSZip() @@ -938,19 +1010,6 @@ export const CreatePage = () => { } else { 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 { // Disarm the add user on enter hit, and trigger search after 1 second disarmAddOnEnter() @@ -969,7 +1028,6 @@ export const CreatePage = () => { return ( <> - {isLoading && } { ) : ( -
- {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 - }) - .map((key) => ( - - ))} -
+ +
{renderSubmissions()}
diff --git a/src/pages/home/style.module.scss b/src/pages/home/style.module.scss index 63917a0..5efd4fd 100644 --- a/src/pages/home/style.module.scss +++ b/src/pages/home/style.module.scss @@ -99,3 +99,10 @@ gap: 25px; grid-template-columns: repeat(auto-fit, minmax(365px, 1fr)); } + +.noResults { + display: flex; + justify-content: center; + font-weight: normal; + color: #a1a1a1; +} diff --git a/src/pages/sign/index.tsx b/src/pages/sign/index.tsx index 3adea40..d4071e0 100644 --- a/src/pages/sign/index.tsx +++ b/src/pages/sign/index.tsx @@ -1,66 +1,54 @@ -import { Box, Button, Typography } from '@mui/material' import axios from 'axios' import saveAs from 'file-saver' import JSZip from 'jszip' import _ from 'lodash' -import { MuiFileInput } from 'mui-file-input' import { Event, verifyEvent } from 'nostr-tools' import { useCallback, useEffect, useState } from 'react' -import { useAppSelector } from '../../hooks/store' +import { useAppSelector } from '../../hooks' import { useLocation, useNavigate, useParams } from 'react-router-dom' import { toast } from 'react-toastify' import { LoadingSpinner } from '../../components/LoadingSpinner' import { NostrController } from '../../controllers' -import { appPublicRoutes } from '../../routes' +import { appPrivateRoutes, appPublicRoutes } from '../../routes' import { CreateSignatureEventContent, Meta, SignedEvent } from '../../types' import { + ARRAY_BUFFER, decryptArrayBuffer, + DEFLATE, encryptArrayBuffer, extractMarksFromSignedMeta, extractZipUrlAndEncryptionKey, + filterMarksByPubkey, + findOtherUserMarks, generateEncryptionKey, generateKeysFile, getCurrentUserFiles, + getCurrentUserMarks, getHash, hexToNpub, isOnline, loadZip, - unixNow, npubToHex, parseJson, + processMarks, readContentOfZipEntry, signEventForMetaFile, - findOtherUserMarks, timeout, - processMarks + unixNow, + updateMarks, + uploadMetaToFileStorage } 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 { getLastSignersSig, isFullySigned } from '../../utils/sign.ts' -import { - filterMarksByPubkey, - getCurrentUserMarks, - isCurrentUserMarksComplete, - updateMarks -} from '../../utils' import PdfMarking from '../../components/PDFView/PdfMarking.tsx' import { convertToSigitFile, getZipWithFiles, SigitFile } from '../../utils/file.ts' -import { ARRAY_BUFFER, DEFLATE } from '../../utils/const.ts' import { generateTimestamp } from '../../utils/opentimestamps.ts' import { MARK_TYPE_CONFIG } from '../../components/MarkTypeStrategy/MarkStrategy.tsx' import { useNDK } from '../../hooks/useNDK.ts' - -enum SignedStatus { - Fully_Signed, - User_Is_Next_Signer, - User_Is_Not_Next_Signer -} +import { getLastSignersSig } from '../../utils/sign.ts' export const SignPage = () => { const navigate = useNavigate() @@ -100,17 +88,12 @@ export const SignPage = () => { } } - const [displayInput, setDisplayInput] = useState(false) - - const [selectedFile, setSelectedFile] = useState(null) - const [files, setFiles] = useState<{ [filename: string]: SigitFile }>({}) const [isLoading, setIsLoading] = useState(true) const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('') const [meta, setMeta] = useState(null) - const [signedStatus, setSignedStatus] = useState() const [submittedBy, setSubmittedBy] = useState() @@ -124,66 +107,14 @@ export const SignPage = () => { [key: string]: string | null }>({}) - const [signedBy, setSignedBy] = useState<`npub1${string}`[]>([]) - - const [nextSinger, setNextSinger] = useState() - - // 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 nostrController = NostrController.getInstance() const [currentUserMarks, setCurrentUserMarks] = useState( [] ) - const [isMarksCompleted, setIsMarksCompleted] = useState(false) const [otherUserMarks, setOtherUserMarks] = useState([]) - 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(() => { const handleUpdatedMeta = async (meta: Meta) => { const createSignatureEvent = await parseJson( @@ -263,11 +194,10 @@ export const SignPage = () => { m.value && encryptionKey ) { - const decrypted = await fetchAndDecrypt( + otherUserMarks[i].value = await fetchAndDecrypt( m.value, encryptionKey ) - otherUserMarks[i].value = decrypted } } catch (error) { console.error(`Error during mark fetchAndDecrypt phase`, error) @@ -278,10 +208,7 @@ export const SignPage = () => { setOtherUserMarks(otherUserMarks) setCurrentUserMarks(currentUserMarks) - setIsMarksCompleted(isCurrentUserMarksComplete(currentUserMarks)) } - - setSignedBy(Object.keys(meta.docSignatures) as `npub1${string}`[]) } if (meta) { @@ -290,29 +217,6 @@ export const SignPage = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [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( async (file: File) => { setLoadingSpinnerDesc('Decrypting file') @@ -424,7 +328,6 @@ export const SignPage = () => { }) } else { setIsLoading(false) - setDisplayInput(true) } }, [decryptedArrayBuffer, uploadedZip, metaInNavState, decrypt]) @@ -541,9 +444,6 @@ export const SignPage = () => { setFiles(files) setCurrentFileHashes(fileHashes) - - setDisplayInput(false) - setLoadingSpinnerDesc('Parsing meta.json') const metaFileContent = await readContentOfZipEntry( @@ -571,21 +471,6 @@ export const SignPage = () => { 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 () => { if (Object.entries(files).length === 0 || !meta) return @@ -635,11 +520,18 @@ export const SignPage = () => { } if (await isOnline()) { - await handleOnlineFlow(updatedMeta) + await handleOnlineFlow(updatedMeta, encryptionKey) } else { setMeta(updatedMeta) 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 @@ -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 const handleZipError = (err: unknown) => { console.log('Error in zip:>> ', err) @@ -741,7 +641,10 @@ export const SignPage = () => { } // 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') const updatedEvent = await updateUsersAppData(meta) if (!updatedEvent) { @@ -749,6 +652,18 @@ export const SignPage = () => { 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}`>() if (submittedBy && submittedBy !== usersPubkey) { userSet.add(hexToNpub(submittedBy)) @@ -781,7 +696,7 @@ export const SignPage = () => { setLoadingSpinnerDesc('Sending notifications') const users = Array.from(userSet) const promises = users.map((user) => - sendNotification(npubToHex(user)!, meta) + sendNotification(npubToHex(user)!, { metaUrl, keys: meta.keys }) ) await Promise.all(promises) .then(() => { @@ -795,16 +710,38 @@ export const SignPage = () => { setIsLoading(false) } - // 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 + const handleExport = async () => { + const arrayBuffer = await prepareZipExport() + if (!arrayBuffer) return + + const blob = new Blob([arrayBuffer]) + saveAs(blob, `exported-${unixNow()}.sigit.zip`) + + setIsLoading(false) + + navigate(appPublicRoutes.verify) } - const handleExport = async () => { - if (Object.entries(files).length === 0 || !meta || !usersPubkey) return + 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 => { + if (Object.entries(files).length === 0 || !meta || !usersPubkey) + return Promise.resolve(null) const usersNpub = hexToNpub(usersPubkey) if ( @@ -812,15 +749,15 @@ export const SignPage = () => { !viewers.includes(usersNpub) && submittedBy !== usersNpub ) - return + return Promise.resolve(null) setIsLoading(true) setLoadingSpinnerDesc('Signing nostr event') - if (!meta) return + if (!meta) return Promise.resolve(null) const prevSig = getLastSignersSig(meta, signers) - if (!prevSig) return + if (!prevSig) return Promise.resolve(null) const signedEvent = await signEventForMetaFile( JSON.stringify({ @@ -830,7 +767,7 @@ export const SignPage = () => { setIsLoading ) - if (!signedEvent) return + if (!signedEvent) return Promise.resolve(null) const exportSignature = JSON.stringify(signedEvent, null, 2) @@ -848,8 +785,8 @@ export const SignPage = () => { const arrayBuffer = await zip .generateAsync({ - type: 'arraybuffer', - compression: 'DEFLATE', + type: ARRAY_BUFFER, + compression: DEFLATE, compressionOptions: { level: 6 } @@ -861,50 +798,9 @@ export const SignPage = () => { return null }) - if (!arrayBuffer) return + if (!arrayBuffer) return Promise.resolve(null) - const blob = new Blob([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`) + return Promise.resolve(arrayBuffer) } /** @@ -944,90 +840,17 @@ export const SignPage = () => { return } - if (!isMarksCompleted && signedStatus === SignedStatus.User_Is_Next_Signer) { - return ( - - ) - } - return ( - <> - - {displayInput && ( - <> - - Select sigit file - - - - setSelectedFile(value)} - /> - - - {selectedFile && ( - - - - )} - - )} - - {submittedBy && Object.entries(files).length > 0 && meta && ( - <> - - - {signedStatus === SignedStatus.Fully_Signed && ( - - - - )} - - {signedStatus === SignedStatus.User_Is_Next_Signer && ( - - - - )} - - {isSignerOrCreator && ( - - - - )} - - )} - - + ) } diff --git a/src/pages/verify/index.tsx b/src/pages/verify/index.tsx index 6dc5180..43dcc4b 100644 --- a/src/pages/verify/index.tsx +++ b/src/pages/verify/index.tsx @@ -21,7 +21,13 @@ import { readContentOfZipEntry, signEventForMetaFile, getCurrentUserFiles, - npubToHex + npubToHex, + generateEncryptionKey, + encryptArrayBuffer, + generateKeysFile, + ARRAY_BUFFER, + DEFLATE, + uploadMetaToFileStorage } from '../../utils' import styles from './style.module.scss' import { useLocation, useParams } from 'react-router-dom' @@ -350,6 +356,11 @@ export const VerifyPage = () => { const updatedEvent = await updateUsersAppData(updatedMeta) if (!updatedEvent) return + const metaUrl = await uploadMetaToFileStorage( + updatedMeta, + encryptionKey + ) + const userSet = new Set<`npub1${string}`>() signers.forEach((signer) => { if (signer !== usersPubkey) { @@ -363,7 +374,10 @@ export const VerifyPage = () => { const users = Array.from(userSet) const promises = users.map((user) => - sendNotification(npubToHex(user)!, updatedMeta) + sendNotification(npubToHex(user)!, { + metaUrl, + keys: meta.keys! + }) ) await Promise.all(promises) @@ -540,8 +554,114 @@ export const VerifyPage = () => { setIsLoading(false) } - const handleMarkedExport = async () => { - if (Object.entries(files).length === 0 || !meta || !usersPubkey) return + // Handle errors during zip file generation + const handleZipError = (err: unknown) => { + console.log('Error in zip:>> ', err) + setIsLoading(false) + if (err instanceof Error) { + toast.error(err.message || 'Error occurred in generating zip file') + } + return null + } + + // 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 => { + // 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() + + 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 => { + if (Object.entries(files).length === 0 || !meta || !usersPubkey) + return Promise.resolve(null) const usersNpub = hexToNpub(usersPubkey) if ( @@ -549,14 +669,14 @@ export const VerifyPage = () => { !viewers.includes(usersNpub) && submittedBy !== usersNpub ) { - return + return Promise.resolve(null) } setIsLoading(true) setLoadingSpinnerDesc('Signing nostr event') const prevSig = getLastSignersSig(meta, signers) - if (!prevSig) return + if (!prevSig) return Promise.resolve(null) const signedEvent = await signEventForMetaFile( JSON.stringify({ prevSig }), @@ -564,7 +684,7 @@ export const VerifyPage = () => { setIsLoading ) - if (!signedEvent) return + if (!signedEvent) return Promise.resolve(null) const exportSignature = JSON.stringify(signedEvent, null, 2) const updatedMeta = { ...meta, exportSignature } @@ -575,8 +695,8 @@ export const VerifyPage = () => { const arrayBuffer = await zip .generateAsync({ - type: 'arraybuffer', - compression: 'DEFLATE', + type: ARRAY_BUFFER, + compression: DEFLATE, compressionOptions: { level: 6 } @@ -588,12 +708,9 @@ export const VerifyPage = () => { return null }) - if (!arrayBuffer) return + if (!arrayBuffer) return Promise.resolve(null) - const blob = new Blob([arrayBuffer]) - saveAs(blob, `exported-${unixNow()}.sigit.zip`) - - setIsLoading(false) + return Promise.resolve(arrayBuffer) } return ( @@ -639,8 +756,8 @@ export const VerifyPage = () => { )} currentFile={currentFile} setCurrentFile={setCurrentFile} - handleDownload={handleMarkedExport} - downloadLabel="Download Sigit" + handleExport={handleExport} + handleEncryptedExport={handleEncryptedExport} /> ) } diff --git a/src/pages/verify/style.module.scss b/src/pages/verify/style.module.scss index b63ba60..a89ea95 100644 --- a/src/pages/verify/style.module.scss +++ b/src/pages/verify/style.module.scss @@ -53,10 +53,6 @@ .mark { position: absolute; - - display: flex; - justify-content: center; - align-items: center; } [data-dev='true'] { diff --git a/src/types/core.ts b/src/types/core.ts index df55a07..f07dbf7 100644 --- a/src/types/core.ts +++ b/src/types/core.ts @@ -83,3 +83,12 @@ export interface UserAppData { export interface DocSignatureEvent extends Event { 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' +} diff --git a/src/types/errors/MetaStorageError.ts b/src/types/errors/MetaStorageError.ts new file mode 100644 index 0000000..a5bc2cd --- /dev/null +++ b/src/types/errors/MetaStorageError.ts @@ -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 + } +} diff --git a/src/utils/const.ts b/src/utils/const.ts index bf38404..2b8e822 100644 --- a/src/utils/const.ts +++ b/src/utils/const.ts @@ -119,6 +119,6 @@ export const SIGNATURE_PAD_OPTIONS = { } as const export const SIGNATURE_PAD_SIZE = { - width: 600, - height: 300 + width: 300, + height: 150 } diff --git a/src/utils/meta.ts b/src/utils/meta.ts index c915f66..8052abf 100644 --- a/src/utils/meta.ts +++ b/src/utils/meta.ts @@ -1,5 +1,12 @@ import { CreateSignatureEventContent, Meta } from '../types' -import { fromUnixTimestamp, parseJson } from '.' +import { + decryptArrayBuffer, + encryptArrayBuffer, + fromUnixTimestamp, + getHash, + parseJson, + uploadToFileStorage +} from '.' import { Event, verifyEvent } from 'nostr-tools' import { toast } from 'react-toastify' import { extractFileExtensions } from './file' @@ -8,6 +15,11 @@ import { MetaParseError, MetaParseErrorType } from '../types/errors/MetaParseError' +import axios from 'axios' +import { + MetaStorageError, + MetaStorageErrorType +} from '../types/errors/MetaStorageError' export enum SignStatus { 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(json) + return meta + } + + throw new MetaStorageError(MetaStorageErrorType.UNHANDLED_FETCH_ERROR) +}