diff --git a/src/components/DisplaySigit/index.tsx b/src/components/DisplaySigit/index.tsx index 0fe5c2f..0d7407f 100644 --- a/src/components/DisplaySigit/index.tsx +++ b/src/components/DisplaySigit/index.tsx @@ -1,9 +1,6 @@ -import { useEffect, useState } from 'react' -import { Meta, ProfileMetadata } from '../../types' +import { Meta } from '../../types' import { SigitCardDisplayInfo, SigitStatus } from '../../utils' -import { Event, kinds } from 'nostr-tools' import { Link } from 'react-router-dom' -import { MetadataController } from '../../controllers' import { formatTimestamp, hexToNpub, npubToHex, shorten } from '../../utils' import { appPublicRoutes, appPrivateRoutes } from '../../routes' import { Button, Divider, Tooltip } from '@mui/material' @@ -22,6 +19,8 @@ import { UserAvatarGroup } from '../UserAvatarGroup' import styles from './style.module.scss' import { TooltipChild } from '../TooltipChild' import { getExtensionIconLabel } from '../getExtensionIconLabel' +import { useSigitProfiles } from '../../hooks/useSigitProfiles' +import { useSigitMeta } from '../../hooks/useSigitMeta' type SigitProps = { meta: Meta @@ -38,61 +37,12 @@ export const DisplaySigit = ({ meta, parsedMeta }: SigitProps) => { fileExtensions } = parsedMeta - const [profiles, setProfiles] = useState<{ [key: string]: ProfileMetadata }>( - {} - ) + const { signersStatus } = useSigitMeta(meta) - useEffect(() => { - const hexKeys = new Set([ - ...signers.map((signer) => npubToHex(signer)!) - ]) - - if (submittedBy) { - hexKeys.add(npubToHex(submittedBy)!) - } - - const metadataController = new MetadataController() - - const handleMetadataEvent = (key: string) => (event: Event) => { - const metadataContent = - metadataController.extractProfileMetadataContent(event) - - if (metadataContent) { - setProfiles((prev) => ({ - ...prev, - [key]: metadataContent - })) - } - } - - const handleEventListener = - (key: string) => (kind: number, event: Event) => { - if (kind === kinds.Metadata) { - handleMetadataEvent(key)(event) - } - } - - hexKeys.forEach((key) => { - if (!(key in profiles)) { - metadataController.on(key, handleEventListener(key)) - - metadataController - .findMetadata(key) - .then((metadataEvent) => { - if (metadataEvent) handleMetadataEvent(key)(metadataEvent) - }) - .catch((err) => { - console.error(`error occurred in finding metadata for: ${key}`, err) - }) - } - }) - - return () => { - hexKeys.forEach((key) => { - metadataController.off(key, handleEventListener(key)) - }) - } - }, [submittedBy, signers, profiles]) + const profiles = useSigitProfiles([ + ...(submittedBy ? [submittedBy] : []), + ...signers + ]) return (
@@ -130,7 +80,7 @@ export const DisplaySigit = ({ meta, parsedMeta }: SigitProps) => { {submittedBy && signers.length ? ( ) : null} - + {signers.map((signer) => { const pubkey = npubToHex(signer)! const profile = profiles[pubkey] @@ -147,7 +97,7 @@ export const DisplaySigit = ({ meta, parsedMeta }: SigitProps) => { > diff --git a/src/components/DisplaySigit/style.module.scss b/src/components/DisplaySigit/style.module.scss index 7544fc4..4bb2f15 100644 --- a/src/components/DisplaySigit/style.module.scss +++ b/src/components/DisplaySigit/style.module.scss @@ -93,26 +93,6 @@ grid-gap: 10px; } -.signers { - padding: 0 0 0 10px; - - > * { - transition: margin ease 0.2s; - margin: 0 0 0 -10px; - position: relative; - z-index: 1; - &:first-child { - margin-left: -10px !important; - } - } - - > *:hover, - > *:focus-within { - margin: 0 15px 0 5px; - z-index: 2; - } -} - .details { color: rgba(0, 0, 0, 0.3); font-size: 14px; diff --git a/src/components/DisplaySigner/index.tsx b/src/components/DisplaySigner/index.tsx index dc4b9ce..63aa154 100644 --- a/src/components/DisplaySigner/index.tsx +++ b/src/components/DisplaySigner/index.tsx @@ -1,58 +1,51 @@ import { Badge } from '@mui/material' -import { Event, verifyEvent } from 'nostr-tools' -import { useState, useEffect } from 'react' -import { Meta, ProfileMetadata } from '../../types' -import { hexToNpub, parseJson } from '../../utils' +import { ProfileMetadata } from '../../types' import styles from './style.module.scss' import { UserAvatar } from '../UserAvatar' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faCheck, faExclamation } from '@fortawesome/free-solid-svg-icons' - -enum SignStatus { - Signed = 'Signed', - Pending = 'Pending', - Invalid = 'Invalid Sign' -} +import { + faCheck, + faEllipsis, + faExclamation, + faEye, + faHourglass, + faQuestion +} from '@fortawesome/free-solid-svg-icons' +import { SignStatus } from '../../utils' +import { Spinner } from '../Spinner' type DisplaySignerProps = { - meta: Meta profile: ProfileMetadata pubkey: string + status: SignStatus } export const DisplaySigner = ({ - meta, + status, profile, pubkey }: DisplaySignerProps) => { - const [signStatus, setSignedStatus] = useState() + const getStatusIcon = (status: SignStatus) => { + switch (status) { + case SignStatus.Signed: + return + case SignStatus.Awaiting: + return ( + + + + ) + case SignStatus.Pending: + return + case SignStatus.Invalid: + return + case SignStatus.Viewer: + return - useEffect(() => { - if (!meta) return - - const updateSignStatus = async () => { - const npub = hexToNpub(pubkey) - if (npub in meta.docSignatures) { - parseJson(meta.docSignatures[npub]) - .then((event) => { - const isValidSignature = verifyEvent(event) - if (isValidSignature) { - setSignedStatus(SignStatus.Signed) - } else { - setSignedStatus(SignStatus.Invalid) - } - }) - .catch((err) => { - console.log(`err in parsing the docSignatures for ${npub}:>> `, err) - setSignedStatus(SignStatus.Invalid) - }) - } else { - setSignedStatus(SignStatus.Pending) - } + default: + return } - - updateSignStatus() - }, [meta, pubkey]) + } return ( - {signStatus === SignStatus.Signed && ( - - )} - {signStatus === SignStatus.Invalid && ( - - )} -
- ) +
{getStatusIcon(status)}
} > diff --git a/src/components/DrawPDFFields/index.tsx b/src/components/DrawPDFFields/index.tsx index e98187c..5318361 100644 --- a/src/components/DrawPDFFields/index.tsx +++ b/src/components/DrawPDFFields/index.tsx @@ -49,7 +49,7 @@ interface Props { } export const DrawPDFFields = (props: Props) => { - const { selectedFiles } = props + const { selectedFiles, onDrawFieldsChange, users } = props const [pdfFiles, setPdfFiles] = useState([]) const [parsingPdf, setParsingPdf] = useState(false) @@ -94,6 +94,15 @@ export const DrawPDFFields = (props: Props) => { }) useEffect(() => { + /** + * Reads the pdf binary files and converts it's pages to images + * creates the pdfFiles object and sets to a state + */ + const parsePdfPages = async () => { + const pdfFiles: PdfFile[] = await toPdfFiles(selectedFiles) + + setPdfFiles(pdfFiles) + } if (selectedFiles) { setParsingPdf(true) @@ -104,8 +113,8 @@ export const DrawPDFFields = (props: Props) => { }, [selectedFiles]) useEffect(() => { - if (pdfFiles) props.onDrawFieldsChange(pdfFiles) - }, [pdfFiles]) + if (pdfFiles) onDrawFieldsChange(pdfFiles) + }, [onDrawFieldsChange, pdfFiles]) /** * Drawing events @@ -132,12 +141,16 @@ export const DrawPDFFields = (props: Props) => { * @param event Mouse event * @param page PdfPage where press happened */ - const onMouseDown = (event: any, page: PdfPage) => { + const onMouseDown = ( + event: React.MouseEvent, + page: PdfPage + ) => { // Proceed only if left click if (event.button !== 0) return // Only allow drawing if mouse is not over other drawn element - const isOverPdfImageWrapper = event.target.tagName === 'IMG' + const target = event.target as HTMLElement + const isOverPdfImageWrapper = target.tagName === 'IMG' if (!selectedTool || !isOverPdfImageWrapper) { return @@ -185,7 +198,10 @@ export const DrawPDFFields = (props: Props) => { * @param event Mouse event * @param page PdfPage where moving is happening */ - const onMouseMove = (event: any, page: PdfPage) => { + const onMouseMove = ( + event: React.MouseEvent, + page: PdfPage + ) => { if (mouseState.clicked && selectedTool) { const lastElementIndex = page.drawnFields.length - 1 const lastDrawnField = page.drawnFields[lastElementIndex] @@ -216,7 +232,7 @@ export const DrawPDFFields = (props: Props) => { * @param event Mouse event * @param drawnField Which we are moving */ - const onDrawnFieldMouseDown = (event: any) => { + const onDrawnFieldMouseDown = (event: React.MouseEvent) => { event.stopPropagation() // Proceed only if left click @@ -239,11 +255,15 @@ export const DrawPDFFields = (props: Props) => { * @param event Mouse event * @param drawnField which we are moving */ - const onDranwFieldMouseMove = (event: any, drawnField: DrawnField) => { + const onDranwFieldMouseMove = ( + event: React.MouseEvent, + drawnField: DrawnField + ) => { + const target = event.target as HTMLElement | null if (mouseState.dragging) { const { mouseX, mouseY, rect } = getMouseCoordinates( event, - event.target.parentNode + target?.parentNode as HTMLElement ) const coordsOffset = mouseState.coordsInWrapper @@ -272,7 +292,7 @@ export const DrawPDFFields = (props: Props) => { * @param event Mouse event * @param drawnField which we are resizing */ - const onResizeHandleMouseDown = (event: any) => { + const onResizeHandleMouseDown = (event: React.MouseEvent) => { // Proceed only if left click if (event.button !== 0) return @@ -288,11 +308,15 @@ export const DrawPDFFields = (props: Props) => { * @param event Mouse event * @param drawnField which we are resizing */ - const onResizeHandleMouseMove = (event: any, drawnField: DrawnField) => { + const onResizeHandleMouseMove = ( + event: React.MouseEvent, + drawnField: DrawnField + ) => { + const target = event.target as HTMLElement | null if (mouseState.resizing) { const { mouseX, mouseY } = getMouseCoordinates( event, - event.target.parentNode.parentNode + target?.parentNode?.parentNode as HTMLElement ) const width = mouseX - drawnField.left @@ -313,7 +337,7 @@ export const DrawPDFFields = (props: Props) => { * @param drawnFileIndex drawn file index */ const onRemoveHandleMouseDown = ( - event: any, + event: React.MouseEvent, pdfFileIndex: number, pdfPageIndex: number, drawnFileIndex: number @@ -331,7 +355,9 @@ export const DrawPDFFields = (props: Props) => { * so select can work properly * @param event Mouse event */ - const onUserSelectHandleMouseDown = (event: any) => { + const onUserSelectHandleMouseDown = ( + event: React.MouseEvent + ) => { event.stopPropagation() } @@ -341,8 +367,11 @@ export const DrawPDFFields = (props: Props) => { * @param customTarget mouse coordinates relative to this element, if not provided * event.target will be used */ - const getMouseCoordinates = (event: any, customTarget?: any) => { - const target = customTarget ? customTarget : event.target + const getMouseCoordinates = ( + event: React.MouseEvent, + customTarget?: HTMLElement + ) => { + const target = (customTarget ? customTarget : event.target) as HTMLElement const rect = target.getBoundingClientRect() const mouseX = event.clientX - rect.left //x position within the element. const mouseY = event.clientY - rect.top //y position within the element. @@ -354,16 +383,6 @@ export const DrawPDFFields = (props: Props) => { } } - /** - * Reads the pdf binary files and converts it's pages to images - * creates the pdfFiles object and sets to a state - */ - const parsePdfPages = async () => { - const pdfFiles: PdfFile[] = await toPdfFiles(selectedFiles) - - setPdfFiles(pdfFiles) - } - /** * * @returns if expanded pdf accordion is present @@ -477,7 +496,7 @@ export const DrawPDFFields = (props: Props) => { labelId="counterparts" label="Counterparts" > - {props.users.map((user, index) => { + {users.map((user, index) => { let displayValue = truncate( hexToNpub(user.pubkey), { diff --git a/src/components/DrawPDFFields/style.module.scss b/src/components/DrawPDFFields/style.module.scss index 08554b2..7490d1f 100644 --- a/src/components/DrawPDFFields/style.module.scss +++ b/src/components/DrawPDFFields/style.module.scss @@ -51,6 +51,14 @@ position: relative; -webkit-user-select: none; user-select: none; + margin-bottom: 10px; + + > img { + display: block; + max-width: 100%; + max-height: 100%; + object-fit: contain; /* Ensure the image fits within the container */ + } &.drawing { cursor: crosshair; @@ -72,6 +80,10 @@ visibility: hidden; } + &.edited { + border: 1px dotted #01aaad + } + .resizeHandle { position: absolute; right: -5px; diff --git a/src/components/FileList/index.tsx b/src/components/FileList/index.tsx new file mode 100644 index 0000000..7cd30eb --- /dev/null +++ b/src/components/FileList/index.tsx @@ -0,0 +1,50 @@ +import { CurrentUserFile } from '../../types/file.ts' +import styles from './style.module.scss' +import { Button } from '@mui/material' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faCheck } from '@fortawesome/free-solid-svg-icons' + +interface FileListProps { + files: CurrentUserFile[] + currentFile: CurrentUserFile + setCurrentFile: (file: CurrentUserFile) => void + handleDownload: () => void +} + +const FileList = ({ + files, + currentFile, + setCurrentFile, + handleDownload +}: FileListProps) => { + const isActive = (file: CurrentUserFile) => file.id === currentFile.id + return ( +
+
+
    + {files.map((file: CurrentUserFile) => ( +
  • setCurrentFile(file)} + > +
    {file.id}
    +
    +
    {file.filename}
    +
    + +
    + {file.isHashValid && } +
    +
  • + ))} +
+
+ +
+ ) +} + +export default FileList diff --git a/src/components/FileList/style.module.scss b/src/components/FileList/style.module.scss new file mode 100644 index 0000000..6f7b64a --- /dev/null +++ b/src/components/FileList/style.module.scss @@ -0,0 +1,123 @@ +.container { + border-radius: 4px; + background: white; + padding: 15px; + display: flex; + flex-direction: column; + grid-gap: 0px; +} + +.filesPageContainer { + width: 100%; + display: grid; + grid-template-columns: 0.75fr 1.5fr 0.75fr; + grid-gap: 30px; + flex-grow: 1; +} + +ul { + list-style-type: none; /* Removes bullet points */ + margin: 0; /* Removes default margin */ + padding: 0; /* Removes default padding */ +} + +li { + list-style-type: none; /* Removes the bullets */ + margin: 0; /* Removes any default margin */ + padding: 0; /* Removes any default padding */ +} + + +.wrap { + display: flex; + flex-direction: column; + grid-gap: 15px; +} + +.files { + display: flex; + flex-direction: column; + width: 100%; + grid-gap: 15px; + max-height: 350px; + overflow: auto; + padding: 0 5px 0 0; + margin: 0 -5px 0 0; +} + +.files::-webkit-scrollbar { + width: 10px; +} + +.files::-webkit-scrollbar-track { + background-color: rgba(0,0,0,0.15); +} + +.files::-webkit-scrollbar-thumb { + max-width: 10px; + border-radius: 2px; + background-color: #4c82a3; + cursor: grab; +} + +.fileItem { + transition: ease 0.2s; + display: flex; + flex-direction: row; + grid-gap: 10px; + border-radius: 4px; + overflow: hidden; + background: #ffffff; + padding: 5px 10px; + align-items: center; + color: rgba(0,0,0,0.5); + cursor: pointer; + flex-grow: 1; + font-size: 16px; + font-weight: 500; + + + &.active { + background: #4c82a3; + color: white; + } +} + +.fileItem:hover { + transition: ease 0.2s; + background: #4c82a3; + color: white; +} + +.fileInfo { + flex-grow: 1; + font-size: 16px; + font-weight: 500; +} + +.fileName { + display: -webkit-box; + -webkit-box-orient: vertical; + overflow: hidden; + -webkit-line-clamp: 1; +} + +.fileNumber { + font-size: 14px; + font-weight: 500; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + width: 10px; +} + +.fileVisual { + display: flex; + flex-shrink: 0; + flex-direction: column; + justify-content: center; + align-items: center; + height: 25px; + width: 25px; +} \ No newline at end of file diff --git a/src/components/MarkFormField/index.tsx b/src/components/MarkFormField/index.tsx new file mode 100644 index 0000000..e1003a0 --- /dev/null +++ b/src/components/MarkFormField/index.tsx @@ -0,0 +1,126 @@ +import { CurrentUserMark } from '../../types/mark.ts' +import styles from './style.module.scss' + +import { MARK_TYPE_TRANSLATION, NEXT, SIGN } from '../../utils/const.ts' +import { + findNextIncompleteCurrentUserMark, + isCurrentUserMarksComplete, + isCurrentValueLast +} from '../../utils' +import React, { useState } from 'react' + +interface MarkFormFieldProps { + currentUserMarks: CurrentUserMark[] + handleCurrentUserMarkChange: (mark: CurrentUserMark) => void + handleSelectedMarkValueChange: ( + event: React.ChangeEvent + ) => void + handleSubmit: (event: React.FormEvent) => void + selectedMark: CurrentUserMark + selectedMarkValue: string +} + +/** + * Responsible for rendering a form field connected to a mark and keeping track of its value. + */ +const MarkFormField = ({ + handleSubmit, + handleSelectedMarkValueChange, + selectedMark, + selectedMarkValue, + currentUserMarks, + handleCurrentUserMarkChange +}: MarkFormFieldProps) => { + const [displayActions, setDisplayActions] = useState(true) + const getSubmitButtonText = () => (isReadyToSign() ? SIGN : NEXT) + const isReadyToSign = () => + isCurrentUserMarksComplete(currentUserMarks) || + isCurrentValueLast(currentUserMarks, selectedMark, selectedMarkValue) + const isCurrent = (currentMark: CurrentUserMark) => + currentMark.id === selectedMark.id + const isDone = (currentMark: CurrentUserMark) => + isCurrent(currentMark) ? !!selectedMarkValue : currentMark.isCompleted + const findNext = () => { + return ( + currentUserMarks[selectedMark.id] || + findNextIncompleteCurrentUserMark(currentUserMarks) + ) + } + const handleFormSubmit = (event: React.FormEvent) => { + event.preventDefault() + console.log('handle form submit runs...') + return isReadyToSign() + ? handleSubmit(event) + : handleCurrentUserMarkChange(findNext()!) + } + const toggleActions = () => setDisplayActions(!displayActions) + return ( +
+
+ +
+
+
+
+
+

Add your signature

+
+
+
+
handleFormSubmit(e)}> + +
+ +
+
+
+
+ {currentUserMarks.map((mark, index) => { + return ( +
+ + {isCurrent(mark) && ( +
+ )} +
+ ) + })} +
+
+
+
+
+
+ ) +} + +export default MarkFormField diff --git a/src/components/MarkFormField/style.module.scss b/src/components/MarkFormField/style.module.scss new file mode 100644 index 0000000..b5c6bb9 --- /dev/null +++ b/src/components/MarkFormField/style.module.scss @@ -0,0 +1,210 @@ +.container { + width: 100%; + display: flex; + flex-direction: column; + position: fixed; + bottom: 0; + right: 0; + left: 0; + align-items: center; + z-index: 1000; +} + +.actions { + background: white; + width: 100%; + border-radius: 4px; + padding: 10px 20px; + display: none; + flex-direction: column; + align-items: center; + grid-gap: 15px; + box-shadow: 0 -2px 4px 0 rgb(0,0,0,0.1); + max-width: 750px; + + &.expanded { + display: flex; + } +} + +.actionsWrapper { + display: flex; + flex-direction: column; + grid-gap: 20px; + flex-grow: 1; + width: 100%; +} + +.actionsTop { + display: flex; + flex-direction: row; + grid-gap: 10px; + align-items: center; +} + +.actionsTopInfo { + flex-grow: 1; +} + +.actionsTopInfoText { + font-size: 16px; + color: #434343; +} + +.actionsTrigger { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + position: relative; +} + +.actionButtons { + display: flex; + flex-direction: row; + grid-gap: 5px; +} + +.inputWrapper { + display: flex; + flex-direction: column; + grid-gap: 10px; +} + +.textInput { + height: 100px; + background: rgba(0,0,0,0.1); + border-radius: 4px; + border: solid 2px #4c82a3; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} + +.input { + border-radius: 4px; + border: solid 1px rgba(0,0,0,0.15); + padding: 5px 10px; + font-size: 16px; + width: 100%; + background: linear-gradient(rgba(0,0,0,0.00), rgba(0,0,0,0.00) 100%), linear-gradient(white, white); +} + +.input:focus { + border: solid 1px rgba(0,0,0,0.15); + outline: none; + background: linear-gradient(rgba(0,0,0,0.05), rgba(0,0,0,0.05) 100%), linear-gradient(white, white); +} + +.actionsBottom { + display: flex; + flex-direction: row; + grid-gap: 5px; + justify-content: center; + align-items: center; +} + +button { + transition: ease 0.2s; + width: auto; + border-radius: 4px; + outline: unset; + border: unset; + background: unset; + color: #ffffff; + background: #4c82a3; + font-weight: 500; + font-size: 14px; + padding: 8px 15px; + white-space: nowrap; + display: flex; + flex-direction: row; + grid-gap: 12px; + justify-content: center; + align-items: center; + text-decoration: unset; + position: relative; + cursor: pointer; +} + +button:hover { + transition: ease 0.2s; + background: #5e8eab; + color: white; +} + +button:active { + transition: ease 0.2s; + background: #447592; + color: white; +} + +.submitButton { + width: 100%; + max-width: 300px; + margin-top: 10px; +} + +.footerContainer { + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; +} + +.footer { + display: flex; + flex-direction: row; + grid-gap: 5px; + align-items: start; + justify-content: center; + width: 100%; +} + +.pagination { + display: flex; + flex-direction: column; + grid-gap: 5px; +} + +.paginationButton { + font-size: 12px; + padding: 5px 10px; + border-radius: 3px; + background: rgba(0,0,0,0.1); + color: rgba(0,0,0,0.5); +} + +.paginationButton:hover { + background: #447592; + color: rgba(255,255,255,0.5); +} + +.paginationButtonDone { + background: #5e8eab; + color: rgb(255,255,255); +} + +.paginationButtonCurrent { + height: 2px; + width: 100%; + background: #4c82a3; +} + +.trigger { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + position: relative; +} + +.triggerBtn { + background: white; + color: #434343; + padding: 5px 30px; + box-shadow: 0px -3px 4px 0 rgb(0,0,0,0.1); + position: absolute; + top: -25px; +} diff --git a/src/components/PDFView/PdfMarkItem.tsx b/src/components/PDFView/PdfMarkItem.tsx index 90caa87..d93c2b2 100644 --- a/src/components/PDFView/PdfMarkItem.tsx +++ b/src/components/PDFView/PdfMarkItem.tsx @@ -20,14 +20,13 @@ const PdfMarkItem = ({ }: PdfMarkItemProps) => { const { location } = userMark.mark const handleClick = () => handleMarkClick(userMark.mark.id) + const isEdited = () => selectedMark?.mark.id === userMark.mark.id const getMarkValue = () => - selectedMark?.mark.id === userMark.mark.id - ? selectedMarkValue - : userMark.mark.value + isEdited() ? selectedMarkValue : userMark.currentValue return (
void setCurrentUserMarks: (currentUserMarks: CurrentUserMark[]) => void setUpdatedMarks: (markToUpdate: Mark) => void + handleDownload: () => void + meta: Meta | null } /** @@ -32,14 +39,28 @@ const PdfMarking = (props: PdfMarkingProps) => { currentUserMarks, setIsReadyToSign, setCurrentUserMarks, - setUpdatedMarks + setUpdatedMarks, + handleDownload, + meta } = props const [selectedMark, setSelectedMark] = useState(null) const [selectedMarkValue, setSelectedMarkValue] = useState('') + const [currentFile, setCurrentFile] = useState(null) useEffect(() => { - setSelectedMark(findNextCurrentUserMark(currentUserMarks) || null) - }, [currentUserMarks]) + if (selectedMark === null && currentUserMarks.length > 0) { + setSelectedMark( + findNextIncompleteCurrentUserMark(currentUserMarks) || + currentUserMarks[0] + ) + } + }, [currentUserMarks, selectedMark]) + + useEffect(() => { + if (currentFile === null && files.length > 0) { + setCurrentFile(files[0]) + } + }, [files, currentFile]) const handleMarkClick = (id: number) => { const nextMark = currentUserMarks.find((mark) => mark.mark.id === id) @@ -47,18 +68,30 @@ const PdfMarking = (props: PdfMarkingProps) => { setSelectedMarkValue(nextMark?.mark.value ?? EMPTY) } + const handleCurrentUserMarkChange = (mark: CurrentUserMark) => { + if (!selectedMark) return + const updatedSelectedMark: CurrentUserMark = getUpdatedMark( + selectedMark, + selectedMarkValue + ) + + const updatedCurrentUserMarks = updateCurrentUserMarks( + currentUserMarks, + updatedSelectedMark + ) + setCurrentUserMarks(updatedCurrentUserMarks) + setSelectedMarkValue(mark.currentValue ?? EMPTY) + setSelectedMark(mark) + } + const handleSubmit = (event: React.FormEvent) => { event.preventDefault() if (!selectedMarkValue || !selectedMark) return - const updatedMark: CurrentUserMark = { - ...selectedMark, - mark: { - ...selectedMark.mark, - value: selectedMarkValue - }, - isCompleted: true - } + const updatedMark: CurrentUserMark = getUpdatedMark( + selectedMark, + selectedMarkValue + ) setSelectedMarkValue(EMPTY) const updatedCurrentUserMarks = updateCurrentUserMarks( @@ -66,33 +99,62 @@ const PdfMarking = (props: PdfMarkingProps) => { updatedMark ) setCurrentUserMarks(updatedCurrentUserMarks) - setSelectedMark(findNextCurrentUserMark(updatedCurrentUserMarks) || null) - console.log(isCurrentUserMarksComplete(updatedCurrentUserMarks)) - setIsReadyToSign(isCurrentUserMarksComplete(updatedCurrentUserMarks)) + setSelectedMark(null) + setIsReadyToSign(true) setUpdatedMarks(updatedMark.mark) } + // const updateCurrentUserMarkValues = () => { + // const updatedMark: CurrentUserMark = getUpdatedMark(selectedMark!, selectedMarkValue) + // const updatedCurrentUserMarks = updateCurrentUserMarks(currentUserMarks, updatedMark) + // setSelectedMarkValue(EMPTY) + // setCurrentUserMarks(updatedCurrentUserMarks) + // } + const handleChange = (event: React.ChangeEvent) => setSelectedMarkValue(event.target.value) return ( <> - - {currentUserMarks?.length > 0 && ( - - )} + + + {currentFile !== null && ( + + )} +
+ } + right={meta !== null && } + > +
+ {currentUserMarks?.length > 0 && ( +
+ +
+ )} +
+ {selectedMark !== null && ( )} diff --git a/src/components/PDFView/PdfPageItem.tsx b/src/components/PDFView/PdfPageItem.tsx index 241474e..a670f2f 100644 --- a/src/components/PDFView/PdfPageItem.tsx +++ b/src/components/PDFView/PdfPageItem.tsx @@ -2,6 +2,7 @@ import styles from '../DrawPDFFields/style.module.scss' import { PdfPage } from '../../types/drawing.ts' import { CurrentUserMark } from '../../types/mark.ts' import PdfMarkItem from './PdfMarkItem.tsx' +import { useEffect, useRef } from 'react' interface PdfPageProps { page: PdfPage currentUserMarks: CurrentUserMark[] @@ -20,24 +21,33 @@ const PdfPageItem = ({ selectedMarkValue, selectedMark }: PdfPageProps) => { + useEffect(() => { + if (selectedMark !== null && !!markRefs.current[selectedMark.id]) { + markRefs.current[selectedMark.id]?.scrollIntoView({ + behavior: 'smooth', + block: 'end' + }) + } + }, [selectedMark]) + const markRefs = useRef<(HTMLDivElement | null)[]>([]) return (
{currentUserMarks.map((m, i) => ( - +
(markRefs.current[m.id] = el)}> + +
))}
) diff --git a/src/components/PDFView/index.tsx b/src/components/PDFView/index.tsx index 8a14e55..d67d372 100644 --- a/src/components/PDFView/index.tsx +++ b/src/components/PDFView/index.tsx @@ -1,14 +1,16 @@ -import { PdfFile } from '../../types/drawing.ts' -import { Box } from '@mui/material' +import { Divider } from '@mui/material' import PdfItem from './PdfItem.tsx' import { CurrentUserMark } from '../../types/mark.ts' +import { CurrentUserFile } from '../../types/file.ts' +import { useEffect, useRef } from 'react' interface PdfViewProps { - files: { pdfFile: PdfFile; filename: string; hash: string | null }[] + files: CurrentUserFile[] currentUserMarks: CurrentUserMark[] handleMarkClick: (id: number) => void selectedMarkValue: string selectedMark: CurrentUserMark | null + currentFile: CurrentUserFile | null } /** @@ -19,8 +21,18 @@ const PdfView = ({ currentUserMarks, handleMarkClick, selectedMarkValue, - selectedMark + selectedMark, + currentFile }: PdfViewProps) => { + const pdfRefs = useRef<(HTMLDivElement | null)[]>([]) + useEffect(() => { + if (currentFile !== null && !!pdfRefs.current[currentFile.id]) { + pdfRefs.current[currentFile.id]?.scrollIntoView({ + behavior: 'smooth', + block: 'end' + }) + } + }, [currentFile]) const filterByFile = ( currentUserMarks: CurrentUserMark[], hash: string @@ -29,22 +41,31 @@ const PdfView = ({ (currentUserMark) => currentUserMark.mark.pdfFileHash === hash ) } + const isNotLastPdfFile = (index: number, files: CurrentUserFile[]): boolean => + index !== files.length - 1 return ( - - {files.map(({ pdfFile, hash }, i) => { + <> + {files.map((currentUserFile, index, arr) => { + const { hash, pdfFile, id } = currentUserFile if (!hash) return return ( - +
(pdfRefs.current[id] = el)} + key={index} + > + + {isNotLastPdfFile(index, arr) && File Separator} +
) })} -
+ ) } diff --git a/src/components/PDFView/style.module.scss b/src/components/PDFView/style.module.scss index 2e6e519..5029747 100644 --- a/src/components/PDFView/style.module.scss +++ b/src/components/PDFView/style.module.scss @@ -13,4 +13,19 @@ max-width: 100%; max-height: 100%; object-fit: contain; /* Ensure the image fits within the container */ -} \ No newline at end of file +} + +.container { + display: flex; + width: 100%; + flex-direction: column; + +} + +.pdfView { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + gap: 10px; +} diff --git a/src/components/Spinner/index.tsx b/src/components/Spinner/index.tsx new file mode 100644 index 0000000..cbc6b43 --- /dev/null +++ b/src/components/Spinner/index.tsx @@ -0,0 +1,6 @@ +import { PropsWithChildren } from 'react' +import styles from './style.module.scss' + +export const Spinner = ({ children }: PropsWithChildren) => ( +
{children}
+) diff --git a/src/components/Spinner/style.module.scss b/src/components/Spinner/style.module.scss new file mode 100644 index 0000000..60158f4 --- /dev/null +++ b/src/components/Spinner/style.module.scss @@ -0,0 +1,12 @@ +.spin { + animation: spin 5s linear infinite; +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} diff --git a/src/components/UserAvatarGroup/index.tsx b/src/components/UserAvatarGroup/index.tsx index 13f8b25..f8e231f 100644 --- a/src/components/UserAvatarGroup/index.tsx +++ b/src/components/UserAvatarGroup/index.tsx @@ -28,7 +28,7 @@ export const UserAvatarGroup = ({ const childrenArray = Children.toArray(children) return ( -
+
{surplus > 1 ? childrenArray.slice(0, surplus * -1).map((c) => c) : children} diff --git a/src/components/UserAvatarGroup/style.module.scss b/src/components/UserAvatarGroup/style.module.scss index 9604202..c9ee551 100644 --- a/src/components/UserAvatarGroup/style.module.scss +++ b/src/components/UserAvatarGroup/style.module.scss @@ -1,5 +1,25 @@ @import '../../styles/colors.scss'; +.container { + padding: 0 0 0 10px; + + > * { + transition: margin ease 0.2s; + margin: 0 0 0 -10px; + position: relative; + z-index: 1; + &:first-child { + margin-left: -10px !important; + } + } + + > *:hover, + > *:focus-within { + margin: 0 15px 0 5px; + z-index: 2; + } +} + .icon { width: 40px; height: 40px; diff --git a/src/components/UsersDetails.tsx/index.tsx b/src/components/UsersDetails.tsx/index.tsx new file mode 100644 index 0000000..fc7d43d --- /dev/null +++ b/src/components/UsersDetails.tsx/index.tsx @@ -0,0 +1,217 @@ +import { Divider, Tooltip } from '@mui/material' +import { useSigitProfiles } from '../../hooks/useSigitProfiles' +import { + extractFileExtensions, + formatTimestamp, + fromUnixTimestamp, + hexToNpub, + npubToHex, + shorten, + SignStatus +} from '../../utils' +import { UserAvatar } from '../UserAvatar' +import { useSigitMeta } from '../../hooks/useSigitMeta' +import { UserAvatarGroup } from '../UserAvatarGroup' + +import styles from './style.module.scss' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { + faCalendar, + faCalendarCheck, + faCalendarPlus, + faEye, + faFile, + faFileCircleExclamation +} from '@fortawesome/free-solid-svg-icons' +import { getExtensionIconLabel } from '../getExtensionIconLabel' +import { useSelector } from 'react-redux' +import { State } from '../../store/rootReducer' +import { TooltipChild } from '../TooltipChild' +import { DisplaySigner } from '../DisplaySigner' +import { Meta } from '../../types' + +interface UsersDetailsProps { + meta: Meta +} + +export const UsersDetails = ({ meta }: UsersDetailsProps) => { + const { + submittedBy, + signers, + viewers, + fileHashes, + signersStatus, + createdAt, + completedAt, + parsedSignatureEvents, + signedStatus + } = useSigitMeta(meta) + const { usersPubkey } = useSelector((state: State) => state.auth) + const profiles = useSigitProfiles([ + ...(submittedBy ? [submittedBy] : []), + ...signers, + ...viewers + ]) + const userCanSign = + typeof usersPubkey !== 'undefined' && + signers.includes(hexToNpub(usersPubkey)) + + const ext = extractFileExtensions(Object.keys(fileHashes)) + + return submittedBy ? ( +
+
+

Signers

+
+ {submittedBy && + (function () { + const profile = profiles[submittedBy] + return ( + + + + + + ) + })()} + + {submittedBy && signers.length ? ( + + ) : null} + + + {signers.map((signer) => { + const pubkey = npubToHex(signer)! + const profile = profiles[pubkey] + + return ( + + + + + + ) + })} + {viewers.map((signer) => { + const pubkey = npubToHex(signer)! + const profile = profiles[pubkey] + + return ( + + + + + + ) + })} + +
+
+
+

Details

+ + + + {' '} + {createdAt ? formatTimestamp(createdAt) : <>—} + + + + + + {' '} + {completedAt ? formatTimestamp(completedAt) : <>—} + + + + {/* User signed date */} + {userCanSign ? ( + + + {' '} + {hexToNpub(usersPubkey) in parsedSignatureEvents ? ( + parsedSignatureEvents[hexToNpub(usersPubkey)].created_at ? ( + formatTimestamp( + fromUnixTimestamp( + parsedSignatureEvents[hexToNpub(usersPubkey)].created_at + ) + ) + ) : ( + <>— + ) + ) : ( + <>— + )} + + + ) : null} + + {signedStatus} + + {ext.length > 0 ? ( + + {ext.length > 1 ? ( + <> + Multiple File Types + + ) : ( + getExtensionIconLabel(ext[0]) + )} + + ) : ( + <> + — + + )} +
+
+ ) : undefined +} diff --git a/src/components/UsersDetails.tsx/style.module.scss b/src/components/UsersDetails.tsx/style.module.scss new file mode 100644 index 0000000..9d906c1 --- /dev/null +++ b/src/components/UsersDetails.tsx/style.module.scss @@ -0,0 +1,46 @@ +@import '../../styles/colors.scss'; + +.container { + border-radius: 4px; + background: $overlay-background-color; + padding: 15px; + display: flex; + flex-direction: column; + grid-gap: 25px; + + font-size: 14px; +} + +.section { + display: flex; + flex-direction: column; + grid-gap: 10px; +} + +.users { + display: flex; + grid-gap: 10px; +} + +.detailsItem { + transition: ease 0.2s; + color: rgba(0, 0, 0, 0.5); + font-size: 14px; + align-items: center; + border-radius: 4px; + padding: 5px; + + display: flex; + align-items: center; + justify-content: start; + + > :first-child { + padding: 5px; + margin-right: 10px; + } + + &:hover { + background: $primary-main; + color: white; + } +} diff --git a/src/hooks/useSigitMeta.tsx b/src/hooks/useSigitMeta.tsx index aebd791..a393824 100644 --- a/src/hooks/useSigitMeta.tsx +++ b/src/hooks/useSigitMeta.tsx @@ -1,9 +1,10 @@ import { useEffect, useState } from 'react' -import { CreateSignatureEventContent, Meta } from '../types' +import { CreateSignatureEventContent, Meta, SignedEventContent } from '../types' import { Mark } from '../types/mark' import { fromUnixTimestamp, - parseCreateSignatureEvent, + hexToNpub, + parseNostrEvent, parseCreateSignatureEventContent, SigitMetaParseError, SigitStatus, @@ -12,11 +13,36 @@ import { import { toast } from 'react-toastify' import { verifyEvent } from 'nostr-tools' import { Event } from 'nostr-tools' +import store from '../store/store' +import { AuthState } from '../store/auth/types' +import { NostrController } from '../controllers' + +/** + * Flattened interface that combines properties `Meta`, `CreateSignatureEventContent`, + * and `Event` (event's fields are made optional and pubkey and created_at replaced with our versions) + */ +export interface FlatMeta + extends Meta, + CreateSignatureEventContent, + Partial> { + // Remove pubkey and use submittedBy as `npub1${string}` + submittedBy?: `npub1${string}` + + // Remove created_at and replace with createdAt + createdAt?: number -interface FlatMeta extends Meta, CreateSignatureEventContent, Partial { // Validated create signature event isValid: boolean + // Decryption + encryptionKey: string | null + + // Parsed Document Signatures + parsedSignatureEvents: { [signer: `npub1${string}`]: Event } + + // Calculated completion time + completedAt?: number + // Calculated status fields signedStatus: SigitStatus signersStatus: { @@ -33,8 +59,8 @@ export const useSigitMeta = (meta: Meta): FlatMeta => { const [isValid, setIsValid] = useState(false) const [kind, setKind] = useState() const [tags, setTags] = useState() - const [created_at, setCreatedAt] = useState() - const [pubkey, setPubkey] = useState() // submittedBy, pubkey from nostr event + const [createdAt, setCreatedAt] = useState() + const [submittedBy, setSubmittedBy] = useState<`npub1${string}`>() // submittedBy, pubkey from nostr event const [id, setId] = useState() const [sig, setSig] = useState() @@ -47,6 +73,12 @@ export const useSigitMeta = (meta: Meta): FlatMeta => { const [title, setTitle] = useState('') const [zipUrl, setZipUrl] = useState('') + const [parsedSignatureEvents, setParsedSignatureEvents] = useState<{ + [signer: `npub1${string}`]: Event + }>({}) + + const [completedAt, setCompletedAt] = useState() + const [signedStatus, setSignedStatus] = useState( SigitStatus.Partial ) @@ -54,13 +86,13 @@ export const useSigitMeta = (meta: Meta): FlatMeta => { [signer: `npub1${string}`]: SignStatus }>({}) + const [encryptionKey, setEncryptionKey] = useState(null) + useEffect(() => { if (!meta) return ;(async function () { try { - const createSignatureEvent = await parseCreateSignatureEvent( - meta.createSignature - ) + const createSignatureEvent = await parseNostrEvent(meta.createSignature) const { kind, tags, created_at, pubkey, id, sig, content } = createSignatureEvent @@ -70,7 +102,7 @@ export const useSigitMeta = (meta: Meta): FlatMeta => { setTags(tags) // created_at in nostr events are stored in seconds setCreatedAt(fromUnixTimestamp(created_at)) - setPubkey(pubkey) + setSubmittedBy(pubkey as `npub1${string}`) setId(id) setSig(sig) @@ -84,30 +116,102 @@ export const useSigitMeta = (meta: Meta): FlatMeta => { setMarkConfig(markConfig) setZipUrl(zipUrl) - // Parse each signature event and set signer status - for (const npub in meta.docSignatures) { - try { - const event = await parseCreateSignatureEvent( - meta.docSignatures[npub as `npub1${string}`] - ) - const isValidSignature = verifyEvent(event) - setSignersStatus((prev) => { - return { - ...prev, - [npub]: isValidSignature - ? SignStatus.Signed - : SignStatus.Invalid - } - }) - } catch (error) { - setSignersStatus((prev) => { - return { - ...prev, - [npub]: SignStatus.Invalid - } - }) + if (meta.keys) { + const { sender, keys } = meta.keys + // Retrieve the user's public key from the state + const usersPubkey = (store.getState().auth as AuthState).usersPubkey! + 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 null + }) + + setEncryptionKey(decrypted) } } + + // Temp. map to hold events and signers + const parsedSignatureEventsMap = new Map<`npub1${string}`, Event>() + const signerStatusMap = new Map<`npub1${string}`, SignStatus>() + + const getPrevSignerSig = (npub: `npub1${string}`) => { + if (signers[0] === npub) { + return sig + } + + // find the index of signer + const currentSignerIndex = signers.findIndex( + (signer) => signer === npub + ) + // return if could not found user in signer's list + if (currentSignerIndex === -1) return + // find prev signer + const prevSigner = signers[currentSignerIndex - 1] + + // get the signature of prev signer + return parsedSignatureEventsMap.get(prevSigner)?.sig + } + + for (const npub in meta.docSignatures) { + try { + // Parse each signature event + const event = await parseNostrEvent( + meta.docSignatures[npub as `npub1${string}`] + ) + + // Save events to a map, to save all at once outside loop + // We need the object to find completedAt + // Avoided using parsedSignatureEvents due to useEffect deps + parsedSignatureEventsMap.set(npub as `npub1${string}`, event) + } catch (error) { + signerStatusMap.set(npub as `npub1${string}`, SignStatus.Invalid) + } + } + + parsedSignatureEventsMap.forEach((event, npub) => { + const isValidSignature = verifyEvent(event) + if (isValidSignature) { + // get the signature of prev signer from the content of current signers signedEvent + const prevSignersSig = getPrevSignerSig(npub) + + try { + const obj: SignedEventContent = JSON.parse(event.content) + if ( + obj.prevSig && + prevSignersSig && + obj.prevSig === prevSignersSig + ) { + signerStatusMap.set(npub as `npub1${string}`, SignStatus.Signed) + } + } catch (error) { + signerStatusMap.set(npub as `npub1${string}`, SignStatus.Invalid) + } + } + }) + + signers + .filter((s) => !parsedSignatureEventsMap.has(s)) + .forEach((s) => signerStatusMap.set(s, SignStatus.Pending)) + + // Get the first signer that hasn't signed + const nextSigner = signers.find((s) => !parsedSignatureEventsMap.has(s)) + if (nextSigner) { + signerStatusMap.set(nextSigner, SignStatus.Awaiting) + } + + setSignersStatus(Object.fromEntries(signerStatusMap)) + setParsedSignatureEvents(Object.fromEntries(parsedSignatureEventsMap)) + const signedBy = Object.keys(meta.docSignatures) as `npub1${string}`[] const isCompletelySigned = signers.every((signer) => signedBy.includes(signer) @@ -115,6 +219,20 @@ export const useSigitMeta = (meta: Meta): FlatMeta => { setSignedStatus( isCompletelySigned ? SigitStatus.Complete : SigitStatus.Partial ) + + // Check if all signers signed + if (isCompletelySigned) { + setCompletedAt( + fromUnixTimestamp( + signedBy.reduce((p, c) => { + return Math.max( + p, + parsedSignatureEventsMap.get(c)?.created_at || 0 + ) + }, 0) + ) + ) + } } catch (error) { if (error instanceof SigitMetaParseError) { toast.error(error.message) @@ -125,15 +243,15 @@ export const useSigitMeta = (meta: Meta): FlatMeta => { }, [meta]) return { - modifiedAt: meta.modifiedAt, - createSignature: meta.createSignature, - docSignatures: meta.docSignatures, - keys: meta.keys, + modifiedAt: meta?.modifiedAt, + createSignature: meta?.createSignature, + docSignatures: meta?.docSignatures, + keys: meta?.keys, isValid, kind, tags, - created_at, - pubkey, + createdAt, + submittedBy, id, sig, signers, @@ -142,7 +260,10 @@ export const useSigitMeta = (meta: Meta): FlatMeta => { markConfig, title, zipUrl, + parsedSignatureEvents, + completedAt, signedStatus, - signersStatus + signersStatus, + encryptionKey } } diff --git a/src/hooks/useSigitProfiles.tsx b/src/hooks/useSigitProfiles.tsx new file mode 100644 index 0000000..8178dd7 --- /dev/null +++ b/src/hooks/useSigitProfiles.tsx @@ -0,0 +1,70 @@ +import { useEffect, useState } from 'react' +import { ProfileMetadata } from '../types' +import { MetadataController } from '../controllers' +import { npubToHex } from '../utils' +import { Event, kinds } from 'nostr-tools' + +/** + * Extracts profiles from metadata events + * @param pubkeys Array of npubs to check + * @returns ProfileMetadata + */ +export const useSigitProfiles = ( + pubkeys: `npub1${string}`[] +): { [key: string]: ProfileMetadata } => { + const [profileMetadata, setProfileMetadata] = useState<{ + [key: string]: ProfileMetadata + }>({}) + + useEffect(() => { + if (pubkeys.length) { + const metadataController = new MetadataController() + + // Remove duplicate keys + const users = new Set([...pubkeys]) + + const handleMetadataEvent = (key: string) => (event: Event) => { + const metadataContent = + metadataController.extractProfileMetadataContent(event) + + if (metadataContent) { + setProfileMetadata((prev) => ({ + ...prev, + [key]: metadataContent + })) + } + } + + users.forEach((user) => { + const hexKey = npubToHex(user) + if (hexKey && !(hexKey in profileMetadata)) { + metadataController.on(hexKey, (kind: number, event: Event) => { + if (kind === kinds.Metadata) { + handleMetadataEvent(hexKey)(event) + } + }) + + metadataController + .findMetadata(hexKey) + .then((metadataEvent) => { + if (metadataEvent) handleMetadataEvent(hexKey)(metadataEvent) + }) + .catch((err) => { + console.error( + `error occurred in finding metadata for: ${user}`, + err + ) + }) + } + }) + + return () => { + users.forEach((key) => { + metadataController.off(key, handleMetadataEvent(key)) + }) + } + } + }, [pubkeys, profileMetadata]) + + return profileMetadata +} diff --git a/src/layouts/StickySideColumns.module.scss b/src/layouts/StickySideColumns.module.scss new file mode 100644 index 0000000..fd99964 --- /dev/null +++ b/src/layouts/StickySideColumns.module.scss @@ -0,0 +1,29 @@ +@import '../styles/colors.scss'; +@import '../styles/sizes.scss'; + +.container { + display: grid; + grid-template-columns: 0.75fr 1.5fr 0.75fr; + grid-gap: 30px; + flex-grow: 1; +} + +.sidesWrap { + position: relative; +} + +.sides { + position: sticky; + top: $header-height + $body-vertical-padding; +} + +.files { + display: flex; + flex-direction: column; + grid-gap: 15px; +} +.content { + max-width: 550px; + width: 550px; + margin: 0 auto; +} diff --git a/src/layouts/StickySideColumns.tsx b/src/layouts/StickySideColumns.tsx new file mode 100644 index 0000000..1ada87f --- /dev/null +++ b/src/layouts/StickySideColumns.tsx @@ -0,0 +1,26 @@ +import { PropsWithChildren, ReactNode } from 'react' + +import styles from './StickySideColumns.module.scss' + +interface StickySideColumnsProps { + left?: ReactNode + right?: ReactNode +} + +export const StickySideColumns = ({ + left, + right, + children +}: PropsWithChildren) => { + return ( +
+
+
{left}
+
+
{children}
+
+
{right}
+
+
+ ) +} diff --git a/src/pages/create/index.tsx b/src/pages/create/index.tsx index 77b7a87..fe4ac7f 100644 --- a/src/pages/create/index.tsx +++ b/src/pages/create/index.tsx @@ -359,7 +359,8 @@ export const CreatePage = () => { width: drawnField.width }, npub: drawnField.counterpart, - pdfFileHash: fileHash + pdfFileHash: fileHash, + fileName: drawnPdf.file.name } }) }) @@ -672,6 +673,7 @@ export const CreatePage = () => { return } + setLoadingSpinnerDesc('Encrypting zip file') const encryptedArrayBuffer = await encryptZipFile( arrayBuffer, encryptionKey diff --git a/src/pages/sign/MarkFormField.tsx b/src/pages/sign/MarkFormField.tsx deleted file mode 100644 index 4cb943b..0000000 --- a/src/pages/sign/MarkFormField.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { CurrentUserMark } from '../../types/mark.ts' -import styles from './style.module.scss' -import { Box, Button, TextField } from '@mui/material' - -import { MARK_TYPE_TRANSLATION } from '../../utils/const.ts' - -interface MarkFormFieldProps { - handleSubmit: (event: any) => void - handleChange: (event: any) => void - selectedMark: CurrentUserMark - selectedMarkValue: string -} - -/** - * Responsible for rendering a form field connected to a mark and keeping track of its value. - */ -const MarkFormField = (props: MarkFormFieldProps) => { - const { handleSubmit, handleChange, selectedMark, selectedMarkValue } = props - const getSubmitButton = () => (selectedMark.isLast ? 'Complete' : 'Next') - return ( -
- - - - -
- ) -} - -export default MarkFormField diff --git a/src/pages/sign/index.tsx b/src/pages/sign/index.tsx index 712ab51..a762292 100644 --- a/src/pages/sign/index.tsx +++ b/src/pages/sign/index.tsx @@ -21,7 +21,7 @@ import { extractZipUrlAndEncryptionKey, generateEncryptionKey, generateKeysFile, - getFilesWithHashes, + getCurrentUserFiles, getHash, hexToNpub, isOnline, @@ -48,6 +48,8 @@ import { updateMarks } from '../../utils' import PdfMarking from '../../components/PDFView/PdfMarking.tsx' +import { getZipWithFiles } from '../../utils/file.ts' +import { ARRAY_BUFFER, DEFLATE } from '../../utils/const.ts' enum SignedStatus { Fully_Signed, User_Is_Next_Signer, @@ -219,6 +221,27 @@ export const SignPage = () => { } }, [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: any) { + console.log('error in zip:>> ', error) + toast.error(error.message || 'Error occurred in generating zip file') + } + } + const decrypt = useCallback( async (file: File) => { setLoadingSpinnerDesc('Decrypting file') @@ -929,11 +952,13 @@ export const SignPage = () => { return ( ) } diff --git a/src/pages/sign/style.module.scss b/src/pages/sign/style.module.scss index 10d03d3..1dbc6c5 100644 --- a/src/pages/sign/style.module.scss +++ b/src/pages/sign/style.module.scss @@ -2,8 +2,8 @@ .container { color: $text-color; - width: 550px; - max-width: 550px; + //width: 550px; + //max-width: 550px; .inputBlock { position: relative; diff --git a/src/pages/verify/index.tsx b/src/pages/verify/index.tsx index 1f6bee1..69f7f0b 100644 --- a/src/pages/verify/index.tsx +++ b/src/pages/verify/index.tsx @@ -1,39 +1,22 @@ -import { - Box, - Button, - List, - ListItem, - ListSubheader, - Tooltip, - Typography, - useTheme -} from '@mui/material' +import { Box, Button, Tooltip, Typography, useTheme } from '@mui/material' import JSZip from 'jszip' import { MuiFileInput } from 'mui-file-input' -import { Event, kinds, verifyEvent } from 'nostr-tools' +import { Event, verifyEvent } from 'nostr-tools' import { useEffect, useState } from 'react' import { toast } from 'react-toastify' import { LoadingSpinner } from '../../components/LoadingSpinner' -import { UserAvatar } from '../../components/UserAvatar' -import { MetadataController, NostrController } from '../../controllers' -import { - CreateSignatureEventContent, - Meta, - ProfileMetadata, - SignedEventContent -} from '../../types' +import { NostrController } from '../../controllers' +import { CreateSignatureEventContent, Meta } from '../../types' import { decryptArrayBuffer, extractMarksFromSignedMeta, - extractZipUrlAndEncryptionKey, getHash, hexToNpub, unixNow, - npubToHex, parseJson, readContentOfZipEntry, - shorten, - signEventForMetaFile + signEventForMetaFile, + shorten } from '../../utils' import styles from './style.module.scss' import { Cancel, CheckCircle } from '@mui/icons-material' @@ -51,6 +34,12 @@ import { useSelector } from 'react-redux' import { getLastSignersSig } from '../../utils/sign.ts' import { saveAs } from 'file-saver' import { Container } from '../../components/Container' +import { useSigitMeta } from '../../hooks/useSigitMeta.tsx' +import { StickySideColumns } from '../../layouts/StickySideColumns.tsx' +import { UsersDetails } from '../../components/UsersDetails.tsx/index.tsx' +import { UserAvatar } from '../../components/UserAvatar/index.tsx' +import { useSigitProfiles } from '../../hooks/useSigitProfiles.tsx' +import { TooltipChild } from '../../components/TooltipChild.tsx' export const VerifyPage = () => { const theme = useTheme() @@ -63,52 +52,36 @@ export const VerifyPage = () => { * uploadedZip will be received from home page when a user uploads a sigit zip wrapper that contains meta.json * meta will be received in navigation from create & home page in online mode */ - const { uploadedZip, meta: metaInNavState } = location.state || {} + const { uploadedZip, meta } = location.state || {} + + const { submittedBy, zipUrl, encryptionKey, signers, viewers, fileHashes } = + useSigitMeta(meta) + + const profiles = useSigitProfiles([ + ...(submittedBy ? [submittedBy] : []), + ...signers, + ...viewers + ]) const [isLoading, setIsLoading] = useState(false) const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('') const [selectedFile, setSelectedFile] = useState(null) - const [meta, setMeta] = useState(null) - const [submittedBy, setSubmittedBy] = useState() - - const [signers, setSigners] = useState<`npub1${string}`[]>([]) - const [viewers, setViewers] = useState<`npub1${string}`[]>([]) - const [creatorFileHashes, setCreatorFileHashes] = useState<{ - [key: string]: string - }>({}) const [currentFileHashes, setCurrentFileHashes] = useState<{ [key: string]: string | null - }>({}) + }>(fileHashes) const [files, setFiles] = useState<{ [filename: string]: PdfFile }>({}) - const [metadata, setMetadata] = useState<{ [key: string]: ProfileMetadata }>( - {} - ) const usersPubkey = useSelector((state: State) => state.auth.usersPubkey) const nostrController = NostrController.getInstance() useEffect(() => { if (uploadedZip) { setSelectedFile(uploadedZip) - } else if (metaInNavState) { + } else if (meta && encryptionKey) { const processSigit = async () => { setIsLoading(true) - setLoadingSpinnerDesc('Extracting zipUrl and encryption key from meta') - - const res = await extractZipUrlAndEncryptionKey(metaInNavState) - if (!res) { - setIsLoading(false) - return - } - - const { - zipUrl, - encryptionKey, - createSignatureEvent, - createSignatureContent - } = res setLoadingSpinnerDesc('Fetching file from file server') axios @@ -175,12 +148,6 @@ export const VerifyPage = () => { setCurrentFileHashes(fileHashes) setFiles(files) - setSigners(createSignatureContent.signers) - setViewers(createSignatureContent.viewers) - setCreatorFileHashes(createSignatureContent.fileHashes) - setSubmittedBy(createSignatureEvent.pubkey) - - setMeta(metaInNavState) setIsLoading(false) } }) @@ -197,49 +164,7 @@ export const VerifyPage = () => { processSigit() } - }, [uploadedZip, metaInNavState]) - - useEffect(() => { - if (submittedBy) { - const metadataController = new MetadataController() - - const users = [submittedBy, ...signers, ...viewers] - - users.forEach((user) => { - const pubkey = npubToHex(user)! - - if (!(pubkey in metadata)) { - const handleMetadataEvent = (event: Event) => { - const metadataContent = - metadataController.extractProfileMetadataContent(event) - if (metadataContent) - setMetadata((prev) => ({ - ...prev, - [pubkey]: metadataContent - })) - } - - metadataController.on(pubkey, (kind: number, event: Event) => { - if (kind === kinds.Metadata) { - handleMetadataEvent(event) - } - }) - - metadataController - .findMetadata(pubkey) - .then((metadataEvent) => { - if (metadataEvent) handleMetadataEvent(metadataEvent) - }) - .catch((err) => { - console.error( - `error occurred in finding metadata for: ${user}`, - err - ) - }) - } - }) - } - }, [submittedBy, signers, viewers, metadata]) + }, [encryptionKey, meta, uploadedZip, zipUrl]) const handleVerify = async () => { if (!selectedFile) return @@ -345,44 +270,9 @@ export const VerifyPage = () => { if (!createSignatureContent) return - setSigners(createSignatureContent.signers) - setViewers(createSignatureContent.viewers) - setCreatorFileHashes(createSignatureContent.fileHashes) - setSubmittedBy(createSignatureEvent.pubkey) - - setMeta(parsedMetaJson) setIsLoading(false) } - const getPrevSignersSig = (npub: string) => { - if (!meta) return null - - // if user is first signer then use creator's signature - if (signers[0] === npub) { - try { - const createSignatureEvent: Event = JSON.parse(meta.createSignature) - return createSignatureEvent.sig - } catch (error) { - return null - } - } - - // find the index of signer - const currentSignerIndex = signers.findIndex((signer) => signer === npub) - // return null if could not found user in signer's list - if (currentSignerIndex === -1) return null - // find prev signer - const prevSigner = signers[currentSignerIndex - 1] - - // get the signature of prev signer - try { - const prevSignersEvent: Event = JSON.parse(meta.docSignatures[prevSigner]) - return prevSignersEvent.sig - } catch (error) { - return null - } - } - const handleExport = async () => { if (Object.entries(files).length === 0 || !meta || !usersPubkey) return @@ -398,8 +288,6 @@ export const VerifyPage = () => { setIsLoading(true) setLoadingSpinnerDesc('Signing nostr event') - if (!meta) return - const prevSig = getLastSignersSig(meta, signers) if (!prevSig) return @@ -424,7 +312,7 @@ export const VerifyPage = () => { for (const [fileName, pdf] of Object.entries(files)) { const pages = await addMarks(pdf.file, marksByPage) const blob = await convertToPdfBlob(pages) - zip.file(`/files/${fileName}`, blob) + zip.file(`files/${fileName}`, blob) } const arrayBuffer = await zip @@ -450,76 +338,6 @@ export const VerifyPage = () => { setIsLoading(false) } - const displayUser = (pubkey: string, verifySignature = false) => { - const profile = metadata[pubkey] - - let isValidSignature = false - - if (verifySignature) { - const npub = hexToNpub(pubkey) - const signedEventString = meta ? meta.docSignatures[npub] : null - if (signedEventString) { - try { - const signedEvent = JSON.parse(signedEventString) - const isVerifiedEvent = verifyEvent(signedEvent) - - if (isVerifiedEvent) { - // get the actual signature of prev signer - const prevSignersSig = getPrevSignersSig(npub) - - // get the signature of prev signer from the content of current signers signedEvent - - try { - const obj: SignedEventContent = JSON.parse(signedEvent.content) - if ( - obj.prevSig && - prevSignersSig && - obj.prevSig === prevSignersSig - ) { - isValidSignature = true - } - } catch (error) { - isValidSignature = false - } - } - } catch (error) { - console.error( - `An error occurred in parsing and verifying the signature event for ${pubkey}`, - error - ) - } - } - } - - return ( - <> - - - {verifySignature && ( - <> - {isValidSignature && ( - - - - )} - - {!isValidSignature && ( - - - - )} - - )} - - ) - } - const displayExportedBy = () => { if (!meta || !meta.exportSignature) return null @@ -529,7 +347,24 @@ export const VerifyPage = () => { const exportSignatureEvent = JSON.parse(exportSignatureString) as Event if (verifyEvent(exportSignatureEvent)) { - return displayUser(exportSignatureEvent.pubkey) + const exportedBy = exportSignatureEvent.pubkey + const profile = profiles[exportedBy] + return ( + + + + + + ) } else { toast.error(`Invalid export signature!`) return ( @@ -576,113 +411,13 @@ export const VerifyPage = () => { )} {meta && ( - <> - - Meta Info - - } - > - {submittedBy && ( - - - Submitted By - - {displayUser(submittedBy)} - - )} - - - - Exported By - - {displayExportedBy()} - - - - - - {signers.length > 0 && ( - - - Signers - -
    - {signers.map((signer) => ( -
  • - {displayUser(npubToHex(signer)!, true)} -
  • - ))} -
-
- )} - - {viewers.length > 0 && ( - - - Viewers - -
    - {viewers.map((viewer) => ( -
  • - {displayUser(npubToHex(viewer)!)} -
  • - ))} -
-
- )} - - - - Files - + {Object.entries(currentFileHashes).map( ([filename, hash], index) => { - const isValidHash = creatorFileHashes[filename] === hash + const isValidHash = fileHashes[filename] === hash return ( @@ -714,9 +449,16 @@ export const VerifyPage = () => { } )} - -
- + {displayExportedBy()} + + + + + } + right={} + /> )} diff --git a/src/types/file.ts b/src/types/file.ts new file mode 100644 index 0000000..cb1a7ff --- /dev/null +++ b/src/types/file.ts @@ -0,0 +1,9 @@ +import { PdfFile } from './drawing.ts' + +export interface CurrentUserFile { + id: number + pdfFile: PdfFile + filename: string + hash?: string + isHashValid: boolean +} diff --git a/src/types/mark.ts b/src/types/mark.ts index 9a6a545..efc1899 100644 --- a/src/types/mark.ts +++ b/src/types/mark.ts @@ -1,9 +1,11 @@ import { MarkType } from './drawing' export interface CurrentUserMark { + id: number mark: Mark isLast: boolean isCompleted: boolean + currentValue?: string } export interface Mark { @@ -12,6 +14,7 @@ export interface Mark { pdfFileHash: string type: MarkType location: MarkLocation + fileName: string value?: string } diff --git a/src/utils/const.ts b/src/utils/const.ts index 33befbf..930ab16 100644 --- a/src/utils/const.ts +++ b/src/utils/const.ts @@ -4,6 +4,11 @@ export const EMPTY: string = '' export const MARK_TYPE_TRANSLATION: { [key: string]: string } = { [MarkType.FULLNAME.valueOf()]: 'Full Name' } +export const SIGN: string = 'Sign' +export const NEXT: string = 'Next' +export const ARRAY_BUFFER = 'arraybuffer' +export const DEFLATE = 'DEFLATE' + /** * Number of milliseconds in one week. * Calc based on: 7 * 24 * 60 * 60 * 1000 diff --git a/src/utils/file.ts b/src/utils/file.ts new file mode 100644 index 0000000..94308d5 --- /dev/null +++ b/src/utils/file.ts @@ -0,0 +1,24 @@ +import { Meta } from '../types' +import { extractMarksFromSignedMeta } from './mark.ts' +import { addMarks, convertToPdfBlob, groupMarksByPage } from './pdf.ts' +import JSZip from 'jszip' +import { PdfFile } from '../types/drawing.ts' + +const getZipWithFiles = async ( + meta: Meta, + files: { [filename: string]: PdfFile } +): Promise => { + const zip = new JSZip() + const marks = extractMarksFromSignedMeta(meta) + const marksByPage = groupMarksByPage(marks) + + for (const [fileName, pdf] of Object.entries(files)) { + const pages = await addMarks(pdf.file, marksByPage) + const blob = await convertToPdfBlob(pages) + zip.file(`/files/${fileName}`, blob) + } + + return zip +} + +export { getZipWithFiles } diff --git a/src/utils/mark.ts b/src/utils/mark.ts index 60868be..18cc3e8 100644 --- a/src/utils/mark.ts +++ b/src/utils/mark.ts @@ -2,6 +2,7 @@ import { CurrentUserMark, Mark } from '../types/mark.ts' import { hexToNpub } from './nostr.ts' import { Meta, SignedEventContent } from '../types' import { Event } from 'nostr-tools' +import { EMPTY } from './const.ts' /** * Takes in an array of Marks already filtered by User. @@ -15,13 +16,12 @@ const getCurrentUserMarks = ( ): CurrentUserMark[] => { return marks.map((mark, index, arr) => { const signedMark = signedMetaMarks.find((m) => m.id === mark.id) - if (signedMark && !!signedMark.value) { - mark.value = signedMark.value - } return { mark, + currentValue: signedMark?.value ?? EMPTY, + id: index + 1, isLast: isLast(index, arr), - isCompleted: !!mark.value + isCompleted: !!signedMark?.value } }) } @@ -30,7 +30,7 @@ const getCurrentUserMarks = ( * Returns next incomplete CurrentUserMark if there is one * @param usersMarks */ -const findNextCurrentUserMark = ( +const findNextIncompleteCurrentUserMark = ( usersMarks: CurrentUserMark[] ): CurrentUserMark | undefined => { return usersMarks.find((mark) => !mark.isCompleted) @@ -99,12 +99,38 @@ const updateCurrentUserMarks = ( const isLast = (index: number, arr: T[]) => index === arr.length - 1 +const isCurrentValueLast = ( + currentUserMarks: CurrentUserMark[], + selectedMark: CurrentUserMark, + selectedMarkValue: string +) => { + const filteredMarks = currentUserMarks.filter( + (mark) => mark.id !== selectedMark.id + ) + return ( + isCurrentUserMarksComplete(filteredMarks) && selectedMarkValue.length > 0 + ) +} + +const getUpdatedMark = ( + selectedMark: CurrentUserMark, + selectedMarkValue: string +): CurrentUserMark => { + return { + ...selectedMark, + currentValue: selectedMarkValue, + isCompleted: !!selectedMarkValue + } +} + export { getCurrentUserMarks, filterMarksByPubkey, extractMarksFromSignedMeta, isCurrentUserMarksComplete, - findNextCurrentUserMark, + findNextIncompleteCurrentUserMark, updateMarks, - updateCurrentUserMarks + updateCurrentUserMarks, + isCurrentValueLast, + getUpdatedMark } diff --git a/src/utils/meta.ts b/src/utils/meta.ts index b3c0c28..4915f19 100644 --- a/src/utils/meta.ts +++ b/src/utils/meta.ts @@ -5,8 +5,10 @@ import { toast } from 'react-toastify' export enum SignStatus { Signed = 'Signed', + Awaiting = 'Awaiting', Pending = 'Pending', - Invalid = 'Invalid Sign' + Invalid = 'Invalid', + Viewer = 'Viewer' } export enum SigitStatus { @@ -62,38 +64,33 @@ function handleError(error: unknown): Error { // Reuse common error messages for meta parsing export enum SigitMetaParseErrorType { - 'PARSE_ERROR_SIGNATURE_EVENT' = 'error occurred in parsing the create signature event', + 'PARSE_ERROR_EVENT' = 'error occurred in parsing the create signature event', 'PARSE_ERROR_SIGNATURE_EVENT_CONTENT' = "err in parsing the createSignature event's content" } export interface SigitCardDisplayInfo { createdAt?: number title?: string - submittedBy?: string + submittedBy?: `npub1${string}` signers: `npub1${string}`[] fileExtensions: string[] signedStatus: SigitStatus } /** - * Wrapper for createSignatureEvent parse that throws custom SigitMetaParseError with cause and context + * Wrapper for event parser that throws custom SigitMetaParseError with cause and context * @param raw Raw string for parsing * @returns parsed Event */ -export const parseCreateSignatureEvent = async ( - raw: string -): Promise => { +export const parseNostrEvent = async (raw: string): Promise => { try { - const createSignatureEvent = await parseJson(raw) - return createSignatureEvent + const event = await parseJson(raw) + return event } catch (error) { - throw new SigitMetaParseError( - SigitMetaParseErrorType.PARSE_ERROR_SIGNATURE_EVENT, - { - cause: handleError(error), - context: raw - } - ) + throw new SigitMetaParseError(SigitMetaParseErrorType.PARSE_ERROR_EVENT, { + cause: handleError(error), + context: raw + }) } } @@ -135,9 +132,7 @@ export const extractSigitCardDisplayInfo = async (meta: Meta) => { } try { - const createSignatureEvent = await parseCreateSignatureEvent( - meta.createSignature - ) + const createSignatureEvent = await parseNostrEvent(meta.createSignature) // created_at in nostr events are stored in seconds sigitInfo.createdAt = fromUnixTimestamp(createSignatureEvent.created_at) @@ -147,13 +142,7 @@ export const extractSigitCardDisplayInfo = async (meta: Meta) => { ) const files = Object.keys(createSignatureContent.fileHashes) - const extensions = files.reduce((result: string[], file: string) => { - const extension = file.split('.').pop() - if (extension) { - result.push(extension) - } - return result - }, []) + const extensions = extractFileExtensions(files) const signedBy = Object.keys(meta.docSignatures) as `npub1${string}`[] const isCompletelySigned = createSignatureContent.signers.every((signer) => @@ -161,7 +150,7 @@ export const extractSigitCardDisplayInfo = async (meta: Meta) => { ) sigitInfo.title = createSignatureContent.title - sigitInfo.submittedBy = createSignatureEvent.pubkey + sigitInfo.submittedBy = createSignatureEvent.pubkey as `npub1${string}` sigitInfo.signers = createSignatureContent.signers sigitInfo.fileExtensions = extensions @@ -179,3 +168,15 @@ export const extractSigitCardDisplayInfo = async (meta: Meta) => { } } } + +export const extractFileExtensions = (fileNames: string[]) => { + const extensions = fileNames.reduce((result: string[], file: string) => { + const extension = file.split('.').pop() + if (extension) { + result.push(extension) + } + return result + }, []) + + return extensions +} diff --git a/src/utils/pdf.ts b/src/utils/pdf.ts index 28bbfde..622a259 100644 --- a/src/utils/pdf.ts +++ b/src/utils/pdf.ts @@ -142,7 +142,7 @@ const addMarks = async ( canvas.width = viewport.width await page.render({ canvasContext: context!, viewport: viewport }).promise - marksPerPage[i].forEach((mark) => draw(mark, context!)) + marksPerPage[i]?.forEach((mark) => draw(mark, context!)) images.push(canvas.toDataURL()) } diff --git a/src/utils/utils.ts b/src/utils/utils.ts index e68e687..bde528b 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -1,4 +1,5 @@ import { PdfFile } from '../types/drawing.ts' +import { CurrentUserFile } from '../types/file.ts' export const compareObjects = ( obj1: object | null | undefined, @@ -71,12 +72,20 @@ export const timeout = (ms: number = 60000) => { * including its name, hash, and content * @param files * @param fileHashes + * @param creatorFileHashes */ -export const getFilesWithHashes = ( +export const getCurrentUserFiles = ( files: { [filename: string]: PdfFile }, - fileHashes: { [key: string]: string | null } -) => { - return Object.entries(files).map(([filename, pdfFile]) => { - return { pdfFile, filename, hash: fileHashes[filename] } + fileHashes: { [key: string]: string | null }, + creatorFileHashes: { [key: string]: string } +): CurrentUserFile[] => { + return Object.entries(files).map(([filename, pdfFile], index) => { + return { + pdfFile, + filename, + id: index + 1, + ...(!!fileHashes[filename] && { hash: fileHashes[filename]! }), + isHashValid: creatorFileHashes[filename] === fileHashes[filename] + } }) }