Merge pull request 'New release' (#275) from staging into main
All checks were successful
Release to Production / build_and_release (push) Successful in 1m34s
All checks were successful
Release to Production / build_and_release (push) Successful in 1m34s
Reviewed-on: #275
This commit is contained in:
commit
5ed3d2f389
@ -6,7 +6,7 @@ module.exports = {
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended'
|
||||
],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs', 'licenseChecker.cjs'],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs', 'licenseChecker.cjs', "*.min.js"],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['react-refresh'],
|
||||
rules: {
|
||||
|
@ -8,6 +8,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script src="/opentimestamps.min.js"></script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
1685
package-lock.json
generated
1685
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -41,6 +41,7 @@
|
||||
"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-tools": "2.7.0",
|
||||
@ -57,6 +58,7 @@
|
||||
"react-singleton-hook": "^4.0.1",
|
||||
"react-toastify": "10.0.4",
|
||||
"redux": "5.0.1",
|
||||
"signature_pad": "^5.0.4",
|
||||
"tseep": "1.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -66,6 +68,7 @@
|
||||
"@types/pdfjs-dist": "^2.10.378",
|
||||
"@types/react": "^18.2.56",
|
||||
"@types/react-dom": "^18.2.19",
|
||||
"@types/svgo": "^3.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.0.2",
|
||||
"@typescript-eslint/parser": "^7.0.2",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
@ -78,6 +81,7 @@
|
||||
"ts-css-modules-vite-plugin": "1.0.20",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.1.4",
|
||||
"vite-plugin-node-polyfills": "^0.22.0",
|
||||
"vite-tsconfig-paths": "4.3.2"
|
||||
},
|
||||
"lint-staged": {
|
||||
|
@ -1,15 +1,15 @@
|
||||
{
|
||||
"names": {
|
||||
"_": "6bf1024c6336093b632db2da21b65a44c7d82830454fb4d75634ba281e161c90"
|
||||
},
|
||||
"relays": {
|
||||
"6bf1024c6336093b632db2da21b65a44c7d82830454fb4d75634ba281e161c90": [
|
||||
"wss://brb.io",
|
||||
"wss://nostr.v0l.io",
|
||||
"wss://nostr.coinos.io",
|
||||
"wss://rsslay.nostr.net",
|
||||
"wss://relay.current.fyi",
|
||||
"wss://nos.io"
|
||||
]
|
||||
}
|
||||
"names": {
|
||||
"_": "6bf1024c6336093b632db2da21b65a44c7d82830454fb4d75634ba281e161c90"
|
||||
},
|
||||
"relays": {
|
||||
"6bf1024c6336093b632db2da21b65a44c7d82830454fb4d75634ba281e161c90": [
|
||||
"wss://brb.io",
|
||||
"wss://nostr.v0l.io",
|
||||
"wss://nostr.coinos.io",
|
||||
"wss://rsslay.nostr.net",
|
||||
"wss://relay.current.fyi",
|
||||
"wss://nos.io"
|
||||
]
|
||||
}
|
||||
}
|
2
public/opentimestamps.min.js
vendored
Normal file
2
public/opentimestamps.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
11
src/App.tsx
11
src/App.tsx
@ -1,16 +1,15 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useAppSelector } from './hooks/store'
|
||||
import { useAppSelector } from './hooks'
|
||||
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { AuthController } from './controllers'
|
||||
import { MainLayout } from './layouts/Main'
|
||||
import { appPrivateRoutes, appPublicRoutes } from './routes'
|
||||
import './App.scss'
|
||||
import {
|
||||
appPrivateRoutes,
|
||||
appPublicRoutes,
|
||||
privateRoutes,
|
||||
publicRoutes,
|
||||
recursiveRouteRenderer
|
||||
} from './routes'
|
||||
import './App.scss'
|
||||
} from './routes/util'
|
||||
|
||||
const App = () => {
|
||||
const authState = useAppSelector((state) => state.auth)
|
||||
@ -29,9 +28,11 @@ const App = () => {
|
||||
|
||||
const handleRootRedirect = () => {
|
||||
if (authState.loggedIn) return appPrivateRoutes.homePage
|
||||
|
||||
const callbackPathEncoded = btoa(
|
||||
window.location.href.split(`${window.location.origin}/#`)[1]
|
||||
)
|
||||
|
||||
return `${appPublicRoutes.login}?callbackPath=${callbackPathEncoded}`
|
||||
}
|
||||
|
||||
|
BIN
src/assets/images/nostr-logo.png
Normal file
BIN
src/assets/images/nostr-logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 186 KiB |
@ -121,7 +121,17 @@ export const AppBar = () => {
|
||||
<Container>
|
||||
<Toolbar className={styles.toolbar} disableGutters={true}>
|
||||
<Box className={styles.logoWrapper}>
|
||||
<img src="/logo.svg" alt="Logo" onClick={() => navigate('/')} />
|
||||
<img
|
||||
src="/logo.svg"
|
||||
alt="Logo"
|
||||
onClick={() => {
|
||||
if (['', '#/'].includes(window.location.hash)) {
|
||||
location.reload()
|
||||
} else {
|
||||
navigate('/')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box className={styles.rightSideBox}>
|
||||
|
@ -42,8 +42,7 @@ export const DisplaySigit = ({
|
||||
<div className={styles.itemWrapper}>
|
||||
{signedStatus === SigitStatus.Complete && (
|
||||
<Link
|
||||
to={appPublicRoutes.verify}
|
||||
state={{ meta }}
|
||||
to={`${appPublicRoutes.verify}/${sigitCreateId}`}
|
||||
className={styles.insetLink}
|
||||
></Link>
|
||||
)}
|
||||
|
@ -9,7 +9,7 @@ import {
|
||||
} from '@mui/material'
|
||||
import styles from './style.module.scss'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { ProfileMetadata, User, UserRole } from '../../types'
|
||||
import { ProfileMetadata, User, UserRole, KeyboardCode } from '../../types'
|
||||
import { MouseState, PdfPage, DrawnField, DrawTool } from '../../types/drawing'
|
||||
import { hexToNpub, npubToHex, getProfileUsername } from '../../utils'
|
||||
import { SigitFile } from '../../utils/file'
|
||||
@ -22,11 +22,20 @@ import { AvatarIconButton } from '../UserAvatarIconButton'
|
||||
import { UserAvatar } from '../UserAvatar'
|
||||
import _ from 'lodash'
|
||||
|
||||
const MINIMUM_RECT_SIZE = {
|
||||
width: 21,
|
||||
height: 21
|
||||
} as const
|
||||
|
||||
const DEFAULT_START_SIZE = {
|
||||
width: 140,
|
||||
height: 40
|
||||
} as const
|
||||
|
||||
interface HideSignersForDrawnField {
|
||||
[key: number]: boolean
|
||||
}
|
||||
|
||||
interface Props {
|
||||
users: User[]
|
||||
metadata: { [key: string]: ProfileMetadata }
|
||||
@ -41,6 +50,9 @@ export const DrawPDFFields = (props: Props) => {
|
||||
const signers = users.filter((u) => u.role === UserRole.signer)
|
||||
const defaultSignerNpub = signers.length ? hexToNpub(signers[0].pubkey) : ''
|
||||
const [lastSigner, setLastSigner] = useState(defaultSignerNpub)
|
||||
const [hideSignersForDrawnField, setHideSignersForDrawnField] =
|
||||
useState<HideSignersForDrawnField>({})
|
||||
|
||||
/**
|
||||
* Return first pubkey that is present in the signers list
|
||||
* @param pubkeys
|
||||
@ -84,6 +96,7 @@ export const DrawPDFFields = (props: Props) => {
|
||||
window.removeEventListener('pointerup', handlePointerUp)
|
||||
window.removeEventListener('pointercancel', handlePointerUp)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const refreshPdfFiles = () => {
|
||||
@ -142,6 +155,18 @@ export const DrawPDFFields = (props: Props) => {
|
||||
* @param event Pointer event
|
||||
*/
|
||||
const handlePointerUp = () => {
|
||||
sigitFiles.forEach((s) => {
|
||||
s.pages?.forEach((p) => {
|
||||
// Remove drawn fields below the MINIMUM_RECT_SIZE threshhold
|
||||
p.drawnFields = p.drawnFields.filter(
|
||||
(f) =>
|
||||
!(
|
||||
f.width < MINIMUM_RECT_SIZE.width ||
|
||||
f.height < MINIMUM_RECT_SIZE.height
|
||||
)
|
||||
)
|
||||
})
|
||||
})
|
||||
setMouseState((prev) => {
|
||||
return {
|
||||
...prev,
|
||||
@ -150,6 +175,7 @@ export const DrawPDFFields = (props: Props) => {
|
||||
resizing: false
|
||||
}
|
||||
})
|
||||
refreshPdfFiles()
|
||||
}
|
||||
|
||||
/**
|
||||
@ -217,6 +243,12 @@ export const DrawPDFFields = (props: Props) => {
|
||||
y: drawingRectangleCoords.y
|
||||
}
|
||||
})
|
||||
|
||||
// make signers dropdown visible
|
||||
setHideSignersForDrawnField((prev) => ({
|
||||
...prev,
|
||||
[drawnFieldIndex]: false
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
@ -338,6 +370,32 @@ export const DrawPDFFields = (props: Props) => {
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles Escape button-down event and hides all signers dropdowns
|
||||
* @param event SyntheticEvent event
|
||||
*/
|
||||
const handleEscapeButtonDown = (event: React.SyntheticEvent) => {
|
||||
// get native event
|
||||
const { nativeEvent } = event
|
||||
|
||||
//check if event is a keyboard event
|
||||
if (nativeEvent instanceof KeyboardEvent) {
|
||||
// check if event code is Escape
|
||||
if (nativeEvent.code === KeyboardCode.Escape) {
|
||||
// hide all signers dropdowns
|
||||
const newHideSignersForDrawnField: HideSignersForDrawnField = {}
|
||||
|
||||
Object.keys(hideSignersForDrawnField).forEach((key) => {
|
||||
// Object.keys always returns an array of strings,
|
||||
// that is why unknown type is used below
|
||||
newHideSignersForDrawnField[key as unknown as number] = true
|
||||
})
|
||||
|
||||
setHideSignersForDrawnField(newHideSignersForDrawnField)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the pointer coordinates relative to a element in the `event` param
|
||||
* @param event PointerEvent
|
||||
@ -361,6 +419,7 @@ export const DrawPDFFields = (props: Props) => {
|
||||
rect
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the pdf pages and drawing elements
|
||||
*/
|
||||
@ -375,6 +434,8 @@ export const DrawPDFFields = (props: Props) => {
|
||||
<div
|
||||
key={pageIndex}
|
||||
className={`image-wrapper ${styles.pdfImageWrapper} ${selectedTool ? styles.drawing : ''}`}
|
||||
tabIndex={-1}
|
||||
onKeyDown={(event) => handleEscapeButtonDown(event)}
|
||||
>
|
||||
<img
|
||||
onPointerMove={(event) => {
|
||||
@ -492,62 +553,78 @@ export const DrawPDFFields = (props: Props) => {
|
||||
fileIndex,
|
||||
pageIndex,
|
||||
drawnFieldIndex
|
||||
) && (
|
||||
<div
|
||||
onPointerDown={handleUserSelectPointerDown}
|
||||
className={styles.userSelect}
|
||||
>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel id="counterparts">Counterpart</InputLabel>
|
||||
<Select
|
||||
value={getAvailableSigner(drawnField.counterpart)}
|
||||
onChange={(event) => {
|
||||
drawnField.counterpart = event.target.value
|
||||
setLastSigner(event.target.value)
|
||||
refreshPdfFiles()
|
||||
}}
|
||||
labelId="counterparts"
|
||||
label="Counterparts"
|
||||
sx={{
|
||||
background: 'white'
|
||||
}}
|
||||
renderValue={(value) =>
|
||||
renderCounterpartValue(value)
|
||||
}
|
||||
>
|
||||
{signers.map((signer, index) => {
|
||||
const npub = hexToNpub(signer.pubkey)
|
||||
const metadata = props.metadata[signer.pubkey]
|
||||
const displayValue = getProfileUsername(
|
||||
npub,
|
||||
metadata
|
||||
)
|
||||
) &&
|
||||
(!hideSignersForDrawnField ||
|
||||
!hideSignersForDrawnField[drawnFieldIndex]) && (
|
||||
<div
|
||||
onPointerDown={handleUserSelectPointerDown}
|
||||
className={styles.userSelect}
|
||||
>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel id="counterparts">
|
||||
Counterpart
|
||||
</InputLabel>
|
||||
<Select
|
||||
value={getAvailableSigner(drawnField.counterpart)}
|
||||
onChange={(event) => {
|
||||
drawnField.counterpart = event.target.value
|
||||
setLastSigner(event.target.value)
|
||||
refreshPdfFiles()
|
||||
}}
|
||||
labelId="counterparts"
|
||||
label="Counterparts"
|
||||
sx={{
|
||||
background: 'white'
|
||||
}}
|
||||
renderValue={(value) =>
|
||||
renderCounterpartValue(value)
|
||||
}
|
||||
>
|
||||
{signers.map((signer, index) => {
|
||||
const npub = hexToNpub(signer.pubkey)
|
||||
const metadata = props.metadata[signer.pubkey]
|
||||
const displayValue = getProfileUsername(
|
||||
npub,
|
||||
metadata
|
||||
)
|
||||
// make current signers dropdown visible
|
||||
if (
|
||||
hideSignersForDrawnField[drawnFieldIndex] ===
|
||||
undefined ||
|
||||
hideSignersForDrawnField[drawnFieldIndex] ===
|
||||
true
|
||||
) {
|
||||
setHideSignersForDrawnField((prev) => ({
|
||||
...prev,
|
||||
[drawnFieldIndex]: false
|
||||
}))
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuItem key={index} value={npub}>
|
||||
<ListItemIcon>
|
||||
<AvatarIconButton
|
||||
src={metadata?.picture}
|
||||
hexKey={signer.pubkey}
|
||||
aria-label={`account of user ${displayValue}`}
|
||||
color="inherit"
|
||||
sx={{
|
||||
padding: 0,
|
||||
'> img': {
|
||||
width: '30px',
|
||||
height: '30px'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ListItemIcon>
|
||||
<ListItemText>{displayValue}</ListItemText>
|
||||
</MenuItem>
|
||||
)
|
||||
})}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</div>
|
||||
)}
|
||||
return (
|
||||
<MenuItem key={index} value={npub}>
|
||||
<ListItemIcon>
|
||||
<AvatarIconButton
|
||||
src={metadata?.picture}
|
||||
hexKey={signer.pubkey}
|
||||
aria-label={`account of user ${displayValue}`}
|
||||
color="inherit"
|
||||
sx={{
|
||||
padding: 0,
|
||||
'> img': {
|
||||
width: '30px',
|
||||
height: '30px'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ListItemIcon>
|
||||
<ListItemText>{displayValue}</ListItemText>
|
||||
</MenuItem>
|
||||
)
|
||||
})}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
@ -13,6 +13,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
.pdfImageWrapper:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
position: absolute;
|
||||
opacity: 0.5;
|
||||
@ -34,10 +38,6 @@
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
&.edited {
|
||||
outline: 1px dotted #01aaad;
|
||||
}
|
||||
|
||||
.resizeHandle {
|
||||
position: absolute;
|
||||
right: -5px;
|
||||
@ -95,3 +95,15 @@
|
||||
height: 21px;
|
||||
}
|
||||
}
|
||||
|
||||
.signingRectangle {
|
||||
position: absolute;
|
||||
outline: 1px solid #01aaad;
|
||||
z-index: 40;
|
||||
background-color: #01aaad4b;
|
||||
cursor: pointer;
|
||||
|
||||
&.edited {
|
||||
outline: 1px dotted #01aaad;
|
||||
}
|
||||
}
|
||||
|
@ -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 = ({
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Button variant="contained" fullWidth onClick={handleDownload}>
|
||||
{downloadLabel || 'Download Files'}
|
||||
</Button>
|
||||
|
||||
<PopupState variant="popover" popupId="download-popup-menu">
|
||||
{(popupState) => (
|
||||
<React.Fragment>
|
||||
<Button variant="contained" {...bindTrigger(popupState)}>
|
||||
Export files
|
||||
</Button>
|
||||
<Menu {...bindMenu(popupState)}>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
popupState.close
|
||||
handleExport()
|
||||
}}
|
||||
>
|
||||
Export Files
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
popupState.close
|
||||
typeof handleEncryptedExport === 'function' &&
|
||||
handleEncryptedExport()
|
||||
}}
|
||||
>
|
||||
Export Encrypted Files
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</PopupState>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -7,14 +7,15 @@ import {
|
||||
isCurrentValueLast
|
||||
} 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'
|
||||
|
||||
interface MarkFormFieldProps {
|
||||
currentUserMarks: CurrentUserMark[]
|
||||
handleCurrentUserMarkChange: (mark: CurrentUserMark) => void
|
||||
handleSelectedMarkValueChange: (
|
||||
event: React.ChangeEvent<HTMLInputElement>
|
||||
) => void
|
||||
handleSubmit: (event: React.FormEvent<HTMLFormElement>) => void
|
||||
handleSelectedMarkValueChange: (value: string) => void
|
||||
handleSubmit: (event: React.MouseEvent<HTMLButtonElement>) => void
|
||||
selectedMark: CurrentUserMark
|
||||
selectedMarkValue: string
|
||||
}
|
||||
@ -31,11 +32,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 = () => {
|
||||
@ -47,12 +50,36 @@ const MarkFormField = ({
|
||||
const handleFormSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
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<HTMLButtonElement>
|
||||
) => {
|
||||
handleSubmit(event)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.trigger}>
|
||||
@ -78,31 +105,51 @@ const MarkFormField = ({
|
||||
<div className={styles.actionsWrapper}>
|
||||
<div className={styles.actionsTop}>
|
||||
<div className={styles.actionsTopInfo}>
|
||||
<p className={styles.actionsTopInfoText}>Add {markLabel}</p>
|
||||
{!complete && (
|
||||
<p className={styles.actionsTopInfoText}>Add {markLabel}</p>
|
||||
)}
|
||||
{complete && <p className={styles.actionsTopInfoText}>Finish</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.inputWrapper}>
|
||||
<form onSubmit={(e) => handleFormSubmit(e)}>
|
||||
<input
|
||||
className={styles.input}
|
||||
placeholder={markLabel}
|
||||
onChange={handleSelectedMarkValueChange}
|
||||
value={selectedMarkValue}
|
||||
/>
|
||||
{!complete && (
|
||||
<form onSubmit={(e) => handleFormSubmit(e)}>
|
||||
<MarkInput
|
||||
markType={selectedMark.mark.type}
|
||||
key={selectedMark.id}
|
||||
value={selectedMarkValue}
|
||||
placeholder={markLabel}
|
||||
handler={handleSelectedMarkValueChange}
|
||||
userMark={selectedMark}
|
||||
/>
|
||||
<div className={styles.actionsBottom}>
|
||||
<button type="submit" className={styles.submitButton}>
|
||||
NEXT
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{complete && (
|
||||
<div className={styles.actionsBottom}>
|
||||
<button type="submit" className={styles.submitButton}>
|
||||
NEXT
|
||||
<button
|
||||
onClick={handleSignAndComplete}
|
||||
className={styles.submitButton}
|
||||
disabled={!isReadyToSign()}
|
||||
>
|
||||
SIGN AND COMPLETE
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className={styles.footerContainer}>
|
||||
<div className={styles.footer}>
|
||||
{currentUserMarks.map((mark, index) => {
|
||||
return (
|
||||
<div className={styles.pagination} key={index}>
|
||||
<button
|
||||
className={`${styles.paginationButton} ${isDone(mark) && styles.paginationButtonDone}`}
|
||||
onClick={() => handleCurrentUserMarkChange(mark)}
|
||||
className={`${styles.paginationButton} ${isDone(mark) ? styles.paginationButtonDone : ''}`}
|
||||
onClick={() => handleCurrentUserMarkClick(mark)}
|
||||
>
|
||||
{mark.id}
|
||||
</button>
|
||||
@ -112,6 +159,20 @@ const MarkFormField = ({
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className={styles.pagination}>
|
||||
<button
|
||||
className={`${styles.paginationButton} ${isReadyToSign() ? styles.paginationButtonDone : ''}`}
|
||||
onClick={handleSelectCompleteMark}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
className={styles.finishPage}
|
||||
icon={faCheck}
|
||||
/>
|
||||
</button>
|
||||
{complete && (
|
||||
<div className={styles.paginationButtonCurrent}></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -122,7 +122,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 +216,7 @@
|
||||
flex-direction: column;
|
||||
grid-gap: 5px;
|
||||
}
|
||||
|
||||
.finishPage {
|
||||
padding: 1px 0;
|
||||
}
|
||||
|
16
src/components/MarkTypeStrategy/MarkInput.tsx
Normal file
16
src/components/MarkTypeStrategy/MarkInput.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import { MarkType } from '../../types/drawing'
|
||||
import { MARK_TYPE_CONFIG, MarkInputProps } from './MarkStrategy'
|
||||
|
||||
interface MarkInputComponentProps extends MarkInputProps {
|
||||
markType: MarkType
|
||||
}
|
||||
|
||||
export const MarkInput = ({ markType, ...rest }: MarkInputComponentProps) => {
|
||||
const { input: InputComponent } = MARK_TYPE_CONFIG[markType] || {}
|
||||
|
||||
if (typeof InputComponent !== 'undefined') {
|
||||
return <InputComponent {...rest} />
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
20
src/components/MarkTypeStrategy/MarkRender.tsx
Normal file
20
src/components/MarkTypeStrategy/MarkRender.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { MarkType } from '../../types/drawing'
|
||||
import { MARK_TYPE_CONFIG, MarkRenderProps } from './MarkStrategy'
|
||||
|
||||
interface MarkRenderComponentProps extends MarkRenderProps {
|
||||
markType: MarkType
|
||||
}
|
||||
|
||||
export const MarkRender = ({ markType, ...rest }: MarkRenderComponentProps) => {
|
||||
const { render: RenderComponent } = MARK_TYPE_CONFIG[markType] || {}
|
||||
|
||||
if (typeof RenderComponent !== 'undefined') {
|
||||
return <RenderComponent {...rest} />
|
||||
}
|
||||
|
||||
return <DefaultRenderComponent {...rest} />
|
||||
}
|
||||
|
||||
const DefaultRenderComponent = ({ value }: MarkRenderProps) => (
|
||||
<span>{value}</span>
|
||||
)
|
32
src/components/MarkTypeStrategy/MarkStrategy.tsx
Normal file
32
src/components/MarkTypeStrategy/MarkStrategy.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import { MarkType } from '../../types/drawing'
|
||||
import { CurrentUserMark, Mark } from '../../types/mark'
|
||||
import { TextStrategy } from './Text'
|
||||
import { SignatureStrategy } from './Signature'
|
||||
|
||||
export interface MarkInputProps {
|
||||
value: string
|
||||
handler: (value: string) => void
|
||||
placeholder?: string
|
||||
userMark?: CurrentUserMark
|
||||
}
|
||||
|
||||
export interface MarkRenderProps {
|
||||
value?: string
|
||||
mark: Mark
|
||||
}
|
||||
|
||||
export interface MarkStrategy {
|
||||
input: React.FC<MarkInputProps>
|
||||
render: React.FC<MarkRenderProps>
|
||||
encryptAndUpload?: (value: string, key?: string) => Promise<string>
|
||||
fetchAndDecrypt?: (value: string, key?: string) => Promise<string>
|
||||
}
|
||||
|
||||
export type MarkStrategies = {
|
||||
[key in MarkType]?: MarkStrategy
|
||||
}
|
||||
|
||||
export const MARK_TYPE_CONFIG: MarkStrategies = {
|
||||
[MarkType.TEXT]: TextStrategy,
|
||||
[MarkType.SIGNATURE]: SignatureStrategy
|
||||
}
|
44
src/components/MarkTypeStrategy/Signature/Input.module.scss
Normal file
44
src/components/MarkTypeStrategy/Signature/Input.module.scss
Normal file
@ -0,0 +1,44 @@
|
||||
@import '../../../styles/colors.scss';
|
||||
|
||||
$padding: 5px;
|
||||
|
||||
.wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: $padding;
|
||||
}
|
||||
|
||||
.relative {
|
||||
position: relative;
|
||||
outline: 1px solid black;
|
||||
}
|
||||
|
||||
.canvas {
|
||||
background-color: $body-background-color;
|
||||
cursor: crosshair;
|
||||
|
||||
// Disable panning/zooming when touching canvas element
|
||||
-ms-touch-action: none;
|
||||
touch-action: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.absolute {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.reset {
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: $padding;
|
||||
color: $primary-main;
|
||||
|
||||
&:hover {
|
||||
color: $primary-dark;
|
||||
}
|
||||
}
|
101
src/components/MarkTypeStrategy/Signature/Input.tsx
Normal file
101
src/components/MarkTypeStrategy/Signature/Input.tsx
Normal file
@ -0,0 +1,101 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { faEraser } from '@fortawesome/free-solid-svg-icons'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { MarkRenderSignature } from './Render'
|
||||
import SignaturePad from 'signature_pad'
|
||||
import { SIGNATURE_PAD_OPTIONS, SIGNATURE_PAD_SIZE } from '../../../utils/const'
|
||||
import { BasicPoint } from 'signature_pad/dist/types/point'
|
||||
import { MarkInputProps } from '../MarkStrategy'
|
||||
import styles from './Input.module.scss'
|
||||
|
||||
export const MarkInputSignature = ({
|
||||
value,
|
||||
handler,
|
||||
userMark
|
||||
}: MarkInputProps) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const signaturePad = useRef<SignaturePad | null>(null)
|
||||
|
||||
const update = useCallback(() => {
|
||||
const data = signaturePad.current?.toData()
|
||||
const reduced = data?.map((pg) => pg.points)
|
||||
const json = JSON.stringify(reduced)
|
||||
|
||||
if (signaturePad.current && !signaturePad.current?.isEmpty()) {
|
||||
handler(json)
|
||||
} else {
|
||||
handler('')
|
||||
}
|
||||
}, [handler])
|
||||
|
||||
useEffect(() => {
|
||||
const handleEndStroke = () => {
|
||||
update()
|
||||
}
|
||||
if (canvasRef.current) {
|
||||
if (signaturePad.current === null) {
|
||||
signaturePad.current = new SignaturePad(
|
||||
canvasRef.current,
|
||||
SIGNATURE_PAD_OPTIONS
|
||||
)
|
||||
}
|
||||
|
||||
signaturePad.current.addEventListener('endStroke', handleEndStroke)
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('endStroke', handleEndStroke)
|
||||
}
|
||||
}, [update])
|
||||
|
||||
useEffect(() => {
|
||||
if (signaturePad.current) {
|
||||
if (value) {
|
||||
signaturePad.current.fromData(
|
||||
JSON.parse(value).map((p: BasicPoint[]) => ({
|
||||
points: p
|
||||
}))
|
||||
)
|
||||
} else {
|
||||
signaturePad.current?.clear()
|
||||
}
|
||||
}
|
||||
update()
|
||||
}, [update, value])
|
||||
|
||||
const handleReset = () => {
|
||||
signaturePad.current?.clear()
|
||||
update()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<div
|
||||
className={styles.relative}
|
||||
style={{
|
||||
width: SIGNATURE_PAD_SIZE.width,
|
||||
height: SIGNATURE_PAD_SIZE.height
|
||||
}}
|
||||
>
|
||||
<canvas
|
||||
width={SIGNATURE_PAD_SIZE.width}
|
||||
height={SIGNATURE_PAD_SIZE.height}
|
||||
ref={canvasRef}
|
||||
className={styles.canvas}
|
||||
></canvas>
|
||||
{typeof userMark?.mark !== 'undefined' && (
|
||||
<div className={styles.absolute}>
|
||||
<MarkRenderSignature
|
||||
key={userMark.mark.value}
|
||||
value={userMark.mark.value}
|
||||
mark={userMark.mark}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.reset}>
|
||||
<FontAwesomeIcon size="sm" icon={faEraser} onClick={handleReset} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
.img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
27
src/components/MarkTypeStrategy/Signature/Render.tsx
Normal file
27
src/components/MarkTypeStrategy/Signature/Render.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import SignaturePad from 'signature_pad'
|
||||
import { SIGNATURE_PAD_OPTIONS, SIGNATURE_PAD_SIZE } from '../../../utils'
|
||||
import { BasicPoint } from 'signature_pad/dist/types/point'
|
||||
import { MarkRenderProps } from '../MarkStrategy'
|
||||
import styles from './Render.module.scss'
|
||||
|
||||
export const MarkRenderSignature = ({ value }: MarkRenderProps) => {
|
||||
const [dataUrl, setDataUrl] = useState<string | undefined>()
|
||||
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = SIGNATURE_PAD_SIZE.width
|
||||
canvas.height = SIGNATURE_PAD_SIZE.height
|
||||
const pad = new SignaturePad(canvas, SIGNATURE_PAD_OPTIONS)
|
||||
pad.fromData(
|
||||
JSON.parse(value).map((p: BasicPoint[]) => ({
|
||||
points: p
|
||||
}))
|
||||
)
|
||||
setDataUrl(canvas.toDataURL('image/webp'))
|
||||
}
|
||||
}, [value])
|
||||
|
||||
return dataUrl ? <img src={dataUrl} className={styles.img} alt="" /> : null
|
||||
}
|
95
src/components/MarkTypeStrategy/Signature/index.tsx
Normal file
95
src/components/MarkTypeStrategy/Signature/index.tsx
Normal file
@ -0,0 +1,95 @@
|
||||
import axios from 'axios'
|
||||
import {
|
||||
decryptArrayBuffer,
|
||||
encryptArrayBuffer,
|
||||
getHash,
|
||||
isOnline,
|
||||
uploadToFileStorage
|
||||
} from '../../../utils'
|
||||
import { MarkStrategy } from '../MarkStrategy'
|
||||
import { MarkInputSignature } from './Input'
|
||||
import { MarkRenderSignature } from './Render'
|
||||
|
||||
export const SignatureStrategy: MarkStrategy = {
|
||||
input: MarkInputSignature,
|
||||
render: MarkRenderSignature,
|
||||
encryptAndUpload: async (value, encryptionKey) => {
|
||||
// Value is the stringified signature object
|
||||
// Encode it to the arrayBuffer
|
||||
const encoder = new TextEncoder()
|
||||
const uint8Array = encoder.encode(value)
|
||||
|
||||
if (!encryptionKey) {
|
||||
throw new Error('Signature requires an encryption key')
|
||||
}
|
||||
|
||||
// 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 Error("Can't get encrypted file hash.")
|
||||
}
|
||||
|
||||
// Create the encrypted json file from array buffer and hash
|
||||
const file = new File([encryptedArrayBuffer], `${hash}.json`)
|
||||
|
||||
if (await isOnline()) {
|
||||
try {
|
||||
const url = await uploadToFileStorage(file)
|
||||
console.info(`${file.name} uploaded to file storage`)
|
||||
return url
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.error(
|
||||
`Error occurred in uploading file ${file.name}`,
|
||||
error.message
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// TOOD: offline
|
||||
}
|
||||
|
||||
return value
|
||||
},
|
||||
fetchAndDecrypt: async (value, encryptionKey) => {
|
||||
if (!encryptionKey) {
|
||||
throw new Error('Signature requires an encryption key')
|
||||
}
|
||||
|
||||
const encryptedArrayBuffer = await axios.get(value, {
|
||||
responseType: 'arraybuffer'
|
||||
})
|
||||
|
||||
// Verify hash
|
||||
const parts = value.split('/')
|
||||
const urlHash = parts[parts.length - 1]
|
||||
const hash = await getHash(encryptedArrayBuffer.data)
|
||||
if (hash !== urlHash) {
|
||||
// TODO: handle hash verification failing
|
||||
throw new Error('Unable to verify signature')
|
||||
}
|
||||
|
||||
const arrayBuffer = await decryptArrayBuffer(
|
||||
encryptedArrayBuffer.data,
|
||||
encryptionKey
|
||||
).catch((err) => {
|
||||
console.log('err in decryption:>> ', err)
|
||||
return null
|
||||
})
|
||||
|
||||
if (arrayBuffer) {
|
||||
// decode json
|
||||
const decoder = new TextDecoder()
|
||||
const json = decoder.decode(arrayBuffer)
|
||||
return json
|
||||
}
|
||||
|
||||
// TOOD: offline
|
||||
return value
|
||||
}
|
||||
}
|
19
src/components/MarkTypeStrategy/Text/Input.tsx
Normal file
19
src/components/MarkTypeStrategy/Text/Input.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import { MarkInputProps } from '../MarkStrategy'
|
||||
import styles from '../../MarkFormField/style.module.scss'
|
||||
|
||||
export const MarkInputText = ({
|
||||
value,
|
||||
handler,
|
||||
placeholder
|
||||
}: MarkInputProps) => {
|
||||
return (
|
||||
<input
|
||||
className={styles.input}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => {
|
||||
handler(e.currentTarget.value)
|
||||
}}
|
||||
value={value}
|
||||
/>
|
||||
)
|
||||
}
|
7
src/components/MarkTypeStrategy/Text/index.tsx
Normal file
7
src/components/MarkTypeStrategy/Text/index.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
import { MarkStrategy } from '../MarkStrategy'
|
||||
import { MarkInputText } from './Input'
|
||||
|
||||
export const TextStrategy: MarkStrategy = {
|
||||
input: MarkInputText,
|
||||
render: ({ value }) => <>{value}</>
|
||||
}
|
@ -4,6 +4,7 @@ import { FONT_SIZE, FONT_TYPE, inPx } from '../../utils/pdf.ts'
|
||||
import { useScale } from '../../hooks/useScale.tsx'
|
||||
import { forwardRef } from 'react'
|
||||
import { npubToHex } from '../../utils/nostr.ts'
|
||||
import { MarkRender } from '../MarkTypeStrategy/MarkRender.tsx'
|
||||
|
||||
interface PdfMarkItemProps {
|
||||
userMark: CurrentUserMark
|
||||
@ -31,7 +32,7 @@ const PdfMarkItem = forwardRef<HTMLDivElement, PdfMarkItemProps>(
|
||||
<div
|
||||
ref={ref}
|
||||
onClick={handleClick}
|
||||
className={`file-mark ${styles.drawingRectangle} ${isEdited() && styles.edited}`}
|
||||
className={`file-mark ${styles.signingRectangle} ${isEdited() && styles.edited}`}
|
||||
style={{
|
||||
backgroundColor: selectedMark?.mark.npub
|
||||
? `#${npubToHex(selectedMark?.mark.npub)?.substring(0, 6)}4b`
|
||||
@ -47,7 +48,12 @@ const PdfMarkItem = forwardRef<HTMLDivElement, PdfMarkItemProps>(
|
||||
fontSize: inPx(from(pageWidth, FONT_SIZE))
|
||||
}}
|
||||
>
|
||||
{getMarkValue()}
|
||||
<MarkRender
|
||||
key={getMarkValue()}
|
||||
markType={userMark.mark.type}
|
||||
value={getMarkValue()}
|
||||
mark={userMark.mark}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -24,11 +24,12 @@ import {
|
||||
interface PdfMarkingProps {
|
||||
currentUserMarks: CurrentUserMark[]
|
||||
files: CurrentUserFile[]
|
||||
handleDownload: () => 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<HTMLFormElement>) => {
|
||||
/**
|
||||
* Sign and Complete
|
||||
*/
|
||||
const handleSubmit = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
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 = () => {
|
||||
@ -117,8 +126,9 @@ const PdfMarking = (props: PdfMarkingProps) => {
|
||||
// setCurrentUserMarks(updatedCurrentUserMarks)
|
||||
// }
|
||||
|
||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setSelectedMarkValue(event.target.value)
|
||||
const handleChange = (value: string) => {
|
||||
setSelectedMarkValue(value)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -131,7 +141,8 @@ const PdfMarking = (props: PdfMarkingProps) => {
|
||||
files={files}
|
||||
currentFile={currentFile}
|
||||
setCurrentFile={setCurrentFile}
|
||||
handleDownload={handleDownload}
|
||||
handleExport={handleExport}
|
||||
handleEncryptedExport={handleEncryptedExport}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
@ -6,6 +6,7 @@ import { useEffect, useRef } from 'react'
|
||||
import pdfViewStyles from './style.module.scss'
|
||||
import { FONT_SIZE, FONT_TYPE, inPx } from '../../utils/pdf.ts'
|
||||
import { useScale } from '../../hooks/useScale.tsx'
|
||||
import { MarkRender } from '../MarkTypeStrategy/MarkRender.tsx'
|
||||
interface PdfPageProps {
|
||||
fileName: string
|
||||
pageIndex: number
|
||||
@ -73,7 +74,7 @@ const PdfPageItem = ({
|
||||
fontSize: inPx(from(page.width, FONT_SIZE))
|
||||
}}
|
||||
>
|
||||
{m.value}
|
||||
<MarkRender value={m.value} mark={m} markType={m.type} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
@ -4,6 +4,7 @@ import {
|
||||
fromUnixTimestamp,
|
||||
hexToNpub,
|
||||
npubToHex,
|
||||
SigitStatus,
|
||||
SignStatus
|
||||
} from '../../utils'
|
||||
import { useSigitMeta } from '../../hooks/useSigitMeta'
|
||||
@ -15,6 +16,8 @@ import {
|
||||
faCalendar,
|
||||
faCalendarCheck,
|
||||
faCalendarPlus,
|
||||
faCheck,
|
||||
faClock,
|
||||
faEye,
|
||||
faFile,
|
||||
faFileCircleExclamation
|
||||
@ -22,7 +25,7 @@ import {
|
||||
import { getExtensionIconLabel } from '../getExtensionIconLabel'
|
||||
import { useAppSelector } from '../../hooks/store'
|
||||
import { DisplaySigner } from '../DisplaySigner'
|
||||
import { Meta } from '../../types'
|
||||
import { Meta, OpenTimestamp } from '../../types'
|
||||
import { extractFileExtensions } from '../../utils/file'
|
||||
import { UserAvatar } from '../UserAvatar'
|
||||
|
||||
@ -42,7 +45,9 @@ export const UsersDetails = ({ meta }: UsersDetailsProps) => {
|
||||
completedAt,
|
||||
parsedSignatureEvents,
|
||||
signedStatus,
|
||||
isValid
|
||||
isValid,
|
||||
id,
|
||||
timestamps
|
||||
} = useSigitMeta(meta)
|
||||
const { usersPubkey } = useAppSelector((state) => state.auth)
|
||||
const userCanSign =
|
||||
@ -51,6 +56,50 @@ export const UsersDetails = ({ meta }: UsersDetailsProps) => {
|
||||
|
||||
const { extensions, isSame } = extractFileExtensions(Object.keys(fileHashes))
|
||||
|
||||
const isTimestampVerified = (
|
||||
timestamps: OpenTimestamp[],
|
||||
nostrId: string
|
||||
): boolean => {
|
||||
const matched = timestamps.find((t) => t.nostrId === nostrId)
|
||||
return !!(matched && matched.verification)
|
||||
}
|
||||
|
||||
const getOpenTimestampsInfo = (
|
||||
timestamps: OpenTimestamp[],
|
||||
nostrId: string
|
||||
) => {
|
||||
if (isTimestampVerified(timestamps, nostrId)) {
|
||||
return <FontAwesomeIcon className={styles.ticket} icon={faCheck} />
|
||||
} else {
|
||||
return <FontAwesomeIcon className={styles.ticket} icon={faClock} />
|
||||
}
|
||||
}
|
||||
|
||||
const getCompletedOpenTimestampsInfo = (timestamp: OpenTimestamp) => {
|
||||
if (timestamp.verification) {
|
||||
return <FontAwesomeIcon className={styles.ticket} icon={faCheck} />
|
||||
} else {
|
||||
return <FontAwesomeIcon className={styles.ticket} icon={faClock} />
|
||||
}
|
||||
}
|
||||
|
||||
const getTimestampTooltipTitle = (label: string, isVerified: boolean) => {
|
||||
return `${label} / Open Timestamp ${isVerified ? 'Verified' : 'Pending'}`
|
||||
}
|
||||
|
||||
const isUserSignatureTimestampVerified = () => {
|
||||
if (
|
||||
userCanSign &&
|
||||
hexToNpub(usersPubkey) in parsedSignatureEvents &&
|
||||
timestamps &&
|
||||
timestamps.length > 0
|
||||
) {
|
||||
const nostrId = parsedSignatureEvents[hexToNpub(usersPubkey)].id
|
||||
return isTimestampVerified(timestamps, nostrId)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
return submittedBy ? (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.section}>
|
||||
@ -115,19 +164,35 @@ export const UsersDetails = ({ meta }: UsersDetailsProps) => {
|
||||
<p>Details</p>
|
||||
|
||||
<Tooltip
|
||||
title={'Publication date'}
|
||||
title={getTimestampTooltipTitle(
|
||||
'Publication date',
|
||||
!!(timestamps && id && isTimestampVerified(timestamps, id))
|
||||
)}
|
||||
placement="top"
|
||||
arrow
|
||||
disableInteractive
|
||||
>
|
||||
<span className={styles.detailsItem}>
|
||||
<FontAwesomeIcon icon={faCalendarPlus} />{' '}
|
||||
{createdAt ? formatTimestamp(createdAt) : <>—</>}
|
||||
{createdAt ? formatTimestamp(createdAt) : <>—</>}{' '}
|
||||
{timestamps &&
|
||||
timestamps.length > 0 &&
|
||||
id &&
|
||||
getOpenTimestampsInfo(timestamps, id)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip
|
||||
title={'Completion date'}
|
||||
title={getTimestampTooltipTitle(
|
||||
'Completion date',
|
||||
!!(
|
||||
signedStatus === SigitStatus.Complete &&
|
||||
completedAt &&
|
||||
timestamps &&
|
||||
timestamps.length > 0 &&
|
||||
timestamps[timestamps.length - 1].verification
|
||||
)
|
||||
)}
|
||||
placement="top"
|
||||
arrow
|
||||
disableInteractive
|
||||
@ -135,13 +200,26 @@ export const UsersDetails = ({ meta }: UsersDetailsProps) => {
|
||||
<span className={styles.detailsItem}>
|
||||
<FontAwesomeIcon icon={faCalendarCheck} />{' '}
|
||||
{completedAt ? formatTimestamp(completedAt) : <>—</>}
|
||||
{signedStatus === SigitStatus.Complete &&
|
||||
completedAt &&
|
||||
timestamps &&
|
||||
timestamps.length > 0 && (
|
||||
<span className={styles.ticket}>
|
||||
{getCompletedOpenTimestampsInfo(
|
||||
timestamps[timestamps.length - 1]
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
{/* User signed date */}
|
||||
{userCanSign ? (
|
||||
<Tooltip
|
||||
title={'Your signature date'}
|
||||
title={getTimestampTooltipTitle(
|
||||
'Your signature date',
|
||||
isUserSignatureTimestampVerified()
|
||||
)}
|
||||
placement="top"
|
||||
arrow
|
||||
disableInteractive
|
||||
@ -161,6 +239,16 @@ export const UsersDetails = ({ meta }: UsersDetailsProps) => {
|
||||
) : (
|
||||
<>—</>
|
||||
)}
|
||||
{hexToNpub(usersPubkey) in parsedSignatureEvents &&
|
||||
timestamps &&
|
||||
timestamps.length > 0 && (
|
||||
<span className={styles.ticket}>
|
||||
{getOpenTimestampsInfo(
|
||||
timestamps,
|
||||
parsedSignatureEvents[hexToNpub(usersPubkey)].id
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
|
@ -31,8 +31,6 @@
|
||||
padding: 5px;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: start;
|
||||
|
||||
> :first-child {
|
||||
padding: 5px;
|
||||
@ -44,3 +42,7 @@
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.ticket {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
@ -14,7 +14,6 @@ import {
|
||||
compareObjects,
|
||||
getAuthToken,
|
||||
getRelayMap,
|
||||
getVisitedLink,
|
||||
saveAuthToken,
|
||||
unixNow
|
||||
} from '../utils'
|
||||
@ -91,21 +90,33 @@ export class AuthController {
|
||||
store.dispatch(setRelayMapAction(relayMap.map))
|
||||
}
|
||||
|
||||
const currentLocation = window.location.hash.replace('#', '')
|
||||
/**
|
||||
* This block was added before we started using the `nostr-login` package
|
||||
* At this point it seems it's not needed anymore and it's even blocking the flow (reloading on /verify)
|
||||
* TODO to remove this if app works fine
|
||||
*/
|
||||
// const currentLocation = window.location.hash.replace('#', '')
|
||||
|
||||
if (!Object.values(appPrivateRoutes).includes(currentLocation)) {
|
||||
// User did change the location to one of the private routes
|
||||
const visitedLink = getVisitedLink()
|
||||
|
||||
if (visitedLink) {
|
||||
const { pathname, search } = visitedLink
|
||||
|
||||
return Promise.resolve(`${pathname}${search}`)
|
||||
} else {
|
||||
// Navigate user in
|
||||
return Promise.resolve(appPrivateRoutes.homePage)
|
||||
}
|
||||
}
|
||||
// if (!Object.values(appPrivateRoutes).includes(currentLocation)) {
|
||||
// // Since verify is both public and private route, we don't use the `visitedLink`
|
||||
// // value for it. Otherwise, when linking to /verify/:id we get redirected
|
||||
// // to the root `/`
|
||||
// if (currentLocation.includes(appPublicRoutes.verify)) {
|
||||
// return Promise.resolve(currentLocation)
|
||||
// }
|
||||
//
|
||||
// // User did change the location to one of the private routes
|
||||
// const visitedLink = getVisitedLink()
|
||||
//
|
||||
// if (visitedLink) {
|
||||
// const { pathname, search } = visitedLink
|
||||
//
|
||||
// return Promise.resolve(`${pathname}${search}`)
|
||||
// } else {
|
||||
// // Navigate user in
|
||||
// return Promise.resolve(appPrivateRoutes.homePage)
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
checkSession() {
|
||||
|
@ -3,7 +3,8 @@ import {
|
||||
CreateSignatureEventContent,
|
||||
DocSignatureEvent,
|
||||
Meta,
|
||||
SignedEventContent
|
||||
SignedEventContent,
|
||||
OpenTimestamp
|
||||
} from '../types'
|
||||
import { Mark } from '../types/mark'
|
||||
import {
|
||||
@ -20,6 +21,7 @@ import { Event } from 'nostr-tools'
|
||||
import store from '../store/store'
|
||||
import { NostrController } from '../controllers'
|
||||
import { MetaParseError } from '../types/errors/MetaParseError'
|
||||
import { MARK_TYPE_CONFIG } from '../components/MarkTypeStrategy/MarkStrategy'
|
||||
|
||||
/**
|
||||
* Flattened interface that combines properties `Meta`, `CreateSignatureEventContent`,
|
||||
@ -58,6 +60,8 @@ export interface FlatMeta
|
||||
signersStatus: {
|
||||
[signer: `npub1${string}`]: SignStatus
|
||||
}
|
||||
|
||||
timestamps?: OpenTimestamp[]
|
||||
}
|
||||
|
||||
/**
|
||||
@ -139,6 +143,7 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
|
||||
setMarkConfig(markConfig)
|
||||
setZipUrl(zipUrl)
|
||||
|
||||
let encryptionKey: string | null = null
|
||||
if (meta.keys) {
|
||||
const { sender, keys } = meta.keys
|
||||
// Retrieve the user's public key from the state
|
||||
@ -159,10 +164,10 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
|
||||
return null
|
||||
})
|
||||
|
||||
encryptionKey = decrypted
|
||||
setEncryptionKey(decrypted)
|
||||
}
|
||||
}
|
||||
|
||||
// Temp. map to hold events and signers
|
||||
const parsedSignatureEventsMap = new Map<
|
||||
`npub1${string}`,
|
||||
@ -204,13 +209,40 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
|
||||
}
|
||||
}
|
||||
|
||||
parsedSignatureEventsMap.forEach((event, npub) => {
|
||||
for (const [npub, event] of parsedSignatureEventsMap) {
|
||||
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)
|
||||
|
||||
// Signature object can include values that need to be fetched and decrypted
|
||||
for (let i = 0; i < obj.marks.length; i++) {
|
||||
const m = obj.marks[i]
|
||||
|
||||
try {
|
||||
const { fetchAndDecrypt } = MARK_TYPE_CONFIG[m.type] || {}
|
||||
if (
|
||||
typeof fetchAndDecrypt === 'function' &&
|
||||
m.value &&
|
||||
encryptionKey
|
||||
) {
|
||||
const decrypted = await fetchAndDecrypt(
|
||||
m.value,
|
||||
encryptionKey
|
||||
)
|
||||
obj.marks[i].value = decrypted
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error during mark fetchAndDecrypt phase`,
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
parsedSignatureEventsMap.set(npub, {
|
||||
...event,
|
||||
parsedContent: obj
|
||||
@ -226,7 +258,7 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
|
||||
signerStatusMap.set(npub as `npub1${string}`, SignStatus.Invalid)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
signers
|
||||
.filter((s) => !parsedSignatureEventsMap.has(s))
|
||||
@ -276,6 +308,7 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
|
||||
createSignature: meta?.createSignature,
|
||||
docSignatures: meta?.docSignatures,
|
||||
keys: meta?.keys,
|
||||
timestamps: meta?.timestamps,
|
||||
isValid,
|
||||
kind,
|
||||
tags,
|
||||
|
@ -1,10 +1,17 @@
|
||||
import styles from './style.module.scss'
|
||||
import { Button, FormHelperText, TextField, Tooltip } from '@mui/material'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
FormHelperText,
|
||||
TextField,
|
||||
Tooltip
|
||||
} from '@mui/material'
|
||||
import type { Identifier, XYCoord } from 'dnd-core'
|
||||
import saveAs from 'file-saver'
|
||||
import JSZip from 'jszip'
|
||||
import { Event, kinds } from 'nostr-tools'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { DndProvider, useDrag, useDrop } from 'react-dnd'
|
||||
import { MultiBackend } from 'react-dnd-multi-backend'
|
||||
import { HTML5toTouch } from 'rdndmb-html5-to-touch'
|
||||
@ -13,12 +20,18 @@ import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { toast } from 'react-toastify'
|
||||
import { LoadingSpinner } from '../../components/LoadingSpinner'
|
||||
import { UserAvatar } from '../../components/UserAvatar'
|
||||
import { MetadataController, NostrController } from '../../controllers'
|
||||
import {
|
||||
MetadataController,
|
||||
NostrController,
|
||||
RelayController
|
||||
} from '../../controllers'
|
||||
import { appPrivateRoutes } from '../../routes'
|
||||
import {
|
||||
CreateSignatureEventContent,
|
||||
KeyboardCode,
|
||||
Meta,
|
||||
ProfileMetadata,
|
||||
SignedEvent,
|
||||
User,
|
||||
UserRole
|
||||
} from '../../types'
|
||||
@ -56,12 +69,19 @@ import {
|
||||
faGripLines,
|
||||
faPen,
|
||||
faPlus,
|
||||
faSearch,
|
||||
faToolbox,
|
||||
faTrash,
|
||||
faUpload
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
import { getSigitFile, SigitFile } from '../../utils/file.ts'
|
||||
import _ from 'lodash'
|
||||
import { generateTimestamp } from '../../utils/opentimestamps.ts'
|
||||
import { Autocomplete } from '@mui/lab'
|
||||
import _, { truncate } from 'lodash'
|
||||
import * as React from 'react'
|
||||
import { AvatarIconButton } from '../../components/UserAvatarIconButton'
|
||||
|
||||
type FoundUser = Event & { npub: string }
|
||||
|
||||
export const CreatePage = () => {
|
||||
const navigate = useNavigate()
|
||||
@ -84,20 +104,16 @@ export const CreatePage = () => {
|
||||
}
|
||||
|
||||
const [userInput, setUserInput] = useState('')
|
||||
const handleInputKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.code === 'Enter' || event.code === 'NumpadEnter') {
|
||||
event.preventDefault()
|
||||
handleAddUser()
|
||||
}
|
||||
}
|
||||
const [userRole, setUserRole] = useState<UserRole>(UserRole.signer)
|
||||
const [userSearchInput, setUserSearchInput] = useState('')
|
||||
|
||||
const [userRole] = useState<UserRole>(UserRole.signer)
|
||||
const [error, setError] = useState<string>()
|
||||
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const signers = users.filter((u) => u.role === UserRole.signer)
|
||||
const viewers = users.filter((u) => u.role === UserRole.viewer)
|
||||
|
||||
const usersPubkey = useAppSelector((state) => state.auth.usersPubkey)
|
||||
const usersPubkey = useAppSelector((state) => state.auth.usersPubkey)!
|
||||
|
||||
const nostrController = NostrController.getInstance()
|
||||
|
||||
@ -106,10 +122,161 @@ export const CreatePage = () => {
|
||||
)
|
||||
const [drawnFiles, setDrawnFiles] = useState<SigitFile[]>([])
|
||||
const [parsingPdf, setIsParsing] = useState<boolean>(false)
|
||||
|
||||
const searchFieldRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const [selectedTool, setSelectedTool] = useState<DrawTool>()
|
||||
|
||||
const [foundUsers, setFoundUsers] = useState<FoundUser[]>([])
|
||||
const [searchUsersLoading, setSearchUsersLoading] = useState<boolean>(false)
|
||||
const [pastedUserNpubOrNip05, setPastedUserNpubOrNip05] = useState<
|
||||
string | undefined
|
||||
>()
|
||||
|
||||
/**
|
||||
* Fired when user select
|
||||
*/
|
||||
const handleSearchUserChange = useCallback(
|
||||
(_event: React.SyntheticEvent, value: string | FoundUser | null) => {
|
||||
if (typeof value === 'object') {
|
||||
const ndkEvent = value as FoundUser
|
||||
if (ndkEvent?.pubkey) {
|
||||
setUserInput(hexToNpub(ndkEvent.pubkey))
|
||||
}
|
||||
}
|
||||
},
|
||||
[setUserInput]
|
||||
)
|
||||
|
||||
const handleSearchUserNip05 = async (
|
||||
nip05: string
|
||||
): Promise<string | null> => {
|
||||
const { pubkey } = await queryNip05(nip05).catch((err) => {
|
||||
console.error(err)
|
||||
return { pubkey: null }
|
||||
})
|
||||
|
||||
return pubkey
|
||||
}
|
||||
|
||||
const handleSearchUsers = async (searchValue?: string) => {
|
||||
const searchString = searchValue || userSearchInput || undefined
|
||||
|
||||
if (!searchString) return
|
||||
|
||||
setSearchUsersLoading(true)
|
||||
|
||||
const relayController = RelayController.getInstance()
|
||||
const metadataController = MetadataController.getInstance()
|
||||
|
||||
const relaySet = await metadataController.findRelayListMetadata(usersPubkey)
|
||||
const searchTerm = searchString.trim()
|
||||
|
||||
relayController
|
||||
.fetchEvents(
|
||||
{
|
||||
kinds: [0],
|
||||
search: searchTerm
|
||||
},
|
||||
[...relaySet.write]
|
||||
)
|
||||
.then((events) => {
|
||||
console.log('events', events)
|
||||
|
||||
const fineFilteredEvents: FoundUser[] = events
|
||||
.filter((event) => {
|
||||
const lowercaseContent = event.content.toLowerCase()
|
||||
|
||||
return (
|
||||
lowercaseContent.includes(
|
||||
`"name":"${searchTerm.toLowerCase()}"`
|
||||
) ||
|
||||
lowercaseContent.includes(
|
||||
`"display_name":"${searchTerm.toLowerCase()}"`
|
||||
) ||
|
||||
lowercaseContent.includes(
|
||||
`"username":"${searchTerm.toLowerCase()}"`
|
||||
) ||
|
||||
lowercaseContent.includes(`"nip05":"${searchTerm.toLowerCase()}"`)
|
||||
)
|
||||
})
|
||||
.reduce((uniqueEvents: FoundUser[], event: Event) => {
|
||||
if (!uniqueEvents.some((e: Event) => e.pubkey === event.pubkey)) {
|
||||
uniqueEvents.push({
|
||||
...event,
|
||||
npub: hexToNpub(event.pubkey)
|
||||
})
|
||||
}
|
||||
return uniqueEvents
|
||||
}, [])
|
||||
|
||||
console.info('fineFilteredEvents', fineFilteredEvents)
|
||||
setFoundUsers(fineFilteredEvents)
|
||||
|
||||
if (!fineFilteredEvents.length)
|
||||
toast.info('No user found with the provided search term')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
})
|
||||
.finally(() => {
|
||||
setSearchUsersLoading(false)
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
if (foundUsers.length) {
|
||||
if (searchFieldRef.current) {
|
||||
searchFieldRef.current.blur()
|
||||
searchFieldRef.current.focus()
|
||||
}
|
||||
}
|
||||
})
|
||||
}, [foundUsers])
|
||||
|
||||
const handleInputKeyDown = async (
|
||||
event: React.KeyboardEvent<HTMLDivElement>
|
||||
) => {
|
||||
if (
|
||||
event.code === KeyboardCode.Enter ||
|
||||
event.code === KeyboardCode.NumpadEnter
|
||||
) {
|
||||
event.preventDefault()
|
||||
|
||||
// If pasted user npub of nip05 is present, we just add the user to the counterparts list
|
||||
if (pastedUserNpubOrNip05) {
|
||||
setUserInput(pastedUserNpubOrNip05)
|
||||
setPastedUserNpubOrNip05(undefined)
|
||||
} else {
|
||||
// Otherwize if search already provided some results, user must manually click the search button
|
||||
if (!foundUsers.length) {
|
||||
// If it's NIP05 (includes @ or is a valid domain) send request to .well-known
|
||||
const domainRegex = /^[a-zA-Z0-9@.-]+\.[a-zA-Z]{2,}$/
|
||||
if (domainRegex.test(userSearchInput)) {
|
||||
setSearchUsersLoading(true)
|
||||
|
||||
const pubkey = await handleSearchUserNip05(userSearchInput)
|
||||
|
||||
setSearchUsersLoading(false)
|
||||
|
||||
if (pubkey) {
|
||||
setUserInput(userSearchInput)
|
||||
} else {
|
||||
toast.error(`No user found with the NIP05: ${userSearchInput}`)
|
||||
}
|
||||
} else {
|
||||
handleSearchUsers()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedFiles) {
|
||||
/**
|
||||
* Reads the binary files and converts to internal file type
|
||||
* Reads the binary files and converts to an internal file type
|
||||
* and sets to a state (adds images if it's a PDF)
|
||||
*/
|
||||
const parsePages = async () => {
|
||||
@ -129,8 +296,6 @@ export const CreatePage = () => {
|
||||
}
|
||||
}, [selectedFiles])
|
||||
|
||||
const [selectedTool, setSelectedTool] = useState<DrawTool>()
|
||||
|
||||
/**
|
||||
* Changes the drawing tool
|
||||
* @param drawTool to draw with
|
||||
@ -203,7 +368,7 @@ export const CreatePage = () => {
|
||||
}
|
||||
}, [usersPubkey])
|
||||
|
||||
const handleAddUser = async () => {
|
||||
const handleAddUser = useCallback(async () => {
|
||||
setError(undefined)
|
||||
|
||||
const addUser = (pubkey: string) => {
|
||||
@ -245,6 +410,8 @@ export const CreatePage = () => {
|
||||
|
||||
const input = userInput.toLowerCase()
|
||||
|
||||
setUserSearchInput('')
|
||||
|
||||
if (input.startsWith('npub')) {
|
||||
return handleAddNpubUser(input)
|
||||
}
|
||||
@ -294,7 +461,20 @@ export const CreatePage = () => {
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}, [
|
||||
userInput,
|
||||
userRole,
|
||||
setError,
|
||||
setUsers,
|
||||
setUserSearchInput,
|
||||
setIsLoading,
|
||||
setLoadingSpinnerDesc,
|
||||
setUserInput
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (userInput?.length > 0) handleAddUser()
|
||||
}, [handleAddUser, userInput])
|
||||
|
||||
const handleUserRoleChange = (role: UserRole, pubkey: string) => {
|
||||
setUsers((prevUsers) =>
|
||||
@ -613,7 +793,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),
|
||||
@ -642,6 +822,11 @@ export const CreatePage = () => {
|
||||
return receivers.map((receiver) => sendNotification(receiver, meta))
|
||||
}
|
||||
|
||||
const extractNostrId = (stringifiedEvent: string): string => {
|
||||
const e = JSON.parse(stringifiedEvent) as SignedEvent
|
||||
return e.id
|
||||
}
|
||||
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
if (!validateInputs()) return
|
||||
@ -691,6 +876,12 @@ export const CreatePage = () => {
|
||||
const keys = await generateKeys(pubkeys, encryptionKey)
|
||||
if (!keys) return
|
||||
|
||||
setLoadingSpinnerDesc('Generating an open timestamp.')
|
||||
|
||||
const timestamp = await generateTimestamp(
|
||||
extractNostrId(createSignature)
|
||||
)
|
||||
|
||||
const meta: Meta = {
|
||||
createSignature,
|
||||
keys,
|
||||
@ -698,6 +889,10 @@ export const CreatePage = () => {
|
||||
docSignatures: {}
|
||||
}
|
||||
|
||||
if (timestamp) {
|
||||
meta.timestamps = [timestamp]
|
||||
}
|
||||
|
||||
setLoadingSpinnerDesc('Updating user app data')
|
||||
const event = await updateUsersAppData(meta)
|
||||
if (!event) return
|
||||
@ -768,6 +963,48 @@ export const CreatePage = () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the user search textfield change
|
||||
* If it's not valid npub or nip05, search will be automatically triggered
|
||||
*/
|
||||
const handleSearchAutocompleteTextfieldChange = async (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||
) => {
|
||||
const value = e.target.value
|
||||
|
||||
const disarmAddOnEnter = () => {
|
||||
setPastedUserNpubOrNip05(undefined)
|
||||
}
|
||||
|
||||
// Seems like it's npub format
|
||||
if (value.startsWith('npub')) {
|
||||
// We will try to convert npub to hex and if it's successfull that means
|
||||
// npub is valid
|
||||
const validHexPubkey = npubToHex(value)
|
||||
|
||||
if (validHexPubkey) {
|
||||
// Arm the manual user npub add after enter is hit, we don't want to trigger search
|
||||
setPastedUserNpubOrNip05(value)
|
||||
} else {
|
||||
disarmAddOnEnter()
|
||||
}
|
||||
} else {
|
||||
// Disarm the add user on enter hit, and trigger search after 1 second
|
||||
disarmAddOnEnter()
|
||||
}
|
||||
|
||||
setUserSearchInput(value)
|
||||
}
|
||||
|
||||
const parseContent = (event: Event) => {
|
||||
try {
|
||||
return JSON.parse(event.content)
|
||||
} catch (e) {
|
||||
return undefined
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
|
||||
@ -831,42 +1068,110 @@ export const CreatePage = () => {
|
||||
moveSigner={moveSigner}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.addCounterpart}>
|
||||
<div className={styles.inputWrapper}>
|
||||
<TextField
|
||||
fullWidth
|
||||
placeholder="Add counterpart"
|
||||
value={userInput}
|
||||
onChange={(e) => setUserInput(e.target.value)}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
error={!!error}
|
||||
<Autocomplete
|
||||
sx={{ width: 300 }}
|
||||
options={foundUsers}
|
||||
onChange={handleSearchUserChange}
|
||||
inputValue={userSearchInput}
|
||||
disableClearable
|
||||
openOnFocus
|
||||
autoHighlight
|
||||
freeSolo
|
||||
filterOptions={(x) => x}
|
||||
getOptionLabel={(option) => {
|
||||
let label: string = (option as FoundUser).npub
|
||||
|
||||
const contentJson = parseContent(option as FoundUser)
|
||||
|
||||
if (contentJson?.name) {
|
||||
label = contentJson.name
|
||||
} else {
|
||||
label = option as string
|
||||
}
|
||||
|
||||
return label
|
||||
}}
|
||||
renderOption={(props, option) => {
|
||||
const { ...optionProps } = props
|
||||
|
||||
const contentJson = parseContent(option)
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="li"
|
||||
sx={{ '& > img': { mr: 2, flexShrink: 0 } }}
|
||||
{...optionProps}
|
||||
key={option.pubkey}
|
||||
>
|
||||
<AvatarIconButton
|
||||
src={contentJson.picture}
|
||||
hexKey={option.pubkey}
|
||||
color="inherit"
|
||||
sx={{
|
||||
padding: '0 10px 0 0'
|
||||
}}
|
||||
/>
|
||||
|
||||
<div>
|
||||
{contentJson.name}{' '}
|
||||
{usersPubkey === option.pubkey ? (
|
||||
<span
|
||||
style={{
|
||||
color: '#4c82a3',
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
>
|
||||
Me
|
||||
</span>
|
||||
) : (
|
||||
''
|
||||
)}{' '}
|
||||
({truncate(option.npub, { length: 16 })})
|
||||
</div>
|
||||
</Box>
|
||||
)
|
||||
}}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
key={params.id}
|
||||
inputRef={searchFieldRef}
|
||||
label="Add/Search counterpart"
|
||||
onKeyDown={handleInputKeyDown}
|
||||
onChange={handleSearchAutocompleteTextfieldChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() =>
|
||||
setUserRole(
|
||||
userRole === UserRole.signer
|
||||
? UserRole.viewer
|
||||
: UserRole.signer
|
||||
)
|
||||
}
|
||||
variant="contained"
|
||||
aria-label="Toggle User Role"
|
||||
className={styles.counterpartToggleButton}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={userRole === UserRole.signer ? faPen : faEye}
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!userInput}
|
||||
onClick={handleAddUser}
|
||||
variant="contained"
|
||||
aria-label="Add"
|
||||
className={styles.counterpartToggleButton}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPlus} />
|
||||
</Button>
|
||||
{!pastedUserNpubOrNip05 ? (
|
||||
<Button
|
||||
disabled={!userSearchInput || searchUsersLoading}
|
||||
onClick={() => handleSearchUsers()}
|
||||
variant="contained"
|
||||
aria-label="Add"
|
||||
className={styles.counterpartToggleButton}
|
||||
>
|
||||
{searchUsersLoading ? (
|
||||
<CircularProgress size={14} />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faSearch} />
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setUserInput(userSearchInput)
|
||||
}}
|
||||
variant="contained"
|
||||
aria-label="Add"
|
||||
className={styles.counterpartToggleButton}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPlus} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={`${styles.paperGroup} ${styles.toolbox}`}>
|
||||
|
@ -135,6 +135,46 @@ export const HomePage = () => {
|
||||
const [filter, setFilter] = useState<Filter>('Show all')
|
||||
const [sort, setSort] = useState<Sort>('desc')
|
||||
|
||||
const renderSubmissions = () => {
|
||||
const submissions = Object.keys(parsedSigits)
|
||||
.filter((s) => {
|
||||
const { title, signedStatus } = parsedSigits[s]
|
||||
const isMatch = title?.toLowerCase().includes(q.toLowerCase())
|
||||
switch (filter) {
|
||||
case 'Completed':
|
||||
return signedStatus === SigitStatus.Complete && isMatch
|
||||
case 'In-progress':
|
||||
return signedStatus === SigitStatus.Partial && isMatch
|
||||
case 'Show all':
|
||||
return isMatch
|
||||
default:
|
||||
console.error('Filter case not handled.')
|
||||
}
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const x = parsedSigits[a].createdAt ?? 0
|
||||
const y = parsedSigits[b].createdAt ?? 0
|
||||
return sort === 'desc' ? y - x : x - y
|
||||
})
|
||||
|
||||
if (submissions.length) {
|
||||
return submissions.map((key) => (
|
||||
<DisplaySigit
|
||||
key={`sigit-${key}`}
|
||||
sigitCreateId={key}
|
||||
parsedMeta={parsedSigits[key]}
|
||||
meta={sigits[key]}
|
||||
/>
|
||||
))
|
||||
} else {
|
||||
return (
|
||||
<div className={styles.noResults}>
|
||||
<p>No results</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div {...getRootProps()} tabIndex={-1}>
|
||||
<Container className={styles.container}>
|
||||
@ -233,36 +273,8 @@ export const HomePage = () => {
|
||||
<label htmlFor="file-upload">Click or drag files to upload!</label>
|
||||
)}
|
||||
</button>
|
||||
<div className={styles.submissions}>
|
||||
{Object.keys(parsedSigits)
|
||||
.filter((s) => {
|
||||
const { title, signedStatus } = parsedSigits[s]
|
||||
const isMatch = title?.toLowerCase().includes(q.toLowerCase())
|
||||
switch (filter) {
|
||||
case 'Completed':
|
||||
return signedStatus === SigitStatus.Complete && isMatch
|
||||
case 'In-progress':
|
||||
return signedStatus === SigitStatus.Partial && isMatch
|
||||
case 'Show all':
|
||||
return isMatch
|
||||
default:
|
||||
console.error('Filter case not handled.')
|
||||
}
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const x = parsedSigits[a].createdAt ?? 0
|
||||
const y = parsedSigits[b].createdAt ?? 0
|
||||
return sort === 'desc' ? y - x : x - y
|
||||
})
|
||||
.map((key) => (
|
||||
<DisplaySigit
|
||||
key={`sigit-${key}`}
|
||||
sigitCreateId={key}
|
||||
parsedMeta={parsedSigits[key]}
|
||||
meta={sigits[key]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.submissions}>{renderSubmissions()}</div>
|
||||
</Container>
|
||||
<Footer />
|
||||
</div>
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -9,6 +9,7 @@ import { toast } from 'react-toastify'
|
||||
import { LoadingSpinner } from '../../components/LoadingSpinner'
|
||||
import { AuthController } from '../../controllers'
|
||||
import { updateKeyPair, updateLoginMethod } from '../../store/actions'
|
||||
import { KeyboardCode } from '../../types'
|
||||
import { LoginMethod } from '../../store/auth/types'
|
||||
import { hexToBytes } from '@noble/hashes/utils'
|
||||
|
||||
@ -52,7 +53,10 @@ export const Nostr = () => {
|
||||
* Call login function when enter is pressed
|
||||
*/
|
||||
const handleInputKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.code === 'Enter' || event.code === 'NumpadEnter') {
|
||||
if (
|
||||
event.code === KeyboardCode.Enter ||
|
||||
event.code === KeyboardCode.NumpadEnter
|
||||
) {
|
||||
event.preventDefault()
|
||||
login()
|
||||
}
|
||||
|
@ -1,64 +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,
|
||||
sendNotification,
|
||||
signEventForMetaFile,
|
||||
updateUsersAppData,
|
||||
findOtherUserMarks,
|
||||
timeout
|
||||
timeout,
|
||||
unixNow,
|
||||
updateMarks,
|
||||
updateUsersAppData
|
||||
} 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'
|
||||
|
||||
enum SignedStatus {
|
||||
Fully_Signed,
|
||||
User_Is_Next_Signer,
|
||||
User_Is_Not_Next_Signer
|
||||
}
|
||||
import { generateTimestamp } from '../../utils/opentimestamps.ts'
|
||||
import { MARK_TYPE_CONFIG } from '../../components/MarkTypeStrategy/MarkStrategy.tsx'
|
||||
import { getLastSignersSig } from '../../utils/sign.ts'
|
||||
|
||||
export const SignPage = () => {
|
||||
const navigate = useNavigate()
|
||||
@ -97,17 +87,12 @@ export const SignPage = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const [displayInput, setDisplayInput] = useState(false)
|
||||
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
||||
|
||||
const [files, setFiles] = useState<{ [filename: string]: SigitFile }>({})
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
|
||||
|
||||
const [meta, setMeta] = useState<Meta | null>(null)
|
||||
const [signedStatus, setSignedStatus] = useState<SignedStatus>()
|
||||
|
||||
const [submittedBy, setSubmittedBy] = useState<string>()
|
||||
|
||||
@ -121,66 +106,14 @@ export const SignPage = () => {
|
||||
[key: string]: string | null
|
||||
}>({})
|
||||
|
||||
const [signedBy, setSignedBy] = useState<`npub1${string}`[]>([])
|
||||
|
||||
const [nextSinger, setNextSinger] = useState<string>()
|
||||
|
||||
// This state variable indicates whether the logged-in user is a signer, a creator, or neither.
|
||||
const [isSignerOrCreator, setIsSignerOrCreator] = useState(false)
|
||||
|
||||
const usersPubkey = useAppSelector((state) => state.auth.usersPubkey)
|
||||
|
||||
const nostrController = NostrController.getInstance()
|
||||
const [currentUserMarks, setCurrentUserMarks] = useState<CurrentUserMark[]>(
|
||||
[]
|
||||
)
|
||||
const [isMarksCompleted, setIsMarksCompleted] = useState(false)
|
||||
const [otherUserMarks, setOtherUserMarks] = useState<Mark[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (signers.length > 0) {
|
||||
// check if all signers have signed then its fully signed
|
||||
if (isFullySigned(signers, signedBy)) {
|
||||
setSignedStatus(SignedStatus.Fully_Signed)
|
||||
} else {
|
||||
for (const signer of signers) {
|
||||
if (!signedBy.includes(signer)) {
|
||||
// signers in meta.json are in npub1 format
|
||||
// so, convert it to hex before setting to nextSigner
|
||||
setNextSinger(npubToHex(signer)!)
|
||||
|
||||
const usersNpub = hexToNpub(usersPubkey!)
|
||||
|
||||
if (signer === usersNpub) {
|
||||
// logged in user is the next signer
|
||||
setSignedStatus(SignedStatus.User_Is_Next_Signer)
|
||||
} else {
|
||||
setSignedStatus(SignedStatus.User_Is_Not_Next_Signer)
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// there's no signer just viewers. So its fully signed
|
||||
setSignedStatus(SignedStatus.Fully_Signed)
|
||||
}
|
||||
|
||||
// Determine and set the status of the user
|
||||
if (submittedBy && usersPubkey && submittedBy === usersPubkey) {
|
||||
// If the submission was made by the user, set the status to true
|
||||
setIsSignerOrCreator(true)
|
||||
} else if (usersPubkey) {
|
||||
// Convert the user's public key from hex to npub format
|
||||
const usersNpub = hexToNpub(usersPubkey)
|
||||
if (signers.includes(usersNpub)) {
|
||||
// If the user's npub is in the list of signers, set the status to true
|
||||
setIsSignerOrCreator(true)
|
||||
}
|
||||
}
|
||||
}, [signers, signedBy, usersPubkey, submittedBy])
|
||||
|
||||
useEffect(() => {
|
||||
const handleUpdatedMeta = async (meta: Meta) => {
|
||||
const createSignatureEvent = await parseJson<Event>(
|
||||
@ -236,42 +169,53 @@ export const SignPage = () => {
|
||||
const signedMarks = extractMarksFromSignedMeta(meta)
|
||||
const currentUserMarks = getCurrentUserMarks(metaMarks, signedMarks)
|
||||
const otherUserMarks = findOtherUserMarks(signedMarks, usersPubkey!)
|
||||
|
||||
if (meta.keys) {
|
||||
for (let i = 0; i < otherUserMarks.length; i++) {
|
||||
const m = otherUserMarks[i]
|
||||
const { sender, keys } = meta.keys
|
||||
const usersNpub = hexToNpub(usersPubkey)
|
||||
if (usersNpub in keys) {
|
||||
const encryptionKey = await nostrController
|
||||
.nip04Decrypt(sender, keys[usersNpub])
|
||||
.catch((err) => {
|
||||
console.log(
|
||||
'An error occurred in decrypting encryption key',
|
||||
err
|
||||
)
|
||||
return null
|
||||
})
|
||||
|
||||
try {
|
||||
const { fetchAndDecrypt } = MARK_TYPE_CONFIG[m.type] || {}
|
||||
if (
|
||||
typeof fetchAndDecrypt === 'function' &&
|
||||
m.value &&
|
||||
encryptionKey
|
||||
) {
|
||||
otherUserMarks[i].value = await fetchAndDecrypt(
|
||||
m.value,
|
||||
encryptionKey
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error during mark fetchAndDecrypt phase`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setOtherUserMarks(otherUserMarks)
|
||||
setCurrentUserMarks(currentUserMarks)
|
||||
setIsMarksCompleted(isCurrentUserMarksComplete(currentUserMarks))
|
||||
}
|
||||
|
||||
setSignedBy(Object.keys(meta.docSignatures) as `npub1${string}`[])
|
||||
}
|
||||
|
||||
if (meta) {
|
||||
handleUpdatedMeta(meta)
|
||||
}
|
||||
// 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')
|
||||
@ -383,7 +327,6 @@ export const SignPage = () => {
|
||||
})
|
||||
} else {
|
||||
setIsLoading(false)
|
||||
setDisplayInput(true)
|
||||
}
|
||||
}, [decryptedArrayBuffer, uploadedZip, metaInNavState, decrypt])
|
||||
|
||||
@ -500,9 +443,6 @@ export const SignPage = () => {
|
||||
|
||||
setFiles(files)
|
||||
setCurrentFileHashes(fileHashes)
|
||||
|
||||
setDisplayInput(false)
|
||||
|
||||
setLoadingSpinnerDesc('Parsing meta.json')
|
||||
|
||||
const metaFileContent = await readContentOfZipEntry(
|
||||
@ -530,29 +470,14 @@ 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
|
||||
|
||||
setIsLoading(true)
|
||||
|
||||
setLoadingSpinnerDesc('Signing nostr event')
|
||||
|
||||
const prevSig = getPrevSignersSig(hexToNpub(usersPubkey!))
|
||||
const usersNpub = hexToNpub(usersPubkey!)
|
||||
const prevSig = getPrevSignersSig(usersNpub)
|
||||
if (!prevSig) {
|
||||
setIsLoading(false)
|
||||
toast.error('Previous signature is invalid')
|
||||
@ -561,17 +486,51 @@ export const SignPage = () => {
|
||||
|
||||
const marks = getSignerMarksForMeta() || []
|
||||
|
||||
const signedEvent = await signEventForMeta({ prevSig, marks })
|
||||
let encryptionKey: string | undefined
|
||||
if (meta.keys) {
|
||||
const { sender, keys } = meta.keys
|
||||
encryptionKey = await nostrController
|
||||
.nip04Decrypt(sender, keys[usersNpub])
|
||||
.catch((err) => {
|
||||
// Log and display an error message if decryption fails
|
||||
console.log('An error occurred in decrypting encryption key', err)
|
||||
toast.error('An error occurred in decrypting encryption key')
|
||||
return undefined
|
||||
})
|
||||
}
|
||||
|
||||
const processedMarks = await processMarks(marks, encryptionKey)
|
||||
|
||||
const signedEvent = await signEventForMeta({
|
||||
prevSig,
|
||||
marks: processedMarks
|
||||
})
|
||||
|
||||
if (!signedEvent) return
|
||||
|
||||
const updatedMeta = updateMetaSignatures(meta, signedEvent)
|
||||
|
||||
setLoadingSpinnerDesc('Generating an open timestamp.')
|
||||
|
||||
const timestamp = await generateTimestamp(signedEvent.id)
|
||||
if (timestamp) {
|
||||
updatedMeta.timestamps = [...(updatedMeta.timestamps || []), timestamp]
|
||||
updatedMeta.modifiedAt = unixNow()
|
||||
}
|
||||
|
||||
if (await isOnline()) {
|
||||
await handleOnlineFlow(updatedMeta)
|
||||
} 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
|
||||
@ -662,6 +621,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)
|
||||
@ -672,7 +639,7 @@ export const SignPage = () => {
|
||||
return null
|
||||
}
|
||||
|
||||
// Handle the online flow: update users app data and send notifications
|
||||
// Handle the online flow: update users' app data and send notifications
|
||||
const handleOnlineFlow = async (meta: Meta) => {
|
||||
setLoadingSpinnerDesc('Updating users app data')
|
||||
const updatedEvent = await updateUsersAppData(meta)
|
||||
@ -727,16 +694,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<ArrayBuffer | null> => {
|
||||
if (Object.entries(files).length === 0 || !meta || !usersPubkey)
|
||||
return Promise.resolve(null)
|
||||
|
||||
const usersNpub = hexToNpub(usersPubkey)
|
||||
if (
|
||||
@ -744,15 +733,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({
|
||||
@ -762,7 +751,7 @@ export const SignPage = () => {
|
||||
setIsLoading
|
||||
)
|
||||
|
||||
if (!signedEvent) return
|
||||
if (!signedEvent) return Promise.resolve(null)
|
||||
|
||||
const exportSignature = JSON.stringify(signedEvent, null, 2)
|
||||
|
||||
@ -780,8 +769,8 @@ export const SignPage = () => {
|
||||
|
||||
const arrayBuffer = await zip
|
||||
.generateAsync({
|
||||
type: 'arraybuffer',
|
||||
compression: 'DEFLATE',
|
||||
type: ARRAY_BUFFER,
|
||||
compression: DEFLATE,
|
||||
compressionOptions: {
|
||||
level: 6
|
||||
}
|
||||
@ -793,50 +782,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)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -876,90 +824,17 @@ export const SignPage = () => {
|
||||
return <LoadingSpinner desc={loadingSpinnerDesc} />
|
||||
}
|
||||
|
||||
if (!isMarksCompleted && signedStatus === SignedStatus.User_Is_Next_Signer) {
|
||||
return (
|
||||
<PdfMarking
|
||||
files={getCurrentUserFiles(files, currentFileHashes, creatorFileHashes)}
|
||||
currentUserMarks={currentUserMarks}
|
||||
setIsMarksCompleted={setIsMarksCompleted}
|
||||
setCurrentUserMarks={setCurrentUserMarks}
|
||||
setUpdatedMarks={setUpdatedMarks}
|
||||
handleDownload={handleDownload}
|
||||
otherUserMarks={otherUserMarks}
|
||||
meta={meta}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container className={styles.container}>
|
||||
{displayInput && (
|
||||
<>
|
||||
<Typography component="label" variant="h6">
|
||||
Select sigit file
|
||||
</Typography>
|
||||
|
||||
<Box className={styles.inputBlock}>
|
||||
<MuiFileInput
|
||||
placeholder="Select file"
|
||||
inputProps={{ accept: '.sigit.zip' }}
|
||||
value={selectedFile}
|
||||
onChange={(value) => setSelectedFile(value)}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{selectedFile && (
|
||||
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'center' }}>
|
||||
<Button onClick={handleDecrypt} variant="contained">
|
||||
Decrypt
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{submittedBy && Object.entries(files).length > 0 && meta && (
|
||||
<>
|
||||
<DisplayMeta
|
||||
meta={meta}
|
||||
files={files}
|
||||
submittedBy={submittedBy}
|
||||
signers={signers}
|
||||
viewers={viewers}
|
||||
creatorFileHashes={creatorFileHashes}
|
||||
currentFileHashes={currentFileHashes}
|
||||
signedBy={signedBy}
|
||||
nextSigner={nextSinger}
|
||||
getPrevSignersSig={getPrevSignersSig}
|
||||
/>
|
||||
|
||||
{signedStatus === SignedStatus.Fully_Signed && (
|
||||
<Box sx={{ mt: 1, display: 'flex', justifyContent: 'center' }}>
|
||||
<Button onClick={handleExport} variant="contained">
|
||||
Export Sigit
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{signedStatus === SignedStatus.User_Is_Next_Signer && (
|
||||
<Box sx={{ mt: 1, display: 'flex', justifyContent: 'center' }}>
|
||||
<Button onClick={handleSign} variant="contained">
|
||||
Sign
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{isSignerOrCreator && (
|
||||
<Box sx={{ mt: 1, display: 'flex', justifyContent: 'center' }}>
|
||||
<Button onClick={handleEncryptedExport} variant="contained">
|
||||
Export Encrypted Sigit
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Container>
|
||||
</>
|
||||
<PdfMarking
|
||||
files={getCurrentUserFiles(files, currentFileHashes, creatorFileHashes)}
|
||||
currentUserMarks={currentUserMarks}
|
||||
setCurrentUserMarks={setCurrentUserMarks}
|
||||
setUpdatedMarks={setUpdatedMarks}
|
||||
handleSign={handleSign}
|
||||
handleExport={handleExport}
|
||||
handleEncryptedExport={handleEncryptedExport}
|
||||
otherUserMarks={otherUserMarks}
|
||||
meta={meta}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
@ -5,7 +5,13 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import { toast } from 'react-toastify'
|
||||
import { LoadingSpinner } from '../../components/LoadingSpinner'
|
||||
import { NostrController } from '../../controllers'
|
||||
import { DocSignatureEvent, Meta } from '../../types'
|
||||
import {
|
||||
DocSignatureEvent,
|
||||
Meta,
|
||||
SignedEvent,
|
||||
OpenTimestamp,
|
||||
OpenTimestampUpgradeVerifyResponse
|
||||
} from '../../types'
|
||||
import {
|
||||
decryptArrayBuffer,
|
||||
getHash,
|
||||
@ -14,19 +20,27 @@ import {
|
||||
parseJson,
|
||||
readContentOfZipEntry,
|
||||
signEventForMetaFile,
|
||||
getCurrentUserFiles
|
||||
getCurrentUserFiles,
|
||||
updateUsersAppData,
|
||||
npubToHex,
|
||||
sendNotification,
|
||||
generateEncryptionKey,
|
||||
encryptArrayBuffer,
|
||||
generateKeysFile,
|
||||
ARRAY_BUFFER,
|
||||
DEFLATE
|
||||
} from '../../utils'
|
||||
import styles from './style.module.scss'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { useLocation, useParams } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import { FONT_SIZE, FONT_TYPE, inPx } from '../../utils/pdf.ts'
|
||||
import { useAppSelector } from '../../hooks/store'
|
||||
import { useAppSelector } from '../../hooks'
|
||||
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 { UsersDetails } from '../../components/UsersDetails.tsx'
|
||||
import FileList from '../../components/FileList'
|
||||
import { CurrentUserFile } from '../../types/file.ts'
|
||||
import { Mark } from '../../types/mark.ts'
|
||||
@ -44,6 +58,9 @@ import {
|
||||
faFile,
|
||||
faFileDownload
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
import { upgradeAndVerifyTimestamp } from '../../utils/opentimestamps.ts'
|
||||
import _ from 'lodash'
|
||||
import { MarkRender } from '../../components/MarkTypeStrategy/MarkRender.tsx'
|
||||
|
||||
interface PdfViewProps {
|
||||
files: CurrentUserFile[]
|
||||
@ -118,7 +135,11 @@ const SlimPdfView = ({
|
||||
fontSize: inPx(from(page.width, FONT_SIZE))
|
||||
}}
|
||||
>
|
||||
{m.value}
|
||||
<MarkRender
|
||||
markType={m.type}
|
||||
value={m.value}
|
||||
mark={m}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@ -149,6 +170,9 @@ const SlimPdfView = ({
|
||||
|
||||
export const VerifyPage = () => {
|
||||
const location = useLocation()
|
||||
const params = useParams()
|
||||
|
||||
const usersAppData = useAppSelector((state) => state.userAppData)
|
||||
const usersPubkey = useAppSelector((state) => state.auth.usersPubkey)
|
||||
|
||||
const nostrController = NostrController.getInstance()
|
||||
@ -160,8 +184,27 @@ 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 || {}
|
||||
let metaInNavState = location?.state?.meta || undefined
|
||||
const { uploadedZip } = location.state || {}
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
||||
|
||||
/**
|
||||
* If `userAppData` is present it means user is logged in and we can extract list of `sigits` from the store.
|
||||
* If ID is present in the URL we search in the `sigits` list
|
||||
* Otherwise sigit is set from the `location.state.meta`
|
||||
*/
|
||||
if (usersAppData) {
|
||||
const sigitCreateId = params.id
|
||||
|
||||
if (sigitCreateId) {
|
||||
const sigit = usersAppData.sigits[sigitCreateId]
|
||||
|
||||
if (sigit) {
|
||||
metaInNavState = sigit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (uploadedZip) {
|
||||
setSelectedFile(uploadedZip)
|
||||
@ -169,6 +212,7 @@ export const VerifyPage = () => {
|
||||
}, [uploadedZip])
|
||||
|
||||
const [meta, setMeta] = useState<Meta>(metaInNavState)
|
||||
|
||||
const {
|
||||
submittedBy,
|
||||
zipUrl,
|
||||
@ -176,7 +220,8 @@ export const VerifyPage = () => {
|
||||
signers,
|
||||
viewers,
|
||||
fileHashes,
|
||||
parsedSignatureEvents
|
||||
parsedSignatureEvents,
|
||||
timestamps
|
||||
} = useSigitMeta(meta)
|
||||
|
||||
const [files, setFiles] = useState<{ [filename: string]: SigitFile }>({})
|
||||
@ -186,6 +231,16 @@ export const VerifyPage = () => {
|
||||
[key: string]: string | null
|
||||
}>({})
|
||||
|
||||
const signTimestampEvent = async (signerContent: {
|
||||
timestamps: OpenTimestamp[]
|
||||
}): Promise<SignedEvent | null> => {
|
||||
return await signEventForMetaFile(
|
||||
JSON.stringify(signerContent),
|
||||
nostrController,
|
||||
setIsLoading
|
||||
)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (Object.entries(files).length > 0) {
|
||||
const tmp = getCurrentUserFiles(files, currentFileHashes, fileHashes)
|
||||
@ -193,88 +248,224 @@ export const VerifyPage = () => {
|
||||
}
|
||||
}, [currentFileHashes, fileHashes, files])
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
timestamps &&
|
||||
timestamps.length > 0 &&
|
||||
usersPubkey &&
|
||||
submittedBy &&
|
||||
parsedSignatureEvents
|
||||
) {
|
||||
if (timestamps.every((t) => !!t.verification)) {
|
||||
return
|
||||
}
|
||||
const upgradeT = async (timestamps: OpenTimestamp[]) => {
|
||||
try {
|
||||
setLoadingSpinnerDesc('Upgrading your timestamps.')
|
||||
|
||||
const findCreatorTimestamp = (timestamps: OpenTimestamp[]) => {
|
||||
if (usersPubkey === submittedBy) {
|
||||
return timestamps[0]
|
||||
}
|
||||
}
|
||||
|
||||
const findSignerTimestamp = (timestamps: OpenTimestamp[]) => {
|
||||
const parsedEvent = parsedSignatureEvents[hexToNpub(usersPubkey)]
|
||||
if (parsedEvent?.id) {
|
||||
return timestamps.find((t) => t.nostrId === parsedEvent.id)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if timestamp verification has been achieved for the first time.
|
||||
* Note that the upgrade flag is separate from verification. It is possible for a timestamp
|
||||
* to not be upgraded, but to be verified for the first time.
|
||||
* @param upgradedTimestamp
|
||||
* @param timestamps
|
||||
*/
|
||||
const isNewlyVerified = (
|
||||
upgradedTimestamp: OpenTimestampUpgradeVerifyResponse,
|
||||
timestamps: OpenTimestamp[]
|
||||
) => {
|
||||
if (!upgradedTimestamp.verified) return false
|
||||
const oldT = timestamps.find(
|
||||
(t) => t.nostrId === upgradedTimestamp.timestamp.nostrId
|
||||
)
|
||||
if (!oldT) return false
|
||||
if (!oldT.verification && upgradedTimestamp.verified) return true
|
||||
}
|
||||
|
||||
const userTimestamps: OpenTimestamp[] = []
|
||||
|
||||
const creatorTimestamp = findCreatorTimestamp(timestamps)
|
||||
if (creatorTimestamp) {
|
||||
userTimestamps.push(creatorTimestamp)
|
||||
}
|
||||
|
||||
const signerTimestamp = findSignerTimestamp(timestamps)
|
||||
if (signerTimestamp) {
|
||||
userTimestamps.push(signerTimestamp)
|
||||
}
|
||||
|
||||
if (userTimestamps.every((t) => !!t.verification)) {
|
||||
return
|
||||
}
|
||||
|
||||
const upgradedUserTimestamps = await Promise.all(
|
||||
userTimestamps.map(upgradeAndVerifyTimestamp)
|
||||
)
|
||||
|
||||
const upgradedTimestamps = upgradedUserTimestamps
|
||||
.filter((t) => t.upgraded || isNewlyVerified(t, userTimestamps))
|
||||
.map((t) => {
|
||||
const timestamp: OpenTimestamp = { ...t.timestamp }
|
||||
if (t.verified) {
|
||||
timestamp.verification = t.verification
|
||||
}
|
||||
return timestamp
|
||||
})
|
||||
|
||||
if (upgradedTimestamps.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
setLoadingSpinnerDesc('Signing a timestamp upgrade event.')
|
||||
|
||||
const signedEvent = await signTimestampEvent({
|
||||
timestamps: upgradedTimestamps
|
||||
})
|
||||
if (!signedEvent) return
|
||||
|
||||
const finalTimestamps = timestamps.map((t) => {
|
||||
const upgraded = upgradedTimestamps.find(
|
||||
(tu) => tu.nostrId === t.nostrId
|
||||
)
|
||||
if (upgraded) {
|
||||
return {
|
||||
...upgraded,
|
||||
signature: JSON.stringify(signedEvent, null, 2)
|
||||
}
|
||||
}
|
||||
return t
|
||||
})
|
||||
|
||||
const updatedMeta = _.cloneDeep(meta)
|
||||
updatedMeta.timestamps = [...finalTimestamps]
|
||||
updatedMeta.modifiedAt = unixNow()
|
||||
|
||||
const updatedEvent = await updateUsersAppData(updatedMeta)
|
||||
if (!updatedEvent) return
|
||||
|
||||
const userSet = new Set<`npub1${string}`>()
|
||||
signers.forEach((signer) => {
|
||||
if (signer !== usersPubkey) {
|
||||
userSet.add(signer)
|
||||
}
|
||||
})
|
||||
|
||||
viewers.forEach((viewer) => {
|
||||
userSet.add(viewer)
|
||||
})
|
||||
|
||||
const users = Array.from(userSet)
|
||||
const promises = users.map((user) =>
|
||||
sendNotification(npubToHex(user)!, updatedMeta)
|
||||
)
|
||||
|
||||
await Promise.all(promises)
|
||||
|
||||
toast.success('Timestamp updates have been sent successfully.')
|
||||
|
||||
setMeta(meta)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
toast.error(
|
||||
'There was an error upgrading or verifying your timestamps!'
|
||||
)
|
||||
}
|
||||
}
|
||||
upgradeT(timestamps)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [timestamps, submittedBy, parsedSignatureEvents])
|
||||
|
||||
useEffect(() => {
|
||||
if (metaInNavState && encryptionKey) {
|
||||
const processSigit = async () => {
|
||||
setIsLoading(true)
|
||||
|
||||
setLoadingSpinnerDesc('Fetching file from file server')
|
||||
axios
|
||||
.get(zipUrl, {
|
||||
try {
|
||||
const res = await axios.get(zipUrl, {
|
||||
responseType: 'arraybuffer'
|
||||
})
|
||||
.then(async (res) => {
|
||||
const fileName = zipUrl.split('/').pop()
|
||||
const file = new File([res.data], fileName!)
|
||||
|
||||
const encryptedArrayBuffer = await file.arrayBuffer()
|
||||
const arrayBuffer = await decryptArrayBuffer(
|
||||
encryptedArrayBuffer,
|
||||
encryptionKey
|
||||
).catch((err) => {
|
||||
console.log('err in decryption:>> ', err)
|
||||
const fileName = zipUrl.split('/').pop()
|
||||
const file = new File([res.data], fileName!)
|
||||
|
||||
const encryptedArrayBuffer = await file.arrayBuffer()
|
||||
const arrayBuffer = await decryptArrayBuffer(
|
||||
encryptedArrayBuffer,
|
||||
encryptionKey
|
||||
).catch((err) => {
|
||||
console.log('err in decryption:>> ', err)
|
||||
toast.error(err.message || 'An error occurred in decrypting file.')
|
||||
return null
|
||||
})
|
||||
|
||||
if (arrayBuffer) {
|
||||
const zip = await JSZip.loadAsync(arrayBuffer).catch((err) => {
|
||||
console.log('err in loading zip file :>> ', err)
|
||||
toast.error(
|
||||
err.message || 'An error occurred in decrypting file.'
|
||||
err.message || 'An error occurred in loading zip file.'
|
||||
)
|
||||
return null
|
||||
})
|
||||
|
||||
if (arrayBuffer) {
|
||||
const zip = await JSZip.loadAsync(arrayBuffer).catch((err) => {
|
||||
console.log('err in loading zip file :>> ', err)
|
||||
toast.error(
|
||||
err.message || 'An error occurred in loading zip file.'
|
||||
)
|
||||
return null
|
||||
})
|
||||
if (!zip) return
|
||||
|
||||
if (!zip) return
|
||||
const files: { [fileName: string]: SigitFile } = {}
|
||||
const fileHashes: { [key: string]: string | null } = {}
|
||||
const fileNames = Object.values(zip.files).map(
|
||||
(entry) => entry.name
|
||||
)
|
||||
|
||||
const files: { [fileName: string]: SigitFile } = {}
|
||||
const fileHashes: { [key: string]: string | null } = {}
|
||||
const fileNames = Object.values(zip.files).map(
|
||||
(entry) => entry.name
|
||||
// generate hashes for all entries in files folder of zipArchive
|
||||
// these hashes can be used to verify the originality of files
|
||||
for (const fileName of fileNames) {
|
||||
const arrayBuffer = await readContentOfZipEntry(
|
||||
zip,
|
||||
fileName,
|
||||
'arraybuffer'
|
||||
)
|
||||
|
||||
// generate hashes for all entries in files folder of zipArchive
|
||||
// these hashes can be used to verify the originality of files
|
||||
for (const fileName of fileNames) {
|
||||
const arrayBuffer = await readContentOfZipEntry(
|
||||
zip,
|
||||
fileName,
|
||||
'arraybuffer'
|
||||
if (arrayBuffer) {
|
||||
files[fileName] = await convertToSigitFile(
|
||||
arrayBuffer,
|
||||
fileName!
|
||||
)
|
||||
const hash = await getHash(arrayBuffer)
|
||||
|
||||
if (arrayBuffer) {
|
||||
files[fileName] = await convertToSigitFile(
|
||||
arrayBuffer,
|
||||
fileName!
|
||||
)
|
||||
const hash = await getHash(arrayBuffer)
|
||||
|
||||
if (hash) {
|
||||
fileHashes[fileName.replace(/^files\//, '')] = hash
|
||||
}
|
||||
} else {
|
||||
fileHashes[fileName.replace(/^files\//, '')] = null
|
||||
if (hash) {
|
||||
fileHashes[fileName.replace(/^files\//, '')] = hash
|
||||
}
|
||||
} else {
|
||||
fileHashes[fileName.replace(/^files\//, '')] = null
|
||||
}
|
||||
|
||||
setCurrentFileHashes(fileHashes)
|
||||
setFiles(files)
|
||||
|
||||
setIsLoading(false)
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(`error occurred in getting file from ${zipUrl}`, err)
|
||||
toast.error(
|
||||
err.message || `error occurred in getting file from ${zipUrl}`
|
||||
)
|
||||
})
|
||||
.finally(() => {
|
||||
|
||||
setCurrentFileHashes(fileHashes)
|
||||
setFiles(files)
|
||||
setIsLoading(false)
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
const message = `error occurred in getting file from ${zipUrl}`
|
||||
console.error(message, err)
|
||||
if (err instanceof Error) toast.error(err.message)
|
||||
else toast.error(message)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
processSigit()
|
||||
@ -355,8 +546,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<File | null> => {
|
||||
// Get the current timestamp in seconds
|
||||
const blob = new Blob([encryptedArrayBuffer])
|
||||
// Create a File object with the Blob data
|
||||
const file = new File([blob], `compressed.sigit`, {
|
||||
type: 'application/sigit'
|
||||
})
|
||||
|
||||
const isLastSigner = checkIsLastSigner(signers)
|
||||
|
||||
const userSet = new Set<string>()
|
||||
|
||||
if (isLastSigner) {
|
||||
if (submittedBy) {
|
||||
userSet.add(submittedBy)
|
||||
}
|
||||
|
||||
signers.forEach((signer) => {
|
||||
userSet.add(npubToHex(signer)!)
|
||||
})
|
||||
|
||||
viewers.forEach((viewer) => {
|
||||
userSet.add(npubToHex(viewer)!)
|
||||
})
|
||||
} else {
|
||||
const usersNpub = hexToNpub(usersPubkey!)
|
||||
const signerIndex = signers.indexOf(usersNpub)
|
||||
const nextSigner = signers[signerIndex + 1]
|
||||
userSet.add(npubToHex(nextSigner)!)
|
||||
}
|
||||
|
||||
const keysFileContent = await generateKeysFile(
|
||||
Array.from(userSet),
|
||||
encryptionKey
|
||||
)
|
||||
if (!keysFileContent) return null
|
||||
|
||||
const zip = new JSZip()
|
||||
zip.file(`compressed.sigit`, file)
|
||||
zip.file('keys.json', keysFileContent)
|
||||
|
||||
const arraybuffer = await zip
|
||||
.generateAsync({
|
||||
type: 'arraybuffer',
|
||||
compression: 'DEFLATE',
|
||||
compressionOptions: { level: 6 }
|
||||
})
|
||||
.catch(handleZipError)
|
||||
|
||||
if (!arraybuffer) return null
|
||||
|
||||
return new File([new Blob([arraybuffer])], `${unixNow()}.sigit.zip`, {
|
||||
type: 'application/zip'
|
||||
})
|
||||
}
|
||||
|
||||
const handleExport = async () => {
|
||||
const arrayBuffer = await prepareZipExport()
|
||||
if (!arrayBuffer) return
|
||||
|
||||
const blob = new Blob([arrayBuffer])
|
||||
saveAs(blob, `exported-${unixNow()}.sigit.zip`)
|
||||
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
const handleEncryptedExport = async () => {
|
||||
const arrayBuffer = await prepareZipExport()
|
||||
if (!arrayBuffer) return
|
||||
|
||||
const key = await generateEncryptionKey()
|
||||
|
||||
setLoadingSpinnerDesc('Encrypting zip file')
|
||||
const encryptedArrayBuffer = await encryptArrayBuffer(arrayBuffer, key)
|
||||
|
||||
const finalZipFile = await createFinalZipFile(encryptedArrayBuffer, key)
|
||||
|
||||
if (!finalZipFile) return
|
||||
saveAs(finalZipFile, `exported-${unixNow()}.sigit.zip`)
|
||||
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
const prepareZipExport = async (): Promise<ArrayBuffer | null> => {
|
||||
if (Object.entries(files).length === 0 || !meta || !usersPubkey)
|
||||
return Promise.resolve(null)
|
||||
|
||||
const usersNpub = hexToNpub(usersPubkey)
|
||||
if (
|
||||
@ -364,14 +661,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 }),
|
||||
@ -379,7 +676,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 }
|
||||
@ -390,8 +687,8 @@ export const VerifyPage = () => {
|
||||
|
||||
const arrayBuffer = await zip
|
||||
.generateAsync({
|
||||
type: 'arraybuffer',
|
||||
compression: 'DEFLATE',
|
||||
type: ARRAY_BUFFER,
|
||||
compression: DEFLATE,
|
||||
compressionOptions: {
|
||||
level: 6
|
||||
}
|
||||
@ -403,12 +700,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 (
|
||||
@ -454,8 +748,8 @@ export const VerifyPage = () => {
|
||||
)}
|
||||
currentFile={currentFile}
|
||||
setCurrentFile={setCurrentFile}
|
||||
handleDownload={handleMarkedExport}
|
||||
downloadLabel="Download Sigit"
|
||||
handleExport={handleExport}
|
||||
handleEncryptedExport={handleEncryptedExport}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
@ -53,10 +53,6 @@
|
||||
|
||||
.mark {
|
||||
position: absolute;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
[data-dev='true'] {
|
||||
|
@ -1,16 +1,4 @@
|
||||
import { CreatePage } from '../pages/create'
|
||||
import { HomePage } from '../pages/home'
|
||||
import { LandingPage } from '../pages/landing'
|
||||
import { ProfilePage } from '../pages/profile'
|
||||
import { SettingsPage } from '../pages/settings/Settings'
|
||||
import { CacheSettingsPage } from '../pages/settings/cache'
|
||||
import { NostrLoginPage } from '../pages/settings/nostrLogin'
|
||||
import { ProfileSettingsPage } from '../pages/settings/profile'
|
||||
import { RelaysPage } from '../pages/settings/relays'
|
||||
import { SignPage } from '../pages/sign'
|
||||
import { VerifyPage } from '../pages/verify'
|
||||
import { hexToNpub } from '../utils'
|
||||
import { Route, RouteProps } from 'react-router-dom'
|
||||
|
||||
export const appPrivateRoutes = {
|
||||
homePage: '/',
|
||||
@ -39,93 +27,3 @@ export const getProfileRoute = (hexKey: string) =>
|
||||
|
||||
export const getProfileSettingsRoute = (hexKey: string) =>
|
||||
appPrivateRoutes.profileSettings.replace(':npub', hexToNpub(hexKey))
|
||||
|
||||
/**
|
||||
* Helper type allows for extending react-router-dom's **RouteProps** with generic type
|
||||
*/
|
||||
type CustomRouteProps<T> = T &
|
||||
Omit<RouteProps, 'children'> & {
|
||||
children?: Array<CustomRouteProps<T>>
|
||||
}
|
||||
|
||||
/**
|
||||
* This function maps over nested routes with optional condition for rendering
|
||||
* @param {CustomRouteProps<T>[]} routes - routes list
|
||||
* @param {RenderConditionCallbackFn} renderConditionCallbackFn - render condition callback (default true)
|
||||
*/
|
||||
export function recursiveRouteRenderer<T>(
|
||||
routes?: CustomRouteProps<T>[],
|
||||
renderConditionCallbackFn: (route: CustomRouteProps<T>) => boolean = () =>
|
||||
true
|
||||
) {
|
||||
if (!routes) return null
|
||||
|
||||
// Callback allows us to pass arbitrary conditions for each route's rendering
|
||||
// Skipping the callback will by default evaluate to true (show route)
|
||||
return routes.map((route, index) =>
|
||||
renderConditionCallbackFn(route) ? (
|
||||
<Route
|
||||
key={`${route.path}${index}`}
|
||||
path={route.path}
|
||||
element={route.element}
|
||||
>
|
||||
{recursiveRouteRenderer(route.children, renderConditionCallbackFn)}
|
||||
</Route>
|
||||
) : null
|
||||
)
|
||||
}
|
||||
|
||||
type PublicRouteProps = CustomRouteProps<{
|
||||
hiddenWhenLoggedIn?: boolean
|
||||
}>
|
||||
|
||||
export const publicRoutes: PublicRouteProps[] = [
|
||||
{
|
||||
path: appPublicRoutes.landingPage,
|
||||
hiddenWhenLoggedIn: true,
|
||||
element: <LandingPage />
|
||||
},
|
||||
{
|
||||
path: appPublicRoutes.profile,
|
||||
element: <ProfilePage />
|
||||
},
|
||||
{
|
||||
path: appPublicRoutes.verify,
|
||||
element: <VerifyPage />
|
||||
}
|
||||
]
|
||||
|
||||
export const privateRoutes = [
|
||||
{
|
||||
path: appPrivateRoutes.homePage,
|
||||
element: <HomePage />
|
||||
},
|
||||
{
|
||||
path: appPrivateRoutes.create,
|
||||
element: <CreatePage />
|
||||
},
|
||||
{
|
||||
path: `${appPrivateRoutes.sign}/:id?`,
|
||||
element: <SignPage />
|
||||
},
|
||||
{
|
||||
path: appPrivateRoutes.settings,
|
||||
element: <SettingsPage />
|
||||
},
|
||||
{
|
||||
path: appPrivateRoutes.profileSettings,
|
||||
element: <ProfileSettingsPage />
|
||||
},
|
||||
{
|
||||
path: appPrivateRoutes.cacheSettings,
|
||||
element: <CacheSettingsPage />
|
||||
},
|
||||
{
|
||||
path: appPrivateRoutes.relays,
|
||||
element: <RelaysPage />
|
||||
},
|
||||
{
|
||||
path: appPrivateRoutes.nostrLogin,
|
||||
element: <NostrLoginPage />
|
||||
}
|
||||
]
|
||||
|
103
src/routes/util.tsx
Normal file
103
src/routes/util.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
import { Route, RouteProps } from 'react-router-dom'
|
||||
import { appPrivateRoutes, appPublicRoutes } from '.'
|
||||
import { CreatePage } from '../pages/create'
|
||||
import { HomePage } from '../pages/home'
|
||||
import { LandingPage } from '../pages/landing'
|
||||
import { ProfilePage } from '../pages/profile'
|
||||
import { CacheSettingsPage } from '../pages/settings/cache'
|
||||
import { NostrLoginPage } from '../pages/settings/nostrLogin'
|
||||
import { ProfileSettingsPage } from '../pages/settings/profile'
|
||||
import { RelaysPage } from '../pages/settings/relays'
|
||||
import { SettingsPage } from '../pages/settings/Settings'
|
||||
import { SignPage } from '../pages/sign'
|
||||
import { VerifyPage } from '../pages/verify'
|
||||
|
||||
/**
|
||||
* Helper type allows for extending react-router-dom's **RouteProps** with generic type
|
||||
*/
|
||||
type CustomRouteProps<T> = T &
|
||||
Omit<RouteProps, 'children'> & {
|
||||
children?: Array<CustomRouteProps<T>>
|
||||
}
|
||||
|
||||
/**
|
||||
* This function maps over nested routes with optional condition for rendering
|
||||
* @param {CustomRouteProps<T>[]} routes - routes list
|
||||
* @param {RenderConditionCallbackFn} renderConditionCallbackFn - render condition callback (default true)
|
||||
*/
|
||||
export function recursiveRouteRenderer<T>(
|
||||
routes?: CustomRouteProps<T>[],
|
||||
renderConditionCallbackFn: (route: CustomRouteProps<T>) => boolean = () =>
|
||||
true
|
||||
) {
|
||||
if (!routes) return null
|
||||
|
||||
// Callback allows us to pass arbitrary conditions for each route's rendering
|
||||
// Skipping the callback will by default evaluate to true (show route)
|
||||
return routes.map((route, index) =>
|
||||
renderConditionCallbackFn(route) ? (
|
||||
<Route
|
||||
key={`${route.path}${index}`}
|
||||
path={route.path}
|
||||
element={route.element}
|
||||
>
|
||||
{recursiveRouteRenderer(route.children, renderConditionCallbackFn)}
|
||||
</Route>
|
||||
) : null
|
||||
)
|
||||
}
|
||||
|
||||
type PublicRouteProps = CustomRouteProps<{
|
||||
hiddenWhenLoggedIn?: boolean
|
||||
}>
|
||||
|
||||
export const publicRoutes: PublicRouteProps[] = [
|
||||
{
|
||||
path: appPublicRoutes.landingPage,
|
||||
hiddenWhenLoggedIn: true,
|
||||
element: <LandingPage />
|
||||
},
|
||||
{
|
||||
path: appPublicRoutes.profile,
|
||||
element: <ProfilePage />
|
||||
},
|
||||
{
|
||||
path: `${appPublicRoutes.verify}/:id?`,
|
||||
element: <VerifyPage />
|
||||
}
|
||||
]
|
||||
|
||||
export const privateRoutes = [
|
||||
{
|
||||
path: appPrivateRoutes.homePage,
|
||||
element: <HomePage />
|
||||
},
|
||||
{
|
||||
path: appPrivateRoutes.create,
|
||||
element: <CreatePage />
|
||||
},
|
||||
{
|
||||
path: `${appPrivateRoutes.sign}/:id?`,
|
||||
element: <SignPage />
|
||||
},
|
||||
{
|
||||
path: appPrivateRoutes.settings,
|
||||
element: <SettingsPage />
|
||||
},
|
||||
{
|
||||
path: appPrivateRoutes.profileSettings,
|
||||
element: <ProfileSettingsPage />
|
||||
},
|
||||
{
|
||||
path: appPrivateRoutes.cacheSettings,
|
||||
element: <CacheSettingsPage />
|
||||
},
|
||||
{
|
||||
path: appPrivateRoutes.relays,
|
||||
element: <RelaysPage />
|
||||
},
|
||||
{
|
||||
path: appPrivateRoutes.nostrLogin,
|
||||
element: <NostrLoginPage />
|
||||
}
|
||||
]
|
@ -18,6 +18,7 @@ export interface Meta {
|
||||
docSignatures: { [key: `npub1${string}`]: string }
|
||||
exportSignature?: string
|
||||
keys?: { sender: string; keys: { [user: `npub1${string}`]: string } }
|
||||
timestamps?: OpenTimestamp[]
|
||||
}
|
||||
|
||||
export interface CreateSignatureEventContent {
|
||||
@ -39,6 +40,25 @@ export interface Sigit {
|
||||
meta: Meta
|
||||
}
|
||||
|
||||
export interface OpenTimestamp {
|
||||
nostrId: string
|
||||
value: string
|
||||
verification?: OpenTimestampVerification
|
||||
signature?: string
|
||||
}
|
||||
|
||||
export interface OpenTimestampVerification {
|
||||
height: number
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export interface OpenTimestampUpgradeVerifyResponse {
|
||||
timestamp: OpenTimestamp
|
||||
upgraded: boolean
|
||||
verified?: boolean
|
||||
verification?: OpenTimestampVerification
|
||||
}
|
||||
|
||||
export interface UserAppData {
|
||||
/**
|
||||
* Key will be id of create signature
|
||||
|
5
src/types/event.ts
Normal file
5
src/types/event.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export enum KeyboardCode {
|
||||
Escape = 'Escape',
|
||||
Enter = 'Enter',
|
||||
NumpadEnter = 'NumpadEnter'
|
||||
}
|
@ -4,3 +4,4 @@ export * from './nostr'
|
||||
export * from './profile'
|
||||
export * from './relay'
|
||||
export * from './zip'
|
||||
export * from './event'
|
||||
|
38
src/types/opentimestamps.d.ts
vendored
Normal file
38
src/types/opentimestamps.d.ts
vendored
Normal file
@ -0,0 +1,38 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
interface OpenTimestamps {
|
||||
// Create a detached timestamp file from a buffer or file hash
|
||||
DetachedTimestampFile: {
|
||||
fromHash(op: any, hash: Uint8Array): any
|
||||
fromBytes(op: any, buffer: Uint8Array): any
|
||||
deserialize(buffer: any): any
|
||||
}
|
||||
|
||||
// Stamp the provided timestamp file and return a Promise
|
||||
stamp(file: any): Promise<void>
|
||||
|
||||
// Verify the provided timestamp proof file
|
||||
verify(
|
||||
ots: string,
|
||||
file: string
|
||||
): Promise<TimestampVerficiationResponse | Record<string, never>>
|
||||
|
||||
// Other utilities or operations (like OpSHA256, serialization)
|
||||
Ops: {
|
||||
OpSHA256: any
|
||||
OpSHA1?: any
|
||||
}
|
||||
|
||||
Context: {
|
||||
StreamSerialization: any
|
||||
}
|
||||
|
||||
// Load a timestamp file from a buffer
|
||||
deserialize(bytes: Uint8Array): any
|
||||
|
||||
// Other potential methods based on repo functions
|
||||
upgrade(file: any): Promise<boolean>
|
||||
}
|
||||
|
||||
interface TimestampVerficiationResponse {
|
||||
bitcoin: { timestamp: number; height: number }
|
||||
}
|
1
src/types/system/index.d.ts
vendored
1
src/types/system/index.d.ts
vendored
@ -3,5 +3,6 @@ import type { WindowNostr } from 'nostr-tools/nip07'
|
||||
declare global {
|
||||
interface Window {
|
||||
nostr?: WindowNostr
|
||||
OpenTimestamps: OpenTimestamps
|
||||
}
|
||||
}
|
||||
|
@ -112,3 +112,13 @@ export const MOST_COMMON_MEDIA_TYPES = new Map([
|
||||
['3g2', 'video/3gpp2'], // 3GPP2 audio/video container
|
||||
['7z', 'application/x-7z-compressed'] // 7-zip archive
|
||||
])
|
||||
|
||||
export const SIGNATURE_PAD_OPTIONS = {
|
||||
minWidth: 0.5,
|
||||
maxWidth: 3
|
||||
} as const
|
||||
|
||||
export const SIGNATURE_PAD_SIZE = {
|
||||
width: 300,
|
||||
height: 150
|
||||
}
|
||||
|
@ -1,7 +1,11 @@
|
||||
import { MARK_TYPE_CONFIG } from '../components/MarkTypeStrategy/MarkStrategy.tsx'
|
||||
import { NostrController } from '../controllers/NostrController.ts'
|
||||
import store from '../store/store.ts'
|
||||
import { Meta } from '../types'
|
||||
import { PdfPage } from '../types/drawing.ts'
|
||||
import { MOST_COMMON_MEDIA_TYPES } from './const.ts'
|
||||
import { extractMarksFromSignedMeta } from './mark.ts'
|
||||
import { hexToNpub } from './nostr.ts'
|
||||
import {
|
||||
addMarks,
|
||||
groupMarksByFileNamePage,
|
||||
@ -21,7 +25,49 @@ export const getZipWithFiles = async (
|
||||
for (const [fileName, file] of Object.entries(files)) {
|
||||
// Handle PDF Files, add marks
|
||||
if (file.isPdf && fileName in marksByFileNamePage) {
|
||||
const blob = await addMarks(file, marksByFileNamePage[fileName])
|
||||
const marksToAdd = marksByFileNamePage[fileName]
|
||||
if (meta.keys) {
|
||||
for (let i = 0; i < marks.length; i++) {
|
||||
const m = marks[i]
|
||||
const { sender, keys } = meta.keys
|
||||
const usersPubkey = store.getState().auth.usersPubkey!
|
||||
const usersNpub = hexToNpub(usersPubkey)
|
||||
if (usersNpub in keys) {
|
||||
const encryptionKey = await NostrController.getInstance()
|
||||
.nip04Decrypt(sender, keys[usersNpub])
|
||||
.catch((err) => {
|
||||
console.log(
|
||||
'An error occurred in decrypting encryption key',
|
||||
err
|
||||
)
|
||||
return null
|
||||
})
|
||||
|
||||
try {
|
||||
const { fetchAndDecrypt } = MARK_TYPE_CONFIG[m.type] || {}
|
||||
if (
|
||||
typeof fetchAndDecrypt === 'function' &&
|
||||
m.value &&
|
||||
encryptionKey
|
||||
) {
|
||||
// Fetch and decrypt the original file
|
||||
const link = m.value.split('/')
|
||||
const decrypted = await fetchAndDecrypt(m.value, encryptionKey)
|
||||
|
||||
// Save decrypted
|
||||
zip.file(
|
||||
`signatures/${link[link.length - 1]}.json`,
|
||||
new Blob([decrypted])
|
||||
)
|
||||
marks[i].value = decrypted
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error during mark fetchAndDecrypt phase`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const blob = await addMarks(file, marksToAdd)
|
||||
zip.file(`marked/${fileName}`, blob)
|
||||
}
|
||||
|
||||
|
@ -11,3 +11,4 @@ export * from './string'
|
||||
export * from './url'
|
||||
export * from './utils'
|
||||
export * from './zip'
|
||||
export * from './const'
|
||||
|
@ -24,6 +24,7 @@ import {
|
||||
faStamp,
|
||||
faTableCellsLarge
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
import { MARK_TYPE_CONFIG } from '../components/MarkTypeStrategy/MarkStrategy.tsx'
|
||||
|
||||
/**
|
||||
* Takes in an array of Marks already filtered by User.
|
||||
@ -158,6 +159,11 @@ export const DEFAULT_TOOLBOX: DrawTool[] = [
|
||||
icon: faT,
|
||||
label: 'Text'
|
||||
},
|
||||
{
|
||||
identifier: MarkType.SIGNATURE,
|
||||
icon: faSignature,
|
||||
label: 'Signature'
|
||||
},
|
||||
{
|
||||
identifier: MarkType.FULLNAME,
|
||||
icon: faIdCard,
|
||||
@ -170,12 +176,6 @@ export const DEFAULT_TOOLBOX: DrawTool[] = [
|
||||
label: 'Job Title',
|
||||
isComingSoon: true
|
||||
},
|
||||
{
|
||||
identifier: MarkType.SIGNATURE,
|
||||
icon: faSignature,
|
||||
label: 'Signature',
|
||||
isComingSoon: true
|
||||
},
|
||||
{
|
||||
identifier: MarkType.DATETIME,
|
||||
icon: faClock,
|
||||
@ -266,6 +266,40 @@ export const getToolboxLabelByMarkType = (markType: MarkType) => {
|
||||
return DEFAULT_TOOLBOX.find((t) => t.identifier === markType)?.label
|
||||
}
|
||||
|
||||
export const getOptimizedPathsWithStrokeWidth = (svgString: string) => {
|
||||
const parser = new DOMParser()
|
||||
const xmlDoc = parser.parseFromString(svgString, 'image/svg+xml')
|
||||
const paths = xmlDoc.querySelectorAll('path')
|
||||
const tuples: string[][] = []
|
||||
paths.forEach((path) => {
|
||||
const d = path.getAttribute('d') ?? ''
|
||||
const strokeWidth = path.getAttribute('stroke-width') ?? ''
|
||||
tuples.push([d, strokeWidth])
|
||||
})
|
||||
|
||||
return tuples
|
||||
}
|
||||
|
||||
export const processMarks = async (marks: Mark[], encryptionKey?: string) => {
|
||||
const _marks = [...marks]
|
||||
for (let i = 0; i < _marks.length; i++) {
|
||||
const mark = _marks[i]
|
||||
const hasProcess =
|
||||
mark.type in MARK_TYPE_CONFIG &&
|
||||
typeof MARK_TYPE_CONFIG[mark.type]?.encryptAndUpload === 'function'
|
||||
|
||||
if (hasProcess) {
|
||||
const value = mark.value!
|
||||
const processFn = MARK_TYPE_CONFIG[mark.type]?.encryptAndUpload
|
||||
if (processFn) {
|
||||
mark.value = await processFn(value, encryptionKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return _marks
|
||||
}
|
||||
|
||||
export {
|
||||
getCurrentUserMarks,
|
||||
filterMarksByPubkey,
|
||||
|
119
src/utils/opentimestamps.ts
Normal file
119
src/utils/opentimestamps.ts
Normal file
@ -0,0 +1,119 @@
|
||||
import { OpenTimestamp, OpenTimestampUpgradeVerifyResponse } from '../types'
|
||||
import { retry } from './retry.ts'
|
||||
import { bytesToHex } from '@noble/hashes/utils'
|
||||
import { utf8Encoder } from 'nostr-tools/utils'
|
||||
import { hexStringToUint8Array } from './string.ts'
|
||||
|
||||
/**
|
||||
* Generates a timestamp for the provided nostr event ID.
|
||||
* @returns Timestamp with its value and the nostr event ID.
|
||||
*/
|
||||
export const generateTimestamp = async (
|
||||
nostrId: string
|
||||
): Promise<OpenTimestamp | undefined> => {
|
||||
try {
|
||||
return {
|
||||
value: await retry(() => timestamp(nostrId)),
|
||||
nostrId: nostrId
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to upgrade (i.e. add Bitcoin blockchain attestations) and verify the provided timestamp.
|
||||
* Returns the same timestamp, alongside additional information required to decide if any further
|
||||
* timestamp updates are required.
|
||||
* @param timestamp
|
||||
*/
|
||||
export const upgradeAndVerifyTimestamp = async (
|
||||
timestamp: OpenTimestamp
|
||||
): Promise<OpenTimestampUpgradeVerifyResponse> => {
|
||||
const upgradedResult = await upgrade(timestamp)
|
||||
return await verify(upgradedResult)
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to upgrade a timestamp. If an upgrade is available,
|
||||
* it will add new data to detachedTimestamp.
|
||||
* The upgraded flag indicates if an upgrade has been performed.
|
||||
* @param t - timestamp
|
||||
*/
|
||||
export const upgrade = async (
|
||||
t: OpenTimestamp
|
||||
): Promise<OpenTimestampUpgradeVerifyResponse> => {
|
||||
const detachedTimestamp =
|
||||
window.OpenTimestamps.DetachedTimestampFile.deserialize(
|
||||
hexStringToUint8Array(t.value)
|
||||
)
|
||||
|
||||
const changed: boolean =
|
||||
await window.OpenTimestamps.upgrade(detachedTimestamp)
|
||||
if (changed) {
|
||||
const updated = detachedTimestamp.serializeToBytes()
|
||||
|
||||
const value = {
|
||||
...t,
|
||||
timestamp: bytesToHex(updated)
|
||||
}
|
||||
|
||||
return {
|
||||
timestamp: value,
|
||||
upgraded: true
|
||||
}
|
||||
}
|
||||
return {
|
||||
timestamp: t,
|
||||
upgraded: false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to verify a timestamp. If verification is available,
|
||||
* it will be included in the returned object.
|
||||
* @param t - timestamp
|
||||
*/
|
||||
export const verify = async (
|
||||
t: OpenTimestampUpgradeVerifyResponse
|
||||
): Promise<OpenTimestampUpgradeVerifyResponse> => {
|
||||
const detachedNostrId = window.OpenTimestamps.DetachedTimestampFile.fromBytes(
|
||||
new window.OpenTimestamps.Ops.OpSHA256(),
|
||||
utf8Encoder.encode(t.timestamp.nostrId)
|
||||
)
|
||||
|
||||
const detachedTimestamp =
|
||||
window.OpenTimestamps.DetachedTimestampFile.deserialize(
|
||||
hexStringToUint8Array(t.timestamp.value)
|
||||
)
|
||||
|
||||
const res = await window.OpenTimestamps.verify(
|
||||
detachedTimestamp,
|
||||
detachedNostrId
|
||||
)
|
||||
|
||||
return {
|
||||
...t,
|
||||
verified: !!res.bitcoin,
|
||||
verification: res?.bitcoin || null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Timestamps a nostrId.
|
||||
* @param nostrId
|
||||
*/
|
||||
const timestamp = async (nostrId: string): Promise<string> => {
|
||||
const detachedTimestamp =
|
||||
window.OpenTimestamps.DetachedTimestampFile.fromBytes(
|
||||
new window.OpenTimestamps.Ops.OpSHA256(),
|
||||
utf8Encoder.encode(nostrId)
|
||||
)
|
||||
|
||||
await window.OpenTimestamps.stamp(detachedTimestamp)
|
||||
const ctx = new window.OpenTimestamps.Context.StreamSerialization()
|
||||
detachedTimestamp.serialize(ctx)
|
||||
const timestampBytes = ctx.getOutput()
|
||||
return bytesToHex(timestampBytes)
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import { PdfPage } from '../types/drawing.ts'
|
||||
import { MarkType, PdfPage } from '../types/drawing.ts'
|
||||
import { PDFDocument, PDFFont, PDFPage, rgb } from 'pdf-lib'
|
||||
import { Mark } from '../types/mark.ts'
|
||||
import * as PDFJS from 'pdfjs-dist'
|
||||
@ -11,6 +11,9 @@ if (!PDFJS.GlobalWorkerOptions.workerPort) {
|
||||
|
||||
import fontkit from '@pdf-lib/fontkit'
|
||||
import defaultFont from '../assets/fonts/roboto-regular.ttf'
|
||||
import { BasicPoint } from 'signature_pad/dist/types/point'
|
||||
import SignaturePad from 'signature_pad'
|
||||
import { SIGNATURE_PAD_OPTIONS, SIGNATURE_PAD_SIZE } from './const.ts'
|
||||
|
||||
/**
|
||||
* Defined font size used when generating a PDF. Currently it is difficult to fully
|
||||
@ -132,9 +135,18 @@ export const addMarks = async (
|
||||
|
||||
for (let i = 0; i < pages.length; i++) {
|
||||
if (marksPerPage && Object.hasOwn(marksPerPage, i)) {
|
||||
marksPerPage[i]?.forEach((mark) =>
|
||||
drawMarkText(mark, pages[i], robotoFont)
|
||||
)
|
||||
for (let j = 0; j < marksPerPage[i].length; j++) {
|
||||
const mark = marksPerPage[i][j]
|
||||
switch (mark.type) {
|
||||
case MarkType.SIGNATURE:
|
||||
await embedSignaturePng(mark, pages[i], pdf)
|
||||
break
|
||||
|
||||
default:
|
||||
drawMarkText(mark, pages[i], robotoFont)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -245,3 +257,42 @@ async function embedFont(pdf: PDFDocument) {
|
||||
const embeddedFont = await pdf.embedFont(fontBytes)
|
||||
return embeddedFont
|
||||
}
|
||||
|
||||
const embedSignaturePng = async (
|
||||
mark: Mark,
|
||||
page: PDFPage,
|
||||
pdf: PDFDocument
|
||||
) => {
|
||||
const { location } = mark
|
||||
const { height } = page.getSize()
|
||||
|
||||
if (hasValue(mark)) {
|
||||
const data = JSON.parse(mark.value!).map((p: BasicPoint[]) => ({
|
||||
points: p
|
||||
}))
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = SIGNATURE_PAD_SIZE.width
|
||||
canvas.height = SIGNATURE_PAD_SIZE.height
|
||||
const pad = new SignaturePad(canvas, SIGNATURE_PAD_OPTIONS)
|
||||
pad.fromData(data)
|
||||
const signatureImage = await pdf.embedPng(pad.toDataURL())
|
||||
|
||||
const scaled = signatureImage.scaleToFit(location.width, location.height)
|
||||
|
||||
// Convert the mark location origin (top, left) to PDF origin (bottom, left)
|
||||
// and center the image
|
||||
const x = location.left + (location.width - scaled.width) / 2
|
||||
const y =
|
||||
height -
|
||||
location.top -
|
||||
location.height +
|
||||
(location.height - scaled.height) / 2
|
||||
|
||||
page.drawImage(signatureImage, {
|
||||
x,
|
||||
y,
|
||||
width: scaled.width,
|
||||
height: scaled.height
|
||||
})
|
||||
}
|
||||
}
|
||||
|
25
src/utils/retry.ts
Normal file
25
src/utils/retry.ts
Normal file
@ -0,0 +1,25 @@
|
||||
export const retryAll = async <T>(
|
||||
promises: (() => Promise<T>)[],
|
||||
retries: number = 3,
|
||||
delay: number = 1000
|
||||
) => {
|
||||
const wrappedPromises = promises.map((fn) => retry(fn, retries, delay))
|
||||
return Promise.allSettled(wrappedPromises)
|
||||
}
|
||||
|
||||
export const retry = async <T>(
|
||||
fn: () => Promise<T>,
|
||||
retries: number = 3,
|
||||
delay: number = 1000
|
||||
): Promise<T> => {
|
||||
try {
|
||||
return await fn()
|
||||
} catch (err) {
|
||||
if (retries === 0) {
|
||||
return Promise.reject(err)
|
||||
}
|
||||
return new Promise((resolve) =>
|
||||
setTimeout(() => resolve(retry(fn, retries - 1)), delay)
|
||||
)
|
||||
}
|
||||
}
|
@ -2,6 +2,18 @@ import { TimeoutError } from '../types/errors/TimeoutError.ts'
|
||||
import { CurrentUserFile } from '../types/file.ts'
|
||||
import { SigitFile } from './file.ts'
|
||||
|
||||
export const debounceCustom = <T extends (...args: never[]) => void>(
|
||||
fn: T,
|
||||
delay: number
|
||||
): ((...args: Parameters<T>) => void) => {
|
||||
let timerId: ReturnType<typeof setTimeout>
|
||||
|
||||
return (...args: Parameters<T>) => {
|
||||
clearTimeout(timerId)
|
||||
timerId = setTimeout(() => fn(...args), delay)
|
||||
}
|
||||
}
|
||||
|
||||
export const compareObjects = (
|
||||
obj1: object | null | undefined,
|
||||
obj2: object | null | undefined
|
||||
@ -119,3 +131,15 @@ export const settleAllFullfilfedPromises = async <Item, FulfilledItem = Item>(
|
||||
return acc
|
||||
}, [])
|
||||
}
|
||||
|
||||
export const isPromiseFulfilled = <T>(
|
||||
result: PromiseSettledResult<T>
|
||||
): result is PromiseFulfilledResult<T> => {
|
||||
return result.status === 'fulfilled'
|
||||
}
|
||||
|
||||
export const isPromiseRejected = <T>(
|
||||
result: PromiseSettledResult<T>
|
||||
): result is PromiseRejectedResult => {
|
||||
return result.status === 'rejected'
|
||||
}
|
||||
|
@ -1,9 +1,16 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tsconfigPaths from 'vite-tsconfig-paths'
|
||||
import { nodePolyfills } from 'vite-plugin-node-polyfills'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tsconfigPaths()],
|
||||
plugins: [
|
||||
react(),
|
||||
tsconfigPaths(),
|
||||
nodePolyfills({
|
||||
include: ['os']
|
||||
})
|
||||
],
|
||||
build: {
|
||||
target: 'ES2022'
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user