chore(git): merge branch 'staging' into 92-send-completion-dm

This commit is contained in:
en 2025-01-24 12:00:17 +01:00
commit 1e643c60e5
111 changed files with 7317 additions and 4001 deletions

View File

@ -6,7 +6,7 @@ module.exports = {
'plugin:@typescript-eslint/recommended', 'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended' 'plugin:react-hooks/recommended'
], ],
ignorePatterns: ['dist', '.eslintrc.cjs', 'licenseChecker.cjs'], ignorePatterns: ['dist', '.eslintrc.cjs', 'licenseChecker.cjs', "*.min.js"],
parser: '@typescript-eslint/parser', parser: '@typescript-eslint/parser',
plugins: ['react-refresh'], plugins: ['react-refresh'],
rules: { rules: {

View File

@ -6,19 +6,19 @@ Welcome to Sigit! We are thrilled that you are interested in contributing to thi
### Reporting Bugs ### Reporting Bugs
If you encounter a bug while using Sigit, please [open an issue](https://git.sigit.io/g/web/issues/new) on this repository. Provide as much detail as possible, including steps to reproduce the bug. If you encounter a bug while using Sigit, please [open an issue](https://git.nostrdev.com/sigit/sigit.io/issues/new) on this repository. Provide as much detail as possible, including steps to reproduce the bug.
### Suggesting Enhancements ### Suggesting Enhancements
If you have an idea for how to improve Sigit, we would love to hear from you! [Open an issue](https://git.sigit.io/g/web/issues/new) to suggest an enhancement. If you have an idea for how to improve Sigit, we would love to hear from you! [Open an issue](https://git.nostrdev.com/sigit/sigit.io/issues/new) to suggest an enhancement.
### Pull Requests ### Pull Requests
We welcome pull requests from contributors! To contribute code changes: We welcome pull requests from contributors! To contribute code changes:
1. Fork the repository and create your branch from `main`. 1. Fork the repository and create your branch from `staging`.
2. Make your changes and ensure they pass any existing tests. 2. Make your changes and ensure they pass any existing tests.
3. Write meaningful commit messages. 3. Write meaningful commit messages (conventional commit standard)
4. Submit a pull request, describing your changes in detail and referencing any related issues. 4. Submit a pull request, describing your changes in detail and referencing any related issues.
## Development Setup ## Development Setup
@ -35,4 +35,14 @@ All contributions, including pull requests, undergo code review. Code review ens
## Contact ## Contact
If you have questions or need further assistance, you can reach out to [maintainer's email]. If you have questions or need further assistance, you can reach out to `npub1d0csynrrxcynkcedktdzrdj6gnras2psg48mf46kxjazs8skrjgq9uzhlq`
## Testing
The following items should be tested with each release:
- Create a SIGit with at least 3 signers
- Create a SIGit where the creator is not the first signer
- Create a SIGit where one co-signer has no marks
- Create a SIGit using a file other than a PDF
- Use several login mechanisms, browsers, operating systems whilst testing

View File

@ -8,6 +8,7 @@
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script src="/opentimestamps.min.js"></script>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>

1822
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -30,19 +30,22 @@
"@mui/lab": "5.0.0-alpha.166", "@mui/lab": "5.0.0-alpha.166",
"@mui/material": "5.15.11", "@mui/material": "5.15.11",
"@noble/hashes": "^1.4.0", "@noble/hashes": "^1.4.0",
"@nostr-dev-kit/ndk": "2.5.0", "@nostr-dev-kit/ndk": "2.10.0",
"@nostr-dev-kit/ndk-cache-dexie": "2.5.1",
"@pdf-lib/fontkit": "^1.1.1", "@pdf-lib/fontkit": "^1.1.1",
"@reduxjs/toolkit": "2.2.1", "@reduxjs/toolkit": "2.2.1",
"axios": "^1.7.4", "axios": "^1.7.4",
"crypto-hash": "3.0.0", "crypto-hash": "3.0.0",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"dexie": "4.0.8",
"dnd-core": "16.0.1", "dnd-core": "16.0.1",
"file-saver": "2.0.5", "file-saver": "2.0.5",
"idb": "8.0.0", "idb": "8.0.0",
"jszip": "3.10.1", "jszip": "3.10.1",
"lodash": "4.17.21", "lodash": "4.17.21",
"material-ui-popup-state": "^5.3.1",
"mui-file-input": "4.0.4", "mui-file-input": "4.0.4",
"nostr-login": "^1.6.6", "nostr-login": "1.6.14",
"nostr-tools": "2.7.0", "nostr-tools": "2.7.0",
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"pdfjs-dist": "^4.4.168", "pdfjs-dist": "^4.4.168",
@ -57,7 +60,9 @@
"react-singleton-hook": "^4.0.1", "react-singleton-hook": "^4.0.1",
"react-toastify": "10.0.4", "react-toastify": "10.0.4",
"redux": "5.0.1", "redux": "5.0.1",
"tseep": "1.2.1" "signature_pad": "^5.0.4",
"tseep": "1.2.1",
"use-immer": "^0.11.0"
}, },
"devDependencies": { "devDependencies": {
"@types/crypto-js": "^4.2.2", "@types/crypto-js": "^4.2.2",
@ -66,6 +71,7 @@
"@types/pdfjs-dist": "^2.10.378", "@types/pdfjs-dist": "^2.10.378",
"@types/react": "^18.2.56", "@types/react": "^18.2.56",
"@types/react-dom": "^18.2.19", "@types/react-dom": "^18.2.19",
"@types/svgo": "^3.0.0",
"@typescript-eslint/eslint-plugin": "^7.0.2", "@typescript-eslint/eslint-plugin": "^7.0.2",
"@typescript-eslint/parser": "^7.0.2", "@typescript-eslint/parser": "^7.0.2",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",
@ -78,6 +84,7 @@
"ts-css-modules-vite-plugin": "1.0.20", "ts-css-modules-vite-plugin": "1.0.20",
"typescript": "^5.2.2", "typescript": "^5.2.2",
"vite": "^5.1.4", "vite": "^5.1.4",
"vite-plugin-node-polyfills": "^0.22.0",
"vite-tsconfig-paths": "4.3.2" "vite-tsconfig-paths": "4.3.2"
}, },
"lint-staged": { "lint-staged": {

2
public/opentimestamps.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -1,18 +1,21 @@
import { useEffect } from 'react' import { useEffect } from 'react'
import { useAppSelector } from './hooks/store'
import { Navigate, Route, Routes } from 'react-router-dom' import { Navigate, Route, Routes } from 'react-router-dom'
import { AuthController } from './controllers'
import { useAppSelector, useAuth } from './hooks'
import { MainLayout } from './layouts/Main' import { MainLayout } from './layouts/Main'
import { appPrivateRoutes, appPublicRoutes } from './routes'
import { import {
appPrivateRoutes,
appPublicRoutes,
privateRoutes, privateRoutes,
publicRoutes, publicRoutes,
recursiveRouteRenderer recursiveRouteRenderer
} from './routes' } from './routes/util'
import './App.scss' import './App.scss'
const App = () => { const App = () => {
const { checkSession } = useAuth()
const authState = useAppSelector((state) => state.auth) const authState = useAppSelector((state) => state.auth)
useEffect(() => { useEffect(() => {
@ -23,16 +26,17 @@ const App = () => {
window.location.hostname = 'localhost' window.location.hostname = 'localhost'
} }
const authController = new AuthController() checkSession()
authController.checkSession() }, [checkSession])
}, [])
const handleRootRedirect = () => { const handleRootRedirect = () => {
if (authState.loggedIn) return appPrivateRoutes.homePage if (authState.loggedIn) return appPrivateRoutes.homePage
const callbackPathEncoded = btoa( const callbackPathEncoded = btoa(
window.location.href.split(`${window.location.origin}/#`)[1] window.location.href.split(`${window.location.origin}/#`)[1]
) )
return `${appPublicRoutes.login}?callbackPath=${callbackPathEncoded}`
return `${appPublicRoutes.landingPage}?callbackPath=${callbackPathEncoded}`
} }
// Hide route only if loggedIn and r.hiddenWhenLoggedIn are both true // Hide route only if loggedIn and r.hiddenWhenLoggedIn are both true

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

View File

@ -37,30 +37,19 @@ export const AppBar = () => {
const [anchorElUser, setAnchorElUser] = useState<null | HTMLElement>(null) const [anchorElUser, setAnchorElUser] = useState<null | HTMLElement>(null)
const authState = useAppSelector((state) => state.auth) const authState = useAppSelector((state) => state.auth)
const metadataState = useAppSelector((state) => state.metadata) const userProfile = useAppSelector((state) => state.user.profile)
const userRobotImage = useAppSelector((state) => state.userRobotImage) const userRobotImage = useAppSelector((state) => state.user.robotImage)
useEffect(() => { useEffect(() => {
if (metadataState) { const npub = authState.usersPubkey ? hexToNpub(authState.usersPubkey) : ''
if (metadataState.content) { if (userProfile) {
const profileMetadata = JSON.parse(metadataState.content) setUserAvatar(userProfile.image || userRobotImage || '')
const { picture } = profileMetadata setUsername(getProfileUsername(npub, userProfile))
if (picture || userRobotImage) {
setUserAvatar(picture || userRobotImage)
}
const npub = authState.usersPubkey
? hexToNpub(authState.usersPubkey)
: ''
setUsername(getProfileUsername(npub, profileMetadata))
} else { } else {
setUserAvatar(userRobotImage || '') setUserAvatar('')
setUsername('') setUsername(getProfileUsername(npub))
} }
} }, [userRobotImage, authState.usersPubkey, userProfile])
}, [metadataState, userRobotImage, authState.usersPubkey])
const handleOpenUserMenu = (event: React.MouseEvent<HTMLElement>) => { const handleOpenUserMenu = (event: React.MouseEvent<HTMLElement>) => {
setAnchorElUser(event.currentTarget) setAnchorElUser(event.currentTarget)
@ -121,7 +110,17 @@ export const AppBar = () => {
<Container> <Container>
<Toolbar className={styles.toolbar} disableGutters={true}> <Toolbar className={styles.toolbar} disableGutters={true}>
<Box className={styles.logoWrapper}> <Box className={styles.logoWrapper}>
<img src="/logo.svg" alt="Logo" onClick={() => navigate('/')} /> <img
src="/logo.svg"
alt="Logo"
onClick={() => {
if (['', '#/'].includes(window.location.hash)) {
location.reload()
} else {
navigate('/')
}
}}
/>
</Box> </Box>
<Box className={styles.rightSideBox}> <Box className={styles.rightSideBox}>

View File

@ -0,0 +1,24 @@
import { PropsWithChildren } from 'react'
import styles from './style.module.scss'
interface ButtonUnderlineProps {
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void
disabled?: boolean | undefined
}
export const ButtonUnderline = ({
onClick,
disabled = false,
children
}: PropsWithChildren<ButtonUnderlineProps>) => {
return (
<button
type="button"
className={styles.button}
disabled={disabled}
onClick={onClick}
>
{children}
</button>
)
}

View File

@ -0,0 +1,25 @@
@import '../../styles/colors.scss';
.button {
color: $primary-main !important;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
width: max-content;
margin: 0 auto;
// Override default styling
border: none !important;
outline: none !important;
// Override leaky css in sign page
background: transparent !important;
&:focus,
&:hover {
text-decoration: underline;
text-decoration-color: inherit;
}
}

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,4 @@
import { CurrentUserMark } from '../../types/mark.ts' import { CurrentUserMark } from '../../types/mark.ts'
import styles from './style.module.scss'
import { import {
findNextIncompleteCurrentUserMark, findNextIncompleteCurrentUserMark,
getToolboxLabelByMarkType, getToolboxLabelByMarkType,
@ -7,15 +6,22 @@ import {
isCurrentValueLast isCurrentValueLast
} from '../../utils' } from '../../utils'
import React, { useState } from 'react' import React, { useState } from 'react'
import { MarkInput } from '../MarkTypeStrategy/MarkInput.tsx'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faCheck, faDownload } from '@fortawesome/free-solid-svg-icons'
import { Button } from '@mui/material'
import styles from './style.module.scss'
import { ButtonUnderline } from '../ButtonUnderline/index.tsx'
interface MarkFormFieldProps { interface MarkFormFieldProps {
currentUserMarks: CurrentUserMark[] currentUserMarks: CurrentUserMark[]
handleCurrentUserMarkChange: (mark: CurrentUserMark) => void handleCurrentUserMarkChange: (mark: CurrentUserMark) => void
handleSelectedMarkValueChange: ( handleSelectedMarkValueChange: (value: string) => void
event: React.ChangeEvent<HTMLInputElement> handleSubmit: (
event: React.MouseEvent<HTMLButtonElement>,
type: 'online' | 'offline'
) => void ) => void
handleSubmit: (event: React.FormEvent<HTMLFormElement>) => void selectedMark: CurrentUserMark | null
selectedMark: CurrentUserMark
selectedMarkValue: string selectedMarkValue: string
} }
@ -31,28 +37,52 @@ const MarkFormField = ({
handleCurrentUserMarkChange handleCurrentUserMarkChange
}: MarkFormFieldProps) => { }: MarkFormFieldProps) => {
const [displayActions, setDisplayActions] = useState(true) const [displayActions, setDisplayActions] = useState(true)
const [complete, setComplete] = useState(false)
const isReadyToSign = () => const isReadyToSign = () =>
isCurrentUserMarksComplete(currentUserMarks) || isCurrentUserMarksComplete(currentUserMarks) ||
isCurrentValueLast(currentUserMarks, selectedMark, selectedMarkValue) isCurrentValueLast(currentUserMarks, selectedMark, selectedMarkValue)
const isCurrent = (currentMark: CurrentUserMark) => const isCurrent = (currentMark: CurrentUserMark) =>
currentMark.id === selectedMark.id currentMark.id === selectedMark?.id && !complete
const isDone = (currentMark: CurrentUserMark) => const isDone = (currentMark: CurrentUserMark) =>
isCurrent(currentMark) ? !!selectedMarkValue : currentMark.isCompleted isCurrent(currentMark) ? !!selectedMarkValue : currentMark.isCompleted
const findNext = () => { const findNext = () => {
return ( return (
currentUserMarks[selectedMark.id] || currentUserMarks[selectedMark!.id] ||
findNextIncompleteCurrentUserMark(currentUserMarks) findNextIncompleteCurrentUserMark(currentUserMarks)
) )
} }
const handleFormSubmit = (event: React.FormEvent<HTMLFormElement>) => { const handleFormSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault() event.preventDefault()
console.log('handle form submit runs...') // Without this line, we lose mark values when switching
return isReadyToSign() handleCurrentUserMarkChange(selectedMark!)
? handleSubmit(event)
if (!complete) {
isReadyToSign()
? setComplete(true)
: handleCurrentUserMarkChange(findNext()!) : handleCurrentUserMarkChange(findNext()!)
} }
}
const toggleActions = () => setDisplayActions(!displayActions) const toggleActions = () => setDisplayActions(!displayActions)
const markLabel = getToolboxLabelByMarkType(selectedMark.mark.type) const markLabel = selectedMark
? getToolboxLabelByMarkType(selectedMark.mark.type)
: ''
const handleCurrentUserMarkClick = (mark: CurrentUserMark) => {
setComplete(false)
handleCurrentUserMarkChange(mark)
}
const handleSelectCompleteMark = () => {
if (currentUserMarks.length) handleCurrentUserMarkChange(selectedMark!)
setComplete(true)
}
const handleSignAndComplete =
(type: 'online' | 'offline') =>
(event: React.MouseEvent<HTMLButtonElement>) => {
handleSubmit(event, type)
}
return ( return (
<div className={styles.container}> <div className={styles.container}>
<div className={styles.trigger}> <div className={styles.trigger}>
@ -78,31 +108,64 @@ const MarkFormField = ({
<div className={styles.actionsWrapper}> <div className={styles.actionsWrapper}>
<div className={styles.actionsTop}> <div className={styles.actionsTop}>
<div className={styles.actionsTopInfo}> <div className={styles.actionsTopInfo}>
{!complete && selectedMark ? (
<p className={styles.actionsTopInfoText}>Add {markLabel}</p> <p className={styles.actionsTopInfoText}>Add {markLabel}</p>
) : (
<p className={styles.actionsTopInfoText}>Finish</p>
)}
</div> </div>
</div> </div>
<div className={styles.inputWrapper}> <div className={styles.inputWrapper}>
{!complete && selectedMark ? (
<form onSubmit={(e) => handleFormSubmit(e)}> <form onSubmit={(e) => handleFormSubmit(e)}>
<input <MarkInput
className={styles.input} markType={selectedMark.mark.type}
placeholder={markLabel} key={selectedMark.id}
onChange={handleSelectedMarkValueChange}
value={selectedMarkValue} value={selectedMarkValue}
placeholder={markLabel}
handler={handleSelectedMarkValueChange}
userMark={selectedMark}
/> />
<div className={styles.actionsBottom}> <div className={styles.actionsBottom}>
<button type="submit" className={styles.submitButton}> <Button type="submit" className={styles.submitButton}>
NEXT NEXT
</button> </Button>
</div> </div>
</form> </form>
) : (
<>
<div className={styles.actionsBottom}>
<Button
onClick={handleSignAndComplete('online')}
className={[
styles.submitButton,
styles.completeButton
].join(' ')}
disabled={!isReadyToSign()}
autoFocus
>
SIGN AND BROADCAST
</Button>
</div>
<ButtonUnderline
onClick={handleSignAndComplete('offline')}
disabled={!isReadyToSign()}
>
<FontAwesomeIcon icon={faDownload} />
Sign and export locally instead
</ButtonUnderline>
</>
)}
<div className={styles.footerContainer}> <div className={styles.footerContainer}>
<div className={styles.footer}> <div className={styles.footer}>
{currentUserMarks.map((mark, index) => { {currentUserMarks.map((mark, index) => {
return ( return (
<div className={styles.pagination} key={index}> <div className={styles.pagination} key={index}>
<button <button
className={`${styles.paginationButton} ${isDone(mark) && styles.paginationButtonDone}`} type="button"
onClick={() => handleCurrentUserMarkChange(mark)} className={`${styles.paginationButton} ${isDone(mark) ? styles.paginationButtonDone : ''}`}
onClick={() => handleCurrentUserMarkClick(mark)}
> >
{mark.id} {mark.id}
</button> </button>
@ -112,6 +175,22 @@ const MarkFormField = ({
</div> </div>
) )
})} })}
<div className={styles.pagination}>
<button
type="button"
className={`${styles.paginationButton} ${isReadyToSign() ? styles.paginationButtonDone : ''}`}
onClick={handleSelectCompleteMark}
title="Complete"
>
<FontAwesomeIcon
className={styles.finishPage}
icon={faCheck}
/>
</button>
{complete && (
<div className={styles.paginationButtonCurrent}></div>
)}
</div>
</div> </div>
</div> </div>
</div> </div>

View File

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

View File

@ -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
}

View 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>
)

View 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
}

View 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;
}
}

View 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>
)
}

View File

@ -0,0 +1,9 @@
.img {
width: 100%;
height: 100%;
object-fit: contain;
overflow: hidden;
pointer-events: none;
-webkit-user-select: none;
user-select: none;
}

View 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
}

View File

@ -0,0 +1,89 @@
import axios from 'axios'
import {
decryptArrayBuffer,
encryptArrayBuffer,
getHash,
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`)
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
)
}
}
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
}
return value
}
}

View 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}
/>
)
}

View File

@ -0,0 +1,7 @@
import { MarkStrategy } from '../MarkStrategy'
import { MarkInputText } from './Input'
export const TextStrategy: MarkStrategy = {
input: MarkInputText,
render: ({ value }) => <>{value}</>
}

View File

@ -4,6 +4,7 @@ import { FONT_SIZE, FONT_TYPE, inPx } from '../../utils/pdf.ts'
import { useScale } from '../../hooks/useScale.tsx' import { useScale } from '../../hooks/useScale.tsx'
import { forwardRef } from 'react' import { forwardRef } from 'react'
import { npubToHex } from '../../utils/nostr.ts' import { npubToHex } from '../../utils/nostr.ts'
import { MarkRender } from '../MarkTypeStrategy/MarkRender.tsx'
interface PdfMarkItemProps { interface PdfMarkItemProps {
userMark: CurrentUserMark userMark: CurrentUserMark
@ -31,7 +32,7 @@ const PdfMarkItem = forwardRef<HTMLDivElement, PdfMarkItemProps>(
<div <div
ref={ref} ref={ref}
onClick={handleClick} onClick={handleClick}
className={`file-mark ${styles.drawingRectangle} ${isEdited() && styles.edited}`} className={`file-mark ${styles.signingRectangle} ${isEdited() && styles.edited}`}
style={{ style={{
backgroundColor: selectedMark?.mark.npub backgroundColor: selectedMark?.mark.npub
? `#${npubToHex(selectedMark?.mark.npub)?.substring(0, 6)}4b` ? `#${npubToHex(selectedMark?.mark.npub)?.substring(0, 6)}4b`
@ -47,7 +48,12 @@ const PdfMarkItem = forwardRef<HTMLDivElement, PdfMarkItemProps>(
fontSize: inPx(from(pageWidth, FONT_SIZE)) fontSize: inPx(from(pageWidth, FONT_SIZE))
}} }}
> >
{getMarkValue()} <MarkRender
key={getMarkValue()}
markType={userMark.mark.type}
value={getMarkValue()}
mark={userMark.mark}
/>
</div> </div>
) )
} }

View File

@ -24,11 +24,11 @@ import {
interface PdfMarkingProps { interface PdfMarkingProps {
currentUserMarks: CurrentUserMark[] currentUserMarks: CurrentUserMark[]
files: CurrentUserFile[] files: CurrentUserFile[]
handleDownload: () => void handleSign: () => void
handleSignOffline: () => void
meta: Meta | null meta: Meta | null
otherUserMarks: Mark[] otherUserMarks: Mark[]
setCurrentUserMarks: (currentUserMarks: CurrentUserMark[]) => void setCurrentUserMarks: (currentUserMarks: CurrentUserMark[]) => void
setIsMarksCompleted: (isMarksCompleted: boolean) => void
setUpdatedMarks: (markToUpdate: Mark) => void setUpdatedMarks: (markToUpdate: Mark) => void
} }
@ -38,17 +38,16 @@ interface PdfMarkingProps {
* @param props * @param props
* @constructor * @constructor
*/ */
const PdfMarking = (props: PdfMarkingProps) => { const PdfMarking = ({
const {
files, files,
currentUserMarks, currentUserMarks,
setIsMarksCompleted,
setCurrentUserMarks, setCurrentUserMarks,
setUpdatedMarks, setUpdatedMarks,
handleDownload, handleSign,
handleSignOffline,
meta, meta,
otherUserMarks otherUserMarks
} = props }: PdfMarkingProps) => {
const [selectedMark, setSelectedMark] = useState<CurrentUserMark | null>(null) const [selectedMark, setSelectedMark] = useState<CurrentUserMark | null>(null)
const [selectedMarkValue, setSelectedMarkValue] = useState<string>('') const [selectedMarkValue, setSelectedMarkValue] = useState<string>('')
const [currentFile, setCurrentFile] = useState<CurrentUserFile | null>(null) const [currentFile, setCurrentFile] = useState<CurrentUserFile | null>(null)
@ -70,8 +69,8 @@ const PdfMarking = (props: PdfMarkingProps) => {
const handleMarkClick = (id: number) => { const handleMarkClick = (id: number) => {
const nextMark = currentUserMarks.find((mark) => mark.mark.id === id) const nextMark = currentUserMarks.find((mark) => mark.mark.id === id)
setSelectedMark(nextMark!)
setSelectedMarkValue(nextMark?.mark.value ?? EMPTY) if (nextMark) handleCurrentUserMarkChange(nextMark)
} }
const handleCurrentUserMarkChange = (mark: CurrentUserMark) => { const handleCurrentUserMarkChange = (mark: CurrentUserMark) => {
@ -86,14 +85,23 @@ const PdfMarking = (props: PdfMarkingProps) => {
updatedSelectedMark updatedSelectedMark
) )
setCurrentUserMarks(updatedCurrentUserMarks) setCurrentUserMarks(updatedCurrentUserMarks)
// If clicking on the same mark, don't update the value, otherwise do update
if (mark.id !== selectedMark.id) {
setSelectedMarkValue(mark.currentValue ?? EMPTY) setSelectedMarkValue(mark.currentValue ?? EMPTY)
setSelectedMark(mark) setSelectedMark(mark)
} }
}
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => { /**
* Sign and Complete
*/
const handleSubmit = (
event: React.MouseEvent<HTMLButtonElement>,
type: 'online' | 'offline'
) => {
event.preventDefault() event.preventDefault()
if (!selectedMarkValue || !selectedMark) return if (selectedMarkValue && selectedMark) {
const updatedMark: CurrentUserMark = getUpdatedMark( const updatedMark: CurrentUserMark = getUpdatedMark(
selectedMark, selectedMark,
selectedMarkValue selectedMarkValue
@ -106,19 +114,16 @@ const PdfMarking = (props: PdfMarkingProps) => {
) )
setCurrentUserMarks(updatedCurrentUserMarks) setCurrentUserMarks(updatedCurrentUserMarks)
setSelectedMark(null) setSelectedMark(null)
setIsMarksCompleted(true)
setUpdatedMarks(updatedMark.mark) setUpdatedMarks(updatedMark.mark)
} }
// const updateCurrentUserMarkValues = () => { if (type === 'online') handleSign()
// const updatedMark: CurrentUserMark = getUpdatedMark(selectedMark!, selectedMarkValue) else if (type === 'offline') handleSignOffline()
// const updatedCurrentUserMarks = updateCurrentUserMarks(currentUserMarks, updatedMark) }
// setSelectedMarkValue(EMPTY)
// setCurrentUserMarks(updatedCurrentUserMarks)
// }
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => const handleChange = (value: string) => {
setSelectedMarkValue(event.target.value) setSelectedMarkValue(value)
}
return ( return (
<> <>
@ -131,7 +136,6 @@ const PdfMarking = (props: PdfMarkingProps) => {
files={files} files={files}
currentFile={currentFile} currentFile={currentFile}
setCurrentFile={setCurrentFile} setCurrentFile={setCurrentFile}
handleDownload={handleDownload}
/> />
)} )}
</div> </div>
@ -141,7 +145,6 @@ const PdfMarking = (props: PdfMarkingProps) => {
centerIcon={faPen} centerIcon={faPen}
rightIcon={faCircleInfo} rightIcon={faCircleInfo}
> >
{currentUserMarks?.length > 0 && (
<PdfView <PdfView
currentFile={currentFile} currentFile={currentFile}
files={files} files={files}
@ -151,9 +154,7 @@ const PdfMarking = (props: PdfMarkingProps) => {
currentUserMarks={currentUserMarks} currentUserMarks={currentUserMarks}
otherUserMarks={otherUserMarks} otherUserMarks={otherUserMarks}
/> />
)}
</StickySideColumns> </StickySideColumns>
{selectedMark !== null && (
<MarkFormField <MarkFormField
handleSubmit={handleSubmit} handleSubmit={handleSubmit}
handleSelectedMarkValueChange={handleChange} handleSelectedMarkValueChange={handleChange}
@ -162,7 +163,6 @@ const PdfMarking = (props: PdfMarkingProps) => {
currentUserMarks={currentUserMarks} currentUserMarks={currentUserMarks}
handleCurrentUserMarkChange={handleCurrentUserMarkChange} handleCurrentUserMarkChange={handleCurrentUserMarkChange}
/> />
)}
</Container> </Container>
</> </>
) )

View File

@ -6,6 +6,7 @@ import { useEffect, useRef } from 'react'
import pdfViewStyles from './style.module.scss' import pdfViewStyles from './style.module.scss'
import { FONT_SIZE, FONT_TYPE, inPx } from '../../utils/pdf.ts' import { FONT_SIZE, FONT_TYPE, inPx } from '../../utils/pdf.ts'
import { useScale } from '../../hooks/useScale.tsx' import { useScale } from '../../hooks/useScale.tsx'
import { MarkRender } from '../MarkTypeStrategy/MarkRender.tsx'
interface PdfPageProps { interface PdfPageProps {
fileName: string fileName: string
pageIndex: number pageIndex: number
@ -73,7 +74,7 @@ const PdfPageItem = ({
fontSize: inPx(from(page.width, FONT_SIZE)) fontSize: inPx(from(page.width, FONT_SIZE))
}} }}
> >
{m.value} <MarkRender value={m.value} mark={m} markType={m.type} />
</div> </div>
) )
})} })}

View File

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

View File

@ -23,7 +23,7 @@ export const UserAvatar = ({
}: UserAvatarProps) => { }: UserAvatarProps) => {
const profile = useProfileMetadata(pubkey) const profile = useProfileMetadata(pubkey)
const name = getProfileUsername(pubkey, profile) const name = getProfileUsername(pubkey, profile)
const image = profile?.picture const image = profile?.image
return ( return (
<Link <Link

View File

@ -4,6 +4,7 @@ import {
fromUnixTimestamp, fromUnixTimestamp,
hexToNpub, hexToNpub,
npubToHex, npubToHex,
SigitStatus,
SignStatus SignStatus
} from '../../utils' } from '../../utils'
import { useSigitMeta } from '../../hooks/useSigitMeta' import { useSigitMeta } from '../../hooks/useSigitMeta'
@ -15,6 +16,8 @@ import {
faCalendar, faCalendar,
faCalendarCheck, faCalendarCheck,
faCalendarPlus, faCalendarPlus,
faCheck,
faClock,
faEye, faEye,
faFile, faFile,
faFileCircleExclamation faFileCircleExclamation
@ -22,7 +25,7 @@ import {
import { getExtensionIconLabel } from '../getExtensionIconLabel' import { getExtensionIconLabel } from '../getExtensionIconLabel'
import { useAppSelector } from '../../hooks/store' import { useAppSelector } from '../../hooks/store'
import { DisplaySigner } from '../DisplaySigner' import { DisplaySigner } from '../DisplaySigner'
import { Meta } from '../../types' import { Meta, OpenTimestamp } from '../../types'
import { extractFileExtensions } from '../../utils/file' import { extractFileExtensions } from '../../utils/file'
import { UserAvatar } from '../UserAvatar' import { UserAvatar } from '../UserAvatar'
@ -42,7 +45,9 @@ export const UsersDetails = ({ meta }: UsersDetailsProps) => {
completedAt, completedAt,
parsedSignatureEvents, parsedSignatureEvents,
signedStatus, signedStatus,
isValid isValid,
id,
timestamps
} = useSigitMeta(meta) } = useSigitMeta(meta)
const { usersPubkey } = useAppSelector((state) => state.auth) const { usersPubkey } = useAppSelector((state) => state.auth)
const userCanSign = const userCanSign =
@ -51,6 +56,50 @@ export const UsersDetails = ({ meta }: UsersDetailsProps) => {
const { extensions, isSame } = extractFileExtensions(Object.keys(fileHashes)) 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 ? ( return submittedBy ? (
<div className={styles.container}> <div className={styles.container}>
<div className={styles.section}> <div className={styles.section}>
@ -115,19 +164,35 @@ export const UsersDetails = ({ meta }: UsersDetailsProps) => {
<p>Details</p> <p>Details</p>
<Tooltip <Tooltip
title={'Publication date'} title={getTimestampTooltipTitle(
'Publication date',
!!(timestamps && id && isTimestampVerified(timestamps, id))
)}
placement="top" placement="top"
arrow arrow
disableInteractive disableInteractive
> >
<span className={styles.detailsItem}> <span className={styles.detailsItem}>
<FontAwesomeIcon icon={faCalendarPlus} />{' '} <FontAwesomeIcon icon={faCalendarPlus} />{' '}
{createdAt ? formatTimestamp(createdAt) : <>&mdash;</>} {createdAt ? formatTimestamp(createdAt) : <>&mdash;</>}{' '}
{timestamps &&
timestamps.length > 0 &&
id &&
getOpenTimestampsInfo(timestamps, id)}
</span> </span>
</Tooltip> </Tooltip>
<Tooltip <Tooltip
title={'Completion date'} title={getTimestampTooltipTitle(
'Completion date',
!!(
signedStatus === SigitStatus.Complete &&
completedAt &&
timestamps &&
timestamps.length > 0 &&
timestamps[timestamps.length - 1].verification
)
)}
placement="top" placement="top"
arrow arrow
disableInteractive disableInteractive
@ -135,13 +200,26 @@ export const UsersDetails = ({ meta }: UsersDetailsProps) => {
<span className={styles.detailsItem}> <span className={styles.detailsItem}>
<FontAwesomeIcon icon={faCalendarCheck} />{' '} <FontAwesomeIcon icon={faCalendarCheck} />{' '}
{completedAt ? formatTimestamp(completedAt) : <>&mdash;</>} {completedAt ? formatTimestamp(completedAt) : <>&mdash;</>}
{signedStatus === SigitStatus.Complete &&
completedAt &&
timestamps &&
timestamps.length > 0 && (
<span className={styles.ticket}>
{getCompletedOpenTimestampsInfo(
timestamps[timestamps.length - 1]
)}
</span>
)}
</span> </span>
</Tooltip> </Tooltip>
{/* User signed date */} {/* User signed date */}
{userCanSign ? ( {userCanSign ? (
<Tooltip <Tooltip
title={'Your signature date'} title={getTimestampTooltipTitle(
'Your signature date',
isUserSignatureTimestampVerified()
)}
placement="top" placement="top"
arrow arrow
disableInteractive disableInteractive
@ -161,6 +239,16 @@ export const UsersDetails = ({ meta }: UsersDetailsProps) => {
) : ( ) : (
<>&mdash;</> <>&mdash;</>
)} )}
{hexToNpub(usersPubkey) in parsedSignatureEvents &&
timestamps &&
timestamps.length > 0 && (
<span className={styles.ticket}>
{getOpenTimestampsInfo(
timestamps,
parsedSignatureEvents[hexToNpub(usersPubkey)].id
)}
</span>
)}
</span> </span>
</Tooltip> </Tooltip>
) : null} ) : null}

View File

@ -31,8 +31,6 @@
padding: 5px; padding: 5px;
display: flex; display: flex;
align-items: center;
justify-content: start;
> :first-child { > :first-child {
padding: 5px; padding: 5px;
@ -44,3 +42,7 @@
color: white; color: white;
} }
} }
.ticket {
margin-left: auto;
}

299
src/contexts/NDKContext.tsx Normal file
View File

@ -0,0 +1,299 @@
import NDK, {
getRelayListForUser,
Hexpubkey,
NDKEvent,
NDKFilter,
NDKRelayList,
NDKRelaySet,
NDKSubscriptionCacheUsage,
NDKSubscriptionOptions,
NDKUser,
NDKUserProfile
} from '@nostr-dev-kit/ndk'
import NDKCacheAdapterDexie from '@nostr-dev-kit/ndk-cache-dexie'
import { Dexie } from 'dexie'
import { createContext, ReactNode, useEffect, useMemo } from 'react'
import { toast } from 'react-toastify'
import { UserRelaysType } from '../types'
import {
DEFAULT_LOOK_UP_RELAY_LIST,
hexToNpub,
orderEventsChronologically,
SIGIT_RELAY,
timeout
} from '../utils'
export interface NDKContextType {
ndk: NDK
fetchEvents: (
filter: NDKFilter,
opts?: NDKSubscriptionOptions
) => Promise<NDKEvent[]>
fetchEvent: (
filter: NDKFilter,
opts?: NDKSubscriptionOptions
) => Promise<NDKEvent | null>
fetchEventsFromUserRelays: (
filter: NDKFilter | NDKFilter[],
hexKey: string,
userRelaysType: UserRelaysType,
opts?: NDKSubscriptionOptions
) => Promise<NDKEvent[]>
fetchEventFromUserRelays: (
filter: NDKFilter | NDKFilter[],
hexKey: string,
userRelaysType: UserRelaysType,
opts?: NDKSubscriptionOptions
) => Promise<NDKEvent | null>
findMetadata: (
pubkey: string,
opts?: NDKSubscriptionOptions
) => Promise<NDKUserProfile | null>
getNDKRelayList: (pubkey: Hexpubkey) => Promise<NDKRelayList>
publish: (event: NDKEvent, explicitRelayUrls?: string[]) => Promise<string[]>
}
// Create the context with an initial value of `null`
export const NDKContext = createContext<NDKContextType | null>(null)
// Create a provider component to wrap around parts of your app
export const NDKContextProvider = ({ children }: { children: ReactNode }) => {
useEffect(() => {
window.onunhandledrejection = async (event: PromiseRejectionEvent) => {
event.preventDefault()
if (event.reason?.name === Dexie.errnames.DatabaseClosed) {
console.log(
'Could not open Dexie DB, probably version change. Deleting old DB and reloading...'
)
await Dexie.delete('degmod-db')
// Must reload to open a brand new DB
window.location.reload()
}
}
}, [])
const ndk = useMemo(() => {
if (import.meta.env.MODE === 'development') {
localStorage.setItem('debug', '*')
}
const dexieAdapter = new NDKCacheAdapterDexie({ dbName: 'sigit-db' })
dexieAdapter.locking = true
const ndk = new NDK({
enableOutboxModel: true,
autoConnectUserRelays: true,
autoFetchUserMutelist: true,
explicitRelayUrls: [...DEFAULT_LOOK_UP_RELAY_LIST],
cacheAdapter: dexieAdapter
})
ndk.connect()
return ndk
}, [])
/**
* Asynchronously retrieves multiple event based on a provided filter.
*
* @param filter - The filter criteria to find the event.
* @returns Returns a promise that resolves to the found event or null if not found.
*/
const fetchEvents = async (
filter: NDKFilter,
opts?: NDKSubscriptionOptions
): Promise<NDKEvent[]> => {
return ndk
.fetchEvents(filter, {
closeOnEose: true,
cacheUsage: NDKSubscriptionCacheUsage.PARALLEL,
...opts
})
.then((ndkEventSet) => {
const ndkEvents = Array.from(ndkEventSet)
return orderEventsChronologically(ndkEvents)
})
.catch((err) => {
// Log the error and show a notification if fetching fails
console.error('An error occurred in fetching events', err)
toast.error('An error occurred in fetching events') // Show error notification
return [] // Return an empty array in case of an error
})
}
/**
* Asynchronously retrieves an event based on a provided filter.
*
* @param filter - The filter criteria to find the event.
* @returns Returns a promise that resolves to the found event or null if not found.
*/
const fetchEvent = async (
filter: NDKFilter,
opts?: NDKSubscriptionOptions
) => {
const events = await fetchEvents(filter, opts)
if (events.length === 0) return null
return events[0]
}
/**
* Asynchronously retrieves multiple events from the user's relays based on a specified filter.
* The function first retrieves the user's relays, and then fetches the events using the provided filter.
*
* @param filter - The event filter to use when fetching the event (e.g., kinds, authors).
* @param hexKey - The hexadecimal representation of the user's public key.
* @param userRelaysType - The type of relays to search (e.g., write, read).
* @returns A promise that resolves with an array of events.
*/
const fetchEventsFromUserRelays = async (
filter: NDKFilter | NDKFilter[],
hexKey: string,
userRelaysType: UserRelaysType,
opts?: NDKSubscriptionOptions
): Promise<NDKEvent[]> => {
// Find the user's relays (10s timeout).
const relayUrls = await Promise.race([
getRelayListForUser(hexKey, ndk),
timeout(3000)
])
.then((ndkRelayList) => {
if (ndkRelayList) return ndkRelayList[userRelaysType]
return [] // Return an empty array if ndkRelayList is undefined
})
.catch((err) => {
console.error(
`An error occurred in fetching user's (${hexKey}) ${userRelaysType}`,
err
)
return [] as string[]
})
if (!relayUrls.includes(SIGIT_RELAY)) {
relayUrls.push(SIGIT_RELAY)
}
return ndk
.fetchEvents(
filter,
{
closeOnEose: true,
cacheUsage: NDKSubscriptionCacheUsage.PARALLEL,
...opts
},
relayUrls.length
? NDKRelaySet.fromRelayUrls(relayUrls, ndk, true)
: undefined
)
.then((ndkEventSet) => {
const ndkEvents = Array.from(ndkEventSet)
return orderEventsChronologically(ndkEvents)
})
.catch((err) => {
// Log the error and show a notification if fetching fails
console.error('An error occurred in fetching events', err)
toast.error('An error occurred in fetching events') // Show error notification
return [] // Return an empty array in case of an error
})
}
/**
* Fetches an event from the user's relays based on a specified filter.
* The function first retrieves the user's relays, and then fetches the event using the provided filter.
*
* @param filter - The event filter to use when fetching the event (e.g., kinds, authors).
* @param hexKey - The hexadecimal representation of the user's public key.
* @param userRelaysType - The type of relays to search (e.g., write, read).
* @returns A promise that resolves to the fetched event or null if the operation fails.
*/
const fetchEventFromUserRelays = async (
filter: NDKFilter | NDKFilter[],
hexKey: string,
userRelaysType: UserRelaysType,
opts?: NDKSubscriptionOptions
) => {
const events = await fetchEventsFromUserRelays(
filter,
hexKey,
userRelaysType,
opts
)
if (events.length === 0) return null
return events[0]
}
/**
* Finds metadata for a given pubkey.
*
* @param hexKey - The pubkey to search for metadata.
* @returns A promise that resolves to the metadata event.
*/
const findMetadata = async (
pubkey: string,
opts?: NDKSubscriptionOptions
): Promise<NDKUserProfile | null> => {
const npub = hexToNpub(pubkey)
const user = new NDKUser({ npub })
user.ndk = ndk
return await user.fetchProfile({
cacheUsage: NDKSubscriptionCacheUsage.PARALLEL,
...(opts || {})
})
}
const getNDKRelayList = async (pubkey: Hexpubkey) => {
const ndkRelayList = await Promise.race([
getRelayListForUser(pubkey, ndk),
timeout(10000)
]).catch(() => {
const relayList = new NDKRelayList(ndk)
relayList.bothRelayUrls = [SIGIT_RELAY]
return relayList
})
return ndkRelayList
}
const publish = async (
event: NDKEvent,
explicitRelayUrls?: string[]
): Promise<string[]> => {
if (!event.sig) throw new Error('Before publishing first sign the event!')
let ndkRelaySet: NDKRelaySet | undefined
if (explicitRelayUrls && explicitRelayUrls.length > 0) {
if (!explicitRelayUrls.includes(SIGIT_RELAY)) {
explicitRelayUrls = [...explicitRelayUrls, SIGIT_RELAY]
}
ndkRelaySet = NDKRelaySet.fromRelayUrls(explicitRelayUrls, ndk)
}
return await Promise.race([event.publish(ndkRelaySet), timeout(3000)])
.then((res) => {
const relaysPublishedOn = Array.from(res)
return relaysPublishedOn.map((relay) => relay.url)
})
.catch((err) => {
console.error(`An error occurred in publishing event`, err)
return []
})
}
return (
<NDKContext.Provider
value={{
ndk,
fetchEvents,
fetchEvent,
fetchEventsFromUserRelays,
fetchEventFromUserRelays,
findMetadata,
getNDKRelayList,
publish
}}
>
{children}
</NDKContext.Provider>
)
}

View File

@ -1,142 +0,0 @@
import { EventTemplate } from 'nostr-tools'
import { MetadataController, NostrController } from '.'
import { appPrivateRoutes } from '../routes'
import {
setAuthState,
setMetadataEvent,
setRelayMapAction
} from '../store/actions'
import store from '../store/store'
import { SignedEvent } from '../types'
import {
base64DecodeAuthToken,
base64EncodeSignedEvent,
compareObjects,
getAuthToken,
getRelayMap,
getVisitedLink,
saveAuthToken,
unixNow
} from '../utils'
export class AuthController {
private nostrController: NostrController
private metadataController: MetadataController
constructor() {
this.nostrController = NostrController.getInstance()
this.metadataController = MetadataController.getInstance()
}
/**
* Function will authenticate user by signing an auth event
* which is done by calling the sign() function, where appropriate
* method will be chosen (extension or keys)
*
* @param pubkey of the user trying to login
* @returns url to redirect if authentication successfull
* or error if otherwise
*/
async authAndGetMetadataAndRelaysMap(pubkey: string) {
const emptyMetadata = this.metadataController.getEmptyMetadataEvent()
this.metadataController
.findMetadata(pubkey)
.then((event) => {
if (event) {
store.dispatch(setMetadataEvent(event))
} else {
store.dispatch(setMetadataEvent(emptyMetadata))
}
})
.catch((err) => {
console.warn('Error occurred while finding metadata', err)
store.dispatch(setMetadataEvent(emptyMetadata))
})
// Nostr uses unix timestamps
const timestamp = unixNow()
const { href } = window.location
const authEvent: EventTemplate = {
kind: 27235,
tags: [
['u', href],
['method', 'GET']
],
content: '',
created_at: timestamp
}
const signedAuthEvent = await this.nostrController.signEvent(authEvent)
this.createAndSaveAuthToken(signedAuthEvent)
store.dispatch(
setAuthState({
loggedIn: true,
usersPubkey: pubkey
})
)
const relayMap = await getRelayMap(pubkey)
if (Object.keys(relayMap).length < 1) {
// Navigate user to relays page if relay map is empty
return Promise.resolve(appPrivateRoutes.relays)
}
if (store.getState().auth.loggedIn) {
if (!compareObjects(store.getState().relays?.map, relayMap.map))
store.dispatch(setRelayMapAction(relayMap.map))
}
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)
}
}
}
checkSession() {
const savedAuthToken = getAuthToken()
if (savedAuthToken) {
const signedEvent = base64DecodeAuthToken(savedAuthToken)
store.dispatch(
setAuthState({
loggedIn: true,
usersPubkey: signedEvent.pubkey
})
)
return
}
store.dispatch(
setAuthState({
loggedIn: false,
usersPubkey: undefined
})
)
}
private createAndSaveAuthToken(signedAuthEvent: SignedEvent) {
const base64Encoded = base64EncodeSignedEvent(signedAuthEvent)
// save newly created auth token (base64 nostr singed event) in local storage along with expiry time
saveAuthToken(base64Encoded)
return base64Encoded
}
}

View File

@ -1,219 +0,0 @@
import {
Event,
Filter,
VerifiedEvent,
kinds,
validateEvent,
verifyEvent
} from 'nostr-tools'
import { toast } from 'react-toastify'
import { EventEmitter } from 'tseep'
import { NostrController, relayController } from '.'
import { localCache } from '../services'
import { ProfileMetadata, RelaySet } from '../types'
import {
findRelayListAndUpdateCache,
findRelayListInCache,
getDefaultRelaySet,
getUserRelaySet,
isOlderThanOneDay,
unixNow
} from '../utils'
import { DEFAULT_LOOK_UP_RELAY_LIST } from '../utils/const'
export class MetadataController extends EventEmitter {
private static instance: MetadataController
private nostrController: NostrController
private specialMetadataRelay = 'wss://purplepag.es'
private pendingFetches = new Map<string, Promise<Event | null>>() // Track pending fetches
constructor() {
super()
this.nostrController = NostrController.getInstance()
}
public static getInstance(): MetadataController {
if (!MetadataController.instance) {
MetadataController.instance = new MetadataController()
}
return MetadataController.instance
}
/**
* Asynchronously checks for more recent metadata events authored by a specific key.
* If a more recent metadata event is found, it is handled and returned.
* If no more recent event is found, the current event is returned.
* @param hexKey The hexadecimal key of the author to filter metadata events.
* @param currentEvent The current metadata event, if any, to compare with newer events.
* @returns A promise resolving to the most recent metadata event found, or null if none is found.
*/
private async checkForMoreRecentMetadata(
hexKey: string,
currentEvent: Event | null
): Promise<Event | null> {
// Return the ongoing fetch promise if one exists for the same hexKey
if (this.pendingFetches.has(hexKey)) {
return this.pendingFetches.get(hexKey)!
}
// Define the event filter to only include metadata events authored by the given key
const eventFilter: Filter = {
kinds: [kinds.Metadata],
authors: [hexKey]
}
const fetchPromise = relayController
.fetchEvent(eventFilter, DEFAULT_LOOK_UP_RELAY_LIST)
.catch((err) => {
console.error(err)
return null
})
.finally(() => {
this.pendingFetches.delete(hexKey)
})
this.pendingFetches.set(hexKey, fetchPromise)
const metadataEvent = await fetchPromise
if (
metadataEvent &&
validateEvent(metadataEvent) &&
verifyEvent(metadataEvent)
) {
if (
!currentEvent ||
metadataEvent.created_at >= currentEvent.created_at
) {
this.handleNewMetadataEvent(metadataEvent)
}
return metadataEvent
}
// todo/implement: if no valid metadata event is found in DEFAULT_LOOK_UP_RELAY_LIST
// try to query user relay list
// if current event is null we should cache empty metadata event for provided hexKey
if (!currentEvent) {
const emptyMetadata = this.getEmptyMetadataEvent(hexKey)
this.handleNewMetadataEvent(emptyMetadata as VerifiedEvent)
}
return currentEvent
}
/**
* Handle new metadata events and emit them to subscribers
*/
private async handleNewMetadataEvent(event: VerifiedEvent) {
// update the event in local cache
localCache.addUserMetadata(event)
// Emit the event to subscribers.
this.emit(event.pubkey, event.kind, event)
}
/**
* Finds metadata for a given hexadecimal key.
*
* @param hexKey - The hexadecimal key to search for metadata.
* @returns A promise that resolves to the metadata event.
*/
public findMetadata = async (hexKey: string): Promise<Event | null> => {
// Attempt to retrieve the metadata event from the local cache
const cachedMetadataEvent = await localCache.getUserMetadata(hexKey)
// If cached metadata is found, check its validity
if (cachedMetadataEvent) {
// Check if the cached metadata is older than one day
if (isOlderThanOneDay(cachedMetadataEvent.cachedAt)) {
// If older than one week, find the metadata from relays in background
this.checkForMoreRecentMetadata(hexKey, cachedMetadataEvent.event)
}
// Return the cached metadata event
return cachedMetadataEvent.event
}
// If no cached metadata is found, retrieve it from relays
return this.checkForMoreRecentMetadata(hexKey, null)
}
/**
* Based on the hexKey of the current user, this method attempts to retrieve a relay set.
* @func findRelayListInCache first checks if there is already an up-to-date
* relay list available in cache; if not -
* @func findRelayListAndUpdateCache checks if the relevant relay event is available from
* the purple pages relay;
* @func findRelayListAndUpdateCache will run again if the previous two calls return null and
* check if the relevant relay event can be obtained from 'most popular relays'
* If relay event is found, it will be saved in cache for future use
* @param hexKey of the current user
* @return RelaySet which will contain either relays extracted from the user Relay Event
* or a fallback RelaySet with Sigit's Relay
*/
public findRelayListMetadata = async (hexKey: string): Promise<RelaySet> => {
const relayEvent =
(await findRelayListInCache(hexKey)) ||
(await findRelayListAndUpdateCache(DEFAULT_LOOK_UP_RELAY_LIST, hexKey))
return relayEvent ? getUserRelaySet(relayEvent.tags) : getDefaultRelaySet()
}
public extractProfileMetadataContent = (event: Event) => {
try {
if (!event.content) return {}
return JSON.parse(event.content) as ProfileMetadata
} catch (error) {
console.log('error in parsing metadata event content :>> ', error)
return null
}
}
/**
* Function will not sign provided event if the SIG exists
*/
public publishMetadataEvent = async (event: Event) => {
let signedMetadataEvent = event
if (event.sig.length < 1) {
const timestamp = unixNow()
// Metadata event to publish to the wss://purplepag.es relay
const newMetadataEvent: Event = {
...event,
created_at: timestamp
}
signedMetadataEvent =
await this.nostrController.signEvent(newMetadataEvent)
}
await relayController
.publish(signedMetadataEvent, [this.specialMetadataRelay])
.then((relays) => {
if (relays.length) {
toast.success(`Metadata event published on: ${relays.join('\n')}`)
this.handleNewMetadataEvent(signedMetadataEvent as VerifiedEvent)
} else {
toast.error('Could not publish metadata event to any relay!')
}
})
.catch((err) => {
toast.error(err.message)
})
}
public validate = (event: Event) => validateEvent(event) && verifyEvent(event)
public getEmptyMetadataEvent = (pubkey?: string): Event => {
return {
content: '',
created_at: new Date().valueOf(),
id: '',
kind: 0,
pubkey: pubkey || '',
sig: '',
tags: []
}
}
}

View File

@ -1,9 +1,12 @@
import { EventTemplate, UnsignedEvent } from 'nostr-tools' import { EventTemplate, UnsignedEvent } from 'nostr-tools'
import { WindowNostr } from 'nostr-tools/nip07'
import { EventEmitter } from 'tseep' import { EventEmitter } from 'tseep'
import store from '../store/store' import store from '../store/store'
import { SignedEvent } from '../types' import { SignedEvent } from '../types'
import { LoginMethodContext } from '../services/LoginMethodStrategy/loginMethodContext' import { LoginMethodContext } from '../services/LoginMethodStrategy/loginMethodContext'
import { clear, unixNow } from '../utils'
import { LoginMethod } from '../store/auth/types'
import { logout as nostrLogout } from 'nostr-login'
import { userLogOutAction } from '../store/actions'
export class NostrController extends EventEmitter { export class NostrController extends EventEmitter {
private static instance: NostrController private static instance: NostrController
@ -11,13 +14,6 @@ export class NostrController extends EventEmitter {
private constructor() { private constructor() {
super() super()
} }
private getNostrObject = () => {
if (window.nostr) return window.nostr as WindowNostr
throw new Error(
`window.nostr object not present. Make sure you have an nostr extension installed/working properly.`
)
}
public static getInstance(): NostrController { public static getInstance(): NostrController {
if (!NostrController.instance) { if (!NostrController.instance) {
@ -72,7 +68,22 @@ export class NostrController extends EventEmitter {
const loginMethod = store.getState().auth.loginMethod const loginMethod = store.getState().auth.loginMethod
const context = new LoginMethodContext(loginMethod) const context = new LoginMethodContext(loginMethod)
return await context.signEvent(event) const authkey = store.getState().auth.usersPubkey
const signedEvent = await context.signEvent(event)
const pubkey = signedEvent.pubkey
// Forcefully log out the user if we detect missmatch between pubkeys
// Allow undefined authkey, intial log in
if (authkey && authkey !== pubkey) {
if (loginMethod === LoginMethod.nostrLogin) {
nostrLogout()
}
store.dispatch(userLogOutAction())
clear()
throw new Error('User missmatch.\n\nPlease log in again.')
}
return signedEvent
} }
nip04Encrypt = async (receiver: string, content: string): Promise<string> => { nip04Encrypt = async (receiver: string, content: string): Promise<string> => {
@ -97,23 +108,37 @@ export class NostrController extends EventEmitter {
} }
/** /**
* Function will capture the public key from the nostr extension or if no extension present * Function will capture the public key from signedEvent
* function wil capture the public key from the local storage
*/ */
capturePublicKey = async (): Promise<string> => { capturePublicKey = async (): Promise<string> => {
const nostr = this.getNostrObject() try {
const pubKey = await nostr.getPublicKey().catch((err: unknown) => { const timestamp = unixNow()
if (err instanceof Error) { const { href } = window.location
return Promise.reject(err.message)
} else {
return Promise.reject(JSON.stringify(err))
}
})
if (!pubKey) { const authEvent: EventTemplate = {
kind: 27235,
tags: [
['u', href],
['method', 'GET']
],
content: '',
created_at: timestamp
}
const signedAuthEvent = await this.signEvent(authEvent)
const pubkey = signedAuthEvent.pubkey
if (!pubkey) {
return Promise.reject('Error getting public key, user canceled') return Promise.reject('Error getting public key, user canceled')
} }
return Promise.resolve(pubKey) return Promise.resolve(pubkey)
} catch (error) {
if (error instanceof Error) {
return Promise.reject(error.message)
} else {
return Promise.reject(JSON.stringify(error))
}
}
} }
} }

View File

@ -1,306 +0,0 @@
import { Event, Filter, Relay } from 'nostr-tools'
import {
settleAllFullfilfedPromises,
normalizeWebSocketURL,
timeout
} from '../utils'
import { SIGIT_RELAY } from '../utils/const'
/**
* Singleton class to manage relay operations.
*/
export class RelayController {
private static instance: RelayController
private pendingConnections = new Map<string, Promise<Relay | null>>() // Track pending connections
public connectedRelays = new Map<string, Relay>()
private constructor() {}
/**
* Provides the singleton instance of RelayController.
*
* @returns The singleton instance of RelayController.
*/
public static getInstance(): RelayController {
if (!RelayController.instance) {
RelayController.instance = new RelayController()
}
return RelayController.instance
}
/**
* Connects to a relay server if not already connected.
*
* This method checks if a relay with the given URL is already in the list of connected relays.
* If it is not connected, it attempts to establish a new connection.
* On successful connection, the relay is added to the list of connected relays and returned.
* If the connection fails, an error is logged and `null` is returned.
*
* @param relayUrl - The URL of the relay server to connect to.
* @returns A promise that resolves to the connected relay object if successful, or `null` if the connection fails.
*/
public connectRelay = async (relayUrl: string): Promise<Relay | null> => {
const normalizedWebSocketURL = normalizeWebSocketURL(relayUrl)
const relay = this.connectedRelays.get(normalizedWebSocketURL)
if (relay) {
if (relay.connected) return relay
// If relay is found in connectedRelay map but not connected,
// remove it from map and call connectRelay method again
this.connectedRelays.delete(relayUrl)
return this.connectRelay(relayUrl)
}
// Check if there's already a pending connection for this relay URL
if (this.pendingConnections.has(relayUrl)) {
// Return the existing promise to avoid making another connection
return this.pendingConnections.get(relayUrl)!
}
// Create a new connection promise and store it in pendingConnections
const connectionPromise = Relay.connect(relayUrl)
.then((relay) => {
if (relay.connected) {
// Add the newly connected relay to the connected relays map
this.connectedRelays.set(relayUrl, relay)
// Return the newly connected relay
return relay
}
return null
})
.catch((err) => {
// Log an error message if the connection fails
console.error(`Relay connection failed: ${relayUrl}`, err)
// Return null to indicate connection failure
return null
})
.finally(() => {
// Remove the connection from pendingConnections once it settles
this.pendingConnections.delete(relayUrl)
})
this.pendingConnections.set(relayUrl, connectionPromise)
return connectionPromise
}
/**
* Asynchronously retrieves multiple event from a set of relays based on a provided filter.
* If no relays are specified, it defaults to using connected relays.
*
* @param filter - The filter criteria to find the event.
* @param relays - An optional array of relay URLs to search for the event.
* @returns Returns a promise that resolves with an array of events.
*/
fetchEvents = async (
filter: Filter,
relayUrls: string[] = []
): Promise<Event[]> => {
if (!relayUrls.includes(SIGIT_RELAY)) {
/**
* NOTE: To avoid side-effects on external relayUrls array passed as argument
* re-assigned relayUrls with added sigit relay instead of just appending to same array
*/
relayUrls = [...relayUrls, SIGIT_RELAY] // Add app relay to relays array if not exists already
}
// connect to all specified relays
const relays = await settleAllFullfilfedPromises(
relayUrls,
this.connectRelay
)
// Check if any relays are connected
if (relays.length === 0) {
throw new Error('No relay is connected to fetch events!')
}
const events: Event[] = []
const eventIds = new Set<string>() // To keep track of event IDs and avoid duplicates
// Create a promise for each relay subscription
const subPromises = relays.map((relay) => {
return new Promise<void>((resolve) => {
if (!relay.connected) {
console.log(`${relay.url} : Not connected!`, 'Skipping subscription')
return resolve()
}
// Subscribe to the relay with the specified filter
const sub = relay.subscribe([filter], {
// Handle incoming events
onevent: (e) => {
// Add the event to the array if it's not a duplicate
if (!eventIds.has(e.id)) {
eventIds.add(e.id) // Record the event ID
events.push(e) // Add the event to the array
}
},
// Handle the End-Of-Stream (EOSE) message
oneose: () => {
sub.close() // Close the subscription
resolve() // Resolve the promise when EOSE is received
}
})
// add a 30 sec of timeout to subscription
setTimeout(() => {
if (!sub.closed) {
sub.close()
resolve()
}
}, 30 * 1000)
})
})
// Wait for all subscriptions to complete
await Promise.allSettled(subPromises)
// It is possible that different relays will send different events and events array may contain more events then specified limit in filter
// To fix this issue we'll first sort these events and then return only limited events
if (filter.limit) {
// Sort events by creation date in descending order
events.sort((a, b) => b.created_at - a.created_at)
return events.slice(0, filter.limit)
}
return events
}
/**
* Asynchronously retrieves an event from a set of relays based on a provided filter.
* If no relays are specified, it defaults to using connected relays.
*
* @param filter - The filter criteria to find the event.
* @param relays - An optional array of relay URLs to search for the event.
* @returns Returns a promise that resolves to the found event or null if not found.
*/
fetchEvent = async (
filter: Filter,
relays: string[] = []
): Promise<Event | null> => {
const events = await this.fetchEvents(filter, relays)
// Sort events by creation date in descending order
events.sort((a, b) => b.created_at - a.created_at)
// Return the most recent event, or null if no events were received
return events[0] || null
}
/**
* Subscribes to events from multiple relays.
*
* This method connects to the specified relay URLs and subscribes to events
* using the provided filter. It handles incoming events through the given
* `eventHandler` callback and manages the subscription lifecycle.
*
* @param filter - The filter criteria to apply when subscribing to events.
* @param relayUrls - An optional array of relay URLs to connect to. The default relay URL (`SIGIT_RELAY`) is added automatically.
* @param eventHandler - A callback function to handle incoming events. It receives an `Event` object.
*
*/
subscribeForEvents = async (
filter: Filter,
relayUrls: string[] = [],
eventHandler: (event: Event) => void
) => {
if (!relayUrls.includes(SIGIT_RELAY)) {
/**
* NOTE: To avoid side-effects on external relayUrls array passed as argument
* re-assigned relayUrls with added sigit relay instead of just appending to same array
*/
relayUrls = [...relayUrls, SIGIT_RELAY] // Add app relay to relays array if not exists already
}
// connect to all specified relays
const relays = await settleAllFullfilfedPromises(
relayUrls,
this.connectRelay
)
// Check if any relays are connected
if (relays.length === 0) {
throw new Error('No relay is connected to fetch events!')
}
const processedEvents: string[] = [] // To keep track of processed events
// Create a promise for each relay subscription
const subPromises = relays.map((relay) => {
return new Promise<void>((resolve) => {
// Subscribe to the relay with the specified filter
const sub = relay.subscribe([filter], {
// Handle incoming events
onevent: (e) => {
// Process event only if it hasn't been processed before
if (!processedEvents.includes(e.id)) {
processedEvents.push(e.id)
eventHandler(e) // Call the event handler with the event
}
},
// Handle the End-Of-Stream (EOSE) message
oneose: () => {
sub.close() // Close the subscription
resolve() // Resolve the promise when EOSE is received
}
})
})
})
// Wait for all subscriptions to complete
await Promise.allSettled(subPromises)
}
publish = async (
event: Event,
relayUrls: string[] = []
): Promise<string[]> => {
if (!relayUrls.includes(SIGIT_RELAY)) {
/**
* NOTE: To avoid side-effects on external relayUrls array passed as argument
* re-assigned relayUrls with added sigit relay instead of just appending to same array
*/
relayUrls = [...relayUrls, SIGIT_RELAY] // Add app relay to relays array if not exists already
}
// connect to all specified relays
const relays = await settleAllFullfilfedPromises(
relayUrls,
this.connectRelay
)
// Check if any relays are connected
if (relays.length === 0) {
throw new Error('No relay is connected to publish event!')
}
const publishedOnRelays: string[] = [] // List to track which relays successfully published the event
// Create a promise for publishing the event to each connected relay
const publishPromises = relays.map(async (relay) => {
try {
await Promise.race([
relay.publish(event), // Publish the event to the relay
timeout(20 * 1000) // Set a timeout to handle cases where publishing takes too long
])
publishedOnRelays.push(relay.url) // Add the relay URL to the list of successfully published relays
} catch (err) {
console.error(`Failed to publish event on relay: ${relay.url}`, err)
}
})
// Wait for all publish operations to complete (either fulfilled or rejected)
await Promise.allSettled(publishPromises)
// Return the list of relay URLs where the event was published
return publishedOnRelays
}
}
export const relayController = RelayController.getInstance()

View File

@ -1,4 +1 @@
export * from './AuthController'
export * from './MetadataController'
export * from './NostrController' export * from './NostrController'
export * from './RelayController'

View File

@ -19,7 +19,7 @@
"page": 1 "page": 1
}, },
"npub": "npub1x77qywdllzetv9ncnhlfpv62kshlgtt0uqlsq3v22uzzkk2xvvrsn6uyfy", "npub": "npub1x77qywdllzetv9ncnhlfpv62kshlgtt0uqlsq3v22uzzkk2xvvrsn6uyfy",
"pdfFileHash": "da5f857e77d3aa59c461efad804116931c059b36e6b4da0b5d9452753ec70c05" "fileHash": "da5f857e77d3aa59c461efad804116931c059b36e6b4da0b5d9452753ec70c05"
} }
], ],
"da5f857e77d3aa59c461efad804116931c059b36e6b4da0b5d9452753ec70c05/2.png": [ "da5f857e77d3aa59c461efad804116931c059b36e6b4da0b5d9452753ec70c05/2.png": [
@ -34,7 +34,7 @@
"page": 2 "page": 2
}, },
"npub": "npub1x77qywdllzetv9ncnhlfpv62kshlgtt0uqlsq3v22uzzkk2xvvrsn6uyfy", "npub": "npub1x77qywdllzetv9ncnhlfpv62kshlgtt0uqlsq3v22uzzkk2xvvrsn6uyfy",
"pdfFileHash": "da5f857e77d3aa59c461efad804116931c059b36e6b4da0b5d9452753ec70c05" "fileHash": "da5f857e77d3aa59c461efad804116931c059b36e6b4da0b5d9452753ec70c05"
} }
] ]
} }
@ -54,7 +54,7 @@
"page": 1 "page": 1
}, },
"npub": "npub1x77qywdllzetv9ncnhlfpv62kshlgtt0uqlsq3v22uzzkk2xvvrsn6uyfy", "npub": "npub1x77qywdllzetv9ncnhlfpv62kshlgtt0uqlsq3v22uzzkk2xvvrsn6uyfy",
"pdfFileHash": "da5f857e77d3aa59c461efad804116931c059b36e6b4da0b5d9452753ec70c05", "fileHash": "da5f857e77d3aa59c461efad804116931c059b36e6b4da0b5d9452753ec70c05",
"value": "Pera Peric" "value": "Pera Peric"
}, },
{ {
@ -68,7 +68,7 @@
"page": 2 "page": 2
}, },
"npub": "npub1x77qywdllzetv9ncnhlfpv62kshlgtt0uqlsq3v22uzzkk2xvvrsn6uyfy", "npub": "npub1x77qywdllzetv9ncnhlfpv62kshlgtt0uqlsq3v22uzzkk2xvvrsn6uyfy",
"pdfFileHash": "da5f857e77d3aa59c461efad804116931c059b36e6b4da0b5d9452753ec70c05", "fileHash": "da5f857e77d3aa59c461efad804116931c059b36e6b4da0b5d9452753ec70c05",
"value": "Pera Peric" "value": "Pera Peric"
} }
] ]

View File

@ -1,2 +1,7 @@
export * from './store' export * from './store'
export * from './useAuth'
export * from './useDidMount' export * from './useDidMount'
export * from './useDvm'
export * from './useLogout'
export * from './useNDK'
export * from './useNDKContext'

127
src/hooks/useAuth.ts Normal file
View File

@ -0,0 +1,127 @@
import { EventTemplate } from 'nostr-tools'
import { useCallback } from 'react'
import { NostrController } from '../controllers'
import { appPrivateRoutes } from '../routes'
import {
setAuthState,
setRelayMapAction,
setUserProfile
} from '../store/actions'
import {
base64DecodeAuthToken,
compareObjects,
createAndSaveAuthToken,
getAuthToken,
getRelayMapFromNDKRelayList,
unixNow
} from '../utils'
import { useAppDispatch, useAppSelector } from './store'
import { useNDKContext } from './useNDKContext'
import { useDvm } from './useDvm'
export const useAuth = () => {
const dispatch = useAppDispatch()
const { getRelayInfo } = useDvm()
const { findMetadata, getNDKRelayList } = useNDKContext()
const authState = useAppSelector((state) => state.auth)
const relaysState = useAppSelector((state) => state.relays)
const checkSession = useCallback(() => {
const savedAuthToken = getAuthToken()
if (savedAuthToken) {
const signedEvent = base64DecodeAuthToken(savedAuthToken)
dispatch(
setAuthState({
loggedIn: true,
usersPubkey: signedEvent.pubkey
})
)
return
}
dispatch(
setAuthState({
loggedIn: false,
usersPubkey: undefined
})
)
}, [dispatch])
/**
* Function will authenticate user by signing an auth event
* which is done by calling the sign() function, where appropriate
* method will be chosen (extension or keys)
*
* @param pubkey of the user trying to login
* @returns url to redirect if authentication successfull
* or error if otherwise
*/
const authAndGetMetadataAndRelaysMap = useCallback(
async (pubkey: string) => {
try {
const profile = await findMetadata(pubkey)
dispatch(setUserProfile(profile))
} catch (err) {
console.warn('Error occurred while finding metadata', err)
}
const timestamp = unixNow()
const { href } = window.location
const authEvent: EventTemplate = {
kind: 27235,
tags: [
['u', href],
['method', 'GET']
],
content: '',
created_at: timestamp
}
const nostrController = NostrController.getInstance()
const signedAuthEvent = await nostrController.signEvent(authEvent)
createAndSaveAuthToken(signedAuthEvent)
dispatch(
setAuthState({
loggedIn: true,
usersPubkey: pubkey
})
)
const ndkRelayList = await getNDKRelayList(pubkey)
const relays = ndkRelayList.relays
if (relays.length < 1) {
// Navigate user to relays page if relay map is empty
return appPrivateRoutes.relays
}
getRelayInfo(relays)
const relayMap = getRelayMapFromNDKRelayList(ndkRelayList)
if (authState.loggedIn && !compareObjects(relaysState?.map, relayMap)) {
dispatch(setRelayMapAction(relayMap))
}
return appPrivateRoutes.homePage
},
[
dispatch,
findMetadata,
getNDKRelayList,
getRelayInfo,
authState,
relaysState
]
)
return {
authAndGetMetadataAndRelaysMap,
checkSession
}
}

98
src/hooks/useDvm.ts Normal file
View File

@ -0,0 +1,98 @@
import { NDKEvent, NDKSubscription } from '@nostr-dev-kit/ndk'
import { EventTemplate } from 'nostr-tools'
import { NostrController } from '../controllers'
import { setRelayInfoAction } from '../store/actions'
import { RelayInfoObject } from '../types'
import { compareObjects, unixNow } from '../utils'
import { useAppDispatch, useAppSelector } from './store'
import { useNDKContext } from './useNDKContext'
export const useDvm = () => {
const dvmRelays = [
'wss://relay.damus.io',
'wss://relay.primal.net',
'wss://relayable.org'
]
const relayInfo = useAppSelector((state) => state.relays.info)
const { ndk, publish } = useNDKContext()
const dispatch = useAppDispatch()
/**
* Sets information about relays into relays.info app state.
* @param relayURIs - relay URIs to get information about
*/
const getRelayInfo = async (relayURIs: string[]) => {
// initialize job request
const jobEventTemplate: EventTemplate = {
content: '',
created_at: unixNow(),
kind: 68001,
tags: [
['i', `${JSON.stringify(relayURIs)}`],
['j', 'relay-info']
]
}
const nostrController = NostrController.getInstance()
// sign job request event
const jobSignedEvent = await nostrController.signEvent(jobEventTemplate)
// publish job request
const ndkEvent = new NDKEvent(ndk, jobSignedEvent)
await publish(ndkEvent, dvmRelays)
const subscribeWithTimeout = (
subscription: NDKSubscription,
timeoutMs: number
): Promise<string> => {
return new Promise((resolve, reject) => {
const eventHandler = (event: NDKEvent) => {
subscription.stop()
resolve(event.content)
}
subscription.on('event', eventHandler)
// Set up a timeout to stop the subscription after a specified time
const timeout = setTimeout(() => {
subscription.stop() // Stop the subscription
reject(new Error('Subscription timed out')) // Reject the promise with a timeout error
}, timeoutMs)
// Handle subscription close event
subscription.on('close', () => clearTimeout(timeout))
})
}
// filter for getting DVM job's result
const sub = ndk.subscribe({
kinds: [68002 as number],
'#e': [jobSignedEvent.id],
'#p': [jobSignedEvent.pubkey]
})
// asynchronously get relay info from dvm job with 20 seconds timeout
const dvmJobResult = await subscribeWithTimeout(sub, 20000)
if (!dvmJobResult) {
return Promise.reject(`Relay(s) information wasn't received`)
}
let newRelaysInfo: RelayInfoObject
try {
newRelaysInfo = JSON.parse(dvmJobResult)
} catch (error) {
return Promise.reject(`Invalid relay(s) information.`)
}
if (newRelaysInfo && !compareObjects(relayInfo, newRelaysInfo)) {
dispatch(setRelayInfoAction(newRelaysInfo))
}
}
return { getRelayInfo }
}

512
src/hooks/useNDK.ts Normal file
View File

@ -0,0 +1,512 @@
import { useCallback } from 'react'
import { toast } from 'react-toastify'
import { bytesToHex } from '@noble/hashes/utils'
import {
NDKEvent,
NDKFilter,
NDKKind,
NDKRelaySet,
NDKSubscriptionCacheUsage
} from '@nostr-dev-kit/ndk'
import _ from 'lodash'
import {
Event,
generateSecretKey,
getPublicKey,
kinds,
UnsignedEvent
} from 'nostr-tools'
import { useAppDispatch, useAppSelector, useNDKContext } from '.'
import { NostrController } from '../controllers'
import {
updateProcessedGiftWraps,
updateUserAppData as updateUserAppDataAction
} from '../store/actions'
import { Keys } from '../store/auth/types'
import {
isSigitNotification,
Meta,
SigitNotification,
UserAppData,
UserRelaysType
} from '../types'
import {
countLeadingZeroes,
createWrap,
deleteBlossomFile,
fetchMetaFromFileStorage,
getDTagForUserAppData,
getUserAppDataFromBlossom,
hexToNpub,
parseJson,
SIGIT_RELAY,
unixNow,
uploadUserAppDataToBlossom
} from '../utils'
export const useNDK = () => {
const dispatch = useAppDispatch()
const {
ndk,
fetchEvent,
fetchEventsFromUserRelays,
publish,
getNDKRelayList
} = useNDKContext()
const usersPubkey = useAppSelector((state) => state.auth.usersPubkey)
const appData = useAppSelector((state) => state.userAppData)
const processedEvents = useAppSelector(
(state) => state.userAppData?.processedGiftWraps
)
/**
* Fetches user application data based on user's public key.
*
* @returns The user application data or null if an error occurs or no data is found.
*/
const getUsersAppData = useCallback(async (): Promise<UserAppData | null> => {
if (!usersPubkey) return null
// Get an instance of the NostrController
const nostrController = NostrController.getInstance()
// Decryption can fail down in the code if extension options changed
// Forcefully log out the user if we detect missmatch between pubkeys
if (usersPubkey !== (await nostrController.capturePublicKey())) {
return null
}
// Generate an identifier for the user's nip78
const dTag = await getDTagForUserAppData()
if (!dTag) return null
// Define a filter for fetching events
const filter: NDKFilter = {
kinds: [NDKKind.AppSpecificData],
authors: [usersPubkey],
'#d': [dTag]
}
const encryptedContent = await fetchEvent(filter, {
cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY
})
.then((event) => {
if (event) return event.content
// If no event is found, return an empty stringified object
return '{}'
})
.catch((err) => {
// Log error and show a toast notification if fetching event fails
console.log(`An error occurred in finding kind 30078 event`, err)
toast.error(
'An error occurred in finding kind 30078 event for data storage'
)
return null
})
// Return null if encrypted content retrieval fails
if (!encryptedContent) return null
// Handle case where the encrypted content is an empty object
if (encryptedContent === '{}') {
// Generate ephemeral key pair
const secret = generateSecretKey()
const pubKey = getPublicKey(secret)
return {
sigits: {},
processedGiftWraps: [],
blossomUrls: [],
keyPair: {
private: bytesToHex(secret),
public: pubKey
}
}
}
// Decrypt the encrypted content
const decrypted = await nostrController
.nip04Decrypt(usersPubkey, encryptedContent)
.catch((err) => {
// Log error and show a toast notification if decryption fails
console.log('An error occurred while decrypting app data', err)
toast.error('An error occurred while decrypting app data')
return null
})
// Return null if decryption fails
if (!decrypted) return null
// Parse the decrypted content
const parsedContent = await parseJson<{
blossomUrls: string[]
keyPair: Keys
}>(decrypted).catch((err) => {
// Log error and show a toast notification if parsing fails
console.log(
'An error occurred in parsing the content of kind 30078 event',
err
)
toast.error(
'An error occurred in parsing the content of kind 30078 event'
)
return null
})
// Return null if parsing fails
if (!parsedContent) return null
const { blossomUrls, keyPair } = parsedContent
// Return null if no blossom URLs are found
if (blossomUrls.length === 0) return null
// Fetch additional user app data from the first blossom URL
const dataFromBlossom = await getUserAppDataFromBlossom(
blossomUrls[0],
keyPair.private
)
// Return null if fetching data from blossom fails
if (!dataFromBlossom) return null
const { sigits, processedGiftWraps } = dataFromBlossom
// Return the final user application data
return {
blossomUrls,
keyPair,
sigits,
processedGiftWraps
}
}, [usersPubkey, fetchEvent])
const updateUsersAppData = useCallback(
async (metaArray: Meta[]) => {
if (!appData || !appData.keyPair || !usersPubkey) return null
const sigits = _.cloneDeep(appData.sigits)
let isUpdated = false
for (const meta of metaArray) {
const createSignatureEvent = await parseJson<Event>(
meta.createSignature
).catch((err) => {
console.log('Error in parsing the createSignature event:', err)
toast.error(
err.message ||
'Error occurred in parsing the create signature event'
)
return null
})
if (!createSignatureEvent) continue
const id = createSignatureEvent.id
// Check if sigit already exists
if (id in sigits) {
// Update meta only if incoming meta is more recent
const existingMeta = sigits[id]
if (existingMeta.modifiedAt < meta.modifiedAt) {
sigits[id] = meta
isUpdated = true
}
} else {
sigits[id] = meta
isUpdated = true
}
}
if (!isUpdated) return null
const blossomUrls = [...appData.blossomUrls]
const newBlossomUrl = await uploadUserAppDataToBlossom(
sigits,
appData.processedGiftWraps,
appData.keyPair.private
).catch((err) => {
console.log(
'Error uploading user app data file to Blossom server:',
err
)
toast.error(
'Error occurred in uploading user app data file to Blossom server'
)
return null
})
if (!newBlossomUrl) return null
// Insert new blossom URL at the start of the array
blossomUrls.unshift(newBlossomUrl)
// Keep only the last 10 Blossom URLs, delete older ones
if (blossomUrls.length > 10) {
const filesToDelete = blossomUrls.splice(10)
filesToDelete.forEach((url) => {
deleteBlossomFile(url, appData.keyPair!.private).catch((err) => {
console.log('Error removing old file from Blossom server:', err)
})
})
}
// Encrypt content for storing in kind 30078 event
const nostrController = NostrController.getInstance()
const encryptedContent = await nostrController
.nip04Encrypt(
usersPubkey,
JSON.stringify({
blossomUrls,
keyPair: appData.keyPair
})
)
.catch((err) => {
console.log('Error encrypting content for app data:', err)
toast.error(err.message || 'Error encrypting content for app data')
return null
})
if (!encryptedContent) return null
// Generate the identifier for user's appData event
const dTag = await getDTagForUserAppData()
if (!dTag) return null
const updatedEvent: UnsignedEvent = {
kind: kinds.Application,
pubkey: usersPubkey,
created_at: unixNow(),
tags: [['d', dTag]],
content: encryptedContent
}
const signedEvent = await nostrController
.signEvent(updatedEvent)
.catch((err) => {
console.log('Error signing event:', err)
toast.error(err.message || 'Error signing event')
return null
})
if (!signedEvent) return null
const ndkEvent = new NDKEvent(ndk, signedEvent)
const publishResult = await publish(ndkEvent)
if (publishResult.length === 0 || !publishResult) {
toast.error('Unexpected error occurred in publishing updated app data')
return null
}
console.count('updateUserAppData useNDK')
// Update Redux store
dispatch(
updateUserAppDataAction({
sigits,
blossomUrls,
processedGiftWraps: [...appData.processedGiftWraps],
keyPair: {
...appData.keyPair
}
})
)
return signedEvent
},
[appData, dispatch, ndk, publish, usersPubkey]
)
const processReceivedEvents = useCallback(
async (events: NDKEvent[], difficulty: number = 5) => {
if (!processedEvents) return
const validMetaArray: Meta[] = [] // Array to store valid Meta objects
const updatedProcessedEvents = [...processedEvents] // Keep track of processed event IDs
for (const event of events) {
// Skip already processed events
if (processedEvents.includes(event.id)) continue
// Validate PoW
const leadingZeroes = countLeadingZeroes(event.id)
if (leadingZeroes < difficulty) continue
// Decrypt the content of the gift wrap event
const nostrController = NostrController.getInstance()
const decrypted = await nostrController
.nip44Decrypt(event.pubkey, event.content)
.catch((err) => {
console.log('An error occurred in decrypting event content', err)
return null
})
if (!decrypted) continue
const internalUnsignedEvent = await parseJson<UnsignedEvent>(
decrypted
).catch((err) => {
console.log(
'An error occurred in parsing the internal unsigned event',
err
)
return null
})
if (!internalUnsignedEvent || internalUnsignedEvent.kind !== 938)
continue
const parsedContent = await parseJson<Meta | SigitNotification>(
internalUnsignedEvent.content
).catch((err) => {
console.log('An error occurred in parsing event content', err)
return null
})
if (!parsedContent) continue
let meta: Meta
if (isSigitNotification(parsedContent)) {
const notification = parsedContent
if (!notification.keys || !usersPubkey) continue
let encryptionKey: string | undefined
const { sender, keys } = notification.keys
const usersNpub = hexToNpub(usersPubkey)
if (usersNpub in keys) {
encryptionKey = await nostrController
.nip04Decrypt(sender, keys[usersNpub])
.catch((err) => {
console.log(
'An error occurred in decrypting encryption key',
err
)
return undefined
})
}
try {
meta = await fetchMetaFromFileStorage(
notification.metaUrl,
encryptionKey
)
} catch (error) {
console.error(
'An error occurred fetching meta file from storage',
error
)
continue
}
} else {
meta = parsedContent
}
validMetaArray.push(meta) // Add valid Meta to the array
updatedProcessedEvents.push(event.id) // Mark event as processed
}
// Update processed events in the Redux store
dispatch(updateProcessedGiftWraps(updatedProcessedEvents))
// Pass the array of Meta objects to updateUsersAppData
if (validMetaArray.length > 0) {
await updateUsersAppData(validMetaArray)
}
},
[dispatch, processedEvents, updateUsersAppData, usersPubkey]
)
const subscribeForSigits = useCallback(
async (pubkey: string) => {
// Define the filter for the subscription
const filter: NDKFilter = {
kinds: [1059 as NDKKind],
'#p': [pubkey]
}
// Process the received event synchronously
const events = await fetchEventsFromUserRelays(
filter,
pubkey,
UserRelaysType.Read,
{
cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY
}
)
await processReceivedEvents(events)
},
[fetchEventsFromUserRelays, processReceivedEvents]
)
/**
* Function to send a notification to a specified receiver.
* @param receiver - The recipient's public key.
* @param notification - Url pointing to metadata associated with the notification on blossom and keys to decrypt.
*/
const sendNotification = useCallback(
async (receiver: string, notification: SigitNotification) => {
if (!usersPubkey) return
// Create an unsigned event object with the provided metadata
const unsignedEvent: UnsignedEvent = {
kind: 938,
pubkey: usersPubkey,
content: JSON.stringify(notification),
tags: [],
created_at: unixNow()
}
// Wrap the unsigned event with the receiver's information
const wrappedEvent = createWrap(unsignedEvent, receiver)
// Publish the notification event to the recipient's read relays
const ndkEvent = new NDKEvent(ndk, wrappedEvent)
const ndkRelayList = await getNDKRelayList(receiver)
const readRelayUrls: string[] = []
if (ndkRelayList?.readRelayUrls) {
readRelayUrls.push(...ndkRelayList.readRelayUrls)
}
if (!readRelayUrls.includes(SIGIT_RELAY)) {
readRelayUrls.push(SIGIT_RELAY)
}
await ndkEvent
.publish(NDKRelaySet.fromRelayUrls(readRelayUrls, ndk, true))
.then((publishedOnRelays) => {
if (publishedOnRelays.size === 0) {
throw new Error('Could not publish to any relay')
}
return publishedOnRelays
})
.catch((err) => {
// Log an error if publishing the notification event fails
console.log(
`An error occurred while publishing notification event for ${hexToNpub(receiver)}`,
err
)
throw err
})
},
[ndk, usersPubkey, getNDKRelayList]
)
return {
getUsersAppData,
subscribeForSigits,
updateUsersAppData,
sendNotification
}
}

View File

@ -0,0 +1,13 @@
import { NDKContext, NDKContextType } from '../contexts/NDKContext'
import { useContext } from 'react'
export const useNDKContext = () => {
const ndkContext = useContext(NDKContext)
if (!ndkContext)
throw new Error(
'NDKContext should not be used in out component tree hierarchy'
)
return { ...ndkContext } as NDKContextType
}

View File

@ -1,33 +1,18 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { ProfileMetadata } from '../types/profile'
import { MetadataController } from '../controllers/MetadataController' import { NDKUserProfile } from '@nostr-dev-kit/ndk'
import { Event, kinds } from 'nostr-tools' import { useNDKContext } from './useNDKContext'
export const useProfileMetadata = (pubkey: string) => { export const useProfileMetadata = (pubkey: string) => {
const [profileMetadata, setProfileMetadata] = useState<ProfileMetadata>() const { findMetadata } = useNDKContext()
const [userProfile, setUserProfile] = useState<NDKUserProfile>()
useEffect(() => { useEffect(() => {
const metadataController = MetadataController.getInstance()
const handleMetadataEvent = (event: Event) => {
const metadataContent =
metadataController.extractProfileMetadataContent(event)
if (metadataContent) {
setProfileMetadata(metadataContent)
}
}
if (pubkey) { if (pubkey) {
metadataController.on(pubkey, (kind: number, event: Event) => { findMetadata(pubkey)
if (kind === kinds.Metadata) { .then((profile) => {
handleMetadataEvent(event) if (profile) setUserProfile(profile)
}
})
metadataController
.findMetadata(pubkey)
.then((metadataEvent) => {
if (metadataEvent) handleMetadataEvent(metadataEvent)
}) })
.catch((err) => { .catch((err) => {
console.error( console.error(
@ -36,11 +21,7 @@ export const useProfileMetadata = (pubkey: string) => {
) )
}) })
} }
}, [pubkey, findMetadata])
return () => { return userProfile
metadataController.off(pubkey, handleMetadataEvent)
}
}, [pubkey])
return profileMetadata
} }

View File

@ -1,10 +1,5 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { import { DocSignatureEvent, Meta, SignedEventContent, FlatMeta } from '../types'
CreateSignatureEventContent,
DocSignatureEvent,
Meta,
SignedEventContent
} from '../types'
import { Mark } from '../types/mark' import { Mark } from '../types/mark'
import { import {
fromUnixTimestamp, fromUnixTimestamp,
@ -16,49 +11,10 @@ import {
} from '../utils' } from '../utils'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { verifyEvent } from 'nostr-tools' import { verifyEvent } from 'nostr-tools'
import { Event } from 'nostr-tools'
import store from '../store/store' import store from '../store/store'
import { NostrController } from '../controllers' import { NostrController } from '../controllers'
import { MetaParseError } from '../types/errors/MetaParseError' import { MetaParseError } from '../types/errors/MetaParseError'
import { MARK_TYPE_CONFIG } from '../components/MarkTypeStrategy/MarkStrategy'
/**
* Flattened interface that combines properties `Meta`, `CreateSignatureEventContent`,
* and `Event` (event's fields are made optional and pubkey and created_at replaced with our versions)
*/
export interface FlatMeta
extends Meta,
CreateSignatureEventContent,
Partial<Omit<Event, 'pubkey' | 'created_at'>> {
// Remove pubkey and use submittedBy as `npub1${string}`
submittedBy?: `npub1${string}`
// Optional field only present on exported sigits
// Exporting adds user's pubkey
exportedBy?: `npub1${string}`
// Remove created_at and replace with createdAt
createdAt?: number
// Validated create signature event
isValid: boolean
// Decryption
encryptionKey: string | null
// Parsed Document Signatures
parsedSignatureEvents: {
[signer: `npub1${string}`]: DocSignatureEvent
}
// Calculated completion time
completedAt?: number
// Calculated status fields
signedStatus: SigitStatus
signersStatus: {
[signer: `npub1${string}`]: SignStatus
}
}
/** /**
* Custom use hook for parsing the Sigit Meta * Custom use hook for parsing the Sigit Meta
@ -70,8 +26,8 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
const [kind, setKind] = useState<number>() const [kind, setKind] = useState<number>()
const [tags, setTags] = useState<string[][]>() const [tags, setTags] = useState<string[][]>()
const [createdAt, setCreatedAt] = useState<number>() const [createdAt, setCreatedAt] = useState<number>()
const [submittedBy, setSubmittedBy] = useState<`npub1${string}`>() // submittedBy, pubkey from nostr event const [submittedBy, setSubmittedBy] = useState<string>() // submittedBy, pubkey from nostr event (hex)
const [exportedBy, setExportedBy] = useState<`npub1${string}`>() // pubkey from export signature nostr event const [exportedBy, setExportedBy] = useState<string>() // pubkey from export signature nostr event (hex)
const [id, setId] = useState<string>() const [id, setId] = useState<string>()
const [sig, setSig] = useState<string>() const [sig, setSig] = useState<string>()
@ -97,25 +53,23 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
[signer: `npub1${string}`]: SignStatus [signer: `npub1${string}`]: SignStatus
}>({}) }>({})
const [encryptionKey, setEncryptionKey] = useState<string | null>(null) const [encryptionKey, setEncryptionKey] = useState<string | undefined>()
useEffect(() => { useEffect(() => {
if (!meta) return if (!meta) return
;(async function () { ;(async function () {
try { try {
if (meta.exportSignature) { if (meta.exportSignature) {
const exportSignatureEvent = await parseNostrEvent( const exportSignatureEvent = parseNostrEvent(meta.exportSignature)
meta.exportSignature
)
if ( if (
verifyEvent(exportSignatureEvent) && verifyEvent(exportSignatureEvent) &&
exportSignatureEvent.pubkey exportSignatureEvent.pubkey
) { ) {
setExportedBy(exportSignatureEvent.pubkey as `npub1${string}`) setExportedBy(exportSignatureEvent.pubkey)
} }
} }
const createSignatureEvent = await parseNostrEvent(meta.createSignature) const createSignatureEvent = parseNostrEvent(meta.createSignature)
const { kind, tags, created_at, pubkey, id, sig, content } = const { kind, tags, created_at, pubkey, id, sig, content } =
createSignatureEvent createSignatureEvent
@ -125,12 +79,12 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
setTags(tags) setTags(tags)
// created_at in nostr events are stored in seconds // created_at in nostr events are stored in seconds
setCreatedAt(fromUnixTimestamp(created_at)) setCreatedAt(fromUnixTimestamp(created_at))
setSubmittedBy(pubkey as `npub1${string}`) setSubmittedBy(pubkey)
setId(id) setId(id)
setSig(sig) setSig(sig)
const { title, signers, viewers, fileHashes, markConfig, zipUrl } = const { title, signers, viewers, fileHashes, markConfig, zipUrl } =
await parseCreateSignatureEventContent(content) parseCreateSignatureEventContent(content)
setTitle(title) setTitle(title)
setSigners(signers) setSigners(signers)
@ -139,6 +93,7 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
setMarkConfig(markConfig) setMarkConfig(markConfig)
setZipUrl(zipUrl) setZipUrl(zipUrl)
let encryptionKey: string | undefined
if (meta.keys) { if (meta.keys) {
const { sender, keys } = meta.keys const { sender, keys } = meta.keys
// Retrieve the user's public key from the state // Retrieve the user's public key from the state
@ -156,13 +111,13 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
'An error occurred in decrypting encryption key', 'An error occurred in decrypting encryption key',
err err
) )
return null return undefined
}) })
encryptionKey = decrypted
setEncryptionKey(decrypted) setEncryptionKey(decrypted)
} }
} }
// Temp. map to hold events and signers // Temp. map to hold events and signers
const parsedSignatureEventsMap = new Map< const parsedSignatureEventsMap = new Map<
`npub1${string}`, `npub1${string}`,
@ -204,13 +159,40 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
} }
} }
parsedSignatureEventsMap.forEach((event, npub) => { for (const [npub, event] of parsedSignatureEventsMap) {
const isValidSignature = verifyEvent(event) const isValidSignature = verifyEvent(event)
if (isValidSignature) { if (isValidSignature) {
// get the signature of prev signer from the content of current signers signedEvent // get the signature of prev signer from the content of current signers signedEvent
const prevSignersSig = getPrevSignerSig(npub) const prevSignersSig = getPrevSignerSig(npub)
try { try {
const obj: SignedEventContent = JSON.parse(event.content) 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, { parsedSignatureEventsMap.set(npub, {
...event, ...event,
parsedContent: obj parsedContent: obj
@ -226,7 +208,7 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
signerStatusMap.set(npub as `npub1${string}`, SignStatus.Invalid) signerStatusMap.set(npub as `npub1${string}`, SignStatus.Invalid)
} }
} }
}) }
signers signers
.filter((s) => !parsedSignatureEventsMap.has(s)) .filter((s) => !parsedSignatureEventsMap.has(s))
@ -276,6 +258,7 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
createSignature: meta?.createSignature, createSignature: meta?.createSignature,
docSignatures: meta?.docSignatures, docSignatures: meta?.docSignatures,
keys: meta?.keys, keys: meta?.keys,
timestamps: meta?.timestamps,
isValid, isValid,
kind, kind,
tags, tags,

View File

@ -1,40 +1,49 @@
import { Event, getPublicKey, kinds, nip19 } from 'nostr-tools'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { Outlet, useNavigate, useSearchParams } from 'react-router-dom' import { Outlet, useNavigate, useSearchParams } from 'react-router-dom'
import { getPublicKey, nip19 } from 'nostr-tools'
import { init as initNostrLogin } from 'nostr-login'
import { NostrLoginAuthOptions } from 'nostr-login/dist/types'
import { AppBar } from '../components/AppBar/AppBar' import { AppBar } from '../components/AppBar/AppBar'
import { LoadingSpinner } from '../components/LoadingSpinner' import { LoadingSpinner } from '../components/LoadingSpinner'
import { NostrController } from '../controllers'
import { import {
AuthController, useAppDispatch,
MetadataController, useAppSelector,
NostrController useAuth,
} from '../controllers' useLogout,
useNDK,
useNDKContext
} from '../hooks'
import { import {
restoreState, restoreState,
setMetadataEvent, setUserProfile,
updateKeyPair, updateKeyPair,
updateLoginMethod, updateLoginMethod,
updateNostrLoginAuthMethod, updateNostrLoginAuthMethod,
updateUserAppData updateUserAppData,
setUserRobotImage
} from '../store/actions' } from '../store/actions'
import { setUserRobotImage } from '../store/userRobotImage/action'
import {
getRoboHashPicture,
getUsersAppData,
loadState,
subscribeForSigits
} from '../utils'
import { useAppDispatch, useAppSelector } from '../hooks'
import styles from './style.module.scss'
import { useLogout } from '../hooks/useLogout'
import { LoginMethod } from '../store/auth/types' import { LoginMethod } from '../store/auth/types'
import { NostrLoginAuthOptions } from 'nostr-login/dist/types'
import { init as initNostrLogin } from 'nostr-login' import { getRoboHashPicture, loadState } from '../utils'
import styles from './style.module.scss'
export const MainLayout = () => { export const MainLayout = () => {
const [searchParams, setSearchParams] = useSearchParams() const [searchParams, setSearchParams] = useSearchParams()
const navigate = useNavigate() const navigate = useNavigate()
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const logout = useLogout() const logout = useLogout()
const { findMetadata } = useNDKContext()
const { authAndGetMetadataAndRelaysMap } = useAuth()
const { getUsersAppData, subscribeForSigits } = useNDK()
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState(`Loading App`) const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState(`Loading App`)
const isLoggedIn = useAppSelector((state) => state.auth?.loggedIn) const isLoggedIn = useAppSelector((state) => state.auth?.loggedIn)
@ -58,14 +67,12 @@ export const MainLayout = () => {
} }
const login = useCallback(async () => { const login = useCallback(async () => {
const nostrController = NostrController.getInstance()
const authController = new AuthController()
const pubkey = await nostrController.capturePublicKey()
dispatch(updateLoginMethod(LoginMethod.nostrLogin)) dispatch(updateLoginMethod(LoginMethod.nostrLogin))
const redirectPath = const nostrController = NostrController.getInstance()
await authController.authAndGetMetadataAndRelaysMap(pubkey) const pubkey = await nostrController.capturePublicKey()
const redirectPath = await authAndGetMetadataAndRelaysMap(pubkey)
if (redirectPath) { if (redirectPath) {
navigateAfterLogin(redirectPath) navigateAfterLogin(redirectPath)
@ -105,10 +112,7 @@ export const MainLayout = () => {
) )
dispatch(updateLoginMethod(LoginMethod.privateKey)) dispatch(updateLoginMethod(LoginMethod.privateKey))
const authController = new AuthController() authAndGetMetadataAndRelaysMap(publickey).catch((err) => {
authController
.authAndGetMetadataAndRelaysMap(publickey)
.catch((err) => {
console.error('Error occurred in authentication: ' + err) console.error('Error occurred in authentication: ' + err)
return null return null
}) })
@ -134,7 +138,15 @@ export const MainLayout = () => {
initNostrLogin({ initNostrLogin({
methods: ['connect', 'extension', 'local'], methods: ['connect', 'extension', 'local'],
noBanner: true, noBanner: true,
onAuth: handleNostrAuth onAuth: handleNostrAuth,
outboxRelays: [
'wss://purplepag.es',
'wss://relay.nos.social',
'wss://user.kindpag.es',
'wss://relay.damus.io',
'wss://nos.lol',
'wss://relay.sigit.io'
]
}).catch((error) => { }).catch((error) => {
console.error('Failed to initialize Nostr-Login', error) console.error('Failed to initialize Nostr-Login', error)
}) })
@ -143,8 +155,6 @@ export const MainLayout = () => {
}, [dispatch]) }, [dispatch])
useEffect(() => { useEffect(() => {
const metadataController = MetadataController.getInstance()
const restoredState = loadState() const restoredState = loadState()
if (restoredState) { if (restoredState) {
dispatch(restoreState(restoredState)) dispatch(restoreState(restoredState))
@ -154,19 +164,8 @@ export const MainLayout = () => {
if (loggedIn) { if (loggedIn) {
if (!loginMethod || !usersPubkey) return logout() if (!loginMethod || !usersPubkey) return logout()
// Update user profile metadata, old state might be outdated findMetadata(usersPubkey).then((profile) => {
const handleMetadataEvent = (event: Event) => { dispatch(setUserProfile(profile))
dispatch(setMetadataEvent(event))
}
metadataController.on(usersPubkey, (kind: number, event: Event) => {
if (kind === kinds.Metadata) {
handleMetadataEvent(event)
}
})
metadataController.findMetadata(usersPubkey).then((metadataEvent) => {
if (metadataEvent) handleMetadataEvent(metadataEvent)
}) })
} else { } else {
setIsLoading(false) setIsLoading(false)
@ -193,7 +192,7 @@ export const MainLayout = () => {
hasSubscribed.current = true hasSubscribed.current = true
} }
} }
}, [authState, isLoggedIn, usersAppData]) }, [authState, isLoggedIn, usersAppData, subscribeForSigits])
/** /**
* When authState change user logged in / or app reloaded * When authState change user logged in / or app reloaded

View File

@ -11,13 +11,13 @@ import './index.css'
import store from './store/store.ts' import store from './store/store.ts'
import { theme } from './theme' import { theme } from './theme'
import { saveState } from './utils' import { saveState } from './utils'
import { NDKContextProvider } from './contexts/NDKContext'
store.subscribe( store.subscribe(
_.throttle(() => { _.throttle(() => {
saveState({ saveState({
auth: store.getState().auth, auth: store.getState().auth,
metadata: store.getState().metadata, user: store.getState().user,
userRobotImage: store.getState().userRobotImage,
relays: store.getState().relays relays: store.getState().relays
}) })
}, 1000) }, 1000)
@ -28,7 +28,9 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
<CssVarsProvider theme={theme}> <CssVarsProvider theme={theme}>
<HashRouter> <HashRouter>
<Provider store={store}> <Provider store={store}>
<NDKContextProvider>
<App /> <App />
</NDKContextProvider>
<ToastContainer /> <ToastContainer />
</Provider> </Provider>
</HashRouter> </HashRouter>

View File

@ -1,10 +1,15 @@
import styles from './style.module.scss' 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 type { Identifier, XYCoord } from 'dnd-core'
import saveAs from 'file-saver'
import JSZip from 'jszip' import JSZip from 'jszip'
import { Event, kinds } from 'nostr-tools' import { useCallback, useEffect, useRef, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { DndProvider, useDrag, useDrop } from 'react-dnd' import { DndProvider, useDrag, useDrop } from 'react-dnd'
import { MultiBackend } from 'react-dnd-multi-backend' import { MultiBackend } from 'react-dnd-multi-backend'
import { HTML5toTouch } from 'rdndmb-html5-to-touch' import { HTML5toTouch } from 'rdndmb-html5-to-touch'
@ -13,13 +18,16 @@ import { useLocation, useNavigate } from 'react-router-dom'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { LoadingSpinner } from '../../components/LoadingSpinner' import { LoadingSpinner } from '../../components/LoadingSpinner'
import { UserAvatar } from '../../components/UserAvatar' import { UserAvatar } from '../../components/UserAvatar'
import { MetadataController, NostrController } from '../../controllers' import { NostrController } from '../../controllers'
import { appPrivateRoutes } from '../../routes' import { appPrivateRoutes, appPublicRoutes } from '../../routes'
import { import {
CreateSignatureEventContent, CreateSignatureEventContent,
KeyboardCode,
Meta, Meta,
ProfileMetadata, SigitNotification,
SignedEvent,
User, User,
UserRelaysType,
UserRole UserRole
} from '../../types' } from '../../types'
import { import {
@ -30,18 +38,16 @@ import {
generateKeysFile, generateKeysFile,
getHash, getHash,
hexToNpub, hexToNpub,
isOnline,
unixNow, unixNow,
npubToHex, npubToHex,
queryNip05, queryNip05,
sendNotification,
signEventForMetaFile, signEventForMetaFile,
updateUsersAppData,
uploadToFileStorage, uploadToFileStorage,
DEFAULT_TOOLBOX, DEFAULT_TOOLBOX,
settleAllFullfilfedPromises, settleAllFullfilfedPromises,
sendPrivateDirectMessage, sendPrivateDirectMessage,
parseNostrEvent parseNostrEvent,
uploadMetaToFileStorage
} from '../../utils' } from '../../utils'
import { Container } from '../../components/Container' import { Container } from '../../components/Container'
import fileListStyles from '../../components/FileList/style.module.scss' import fileListStyles from '../../components/FileList/style.module.scss'
@ -51,6 +57,7 @@ import { Mark } from '../../types/mark.ts'
import { StickySideColumns } from '../../layouts/StickySideColumns.tsx' import { StickySideColumns } from '../../layouts/StickySideColumns.tsx'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { import {
faDownload,
faEllipsis, faEllipsis,
faEye, faEye,
faFile, faFile,
@ -58,17 +65,32 @@ import {
faGripLines, faGripLines,
faPen, faPen,
faPlus, faPlus,
faSearch,
faToolbox, faToolbox,
faTrash, faTrash,
faUpload faUpload
} from '@fortawesome/free-solid-svg-icons' } from '@fortawesome/free-solid-svg-icons'
import { getSigitFile, SigitFile } from '../../utils/file.ts' import { getSigitFile, SigitFile } from '../../utils/file.ts'
import _ from 'lodash' import { generateTimestamp } from '../../utils/opentimestamps.ts'
import { Autocomplete } from '@mui/material'
import _, { truncate } from 'lodash'
import { SendDMError } from '../../types/errors/SendDMError.ts' import { SendDMError } from '../../types/errors/SendDMError.ts'
import * as React from 'react'
import { AvatarIconButton } from '../../components/UserAvatarIconButton'
import { NDKUserProfile, NostrEvent } from '@nostr-dev-kit/ndk'
import { useNDKContext } from '../../hooks/useNDKContext.ts'
import { useNDK } from '../../hooks/useNDK.ts'
import { useImmer } from 'use-immer'
import { ButtonUnderline } from '../../components/ButtonUnderline/index.tsx'
type FoundUser = NostrEvent & { npub: string }
export const CreatePage = () => { export const CreatePage = () => {
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const { findMetadata, fetchEventsFromUserRelays } = useNDKContext()
const { updateUsersAppData, sendNotification } = useNDK()
const { uploadedFiles } = location.state || {} const { uploadedFiles } = location.state || {}
const [currentFile, setCurrentFile] = useState<File>() const [currentFile, setCurrentFile] = useState<File>()
const isActive = (file: File) => file.name === currentFile?.name const isActive = (file: File) => file.name === currentFile?.name
@ -78,7 +100,7 @@ export const CreatePage = () => {
const [title, setTitle] = useState(`sigit_${formatTimestamp(Date.now())}`) const [title, setTitle] = useState(`sigit_${formatTimestamp(Date.now())}`)
const [selectedFiles, setSelectedFiles] = useState<File[]>([]) const [selectedFiles, setSelectedFiles] = useState<File[]>([...uploadedFiles])
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const handleUploadButtonClick = () => { const handleUploadButtonClick = () => {
if (fileInputRef.current) { if (fileInputRef.current) {
@ -87,32 +109,176 @@ export const CreatePage = () => {
} }
const [userInput, setUserInput] = useState('') const [userInput, setUserInput] = useState('')
const handleInputKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => { const [userSearchInput, setUserSearchInput] = useState('')
if (event.code === 'Enter' || event.code === 'NumpadEnter') {
event.preventDefault() const [userRole] = useState<UserRole>(UserRole.signer)
handleAddUser()
}
}
const [userRole, setUserRole] = useState<UserRole>(UserRole.signer)
const [error, setError] = useState<string>() const [error, setError] = useState<string>()
const [users, setUsers] = useState<User[]>([]) const [users, setUsers] = useState<User[]>([])
const signers = users.filter((u) => u.role === UserRole.signer) const signers = users.filter((u) => u.role === UserRole.signer)
const viewers = users.filter((u) => u.role === UserRole.viewer) 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() const nostrController = NostrController.getInstance()
const [metadata, setMetadata] = useState<{ [key: string]: ProfileMetadata }>( const [userProfiles, setUserProfiles] = useState<{
{} [key: string]: NDKUserProfile
) }>({})
const [drawnFiles, setDrawnFiles] = useState<SigitFile[]>([])
const [drawnFiles, updateDrawnFiles] = useImmer<SigitFile[]>([])
const [parsingPdf, setIsParsing] = useState<boolean>(false) const [parsingPdf, setIsParsing] = useState<boolean>(false)
const searchFieldRef = useRef<HTMLInputElement>(null)
const [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 searchTerm = searchString.trim()
fetchEventsFromUserRelays(
{
kinds: [0],
search: searchTerm
},
usersPubkey,
UserRelaysType.Write
)
.then((events) => {
const nostrEvents = events.map((event) => event.rawEvent())
const fineFilteredEvents = nostrEvents
.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, event) => {
if (!uniqueEvents.some((e) => e.pubkey === event.pubkey)) {
uniqueEvents.push({
...event,
npub: hexToNpub(event.pubkey)
})
}
return uniqueEvents
}, [] as FoundUser[])
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(() => { useEffect(() => {
if (selectedFiles) { 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) * and sets to a state (adds images if it's a PDF)
*/ */
const parsePages = async () => { const parsePages = async () => {
@ -120,8 +286,28 @@ export const CreatePage = () => {
selectedFiles, selectedFiles,
getSigitFile getSigitFile
) )
updateDrawnFiles((draft) => {
// Existing files are untouched
setDrawnFiles(files) // Handle removed files
// Remove in reverse to avoid index issues
for (let i = draft.length - 1; i >= 0; i--) {
if (
!files.some(
(f) => f.name === draft[i].name && f.size === draft[i].size
)
) {
draft.splice(i, 1)
}
}
// Add new files
files.forEach((f) => {
if (!draft.some((d) => d.name === f.name && d.size === f.size)) {
draft.push(f)
}
})
})
} }
setIsParsing(true) setIsParsing(true)
@ -130,9 +316,7 @@ export const CreatePage = () => {
setIsParsing(false) setIsParsing(false)
}) })
} }
}, [selectedFiles]) }, [selectedFiles, updateDrawnFiles])
const [selectedTool, setSelectedTool] = useState<DrawTool>()
/** /**
* Changes the drawing tool * Changes the drawing tool
@ -150,29 +334,15 @@ export const CreatePage = () => {
useEffect(() => { useEffect(() => {
users.forEach((user) => { users.forEach((user) => {
if (!(user.pubkey in metadata)) { if (!(user.pubkey in userProfiles)) {
const metadataController = MetadataController.getInstance() findMetadata(user.pubkey)
.then((profile) => {
const handleMetadataEvent = (event: Event) => { if (profile) {
const metadataContent = setUserProfiles((prev) => ({
metadataController.extractProfileMetadataContent(event)
if (metadataContent)
setMetadata((prev) => ({
...prev, ...prev,
[user.pubkey]: metadataContent [user.pubkey]: profile
})) }))
} }
metadataController.on(user.pubkey, (kind: number, event: Event) => {
if (kind === kinds.Metadata) {
handleMetadataEvent(event)
}
})
metadataController
.findMetadata(user.pubkey)
.then((metadataEvent) => {
if (metadataEvent) handleMetadataEvent(metadataEvent)
}) })
.catch((err) => { .catch((err) => {
console.error( console.error(
@ -182,13 +352,7 @@ export const CreatePage = () => {
}) })
} }
}) })
}, [metadata, users]) }, [userProfiles, users, findMetadata])
useEffect(() => {
if (uploadedFiles) {
setSelectedFiles([...uploadedFiles])
}
}, [uploadedFiles])
useEffect(() => { useEffect(() => {
if (usersPubkey) { if (usersPubkey) {
@ -206,7 +370,7 @@ export const CreatePage = () => {
} }
}, [usersPubkey]) }, [usersPubkey])
const handleAddUser = async () => { const handleAddUser = useCallback(async () => {
setError(undefined) setError(undefined)
const addUser = (pubkey: string) => { const addUser = (pubkey: string) => {
@ -248,6 +412,8 @@ export const CreatePage = () => {
const input = userInput.toLowerCase() const input = userInput.toLowerCase()
setUserSearchInput('')
if (input.startsWith('npub')) { if (input.startsWith('npub')) {
return handleAddNpubUser(input) return handleAddNpubUser(input)
} }
@ -297,7 +463,20 @@ export const CreatePage = () => {
} }
return return
} }
} }, [
userInput,
userRole,
setError,
setUsers,
setUserSearchInput,
setIsLoading,
setLoadingSpinnerDesc,
setUserInput
])
useEffect(() => {
if (userInput?.length > 0) handleAddUser()
}, [handleAddUser, userInput])
const handleUserRoleChange = (role: UserRole, pubkey: string) => { const handleUserRoleChange = (role: UserRole, pubkey: string) => {
setUsers((prevUsers) => setUsers((prevUsers) =>
@ -328,7 +507,7 @@ export const CreatePage = () => {
}) })
}) })
}) })
setDrawnFiles(drawnFilesCopy) updateDrawnFiles(drawnFilesCopy)
} }
/** /**
@ -352,11 +531,16 @@ export const CreatePage = () => {
const files = Array.from(event.target.files) const files = Array.from(event.target.files)
// Remove duplicates based on the file.name // Remove duplicates based on the file.name
setSelectedFiles((p) => setSelectedFiles((p) => {
[...p, ...files].filter( const unique = [...p, ...files].filter(
(file, i, array) => i === array.findIndex((t) => t.name === file.name) (file, i, array) => i === array.findIndex((t) => t.name === file.name)
) )
) navigate('.', {
state: { uploadedFiles: unique },
replace: true
})
return unique
})
} }
} }
@ -370,9 +554,14 @@ export const CreatePage = () => {
) => { ) => {
event.stopPropagation() event.stopPropagation()
setSelectedFiles((prevFiles) => setSelectedFiles((prevFiles) => {
prevFiles.filter((file) => file.name !== fileToRemove.name) const files = prevFiles.filter((file) => file.name !== fileToRemove.name)
) navigate('.', {
state: { uploadedFiles: files },
replace: true
})
return files
})
} }
// Validate inputs before proceeding // Validate inputs before proceeding
@ -450,8 +639,8 @@ export const CreatePage = () => {
width: drawnField.width width: drawnField.width
}, },
npub: drawnField.counterpart, npub: drawnField.counterpart,
pdfFileHash: fileHash, fileName: file.name,
fileName: file.name fileHash
} }
}) })
}) || [] }) || []
@ -506,10 +695,18 @@ export const CreatePage = () => {
type: 'application/sigit' type: 'application/sigit'
}) })
const firstSigner = users.filter((user) => user.role === UserRole.signer)[0] const userSet = new Set<string>()
const nostrController = NostrController.getInstance()
const pubkey = await nostrController.capturePublicKey()
userSet.add(pubkey)
signers.forEach((signer) => {
userSet.add(signer.pubkey)
})
viewers.forEach((viewer) => {
userSet.add(viewer.pubkey)
})
const keysFileContent = await generateKeysFile( const keysFileContent = await generateKeysFile(
[firstSigner.pubkey], Array.from(userSet),
encryptionKey encryptionKey
) )
if (!keysFileContent) return null if (!keysFileContent) return null
@ -561,30 +758,6 @@ export const CreatePage = () => {
.catch(handleUploadError) .catch(handleUploadError)
} }
// Manage offline scenarios for signing or viewing the file
const handleOfflineFlow = async (
encryptedArrayBuffer: ArrayBuffer,
encryptionKey: string
) => {
const finalZipFile = await createFinalZipFile(
encryptedArrayBuffer,
encryptionKey
)
if (!finalZipFile) {
setIsLoading(false)
return
}
saveAs(finalZipFile, `request-${unixNow()}.sigit.zip`)
// If user is the next signer, we can navigate directly to sign page
if (signers[0].pubkey === usersPubkey) {
navigate(appPrivateRoutes.sign, { state: { uploadedZip: finalZipFile } })
}
setIsLoading(false)
}
const generateFilesZip = async (): Promise<ArrayBuffer | null> => { const generateFilesZip = async (): Promise<ArrayBuffer | null> => {
const zip = new JSZip() const zip = new JSZip()
selectedFiles.forEach((file) => { selectedFiles.forEach((file) => {
@ -616,7 +789,7 @@ export const CreatePage = () => {
title title
} }
setLoadingSpinnerDesc('Signing nostr event for create signature') setLoadingSpinnerDesc('Preparing document(s) for signing')
const createSignature = await signEventForMetaFile( const createSignature = await signEventForMetaFile(
JSON.stringify(content), JSON.stringify(content),
@ -634,7 +807,7 @@ export const CreatePage = () => {
} }
// Send notifications to signers and viewers // Send notifications to signers and viewers
const sendNotifications = (meta: Meta) => { const sendNotifications = (notification: SigitNotification) => {
// no need to send notification to self so remove it from the list // no need to send notification to self so remove it from the list
const receivers = ( const receivers = (
signers.length > 0 signers.length > 0
@ -642,10 +815,15 @@ export const CreatePage = () => {
: viewers.map((viewer) => viewer.pubkey) : viewers.map((viewer) => viewer.pubkey)
).filter((receiver) => receiver !== usersPubkey) ).filter((receiver) => receiver !== usersPubkey)
return receivers.map((receiver) => sendNotification(receiver, meta)) return receivers.map((receiver) => sendNotification(receiver, notification))
} }
const handleCreate = async () => { const extractNostrId = (stringifiedEvent: string): string => {
const e = JSON.parse(stringifiedEvent) as SignedEvent
return e.id
}
const initCreation = async () => {
try { try {
if (!validateInputs()) return if (!validateInputs()) return
@ -657,7 +835,30 @@ export const CreatePage = () => {
setLoadingSpinnerDesc('Generating encryption key') setLoadingSpinnerDesc('Generating encryption key')
const encryptionKey = await generateEncryptionKey() const encryptionKey = await generateEncryptionKey()
if (await isOnline()) { setLoadingSpinnerDesc('Creating marks')
const markConfig = createMarks(fileHashes)
return {
encryptionKey,
markConfig,
fileHashes
}
} catch (error) {
if (error instanceof Error) {
toast.error(error.message)
}
console.error(error)
setIsLoading(false)
}
}
const handleCreate = async () => {
try {
const result = await initCreation()
if (!result) return
const { encryptionKey, markConfig, fileHashes } = result
setLoadingSpinnerDesc('generating files.zip') setLoadingSpinnerDesc('generating files.zip')
const arrayBuffer = await generateFilesZip() const arrayBuffer = await generateFilesZip()
if (!arrayBuffer) return if (!arrayBuffer) return
@ -668,8 +869,6 @@ export const CreatePage = () => {
encryptionKey encryptionKey
) )
const markConfig = createMarks(fileHashes)
setLoadingSpinnerDesc('Uploading files.zip to file storage') setLoadingSpinnerDesc('Uploading files.zip to file storage')
const fileUrl = await uploadFile(encryptedArrayBuffer) const fileUrl = await uploadFile(encryptedArrayBuffer)
if (!fileUrl) return if (!fileUrl) return
@ -694,6 +893,10 @@ export const CreatePage = () => {
const keys = await generateKeys(pubkeys, encryptionKey) const keys = await generateKeys(pubkeys, encryptionKey)
if (!keys) return if (!keys) return
setLoadingSpinnerDesc('Generating an open timestamp.')
const timestamp = await generateTimestamp(extractNostrId(createSignature))
const meta: Meta = { const meta: Meta = {
createSignature, createSignature,
keys, keys,
@ -701,12 +904,22 @@ export const CreatePage = () => {
docSignatures: {} docSignatures: {}
} }
if (timestamp) {
meta.timestamps = [timestamp]
}
setLoadingSpinnerDesc('Updating user app data') setLoadingSpinnerDesc('Updating user app data')
const event = await updateUsersAppData(meta)
const event = await updateUsersAppData([meta])
if (!event) return if (!event) return
const metaUrl = await uploadMetaToFileStorage(meta, encryptionKey)
setLoadingSpinnerDesc('Sending notifications to counterparties') setLoadingSpinnerDesc('Sending notifications to counterparties')
const promises = sendNotifications(meta) const promises = sendNotifications({
metaUrl,
keys: meta.keys
})
await Promise.all(promises) await Promise.all(promises)
.then(() => { .then(() => {
@ -716,16 +929,15 @@ export const CreatePage = () => {
toast.error('Failed to publish notifications') toast.error('Failed to publish notifications')
}) })
const isFirstSigner =
signers.length > 0 && signers[0].pubkey === usersPubkey
// Don't send notification if creator is next signer
if (signers.length > 0 && !isFirstSigner) {
// Send DM to the next signer // Send DM to the next signer
setLoadingSpinnerDesc('Sending DMs') setLoadingSpinnerDesc('Sending DMs')
if (signers.length > 0 && signers[0].pubkey !== usersPubkey) {
// No need to send notification to self so remove it from the list
const nextSigner = signers[0].pubkey const nextSigner = signers[0].pubkey
const createSignatureEvent = parseNostrEvent(meta.createSignature)
if (nextSigner) {
const createSignatureEvent = await parseNostrEvent(
meta.createSignature
)
const { id } = createSignatureEvent const { id } = createSignatureEvent
try { try {
await sendPrivateDirectMessage( await sendPrivateDirectMessage(
@ -739,18 +951,36 @@ export const CreatePage = () => {
console.error(error) console.error(error)
} }
} }
}
if (isFirstSigner) {
navigate(appPrivateRoutes.sign, { state: { meta } }) navigate(appPrivateRoutes.sign, { state: { meta } })
} else { } else {
const createSignatureJson = JSON.parse(createSignature)
navigate(`${appPublicRoutes.verify}/${createSignatureJson.id}`)
}
} catch (error) {
if (error instanceof Error) {
toast.error(error.message)
}
console.error(error)
} finally {
setIsLoading(false)
}
}
const handleCreateOffline = async () => {
try {
const result = await initCreation()
if (!result) return
const { encryptionKey, markConfig, fileHashes } = result
const zip = new JSZip() const zip = new JSZip()
selectedFiles.forEach((file) => { selectedFiles.forEach((file) => {
zip.file(`files/${file.name}`, file) zip.file(`files/${file.name}`, file)
}) })
const markConfig = createMarks(fileHashes)
setLoadingSpinnerDesc('Generating create signature') setLoadingSpinnerDesc('Generating create signature')
const createSignature = await generateCreateSignature( const createSignature = await generateCreateSignature(
markConfig, markConfig,
@ -784,7 +1014,26 @@ export const CreatePage = () => {
encryptionKey encryptionKey
) )
await handleOfflineFlow(encryptedArrayBuffer, encryptionKey) const finalZipFile = await createFinalZipFile(
encryptedArrayBuffer,
encryptionKey
)
if (!finalZipFile) {
setIsLoading(false)
return
}
// If user is the next signer, we can navigate directly to sign page
const isFirstSigner = signers[0].pubkey === usersPubkey
if (isFirstSigner) {
navigate(appPrivateRoutes.sign, {
state: { arrayBuffer }
})
} else {
navigate(appPublicRoutes.verify, {
state: { uploadedZip: arrayBuffer }
})
} }
} catch (error) { } catch (error) {
if (error instanceof Error) { if (error instanceof Error) {
@ -796,9 +1045,49 @@ 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: NostrEvent) => {
try {
return JSON.parse(event.content)
} catch (e) {
return undefined
}
}
return ( return (
<> <>
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
<Container className={styles.container}> <Container className={styles.container}>
<StickySideColumns <StickySideColumns
left={ left={
@ -859,42 +1148,110 @@ export const CreatePage = () => {
moveSigner={moveSigner} moveSigner={moveSigner}
/> />
</div> </div>
<div className={styles.addCounterpart}> <div className={styles.addCounterpart}>
<div className={styles.inputWrapper}> <div className={styles.inputWrapper}>
<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 || contentJson.image}
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 <TextField
fullWidth {...params}
placeholder="Add counterpart" key={params.id}
value={userInput} inputRef={searchFieldRef}
onChange={(e) => setUserInput(e.target.value)} label="Add/Search counterpart"
onKeyDown={handleInputKeyDown} onKeyDown={handleInputKeyDown}
error={!!error} onChange={handleSearchAutocompleteTextfieldChange}
/>
)}
/> />
</div> </div>
{!pastedUserNpubOrNip05 ? (
<Button <Button
onClick={() => disabled={!userSearchInput || searchUsersLoading}
setUserRole( onClick={() => handleSearchUsers()}
userRole === UserRole.signer
? UserRole.viewer
: UserRole.signer
)
}
variant="contained" variant="contained"
aria-label="Toggle User Role" aria-label="Add"
className={styles.counterpartToggleButton} className={styles.counterpartToggleButton}
> >
<FontAwesomeIcon {searchUsersLoading ? (
icon={userRole === UserRole.signer ? faPen : faEye} <CircularProgress size={14} />
/> ) : (
<FontAwesomeIcon icon={faSearch} />
)}
</Button> </Button>
) : (
<Button <Button
disabled={!userInput} onClick={() => {
onClick={handleAddUser} setUserInput(userSearchInput)
}}
variant="contained" variant="contained"
aria-label="Add" aria-label="Add"
className={styles.counterpartToggleButton} className={styles.counterpartToggleButton}
> >
<FontAwesomeIcon icon={faPlus} /> <FontAwesomeIcon icon={faPlus} />
</Button> </Button>
)}
</div> </div>
<div className={`${styles.paperGroup} ${styles.toolbox}`}> <div className={`${styles.paperGroup} ${styles.toolbox}`}>
@ -934,6 +1291,11 @@ export const CreatePage = () => {
Publish Publish
</Button> </Button>
<ButtonUnderline onClick={handleCreateOffline}>
<FontAwesomeIcon icon={faDownload} />
Create and export locally
</ButtonUnderline>
{!!error && ( {!!error && (
<FormHelperText error={!!error}>{error}</FormHelperText> <FormHelperText error={!!error}>{error}</FormHelperText>
)} )}
@ -943,19 +1305,17 @@ export const CreatePage = () => {
centerIcon={faFile} centerIcon={faFile}
rightIcon={faToolbox} rightIcon={faToolbox}
> >
{parsingPdf ? (
<LoadingSpinner variant="small" />
) : (
<DrawPDFFields <DrawPDFFields
users={users} users={users}
metadata={metadata} userProfiles={userProfiles}
selectedTool={selectedTool} selectedTool={selectedTool}
sigitFiles={drawnFiles} sigitFiles={drawnFiles}
setSigitFiles={setDrawnFiles} updateSigitFiles={updateDrawnFiles}
/> />
)} {parsingPdf && <LoadingSpinner variant="small" />}
</StickySideColumns> </StickySideColumns>
</Container> </Container>
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
</> </>
) )
} }

View File

@ -1,10 +1,9 @@
import { Button, TextField } from '@mui/material' import { Button, TextField } from '@mui/material'
import JSZip from 'jszip'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom' import { useNavigate, useSearchParams } from 'react-router-dom'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { useAppSelector } from '../../hooks' import { useAppSelector } from '../../hooks'
import { appPrivateRoutes, appPublicRoutes } from '../../routes' import { appPrivateRoutes } from '../../routes'
import { Meta } from '../../types' import { Meta } from '../../types'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faSearch } from '@fortawesome/free-solid-svg-icons' import { faSearch } from '@fortawesome/free-solid-svg-icons'
@ -15,6 +14,7 @@ import { Container } from '../../components/Container'
import styles from './style.module.scss' import styles from './style.module.scss'
import { import {
extractSigitCardDisplayInfo, extractSigitCardDisplayInfo,
navigateFromZip,
SigitCardDisplayInfo, SigitCardDisplayInfo,
SigitStatus SigitStatus
} from '../../utils' } from '../../utils'
@ -56,14 +56,15 @@ export const HomePage = () => {
[key: string]: SigitCardDisplayInfo [key: string]: SigitCardDisplayInfo
}>({}) }>({})
const usersAppData = useAppSelector((state) => state.userAppData) const usersAppData = useAppSelector((state) => state.userAppData)
const usersPubkey = useAppSelector((state) => state.auth.usersPubkey)
useEffect(() => { useEffect(() => {
if (usersAppData) { if (usersAppData?.sigits) {
const getSigitInfo = async () => { const getSigitInfo = async () => {
const parsedSigits: { [key: string]: SigitCardDisplayInfo } = {} const parsedSigits: { [key: string]: SigitCardDisplayInfo } = {}
for (const key in usersAppData.sigits) { for (const key in usersAppData.sigits) {
if (Object.prototype.hasOwnProperty.call(usersAppData.sigits, key)) { if (Object.prototype.hasOwnProperty.call(usersAppData.sigits, key)) {
const sigitInfo = await extractSigitCardDisplayInfo( const sigitInfo = extractSigitCardDisplayInfo(
usersAppData.sigits[key] usersAppData.sigits[key]
) )
if (sigitInfo) { if (sigitInfo) {
@ -80,7 +81,7 @@ export const HomePage = () => {
setSigits(usersAppData.sigits) setSigits(usersAppData.sigits)
getSigitInfo() getSigitInfo()
} }
}, [usersAppData]) }, [usersAppData?.sigits])
const onDrop = useCallback( const onDrop = useCallback(
async (acceptedFiles: File[]) => { async (acceptedFiles: File[]) => {
@ -92,27 +93,12 @@ export const HomePage = () => {
const fileName = file.name const fileName = file.name
const fileExtension = fileName.slice(-10) // ".sigit.zip" has 10 characters const fileExtension = fileName.slice(-10) // ".sigit.zip" has 10 characters
if (fileExtension === '.sigit.zip') { if (fileExtension === '.sigit.zip') {
const zip = await JSZip.loadAsync(file).catch((err) => { const nav = await navigateFromZip(
console.log('err in loading zip file :>> ', err) file,
toast.error(err.message || 'An error occurred in loading zip file.') usersPubkey as `npub1${string}`
return null )
})
if (!zip) return if (nav) return navigate(nav.to, nav.options)
// navigate to sign page if zip contains keys.json
if ('keys.json' in zip.files) {
return navigate(appPrivateRoutes.sign, {
state: { uploadedZip: file }
})
}
// navigate to verify page if zip contains meta.json
if ('meta.json' in zip.files) {
return navigate(appPublicRoutes.verify, {
state: { uploadedZip: file }
})
}
toast.error('Invalid SiGit zip file') toast.error('Invalid SiGit zip file')
return return
@ -124,7 +110,7 @@ export const HomePage = () => {
state: { uploadedFiles: acceptedFiles } state: { uploadedFiles: acceptedFiles }
}) })
}, },
[navigate] [navigate, usersPubkey]
) )
const { getRootProps, getInputProps, isDragActive, open } = useDropzone({ const { getRootProps, getInputProps, isDragActive, open } = useDropzone({
@ -135,6 +121,46 @@ export const HomePage = () => {
const [filter, setFilter] = useState<Filter>('Show all') const [filter, setFilter] = useState<Filter>('Show all')
const [sort, setSort] = useState<Sort>('desc') const [sort, setSort] = useState<Sort>('desc')
const renderSubmissions = () => {
const submissions = Object.keys(parsedSigits)
.filter((s) => {
const { title, signedStatus } = parsedSigits[s]
const isMatch = title?.toLowerCase().includes(q.toLowerCase())
switch (filter) {
case 'Completed':
return signedStatus === SigitStatus.Complete && isMatch
case 'In-progress':
return signedStatus === SigitStatus.Partial && isMatch
case 'Show all':
return isMatch
default:
console.error('Filter case not handled.')
}
})
.sort((a, b) => {
const x = parsedSigits[a].createdAt ?? 0
const y = parsedSigits[b].createdAt ?? 0
return sort === 'desc' ? y - x : x - y
})
if (submissions.length) {
return submissions.map((key) => (
<DisplaySigit
key={`sigit-${key}`}
sigitCreateId={key}
parsedMeta={parsedSigits[key]}
meta={sigits[key]}
/>
))
} else {
return (
<div className={styles.noResults}>
<p>No results</p>
</div>
)
}
}
return ( return (
<div {...getRootProps()} tabIndex={-1}> <div {...getRootProps()} tabIndex={-1}>
<Container className={styles.container}> <Container className={styles.container}>
@ -233,36 +259,8 @@ export const HomePage = () => {
<label htmlFor="file-upload">Click or drag files to upload!</label> <label htmlFor="file-upload">Click or drag files to upload!</label>
)} )}
</button> </button>
<div className={styles.submissions}>
{Object.keys(parsedSigits) <div className={styles.submissions}>{renderSubmissions()}</div>
.filter((s) => {
const { title, signedStatus } = parsedSigits[s]
const isMatch = title?.toLowerCase().includes(q.toLowerCase())
switch (filter) {
case 'Completed':
return signedStatus === SigitStatus.Complete && isMatch
case 'In-progress':
return signedStatus === SigitStatus.Partial && isMatch
case 'Show all':
return isMatch
default:
console.error('Filter case not handled.')
}
})
.sort((a, b) => {
const x = parsedSigits[a].createdAt ?? 0
const y = parsedSigits[b].createdAt ?? 0
return sort === 'desc' ? y - x : x - y
})
.map((key) => (
<DisplaySigit
key={`sigit-${key}`}
sigitCreateId={key}
parsedMeta={parsedSigits[key]}
meta={sigits[key]}
/>
))}
</div>
</Container> </Container>
<Footer /> <Footer />
</div> </div>

View File

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

View File

@ -69,8 +69,8 @@ export const LandingPage = () => {
title: <>Verifiable</>, title: <>Verifiable</>,
description: ( description: (
<> <>
Thanks to Schnorr Signatures and Web of Trust, SIGit is far more SIGit Agreements can be directly verified - unlike traditional,
auditable than traditional server-based offerings. server-based offerings.
</> </>
) )
}, },
@ -84,8 +84,8 @@ export const LandingPage = () => {
title: <>Works Offline</>, title: <>Works Offline</>,
description: ( description: (
<> <>
Presuming you have a hardware signing device, it is possible to It is possible to complete a SIGit round without an internet
complete a SIGit round without an internet connection. connection.
</> </>
) )
}, },
@ -94,8 +94,8 @@ export const LandingPage = () => {
title: <>Multi-Party Signing</>, title: <>Multi-Party Signing</>,
description: ( description: (
<> <>
Choose any number of Signers and Viewers, track the signature status, Choose any number of Signers and Viewers, track status, get
send reminders, get notifications on completion. notifications on completion.
</> </>
) )
} }

View File

@ -1,27 +1,28 @@
import { launch as launchNostrLoginDialog } from 'nostr-login' import { useEffect, useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { Button, Divider, TextField } from '@mui/material' import { Button, Divider, TextField } from '@mui/material'
import { getPublicKey, nip19 } from 'nostr-tools'
import { useEffect, useState } from 'react'
import { useAppDispatch } from '../../hooks/store'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { hexToBytes } from '@noble/hashes/utils'
import { launch as launchNostrLoginDialog } from 'nostr-login'
import { getPublicKey, nip19 } from 'nostr-tools'
import { LoadingSpinner } from '../../components/LoadingSpinner' import { LoadingSpinner } from '../../components/LoadingSpinner'
import { AuthController } from '../../controllers' import { useAppDispatch, useAuth } from '../../hooks'
import { updateKeyPair, updateLoginMethod } from '../../store/actions' import { updateKeyPair, updateLoginMethod } from '../../store/actions'
import { LoginMethod } from '../../store/auth/types' import { LoginMethod } from '../../store/auth/types'
import { hexToBytes } from '@noble/hashes/utils' import { KeyboardCode } from '../../types'
import styles from './styles.module.scss' import styles from './styles.module.scss'
export const Nostr = () => { export const Nostr = () => {
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
const { authAndGetMetadataAndRelaysMap } = useAuth()
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const navigate = useNavigate() const navigate = useNavigate()
const authController = new AuthController()
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('') const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
const [inputValue, setInputValue] = useState('') const [inputValue, setInputValue] = useState('')
@ -52,7 +53,10 @@ export const Nostr = () => {
* Call login function when enter is pressed * Call login function when enter is pressed
*/ */
const handleInputKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => { const handleInputKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.code === 'Enter' || event.code === 'NumpadEnter') { if (
event.code === KeyboardCode.Enter ||
event.code === KeyboardCode.NumpadEnter
) {
event.preventDefault() event.preventDefault()
login() login()
} }
@ -98,12 +102,12 @@ export const Nostr = () => {
setIsLoading(true) setIsLoading(true)
setLoadingSpinnerDesc('Authenticating and finding metadata') setLoadingSpinnerDesc('Authenticating and finding metadata')
const redirectPath = await authController const redirectPath = await authAndGetMetadataAndRelaysMap(publickey).catch(
.authAndGetMetadataAndRelaysMap(publickey) (err) => {
.catch((err) => {
toast.error('Error occurred in authentication: ' + err) toast.error('Error occurred in authentication: ' + err)
return null return null
}) }
)
if (redirectPath) navigateAfterLogin(redirectPath) if (redirectPath) navigateAfterLogin(redirectPath)

View File

@ -1,48 +1,49 @@
import { useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { toast } from 'react-toastify'
import ContentCopyIcon from '@mui/icons-material/ContentCopy' import ContentCopyIcon from '@mui/icons-material/ContentCopy'
import EditIcon from '@mui/icons-material/Edit' import EditIcon from '@mui/icons-material/Edit'
import { Box, IconButton, SxProps, Theme, Typography } from '@mui/material' import { Box, IconButton, SxProps, Theme, Typography } from '@mui/material'
import { Event, VerifiedEvent, kinds, nip19 } from 'nostr-tools'
import { useEffect, useMemo, useState } from 'react' import { nip19 } from 'nostr-tools'
import { useAppSelector } from '../../hooks/store'
import { Link, useNavigate, useParams } from 'react-router-dom' import { Container } from '../../components/Container'
import { toast } from 'react-toastify' import { Footer } from '../../components/Footer/Footer'
import { LoadingSpinner } from '../../components/LoadingSpinner' import { LoadingSpinner } from '../../components/LoadingSpinner'
import { MetadataController } from '../../controllers' import { useAppSelector } from '../../hooks/store'
import { getProfileSettingsRoute } from '../../routes' import { getProfileSettingsRoute } from '../../routes'
import { NostrJoiningBlock, ProfileMetadata } from '../../types'
import { import {
getNostrJoiningBlockNumber,
getProfileUsername, getProfileUsername,
getRoboHashPicture, getRoboHashPicture,
hexToNpub, hexToNpub,
shorten shorten
} from '../../utils' } from '../../utils'
import { NDKUserProfile } from '@nostr-dev-kit/ndk'
import { useNDKContext } from '../../hooks'
import styles from './style.module.scss' import styles from './style.module.scss'
import { Container } from '../../components/Container'
import { Footer } from '../../components/Footer/Footer'
export const ProfilePage = () => { export const ProfilePage = () => {
const navigate = useNavigate() const navigate = useNavigate()
const { npub } = useParams() const { npub } = useParams()
const { ndk, findMetadata } = useNDKContext()
const metadataController = useMemo(() => MetadataController.getInstance(), [])
const [pubkey, setPubkey] = useState<string>() const [pubkey, setPubkey] = useState<string>()
const [nostrJoiningBlock, setNostrJoiningBlock] = const [userProfile, setUserProfile] = useState<NDKUserProfile | null>(null)
useState<NostrJoiningBlock | null>(null)
const [profileMetadata, setProfileMetadata] = useState<ProfileMetadata>() const userRobotImage = useAppSelector((state) => state.user.robotImage)
const metadataState = useAppSelector((state) => state.metadata) const currentUserProfile = useAppSelector((state) => state.user.profile)
const { usersPubkey } = useAppSelector((state) => state.auth) const { usersPubkey } = useAppSelector((state) => state.auth)
const userRobotImage = useAppSelector((state) => state.userRobotImage)
const [isUsersOwnProfile, setIsUsersOwnProfile] = useState(false) const [isUsersOwnProfile, setIsUsersOwnProfile] = useState(false)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [loadingSpinnerDesc] = useState('Fetching metadata') const [loadingSpinnerDesc] = useState('Fetching metadata')
const profileName = pubkey && getProfileUsername(pubkey, profileMetadata)
useEffect(() => { useEffect(() => {
if (npub) { if (npub) {
try { try {
@ -57,60 +58,26 @@ export const ProfilePage = () => {
}, [npub, usersPubkey]) }, [npub, usersPubkey])
useEffect(() => { useEffect(() => {
if (pubkey) { if (isUsersOwnProfile && currentUserProfile) {
getNostrJoiningBlockNumber(pubkey) setUserProfile(currentUserProfile)
.then((res) => {
setNostrJoiningBlock(res)
})
.catch((err) => {
// todo: handle error
console.log('err :>> ', err)
})
}
if (isUsersOwnProfile && metadataState) {
const metadataContent = metadataController.extractProfileMetadataContent(
metadataState as VerifiedEvent
)
if (metadataContent) {
setProfileMetadata(metadataContent)
setIsLoading(false) setIsLoading(false)
}
return return
} }
if (pubkey) { if (pubkey) {
const getMetadata = async (pubkey: string) => { findMetadata(pubkey)
const handleMetadataEvent = (event: Event) => { .then((profile) => {
const metadataContent = setUserProfile(profile)
metadataController.extractProfileMetadataContent(event)
if (metadataContent) {
setProfileMetadata(metadataContent)
}
}
metadataController.on(pubkey, (kind: number, event: Event) => {
if (kind === kinds.Metadata) {
handleMetadataEvent(event)
}
}) })
const metadataEvent = await metadataController
.findMetadata(pubkey)
.catch((err) => { .catch((err) => {
toast.error(err) toast.error(err)
return null
}) })
.finally(() => {
if (metadataEvent) handleMetadataEvent(metadataEvent)
setIsLoading(false) setIsLoading(false)
})
} }
}, [ndk, isUsersOwnProfile, currentUserProfile, pubkey, findMetadata])
getMetadata(pubkey)
}
}, [isUsersOwnProfile, metadataState, pubkey, metadataController])
/** /**
* Rendering text with button which copies the provided text * Rendering text with button which copies the provided text
@ -146,29 +113,32 @@ export const ProfilePage = () => {
* *
* @returns robohash image url * @returns robohash image url
*/ */
const getProfileImage = (metadata: ProfileMetadata) => { const getProfileImage = (profile: NDKUserProfile | null) => {
if (!metadata) return '' if (!profile) return getRoboHashPicture(npub)
if (!isUsersOwnProfile) { if (!isUsersOwnProfile) {
return metadata.picture || getRoboHashPicture(npub!) return profile.image || getRoboHashPicture(npub!)
} }
// userRobotImage is used only when visiting own profile // userRobotImage is used only when visiting own profile
// while kind 0 picture is not set // while kind 0 picture is not set
return metadata.picture || userRobotImage || getRoboHashPicture(npub!) return profile.image || userRobotImage || getRoboHashPicture(npub!)
} }
const profileName =
pubkey && getProfileUsername(pubkey, userProfile || undefined)
return ( return (
<> <>
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />} {isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
{pubkey && ( {pubkey && (
<Container className={styles.container}> <Container className={styles.container}>
<Box <Box
className={`${styles.banner} ${!profileMetadata || !profileMetadata.banner ? styles.noImage : ''}`} className={`${styles.banner} ${!userProfile || !userProfile.banner ? styles.noImage : ''}`}
> >
{profileMetadata && profileMetadata.banner ? ( {userProfile && userProfile.banner ? (
<img <img
src={profileMetadata.banner} src={userProfile.banner}
alt={`banner image for ${profileName}`} alt={`banner image for ${profileName}`}
/> />
) : ( ) : (
@ -189,24 +159,12 @@ export const ProfilePage = () => {
> >
<img <img
className={styles['image-placeholder']} className={styles['image-placeholder']}
src={getProfileImage(profileMetadata!)} src={getProfileImage(userProfile)}
alt={profileName} alt={profileName}
/> />
</div> </div>
</Box> </Box>
<Box className={styles.middle}>
<Typography
component={Link}
to={`https://njump.me/${nostrJoiningBlock?.encodedEventPointer || ''}`}
target="_blank"
className={`${styles.nostrSince} ${styles.link}`}
variant="caption"
>
{nostrJoiningBlock
? `On nostr since ${nostrJoiningBlock.block.toLocaleString()}`
: 'On nostr since: unknown'}
</Typography>
</Box>
<Box className={styles.right}> <Box className={styles.right}>
{isUsersOwnProfile && ( {isUsersOwnProfile && (
<IconButton <IconButton
@ -224,7 +182,6 @@ export const ProfilePage = () => {
display: 'flex' display: 'flex'
}} }}
> >
{profileMetadata && (
<Typography <Typography
sx={{ margin: '5px 0 5px 0' }} sx={{ margin: '5px 0 5px 0' }}
variant="h6" variant="h6"
@ -232,7 +189,6 @@ export const ProfilePage = () => {
> >
{profileName} {profileName}
</Typography> </Typography>
)}
</Box> </Box>
<Box> <Box>
{textElementWithCopyIcon( {textElementWithCopyIcon(
@ -242,42 +198,34 @@ export const ProfilePage = () => {
)} )}
</Box> </Box>
<Box> <Box>
{profileMetadata?.nip05 && {userProfile?.nip05 &&
textElementWithCopyIcon( textElementWithCopyIcon(userProfile.nip05, undefined, 15)}
profileMetadata.nip05,
undefined,
15
)}
</Box> </Box>
<Box> <Box>
{profileMetadata?.lud16 && {userProfile?.lud16 &&
textElementWithCopyIcon( textElementWithCopyIcon(userProfile.lud16, undefined, 15)}
profileMetadata.lud16,
undefined,
15
)}
</Box> </Box>
</Box> </Box>
<Box> <Box>
{profileMetadata?.website && ( {userProfile?.website && (
<Typography <Typography
sx={{ marginTop: '10px' }} sx={{ marginTop: '10px' }}
variant="caption" variant="caption"
component={Link} component={Link}
to={profileMetadata.website} to={userProfile.website}
target="_blank" target="_blank"
className={`${styles.website} ${styles.link} ${styles.captionWrapper}`} className={`${styles.website} ${styles.link} ${styles.captionWrapper}`}
> >
{profileMetadata.website} {userProfile.website}
</Typography> </Typography>
)} )}
</Box> </Box>
</Box> </Box>
<Box> <Box>
{profileMetadata?.about && ( {userProfile?.about && (
<Typography mt={1} className={styles.about}> <Typography mt={1} className={styles.about}>
{profileMetadata.about} {userProfile.about}
</Typography> </Typography>
)} )}
</Box> </Box>

View File

@ -1,4 +1,11 @@
import React, { useEffect, useRef, useState } from 'react'
import { useParams } from 'react-router-dom'
import { toast } from 'react-toastify'
import { SmartToy } from '@mui/icons-material'
import ContentCopyIcon from '@mui/icons-material/ContentCopy' import ContentCopyIcon from '@mui/icons-material/ContentCopy'
import LaunchIcon from '@mui/icons-material/Launch'
import { LoadingButton } from '@mui/lab'
import { import {
Box, Box,
IconButton, IconButton,
@ -7,59 +14,48 @@ import {
ListItem, ListItem,
ListSubheader, ListSubheader,
TextField, TextField,
Tooltip, Tooltip
Typography,
useTheme
} from '@mui/material' } from '@mui/material'
import { UnsignedEvent, nip19, kinds, VerifiedEvent, Event } from 'nostr-tools'
import React, { useEffect, useRef, useState } from 'react' import { NDKEvent, NDKUserProfile, serializeProfile } from '@nostr-dev-kit/ndk'
import { Link, useParams } from 'react-router-dom' import { launch as launchNostrLoginDialog } from 'nostr-login'
import { toast } from 'react-toastify' import { kinds, nip19, UnsignedEvent } from 'nostr-tools'
import { MetadataController, NostrController } from '../../../controllers'
import { NostrJoiningBlock, ProfileMetadata } from '../../../types' import { NostrController } from '../../../controllers'
import styles from './style.module.scss'
import { useNDKContext } from '../../../hooks'
import { useAppDispatch, useAppSelector } from '../../../hooks/store' import { useAppDispatch, useAppSelector } from '../../../hooks/store'
import { LoadingButton } from '@mui/lab' import { getRoboHashPicture, unixNow } from '../../../utils'
import { Dispatch } from '../../../store/store'
import { setMetadataEvent } from '../../../store/actions'
import { LoadingSpinner } from '../../../components/LoadingSpinner'
import { LoginMethod, NostrLoginAuthMethod } from '../../../store/auth/types'
import { SmartToy } from '@mui/icons-material'
import {
getNostrJoiningBlockNumber,
getRoboHashPicture,
unixNow
} from '../../../utils'
import { Container } from '../../../components/Container' import { Container } from '../../../components/Container'
import { Footer } from '../../../components/Footer/Footer' import { Footer } from '../../../components/Footer/Footer'
import LaunchIcon from '@mui/icons-material/Launch' import { LoadingSpinner } from '../../../components/LoadingSpinner'
import { launch as launchNostrLoginDialog } from 'nostr-login'
import { setUserProfile as updateUserProfile } from '../../../store/actions'
import { LoginMethod, NostrLoginAuthMethod } from '../../../store/auth/types'
import { Dispatch } from '../../../store/store'
import styles from './style.module.scss'
export const ProfileSettingsPage = () => { export const ProfileSettingsPage = () => {
const theme = useTheme()
const { npub } = useParams()
const dispatch: Dispatch = useAppDispatch() const dispatch: Dispatch = useAppDispatch()
const metadataController = MetadataController.getInstance() const { npub } = useParams()
const nostrController = NostrController.getInstance() const { ndk, findMetadata, publish } = useNDKContext()
const [pubkey, setPubkey] = useState<string>() const [pubkey, setPubkey] = useState<string>()
const [nostrJoiningBlock, setNostrJoiningBlock] = const [userProfile, setUserProfile] = useState<NDKUserProfile | null>(null)
useState<NostrJoiningBlock | null>(null)
const [profileMetadata, setProfileMetadata] = useState<ProfileMetadata>() const userRobotImage = useAppSelector((state) => state.user.robotImage)
const [savingProfileMetadata, setSavingProfileMetadata] = useState(false) const currentUserProfile = useAppSelector((state) => state.user.profile)
const metadataState = useAppSelector((state) => state.metadata)
const keys = useAppSelector((state) => state.auth?.keyPair) const keys = useAppSelector((state) => state.auth?.keyPair)
const { usersPubkey, loginMethod, nostrLoginAuthMethod } = useAppSelector( const { usersPubkey, loginMethod, nostrLoginAuthMethod } = useAppSelector(
(state) => state.auth (state) => state.auth
) )
const userRobotImage = useAppSelector((state) => state.userRobotImage)
const [savingProfileMetadata, setSavingProfileMetadata] = useState(false)
const [isUsersOwnProfile, setIsUsersOwnProfile] = useState(false) const [isUsersOwnProfile, setIsUsersOwnProfile] = useState(false)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [loadingSpinnerDesc] = useState('Fetching metadata') const [loadingSpinnerDesc] = useState('Fetching metadata')
@ -79,63 +75,30 @@ export const ProfileSettingsPage = () => {
}, [npub, usersPubkey]) }, [npub, usersPubkey])
useEffect(() => { useEffect(() => {
if (pubkey) { if (isUsersOwnProfile && currentUserProfile) {
getNostrJoiningBlockNumber(pubkey) setUserProfile(currentUserProfile)
.then((res) => {
setNostrJoiningBlock(res)
})
.catch((err) => {
// todo: handle error
console.log('err :>> ', err)
})
}
if (isUsersOwnProfile && metadataState) {
const metadataContent = metadataController.extractProfileMetadataContent(
metadataState as VerifiedEvent
)
if (metadataContent) {
setProfileMetadata(metadataContent)
setIsLoading(false) setIsLoading(false)
}
return return
} }
if (pubkey) { if (pubkey) {
const getMetadata = async (pubkey: string) => { findMetadata(pubkey)
const handleMetadataEvent = (event: Event) => { .then((profile) => {
const metadataContent = setUserProfile(profile)
metadataController.extractProfileMetadataContent(event)
if (metadataContent) {
setProfileMetadata(metadataContent)
}
}
metadataController.on(pubkey, (kind: number, event: Event) => {
if (kind === kinds.Metadata) {
handleMetadataEvent(event)
}
}) })
const metadataEvent = await metadataController
.findMetadata(pubkey)
.catch((err) => { .catch((err) => {
toast.error(err) toast.error(err)
return null
}) })
.finally(() => {
if (metadataEvent) handleMetadataEvent(metadataEvent)
setIsLoading(false) setIsLoading(false)
})
} }
}, [ndk, isUsersOwnProfile, currentUserProfile, pubkey, findMetadata])
getMetadata(pubkey)
}
}, [isUsersOwnProfile, metadataState, pubkey, metadataController])
const editItem = ( const editItem = (
key: keyof ProfileMetadata, key: keyof NDKUserProfile,
label: string, label: string,
multiline = false, multiline = false,
rows = 1, rows = 1,
@ -145,7 +108,7 @@ export const ProfileSettingsPage = () => {
<TextField <TextField
label={label} label={label}
id={label.split(' ').join('-')} id={label.split(' ').join('-')}
value={profileMetadata![key] || ''} value={userProfile![key] || ''}
size="small" size="small"
multiline={multiline} multiline={multiline}
rows={rows} rows={rows}
@ -155,7 +118,7 @@ export const ProfileSettingsPage = () => {
onChange={(event: React.ChangeEvent<HTMLInputElement>) => { onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
const { value } = event.target const { value } = event.target
setProfileMetadata((prev) => ({ setUserProfile((prev) => ({
...prev, ...prev,
[key]: value [key]: value
})) }))
@ -197,34 +160,47 @@ export const ProfileSettingsPage = () => {
) )
const handleSaveMetadata = async () => { const handleSaveMetadata = async () => {
if (!userProfile) return
setSavingProfileMetadata(true) setSavingProfileMetadata(true)
const content = JSON.stringify(profileMetadata) const serializedProfile = serializeProfile(userProfile)
// We need to omit cachedAt and create new event const unsignedEvent: UnsignedEvent = {
// Relay will reject if created_at is too late content: serializedProfile,
const updatedMetadataState: UnsignedEvent = {
content: content,
created_at: unixNow(), created_at: unixNow(),
kind: kinds.Metadata, kind: kinds.Metadata,
pubkey: pubkey!, pubkey: pubkey!,
tags: metadataState?.tags || [] tags: []
} }
const nostrController = NostrController.getInstance()
const signedEvent = await nostrController const signedEvent = await nostrController
.signEvent(updatedMetadataState) .signEvent(unsignedEvent)
.catch((error) => { .catch((error) => {
toast.error(`Error saving profile metadata. ${error}`) toast.error(`Error saving profile metadata. ${error}`)
return null
}) })
if (signedEvent) { if (!signedEvent) {
if (!metadataController.validate(signedEvent)) { setSavingProfileMetadata(false)
toast.error(`Metadata is not valid.`) return
} }
await metadataController.publishMetadataEvent(signedEvent) const ndkEvent = new NDKEvent(ndk, signedEvent)
const publishedOnRelays = await publish(ndkEvent)
dispatch(setMetadataEvent(signedEvent)) // Handle cases where publishing failed or succeeded
if (publishedOnRelays.length === 0) {
toast.error('Failed to publish event on any relay')
} else {
toast.success(
`Event published successfully to the following relays\n\n${publishedOnRelays.join(
'\n'
)}`
)
dispatch(updateUserProfile(userProfile))
} }
setSavingProfileMetadata(false) setSavingProfileMetadata(false)
@ -241,7 +217,7 @@ export const ProfileSettingsPage = () => {
const robotAvatarLink = getRoboHashPicture(npub!, robotSet.current) const robotAvatarLink = getRoboHashPicture(npub!, robotSet.current)
setProfileMetadata((prev) => ({ setUserProfile((prev) => ({
...prev, ...prev,
picture: robotAvatarLink picture: robotAvatarLink
})) }))
@ -267,14 +243,14 @@ export const ProfileSettingsPage = () => {
* *
* @returns robohash image url * @returns robohash image url
*/ */
const getProfileImage = (metadata: ProfileMetadata) => { const getProfileImage = (profile: NDKUserProfile) => {
if (!isUsersOwnProfile) { if (!isUsersOwnProfile) {
return metadata.picture || getRoboHashPicture(npub!) return profile.image || getRoboHashPicture(npub!)
} }
// userRobotImage is used only when visiting own profile // userRobotImage is used only when visiting own profile
// while kind 0 picture is not set // while kind 0 picture is not set
return metadata.picture || userRobotImage || getRoboHashPicture(npub!) return profile.image || userRobotImage || getRoboHashPicture(npub!)
} }
return ( return (
@ -300,7 +276,7 @@ export const ProfileSettingsPage = () => {
</ListSubheader> </ListSubheader>
} }
> >
{profileMetadata && ( {userProfile && (
<div> <div>
<ListItem <ListItem
sx={{ sx={{
@ -309,10 +285,10 @@ export const ProfileSettingsPage = () => {
flexDirection: 'column' flexDirection: 'column'
}} }}
> >
{profileMetadata.banner ? ( {userProfile.banner ? (
<img <img
className={styles.bannerImg} className={styles.bannerImg}
src={profileMetadata.banner} src={userProfile.banner}
alt="Banner Image" alt="Banner Image"
/> />
) : ( ) : (
@ -334,32 +310,17 @@ export const ProfileSettingsPage = () => {
event.currentTarget.src = getRoboHashPicture(npub!) event.currentTarget.src = getRoboHashPicture(npub!)
}} }}
className={styles.img} className={styles.img}
src={getProfileImage(profileMetadata)} src={getProfileImage(userProfile)}
alt="Profile Image" alt="Profile Image"
/> />
{nostrJoiningBlock && (
<Typography
sx={{
color: theme.palette.getContrastText(
theme.palette.background.paper
)
}}
component={Link}
to={`https://njump.me/${nostrJoiningBlock.encodedEventPointer}`}
target="_blank"
>
On nostr since {nostrJoiningBlock.block.toLocaleString()}
</Typography>
)}
</ListItem> </ListItem>
{editItem('picture', 'Picture URL', undefined, undefined, { {editItem('image', 'Picture URL', undefined, undefined, {
endAdornment: isUsersOwnProfile ? robohashButton() : undefined endAdornment: isUsersOwnProfile ? robohashButton() : undefined
})} })}
{editItem('name', 'Username')} {editItem('name', 'Username')}
{editItem('display_name', 'Display Name')} {editItem('displayName', 'Display Name')}
{editItem('nip05', 'Nostr Address (nip05)')} {editItem('nip05', 'Nostr Address (nip05)')}
{editItem('lud16', 'Lightning Address (lud16)')} {editItem('lud16', 'Lightning Address (lud16)')}
{editItem('about', 'About', true, 4)} {editItem('about', 'About', true, 4)}
@ -368,6 +329,7 @@ export const ProfileSettingsPage = () => {
<> <>
{usersPubkey && {usersPubkey &&
copyItem(nip19.npubEncode(usersPubkey), 'Public Key')} copyItem(nip19.npubEncode(usersPubkey), 'Public Key')}
{loginMethod === LoginMethod.privateKey && {loginMethod === LoginMethod.privateKey &&
keys && keys &&
keys.private && keys.private &&

View File

@ -13,26 +13,40 @@ import Switch from '@mui/material/Switch'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { Container } from '../../../components/Container' import { Container } from '../../../components/Container'
import { relayController } from '../../../controllers' import {
import { useAppDispatch, useAppSelector, useDidMount } from '../../../hooks' useAppDispatch,
useAppSelector,
useDidMount,
useDvm,
useNDKContext
} from '../../../hooks'
import { setRelayMapAction } from '../../../store/actions' import { setRelayMapAction } from '../../../store/actions'
import { RelayConnectionState, RelayFee, RelayInfo } from '../../../types' import { RelayConnectionState, RelayFee, RelayInfo } from '../../../types'
import { import {
capitalizeFirstLetter, capitalizeFirstLetter,
compareObjects, compareObjects,
getRelayInfo, getRelayMapFromNDKRelayList,
getRelayMap,
hexToNpub, hexToNpub,
publishRelayMap, publishRelayMap,
shorten shorten,
timeout
} from '../../../utils' } from '../../../utils'
import styles from './style.module.scss' import styles from './style.module.scss'
import { Footer } from '../../../components/Footer/Footer' import { Footer } from '../../../components/Footer/Footer'
import {
getRelayListForUser,
NDKRelayList,
NDKRelayStatus
} from '@nostr-dev-kit/ndk'
export const RelaysPage = () => { export const RelaysPage = () => {
const dispatch = useAppDispatch()
const { ndk, publish } = useNDKContext()
const { getRelayInfo } = useDvm()
const usersPubkey = useAppSelector((state) => state.auth?.usersPubkey) const usersPubkey = useAppSelector((state) => state.auth?.usersPubkey)
const dispatch = useAppDispatch() const [ndkRelayList, setNDKRelayList] = useState<NDKRelayList | null>(null)
const [newRelayURI, setNewRelayURI] = useState<string>() const [newRelayURI, setNewRelayURI] = useState<string>()
const [newRelayURIerror, setNewRelayURIerror] = useState<string>() const [newRelayURIerror, setNewRelayURIerror] = useState<string>()
@ -40,24 +54,56 @@ export const RelaysPage = () => {
const relayMap = useAppSelector((state) => state.relays?.map) const relayMap = useAppSelector((state) => state.relays?.map)
const relaysInfo = useAppSelector((state) => state.relays?.info) const relaysInfo = useAppSelector((state) => state.relays?.info)
const webSocketPrefix = 'wss://' const webSocketPrefix =
newRelayURI?.startsWith('wss://') || newRelayURI?.startsWith('ws://')
? ''
: 'wss://'
useDidMount(() => { // fetch relay list from relays
useEffect(() => {
if (usersPubkey) { if (usersPubkey) {
getRelayMap(usersPubkey).then((newRelayMap) => { Promise.race([getRelayListForUser(usersPubkey, ndk), timeout(10000)])
if (!compareObjects(relayMap, newRelayMap.map)) { .then((res) => {
dispatch(setRelayMapAction(newRelayMap.map)) setNDKRelayList(res)
} })
.catch((err) => {
toast.error(
`An error occurred in fetching user relay list: ${
err.message || err
}`
)
setNDKRelayList(new NDKRelayList(ndk))
}) })
} }
}) }, [usersPubkey, ndk])
// construct the RelayMap from newly received NDKRelayList event
// and compare it with existing relay map in redux store
// if there are any differences then update the redux store with
// new relay map
useEffect(() => {
if (ndkRelayList) {
const newRelayMap = getRelayMapFromNDKRelayList(ndkRelayList)
if (!compareObjects(relayMap, newRelayMap)) {
dispatch(setRelayMapAction(newRelayMap))
}
}
// we want to run this effect only when ndkRelayList is changed
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ndkRelayList])
useEffect(() => { useEffect(() => {
if (!relayMap) return
// Display notification if an empty relay map has been received // Display notification if an empty relay map has been received
if (relayMap && Object.keys(relayMap).length === 0) { if (Object.keys(relayMap).length === 0) {
relayRequirementWarning() relayRequirementWarning()
} else {
getRelayInfo(Object.keys(relayMap))
} }
}, [relayMap]) }, [relayMap, getRelayInfo])
const relayRequirementWarning = () => const relayRequirementWarning = () =>
toast.warning('At least one write relay is needed for SIGit to work.') toast.warning('At least one write relay is needed for SIGit to work.')
@ -85,7 +131,8 @@ export const RelaysPage = () => {
const relayMapPublishingRes = await publishRelayMap( const relayMapPublishingRes = await publishRelayMap(
relayMapCopy, relayMapCopy,
usersPubkey, usersPubkey,
[relay] ndk,
publish
).catch((err) => handlePublishRelayMapError(err)) ).catch((err) => handlePublishRelayMapError(err))
if (relayMapPublishingRes) { if (relayMapPublishingRes) {
@ -132,7 +179,9 @@ export const RelaysPage = () => {
// Publish updated relay map // Publish updated relay map
const relayMapPublishingRes = await publishRelayMap( const relayMapPublishingRes = await publishRelayMap(
relayMapCopy, relayMapCopy,
usersPubkey usersPubkey,
ndk,
publish
).catch((err) => handlePublishRelayMapError(err)) ).catch((err) => handlePublishRelayMapError(err))
if (relayMapPublishingRes) { if (relayMapPublishingRes) {
@ -151,7 +200,7 @@ export const RelaysPage = () => {
// Check if new relay URI is a valid string // Check if new relay URI is a valid string
if ( if (
relayURI && relayURI &&
!/^wss:\/\/[-a-zA-Z0-9@:%._\\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}/.test( !/^wss?:\/\/[-a-zA-Z0-9@:%._\\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}/.test(
relayURI relayURI
) )
) { ) {
@ -161,9 +210,10 @@ export const RelaysPage = () => {
) )
} }
} else if (relayURI && usersPubkey) { } else if (relayURI && usersPubkey) {
const relay = await relayController.connectRelay(relayURI) const ndkRelay = ndk.pool.getRelay(relayURI)
await ndkRelay.connect(5000)
if (relay && relay.connected) { if (ndkRelay.status >= NDKRelayStatus.CONNECTED) {
const relayMapCopy = JSON.parse(JSON.stringify(relayMap)) const relayMapCopy = JSON.parse(JSON.stringify(relayMap))
relayMapCopy[relayURI] = { write: true, read: true } relayMapCopy[relayURI] = { write: true, read: true }
@ -171,7 +221,9 @@ export const RelaysPage = () => {
// Publish updated relay map // Publish updated relay map
const relayMapPublishingRes = await publishRelayMap( const relayMapPublishingRes = await publishRelayMap(
relayMapCopy, relayMapCopy,
usersPubkey usersPubkey,
ndk,
publish
).catch((err) => handlePublishRelayMapError(err)) ).catch((err) => handlePublishRelayMapError(err))
if (relayMapPublishingRes) { if (relayMapPublishingRes) {
@ -211,7 +263,13 @@ export const RelaysPage = () => {
}} }}
className={styles.relayURItextfield} className={styles.relayURItextfield}
/> />
<Button variant="contained" onClick={() => handleAddNewRelay()}> <Button
sx={{
height: '56px'
}}
variant="contained"
onClick={() => handleAddNewRelay()}
>
Add Add
</Button> </Button>
</Box> </Box>
@ -256,19 +314,36 @@ const RelayItem = ({
handleLeaveRelay, handleLeaveRelay,
handleRelayWriteChange handleRelayWriteChange
}: RelayItemProp) => { }: RelayItemProp) => {
const { ndk } = useNDKContext()
const [relayConnectionStatus, setRelayConnectionStatus] = const [relayConnectionStatus, setRelayConnectionStatus] =
useState<RelayConnectionState>() useState<RelayConnectionState>()
const [displayRelayInfo, setDisplayRelayInfo] = useState(false) const [displayRelayInfo, setDisplayRelayInfo] = useState(false)
useDidMount(() => { useDidMount(() => {
relayController.connectRelay(relayURI).then((relay) => { const ndkPool = ndk.pool
if (relay && relay.connected) {
ndkPool.on('relay:connect', (relay) => {
if (relay.url === relayURI) {
setRelayConnectionStatus(RelayConnectionState.Connected) setRelayConnectionStatus(RelayConnectionState.Connected)
} else { }
})
ndkPool.on('relay:disconnect', (relay) => {
if (relay.url === relayURI) {
setRelayConnectionStatus(RelayConnectionState.NotConnected) setRelayConnectionStatus(RelayConnectionState.NotConnected)
} }
}) })
const relay = ndkPool.getRelay(relayURI)
if (relay) {
setRelayConnectionStatus(
relay.status >= NDKRelayStatus.CONNECTED
? RelayConnectionState.Connected
: RelayConnectionState.NotConnected
)
}
}) })
return ( return (

View File

@ -12,6 +12,7 @@
flex-direction: row; flex-direction: row;
gap: 10px; gap: 10px;
width: 100%; width: 100%;
align-items: start;
} }
.sectionIcon { .sectionIcon {

View File

@ -1,12 +1,9 @@
import { Box, Button, Typography } from '@mui/material'
import axios from 'axios' import axios from 'axios'
import saveAs from 'file-saver'
import JSZip from 'jszip' import JSZip from 'jszip'
import _ from 'lodash' import _ from 'lodash'
import { MuiFileInput } from 'mui-file-input'
import { Event, verifyEvent } from 'nostr-tools' import { Event, verifyEvent } from 'nostr-tools'
import { useCallback, useEffect, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useAppSelector } from '../../hooks/store' import { useAppSelector } from '../../hooks'
import { useLocation, useNavigate, useParams } from 'react-router-dom' import { useLocation, useNavigate, useParams } from 'react-router-dom'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { LoadingSpinner } from '../../components/LoadingSpinner' import { LoadingSpinner } from '../../components/LoadingSpinner'
@ -15,79 +12,47 @@ import { appPublicRoutes } from '../../routes'
import { CreateSignatureEventContent, Meta, SignedEvent } from '../../types' import { CreateSignatureEventContent, Meta, SignedEvent } from '../../types'
import { import {
decryptArrayBuffer, decryptArrayBuffer,
encryptArrayBuffer,
extractMarksFromSignedMeta, extractMarksFromSignedMeta,
extractZipUrlAndEncryptionKey, extractZipUrlAndEncryptionKey,
generateEncryptionKey, filterMarksByPubkey,
generateKeysFile, findOtherUserMarks,
getCurrentUserFiles, getCurrentUserFiles,
getCurrentUserMarks,
getHash, getHash,
hexToNpub, hexToNpub,
isOnline,
loadZip, loadZip,
unixNow,
npubToHex, npubToHex,
parseJson, parseJson,
encryptAndUploadMarks,
readContentOfZipEntry, readContentOfZipEntry,
sendNotification,
signEventForMetaFile, signEventForMetaFile,
updateUsersAppData, unixNow,
findOtherUserMarks, updateMarks,
timeout, uploadMetaToFileStorage,
sendPrivateDirectMessage, sendPrivateDirectMessage,
parseNostrEvent parseNostrEvent
} from '../../utils' } from '../../utils'
import { Container } from '../../components/Container'
import { DisplayMeta } from './internal/displayMeta'
import styles from './style.module.scss'
import { CurrentUserMark, Mark } from '../../types/mark.ts' import { CurrentUserMark, Mark } from '../../types/mark.ts'
import { getLastSignersSig, isFullySigned } from '../../utils/sign.ts'
import {
filterMarksByPubkey,
getCurrentUserMarks,
isCurrentUserMarksComplete,
updateMarks
} from '../../utils'
import PdfMarking from '../../components/PDFView/PdfMarking.tsx' import PdfMarking from '../../components/PDFView/PdfMarking.tsx'
import { import { convertToSigitFile, SigitFile } from '../../utils/file.ts'
convertToSigitFile, import { generateTimestamp } from '../../utils/opentimestamps.ts'
getZipWithFiles, import { MARK_TYPE_CONFIG } from '../../components/MarkTypeStrategy/MarkStrategy.tsx'
SigitFile import { useNDK } from '../../hooks/useNDK.ts'
} from '../../utils/file.ts'
import { ARRAY_BUFFER, DEFLATE } from '../../utils/const.ts'
import { SendDMError } from '../../types/errors/SendDMError.ts' import { SendDMError } from '../../types/errors/SendDMError.ts'
enum SignedStatus {
Fully_Signed,
User_Is_Next_Signer,
User_Is_Not_Next_Signer
}
export const SignPage = () => { export const SignPage = () => {
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const params = useParams() const params = useParams()
const { updateUsersAppData, sendNotification } = useNDK()
const usersAppData = useAppSelector((state) => state.userAppData) const usersAppData = useAppSelector((state) => state.userAppData)
/** /**
* Received from `location.state` * In the online mode, Sigit ID can be obtained either from the router state
* * using location or from UsersAppData
* uploadedZip will be received from home page when a user uploads a sigit zip wrapper that contains keys.json
* arrayBuffer (decryptedArrayBuffer) will be received in navigation from create page in offline mode
* meta (metaInNavState) will be received in navigation from create & home page in online mode
*/
let metaInNavState = location?.state?.meta || undefined
const { arrayBuffer: decryptedArrayBuffer, uploadedZip } = location.state || {
decryptedArrayBuffer: undefined,
uploadedZip: undefined
}
/**
* If userAppData (redux) is available, and we have the route param (sigit id)
* which is actually a `createEventId`, we will fetch a `sigit`
* based on the provided route ID and set fetched `sigit` to the `metaInNavState`
*/ */
const metaInNavState = useMemo(() => {
if (usersAppData) { if (usersAppData) {
const sigitCreateId = params.id const sigitCreateId = params.id
@ -95,14 +60,22 @@ export const SignPage = () => {
const sigit = usersAppData.sigits[sigitCreateId] const sigit = usersAppData.sigits[sigitCreateId]
if (sigit) { if (sigit) {
metaInNavState = sigit return sigit
} }
} }
} }
const [displayInput, setDisplayInput] = useState(false) return location?.state?.meta || undefined
}, [location, usersAppData, params.id])
const [selectedFile, setSelectedFile] = useState<File | null>(null) /**
* Received from `location.state`
*
* arrayBuffer (decryptedArrayBuffer) will be received in navigation from create page in offline mode
*/
const { arrayBuffer: decryptedArrayBuffer, uploadedZip } = location.state || {
decryptedArrayBuffer: undefined
}
const [files, setFiles] = useState<{ [filename: string]: SigitFile }>({}) const [files, setFiles] = useState<{ [filename: string]: SigitFile }>({})
@ -110,7 +83,6 @@ export const SignPage = () => {
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('') const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
const [meta, setMeta] = useState<Meta | null>(null) const [meta, setMeta] = useState<Meta | null>(null)
const [signedStatus, setSignedStatus] = useState<SignedStatus>()
const [submittedBy, setSubmittedBy] = useState<string>() const [submittedBy, setSubmittedBy] = useState<string>()
@ -124,66 +96,14 @@ export const SignPage = () => {
[key: string]: string | null [key: string]: string | null
}>({}) }>({})
const [signedBy, setSignedBy] = useState<`npub1${string}`[]>([])
const [nextSinger, setNextSinger] = useState<string>()
// This state variable indicates whether the logged-in user is a signer, a creator, or neither.
const [isSignerOrCreator, setIsSignerOrCreator] = useState(false)
const usersPubkey = useAppSelector((state) => state.auth.usersPubkey) const usersPubkey = useAppSelector((state) => state.auth.usersPubkey)
const nostrController = NostrController.getInstance() const nostrController = NostrController.getInstance()
const [currentUserMarks, setCurrentUserMarks] = useState<CurrentUserMark[]>( const [currentUserMarks, setCurrentUserMarks] = useState<CurrentUserMark[]>(
[] []
) )
const [isMarksCompleted, setIsMarksCompleted] = useState(false)
const [otherUserMarks, setOtherUserMarks] = useState<Mark[]>([]) const [otherUserMarks, setOtherUserMarks] = useState<Mark[]>([])
useEffect(() => {
if (signers.length > 0) {
// check if all signers have signed then its fully signed
if (isFullySigned(signers, signedBy)) {
setSignedStatus(SignedStatus.Fully_Signed)
} else {
for (const signer of signers) {
if (!signedBy.includes(signer)) {
// signers in meta.json are in npub1 format
// so, convert it to hex before setting to nextSigner
setNextSinger(npubToHex(signer)!)
const usersNpub = hexToNpub(usersPubkey!)
if (signer === usersNpub) {
// logged in user is the next signer
setSignedStatus(SignedStatus.User_Is_Next_Signer)
} else {
setSignedStatus(SignedStatus.User_Is_Not_Next_Signer)
}
break
}
}
}
} else {
// there's no signer just viewers. So its fully signed
setSignedStatus(SignedStatus.Fully_Signed)
}
// Determine and set the status of the user
if (submittedBy && usersPubkey && submittedBy === usersPubkey) {
// If the submission was made by the user, set the status to true
setIsSignerOrCreator(true)
} else if (usersPubkey) {
// Convert the user's public key from hex to npub format
const usersNpub = hexToNpub(usersPubkey)
if (signers.includes(usersNpub)) {
// If the user's npub is in the list of signers, set the status to true
setIsSignerOrCreator(true)
}
}
}, [signers, signedBy, usersPubkey, submittedBy])
useEffect(() => { useEffect(() => {
const handleUpdatedMeta = async (meta: Meta) => { const handleUpdatedMeta = async (meta: Meta) => {
const createSignatureEvent = await parseJson<Event>( const createSignatureEvent = await parseJson<Event>(
@ -239,101 +159,54 @@ export const SignPage = () => {
const signedMarks = extractMarksFromSignedMeta(meta) const signedMarks = extractMarksFromSignedMeta(meta)
const currentUserMarks = getCurrentUserMarks(metaMarks, signedMarks) const currentUserMarks = getCurrentUserMarks(metaMarks, signedMarks)
const otherUserMarks = findOtherUserMarks(signedMarks, usersPubkey!) const otherUserMarks = findOtherUserMarks(signedMarks, usersPubkey!)
setOtherUserMarks(otherUserMarks)
setCurrentUserMarks(currentUserMarks) if (meta.keys) {
setIsMarksCompleted(isCurrentUserMarksComplete(currentUserMarks)) 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)
}
}
}
} }
setSignedBy(Object.keys(meta.docSignatures) as `npub1${string}`[]) setOtherUserMarks(otherUserMarks)
setCurrentUserMarks(currentUserMarks)
}
} }
if (meta) { if (meta) {
handleUpdatedMeta(meta) handleUpdatedMeta(meta)
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [meta, usersPubkey]) }, [meta, usersPubkey])
const handleDownload = async () => {
if (Object.entries(files).length === 0 || !meta || !usersPubkey) return
setLoadingSpinnerDesc('Generating file')
try {
const zip = await getZipWithFiles(meta, files)
const arrayBuffer = await zip.generateAsync({
type: ARRAY_BUFFER,
compression: DEFLATE,
compressionOptions: {
level: 6
}
})
if (!arrayBuffer) return
const blob = new Blob([arrayBuffer])
saveAs(blob, `exported-${unixNow()}.sigit.zip`)
} catch (error) {
console.log('error in zip:>> ', error)
if (error instanceof Error) {
toast.error(error.message || 'Error occurred in generating zip file')
}
}
}
const decrypt = useCallback(
async (file: File) => {
setLoadingSpinnerDesc('Decrypting file')
const zip = await loadZip(file)
if (!zip) return
const parsedKeysJson = await parseKeysJson(zip)
if (!parsedKeysJson) return
const encryptedArrayBuffer = await readContentOfZipEntry(
zip,
'compressed.sigit',
'arraybuffer'
)
if (!encryptedArrayBuffer) return
const { keys, sender } = parsedKeysJson
for (const key of keys) {
// decrypt the encryptionKey, with timeout (duration = 60 seconds)
const encryptionKey = await Promise.race([
nostrController.nip04Decrypt(sender, key),
timeout(60000)
])
.then((res) => {
return res
})
.catch((err) => {
console.log('err :>> ', err)
return null
})
// Return if encryption failed
if (!encryptionKey) continue
const arrayBuffer = await decryptArrayBuffer(
encryptedArrayBuffer,
encryptionKey
)
.catch((err) => {
console.log('err in decryption:>> ', err)
return null
})
.finally(() => {
setIsLoading(false)
})
if (arrayBuffer) return arrayBuffer
}
return null
},
[nostrController]
)
useEffect(() => { useEffect(() => {
// online mode - from create and home page views
if (metaInNavState) { if (metaInNavState) {
const processSigit = async () => { const processSigit = async () => {
setIsLoading(true) setIsLoading(true)
@ -368,27 +241,20 @@ export const SignPage = () => {
} }
processSigit() processSigit()
} else if (decryptedArrayBuffer) { }
handleDecryptedArrayBuffer(decryptedArrayBuffer).finally(() =>
setIsLoading(false) // eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
if (decryptedArrayBuffer || uploadedZip) {
handleDecryptedArrayBuffer(decryptedArrayBuffer || uploadedZip).finally(
() => setIsLoading(false)
) )
} else if (uploadedZip) {
decrypt(uploadedZip)
.then((arrayBuffer) => {
if (arrayBuffer) handleDecryptedArrayBuffer(arrayBuffer)
})
.catch((err) => {
console.error(`error occurred in decryption`, err)
toast.error(err.message || `error occurred in decryption`)
})
.finally(() => {
setIsLoading(false)
})
} else { } else {
setIsLoading(false) setIsLoading(false)
setDisplayInput(true)
} }
}, [decryptedArrayBuffer, uploadedZip, metaInNavState, decrypt]) }, [decryptedArrayBuffer, uploadedZip])
const handleArrayBufferFromBlossom = async ( const handleArrayBufferFromBlossom = async (
arrayBuffer: ArrayBuffer, arrayBuffer: ArrayBuffer,
@ -447,30 +313,12 @@ export const SignPage = () => {
setMarks(updatedMarks) setMarks(updatedMarks)
} }
const parseKeysJson = async (zip: JSZip) => { const handleDecryptedArrayBuffer = async (
const keysFileContent = await readContentOfZipEntry( decryptedArrayBuffer: ArrayBuffer
zip, ) => {
'keys.json',
'string'
)
if (!keysFileContent) return null
return await parseJson<{ sender: string; keys: string[] }>(
keysFileContent
).catch((err) => {
console.log(`Error parsing content of keys.json:`, err)
toast.error(err.message || `Error parsing content of keys.json`)
return null
})
}
const handleDecryptedArrayBuffer = async (arrayBuffer: ArrayBuffer) => {
const decryptedZipFile = new File([arrayBuffer], 'decrypted.zip')
setLoadingSpinnerDesc('Parsing zip file') setLoadingSpinnerDesc('Parsing zip file')
const zip = await loadZip(decryptedZipFile) const zip = await loadZip(decryptedArrayBuffer)
if (!zip) return if (!zip) return
const files: { [filename: string]: SigitFile } = {} const files: { [filename: string]: SigitFile } = {}
@ -503,9 +351,6 @@ export const SignPage = () => {
setFiles(files) setFiles(files)
setCurrentFileHashes(fileHashes) setCurrentFileHashes(fileHashes)
setDisplayInput(false)
setLoadingSpinnerDesc('Parsing meta.json') setLoadingSpinnerDesc('Parsing meta.json')
const metaFileContent = await readContentOfZipEntry( const metaFileContent = await readContentOfZipEntry(
@ -533,46 +378,130 @@ export const SignPage = () => {
setMeta(parsedMetaJson) setMeta(parsedMetaJson)
} }
const handleDecrypt = async () => { const initializeSigning = async (type: 'online' | 'offline') => {
if (!selectedFile) return
setIsLoading(true)
const arrayBuffer = await decrypt(selectedFile)
if (!arrayBuffer) return
handleDecryptedArrayBuffer(arrayBuffer)
}
const handleSign = async () => {
if (Object.entries(files).length === 0 || !meta) return if (Object.entries(files).length === 0 || !meta) return
setIsLoading(true) setIsLoading(true)
setLoadingSpinnerDesc('Signing nostr event') setLoadingSpinnerDesc('Signing nostr event')
const prevSig = getPrevSignersSig(hexToNpub(usersPubkey!)) const usersNpub = hexToNpub(usersPubkey!)
const prevSig = getPrevSignersSig(usersNpub)
if (!prevSig) { if (!prevSig) {
setIsLoading(false)
toast.error('Previous signature is invalid') toast.error('Previous signature is invalid')
return return
} }
const marks = getSignerMarksForMeta() || [] 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 =
type === 'online'
? await encryptAndUploadMarks(marks, encryptionKey)
: marks
const signedEvent = await signEventForMeta({
prevSig,
marks: processedMarks
})
if (!signedEvent) return if (!signedEvent) return
const updatedMeta = updateMetaSignatures(meta, signedEvent) const updatedMeta = updateMetaSignatures(meta, signedEvent)
if (await isOnline()) { return {
await handleOnlineFlow(updatedMeta) encryptionKey,
} else { updatedMeta,
setMeta(updatedMeta) signedEvent
setIsLoading(false)
} }
} }
const handleSign = async () => {
const result = await initializeSigning('online')
if (!result) {
setIsLoading(false)
return
}
const { encryptionKey, updatedMeta, signedEvent } = result
setLoadingSpinnerDesc('Generating an open timestamp.')
const timestamp = await generateTimestamp(signedEvent.id)
if (timestamp) {
updatedMeta.timestamps = [...(updatedMeta.timestamps || []), timestamp]
updatedMeta.modifiedAt = unixNow()
}
await handleOnlineFlow(updatedMeta, encryptionKey)
const createSignature = JSON.parse(updatedMeta.createSignature)
navigate(`${appPublicRoutes.verify}/${createSignature.id}`)
}
const handleSignOffline = async () => {
const result = await initializeSigning('offline')
if (!result) {
setIsLoading(false)
return
}
const { updatedMeta } = result
const zip = new JSZip()
for (const [filename, value] of Object.entries(files)) {
zip.file(`files/${filename}`, await value.arrayBuffer())
}
const stringifiedMeta = JSON.stringify(updatedMeta, null, 2)
zip.file('meta.json', stringifiedMeta)
// 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
}
setLoadingSpinnerDesc('Generating zip file')
const arrayBuffer = await zip
.generateAsync({
type: 'arraybuffer',
compression: 'DEFLATE',
compressionOptions: { level: 6 }
})
.catch(handleZipError)
if (!arrayBuffer) {
setIsLoading(false)
return
}
// Create a File object with the Blob data
const blob = new Blob([arrayBuffer])
const file = new File([blob], `request-${unixNow()}.sigit.zip`, {
type: 'application/zip'
})
setIsLoading(false)
navigate(`${appPublicRoutes.verify}`, { state: { uploadedZip: file } })
}
// Sign the event for the meta file // Sign the event for the meta file
const signEventForMeta = async (signerContent: { const signEventForMeta = async (signerContent: {
prevSig: string prevSig: string
@ -601,85 +530,38 @@ export const SignPage = () => {
return metaCopy return metaCopy
} }
// create final zip file // Check if the current user is the last signer
const createFinalZipFile = async ( const checkIsLastSigner = (signers: string[]): boolean => {
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 usersNpub = hexToNpub(usersPubkey!)
const lastSignerIndex = signers.length - 1
const signerIndex = signers.indexOf(usersNpub) const signerIndex = signers.indexOf(usersNpub)
const nextSigner = signers[signerIndex + 1] return signerIndex === lastSignerIndex
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'
})
}
// 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
} }
// Handle the online flow: update users app data and send notifications // Handle the online flow: update users app data and send notifications
const handleOnlineFlow = async (meta: Meta) => { const handleOnlineFlow = async (
meta: Meta,
encryptionKey: string | undefined
) => {
setLoadingSpinnerDesc('Updating users app data') setLoadingSpinnerDesc('Updating users app data')
const updatedEvent = await updateUsersAppData(meta) const updatedEvent = await updateUsersAppData([meta])
if (!updatedEvent) { if (!updatedEvent) {
setIsLoading(false) setIsLoading(false)
return return
} }
let metaUrl: string
try {
metaUrl = await uploadMetaToFileStorage(meta, encryptionKey)
} catch (error) {
if (error instanceof Error) {
toast.error(error.message)
}
console.error(error)
setIsLoading(false)
return
}
const userSet = new Set<`npub1${string}`>() const userSet = new Set<`npub1${string}`>()
if (submittedBy && submittedBy !== usersPubkey) { if (submittedBy && submittedBy !== usersPubkey) {
userSet.add(hexToNpub(submittedBy)) userSet.add(hexToNpub(submittedBy))
@ -712,7 +594,7 @@ export const SignPage = () => {
setLoadingSpinnerDesc('Sending notifications') setLoadingSpinnerDesc('Sending notifications')
const users = Array.from(userSet) const users = Array.from(userSet)
const promises = users.map((user) => const promises = users.map((user) =>
sendNotification(npubToHex(user)!, meta) sendNotification(npubToHex(user)!, { metaUrl, keys: meta.keys })
) )
await Promise.all(promises) await Promise.all(promises)
.then(() => { .then(() => {
@ -785,128 +667,6 @@ export const SignPage = () => {
setIsLoading(false) 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 () => {
if (Object.entries(files).length === 0 || !meta || !usersPubkey) return
const usersNpub = hexToNpub(usersPubkey)
if (
!signers.includes(usersNpub) &&
!viewers.includes(usersNpub) &&
submittedBy !== usersNpub
)
return
setIsLoading(true)
setLoadingSpinnerDesc('Signing nostr event')
if (!meta) return
const prevSig = getLastSignersSig(meta, signers)
if (!prevSig) return
const signedEvent = await signEventForMetaFile(
JSON.stringify({
prevSig
}),
nostrController,
setIsLoading
)
if (!signedEvent) return
const exportSignature = JSON.stringify(signedEvent, null, 2)
const stringifiedMeta = JSON.stringify(
{
...meta,
exportSignature
},
null,
2
)
const zip = new JSZip()
zip.file('meta.json', stringifiedMeta)
for (const [fileName, file] of Object.entries(files)) {
zip.file(`files/${fileName}`, await file.arrayBuffer())
}
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 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 zip = new JSZip()
const stringifiedMeta = JSON.stringify(meta, null, 2)
zip.file('meta.json', stringifiedMeta)
for (const [fileName, file] of Object.entries(files)) {
zip.file(`files/${fileName}`, await file.arrayBuffer())
}
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`)
}
/** /**
* This function accepts an npub of a signer and return the signature of its previous signer. * This function accepts an npub of a signer and return the signature of its previous signer.
* This prevSig will be used in the content of the provided signer's signedEvent * This prevSig will be used in the content of the provided signer's signedEvent
@ -944,90 +704,16 @@ export const SignPage = () => {
return <LoadingSpinner desc={loadingSpinnerDesc} /> return <LoadingSpinner desc={loadingSpinnerDesc} />
} }
if (!isMarksCompleted && signedStatus === SignedStatus.User_Is_Next_Signer) {
return ( return (
<PdfMarking <PdfMarking
files={getCurrentUserFiles(files, currentFileHashes, creatorFileHashes)} files={getCurrentUserFiles(files, currentFileHashes, creatorFileHashes)}
currentUserMarks={currentUserMarks} currentUserMarks={currentUserMarks}
setIsMarksCompleted={setIsMarksCompleted}
setCurrentUserMarks={setCurrentUserMarks} setCurrentUserMarks={setCurrentUserMarks}
setUpdatedMarks={setUpdatedMarks} setUpdatedMarks={setUpdatedMarks}
handleDownload={handleDownload} handleSign={handleSign}
handleSignOffline={handleSignOffline}
otherUserMarks={otherUserMarks} otherUserMarks={otherUserMarks}
meta={meta} meta={meta}
/> />
) )
} }
return (
<>
<Container className={styles.container}>
{displayInput && (
<>
<Typography component="label" variant="h6">
Select sigit file
</Typography>
<Box className={styles.inputBlock}>
<MuiFileInput
placeholder="Select file"
inputProps={{ accept: '.sigit.zip' }}
value={selectedFile}
onChange={(value) => setSelectedFile(value)}
/>
</Box>
{selectedFile && (
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'center' }}>
<Button onClick={handleDecrypt} variant="contained">
Decrypt
</Button>
</Box>
)}
</>
)}
{submittedBy && Object.entries(files).length > 0 && meta && (
<>
<DisplayMeta
meta={meta}
files={files}
submittedBy={submittedBy}
signers={signers}
viewers={viewers}
creatorFileHashes={creatorFileHashes}
currentFileHashes={currentFileHashes}
signedBy={signedBy}
nextSigner={nextSinger}
getPrevSignersSig={getPrevSignersSig}
/>
{signedStatus === SignedStatus.Fully_Signed && (
<Box sx={{ mt: 1, display: 'flex', justifyContent: 'center' }}>
<Button onClick={handleExport} variant="contained">
Export Sigit
</Button>
</Box>
)}
{signedStatus === SignedStatus.User_Is_Next_Signer && (
<Box sx={{ mt: 1, display: 'flex', justifyContent: 'center' }}>
<Button onClick={handleSign} variant="contained">
Sign
</Button>
</Box>
)}
{isSignerOrCreator && (
<Box sx={{ mt: 1, display: 'flex', justifyContent: 'center' }}>
<Button onClick={handleEncryptedExport} variant="contained">
Export Encrypted Sigit
</Button>
</Box>
)}
</>
)}
</Container>
</>
)
}

View File

@ -1,10 +1,12 @@
import { useEffect, useState } from 'react'
import { toast } from 'react-toastify'
import { import {
Meta, Cancel,
ProfileMetadata, CheckCircle,
SignedEventContent, Download,
User, HourglassTop
UserRole } from '@mui/icons-material'
} from '../../../types'
import { import {
Box, Box,
IconButton, IconButton,
@ -20,22 +22,19 @@ import {
Typography, Typography,
useTheme useTheme
} from '@mui/material' } from '@mui/material'
import {
Download,
CheckCircle,
Cancel,
HourglassTop
} from '@mui/icons-material'
import saveAs from 'file-saver' import saveAs from 'file-saver'
import { kinds, Event } from 'nostr-tools'
import { useState, useEffect } from 'react' import { Event } from 'nostr-tools'
import { toast } from 'react-toastify'
import { UserAvatar } from '../../../components/UserAvatar' import { UserAvatar } from '../../../components/UserAvatar'
import { MetadataController } from '../../../controllers'
import { npubToHex, hexToNpub, parseJson } from '../../../utils' import { Meta, SignedEventContent, User, UserRole } from '../../../types'
import styles from '../style.module.scss' import { hexToNpub, npubToHex, parseJson } from '../../../utils'
import { SigitFile } from '../../../utils/file' import { SigitFile } from '../../../utils/file'
import styles from '../style.module.scss'
type DisplayMetaProps = { type DisplayMetaProps = {
meta: Meta meta: Meta
files: { [fileName: string]: SigitFile } files: { [fileName: string]: SigitFile }
@ -67,9 +66,6 @@ export const DisplayMeta = ({
theme.palette.background.paper theme.palette.background.paper
) )
const [metadata, setMetadata] = useState<{ [key: string]: ProfileMetadata }>(
{}
)
const [users, setUsers] = useState<User[]>([]) const [users, setUsers] = useState<User[]>([])
useEffect(() => { useEffect(() => {
@ -104,45 +100,6 @@ export const DisplayMeta = ({
}) })
}, [signers, viewers]) }, [signers, viewers])
useEffect(() => {
const metadataController = MetadataController.getInstance()
const hexKeys: string[] = [
npubToHex(submittedBy)!,
...users.map((user) => user.pubkey)
]
hexKeys.forEach((key) => {
if (!(key in metadata)) {
const handleMetadataEvent = (event: Event) => {
const metadataContent =
metadataController.extractProfileMetadataContent(event)
if (metadataContent)
setMetadata((prev) => ({
...prev,
[key]: metadataContent
}))
}
metadataController.on(key, (kind: number, event: Event) => {
if (kind === kinds.Metadata) {
handleMetadataEvent(event)
}
})
metadataController
.findMetadata(key)
.then((metadataEvent) => {
if (metadataEvent) handleMetadataEvent(metadataEvent)
})
.catch((err) => {
console.error(`error occurred in finding metadata for: ${key}`, err)
})
}
})
}, [users, submittedBy, metadata])
const downloadFile = async (fileName: string) => { const downloadFile = async (fileName: string) => {
const file = files[fileName] const file = files[fileName]
saveAs(file) saveAs(file)
@ -229,7 +186,6 @@ export const DisplayMeta = ({
key={user.pubkey} key={user.pubkey}
meta={meta} meta={meta}
user={user} user={user}
metadata={metadata}
signedBy={signedBy} signedBy={signedBy}
nextSigner={nextSigner} nextSigner={nextSigner}
getPrevSignersSig={getPrevSignersSig} getPrevSignersSig={getPrevSignersSig}
@ -258,7 +214,6 @@ enum UserStatus {
type DisplayUserProps = { type DisplayUserProps = {
meta: Meta meta: Meta
user: User user: User
metadata: { [key: string]: ProfileMetadata }
signedBy: `npub1${string}`[] signedBy: `npub1${string}`[]
nextSigner?: string nextSigner?: string
getPrevSignersSig: (usersNpub: string) => string | null getPrevSignersSig: (usersNpub: string) => string | null

View File

@ -1,11 +1,17 @@
import { Box, Button, Typography } from '@mui/material' import { Box, Button, Typography } from '@mui/material'
import JSZip from 'jszip' import JSZip from 'jszip'
import { MuiFileInput } from 'mui-file-input' import { MuiFileInput } from 'mui-file-input'
import { useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import { LoadingSpinner } from '../../components/LoadingSpinner' import { LoadingSpinner } from '../../components/LoadingSpinner'
import { NostrController } from '../../controllers' import { NostrController } from '../../controllers'
import { DocSignatureEvent, Meta } from '../../types' import {
DocSignatureEvent,
Meta,
SignedEvent,
OpenTimestamp,
OpenTimestampUpgradeVerifyResponse
} from '../../types'
import { import {
decryptArrayBuffer, decryptArrayBuffer,
getHash, getHash,
@ -14,19 +20,26 @@ import {
parseJson, parseJson,
readContentOfZipEntry, readContentOfZipEntry,
signEventForMetaFile, signEventForMetaFile,
getCurrentUserFiles getCurrentUserFiles,
npubToHex,
generateEncryptionKey,
encryptArrayBuffer,
generateKeysFile,
ARRAY_BUFFER,
DEFLATE,
uploadMetaToFileStorage,
decrypt
} from '../../utils' } from '../../utils'
import styles from './style.module.scss' import styles from './style.module.scss'
import { useLocation } from 'react-router-dom' import { useLocation, useParams } from 'react-router-dom'
import axios from 'axios' import axios from 'axios'
import { FONT_SIZE, FONT_TYPE, inPx } from '../../utils/pdf.ts' import { FONT_SIZE, FONT_TYPE, inPx } from '../../utils/pdf.ts'
import { useAppSelector } from '../../hooks/store' import { useAppSelector, useNDK } from '../../hooks'
import { getLastSignersSig } from '../../utils/sign.ts'
import { saveAs } from 'file-saver' import { saveAs } from 'file-saver'
import { Container } from '../../components/Container' import { Container } from '../../components/Container'
import { useSigitMeta } from '../../hooks/useSigitMeta.tsx' import { useSigitMeta } from '../../hooks/useSigitMeta.tsx'
import { StickySideColumns } from '../../layouts/StickySideColumns.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 FileList from '../../components/FileList'
import { CurrentUserFile } from '../../types/file.ts' import { CurrentUserFile } from '../../types/file.ts'
import { Mark } from '../../types/mark.ts' import { Mark } from '../../types/mark.ts'
@ -44,6 +57,10 @@ import {
faFile, faFile,
faFileDownload faFileDownload
} from '@fortawesome/free-solid-svg-icons' } from '@fortawesome/free-solid-svg-icons'
import { upgradeAndVerifyTimestamp } from '../../utils/opentimestamps.ts'
import _ from 'lodash'
import { MarkRender } from '../../components/MarkTypeStrategy/MarkRender.tsx'
import { SignerService } from '../../services/index.ts'
interface PdfViewProps { interface PdfViewProps {
files: CurrentUserFile[] files: CurrentUserFile[]
@ -89,7 +106,10 @@ const SlimPdfView = ({
const m = parsedSignatureEvents[ const m = parsedSignatureEvents[
e as `npub1${string}` e as `npub1${string}`
].parsedContent?.marks.filter( ].parsedContent?.marks.filter(
(m) => m.pdfFileHash == hash && m.location.page == i (m) =>
(m.pdfFileHash
? m.pdfFileHash == hash
: m.fileHash == hash) && m.location.page == i
) )
if (m) { if (m) {
marks.push(...m) marks.push(...m)
@ -118,7 +138,11 @@ const SlimPdfView = ({
fontSize: inPx(from(page.width, FONT_SIZE)) fontSize: inPx(from(page.width, FONT_SIZE))
}} }}
> >
{m.value} <MarkRender
markType={m.type}
value={m.value}
mark={m}
/>
</div> </div>
) )
})} })}
@ -149,6 +173,10 @@ const SlimPdfView = ({
export const VerifyPage = () => { export const VerifyPage = () => {
const location = useLocation() const location = useLocation()
const params = useParams()
const { updateUsersAppData, sendNotification } = useNDK()
const usersAppData = useAppSelector((state) => state.userAppData)
const usersPubkey = useAppSelector((state) => state.auth.usersPubkey) const usersPubkey = useAppSelector((state) => state.auth.usersPubkey)
const nostrController = NostrController.getInstance() const nostrController = NostrController.getInstance()
@ -160,15 +188,29 @@ export const VerifyPage = () => {
* uploadedZip will be received from home page when a user uploads a sigit zip wrapper that contains meta.json * 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 * 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?.uploadedZip || undefined
const [selectedFile, setSelectedFile] = useState<File | null>(null) const [selectedFile, setSelectedFile] = useState<File | null>(null)
useEffect(() => {
if (uploadedZip) { /**
setSelectedFile(uploadedZip) * 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
}
}
} }
}, [uploadedZip])
const [meta, setMeta] = useState<Meta>(metaInNavState) const [meta, setMeta] = useState<Meta>(metaInNavState)
const { const {
submittedBy, submittedBy,
zipUrl, zipUrl,
@ -176,7 +218,8 @@ export const VerifyPage = () => {
signers, signers,
viewers, viewers,
fileHashes, fileHashes,
parsedSignatureEvents parsedSignatureEvents,
timestamps
} = useSigitMeta(meta) } = useSigitMeta(meta)
const [files, setFiles] = useState<{ [filename: string]: SigitFile }>({}) const [files, setFiles] = useState<{ [filename: string]: SigitFile }>({})
@ -186,6 +229,16 @@ export const VerifyPage = () => {
[key: string]: string | null [key: string]: string | null
}>({}) }>({})
const signTimestampEvent = async (signerContent: {
timestamps: OpenTimestamp[]
}): Promise<SignedEvent | null> => {
return await signEventForMetaFile(
JSON.stringify(signerContent),
nostrController,
setIsLoading
)
}
useEffect(() => { useEffect(() => {
if (Object.entries(files).length > 0) { if (Object.entries(files).length > 0) {
const tmp = getCurrentUserFiles(files, currentFileHashes, fileHashes) const tmp = getCurrentUserFiles(files, currentFileHashes, fileHashes)
@ -193,17 +246,166 @@ export const VerifyPage = () => {
} }
}, [currentFileHashes, fileHashes, files]) }, [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 metaUrl = await uploadMetaToFileStorage(
updatedMeta,
encryptionKey
)
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)!, {
metaUrl,
keys: meta.keys!
})
)
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(() => { useEffect(() => {
if (metaInNavState && encryptionKey) { if (metaInNavState && encryptionKey) {
const processSigit = async () => { const processSigit = async () => {
setIsLoading(true) setIsLoading(true)
setLoadingSpinnerDesc('Fetching file from file server') setLoadingSpinnerDesc('Fetching file from file server')
axios try {
.get(zipUrl, { const res = await axios.get(zipUrl, {
responseType: 'arraybuffer' responseType: 'arraybuffer'
}) })
.then(async (res) => {
const fileName = zipUrl.split('/').pop() const fileName = zipUrl.split('/').pop()
const file = new File([res.data], fileName!) const file = new File([res.data], fileName!)
@ -213,9 +415,7 @@ export const VerifyPage = () => {
encryptionKey encryptionKey
).catch((err) => { ).catch((err) => {
console.log('err in decryption:>> ', err) console.log('err in decryption:>> ', err)
toast.error( toast.error(err.message || 'An error occurred in decrypting file.')
err.message || 'An error occurred in decrypting file.'
)
return null return null
}) })
@ -262,36 +462,51 @@ export const VerifyPage = () => {
setCurrentFileHashes(fileHashes) setCurrentFileHashes(fileHashes)
setFiles(files) setFiles(files)
setIsLoading(false) setIsLoading(false)
} }
}) } catch (err) {
.catch((err) => { const message = `error occurred in getting file from ${zipUrl}`
console.error(`error occurred in getting file from ${zipUrl}`, err) console.error(message, err)
toast.error( if (err instanceof Error) toast.error(err.message)
err.message || `error occurred in getting file from ${zipUrl}` else toast.error(message)
) } finally {
})
.finally(() => {
setIsLoading(false) setIsLoading(false)
}) }
} }
processSigit() processSigit()
} }
}, [encryptionKey, metaInNavState, zipUrl]) }, [encryptionKey, metaInNavState, zipUrl])
const handleVerify = async () => { const handleVerify = useCallback(async (selectedFile: File) => {
if (!selectedFile) return
setIsLoading(true) setIsLoading(true)
setLoadingSpinnerDesc('Loading zip file')
const zip = await JSZip.loadAsync(selectedFile).catch((err) => { let zip = await JSZip.loadAsync(selectedFile).catch((err) => {
console.log('err in loading zip file :>> ', err) console.log('err in loading zip file :>> ', err)
toast.error(err.message || 'An error occurred in loading zip file.') toast.error(err.message || 'An error occurred in loading zip file.')
return null return null
}) })
if (!zip) return if (!zip) {
return setIsLoading(false)
}
if ('keys.json' in zip.files) {
// Decrypt
setLoadingSpinnerDesc('Decrypting zip file content')
const arrayBuffer = await decrypt(selectedFile).catch((err) => {
console.error(`error occurred in decryption`, err)
toast.error(err.message || `error occurred in decryption`)
})
if (arrayBuffer) {
// Replace the zip and continue processing
zip = await JSZip.loadAsync(arrayBuffer)
}
}
setLoadingSpinnerDesc('Opening zip file content')
const files: { [filename: string]: SigitFile } = {} const files: { [filename: string]: SigitFile } = {}
const fileHashes: { [key: string]: string | null } = {} const fileHashes: { [key: string]: string | null } = {}
@ -348,15 +563,120 @@ export const VerifyPage = () => {
} }
) )
if (!parsedMetaJson) return if (!parsedMetaJson) {
setIsLoading(false)
return
}
setMeta(parsedMetaJson) setMeta(parsedMetaJson)
setIsLoading(false) setIsLoading(false)
}, [])
useEffect(() => {
if (uploadedZip) {
handleVerify(uploadedZip)
}
}, [handleVerify, uploadedZip])
// 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
} }
const handleMarkedExport = async () => { // create final zip file
if (Object.entries(files).length === 0 || !meta || !usersPubkey) return 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 userSet = new Set<string>()
if (submittedBy) {
userSet.add(submittedBy)
}
signers.forEach((signer) => {
userSet.add(npubToHex(signer)!)
})
viewers.forEach((viewer) => {
userSet.add(npubToHex(viewer)!)
})
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) {
setIsLoading(false)
return
}
const blob = new Blob([arrayBuffer])
saveAs(blob, `exported-${unixNow()}.sigit.zip`)
setIsLoading(false)
}
const handleEncryptedExport = async () => {
const arrayBuffer = await prepareZipExport()
if (!arrayBuffer) {
setIsLoading(false)
return
}
const key = await generateEncryptionKey()
setLoadingSpinnerDesc('Encrypting zip file')
const encryptedArrayBuffer = await encryptArrayBuffer(arrayBuffer, key)
const finalZipFile = await createFinalZipFile(encryptedArrayBuffer, key)
if (!finalZipFile) {
setIsLoading(false)
return
}
saveAs(finalZipFile, `exported-${unixNow()}.sigit.zip`)
setIsLoading(false)
}
const prepareZipExport = async (): Promise<ArrayBuffer | null> => {
if (Object.entries(files).length === 0 || !meta || !usersPubkey)
return Promise.resolve(null)
const usersNpub = hexToNpub(usersPubkey) const usersNpub = hexToNpub(usersPubkey)
if ( if (
@ -364,14 +684,18 @@ export const VerifyPage = () => {
!viewers.includes(usersNpub) && !viewers.includes(usersNpub) &&
submittedBy !== usersNpub submittedBy !== usersNpub
) { ) {
return return Promise.resolve(null)
} }
setIsLoading(true) setIsLoading(true)
setLoadingSpinnerDesc('Signing nostr event') setLoadingSpinnerDesc('Signing nostr event')
const prevSig = getLastSignersSig(meta, signers) if (!meta) return Promise.resolve(null)
if (!prevSig) return
const signerService = new SignerService(meta)
const prevSig = signerService.getLastSignerSig()
if (!prevSig) return Promise.resolve(null)
const signedEvent = await signEventForMetaFile( const signedEvent = await signEventForMetaFile(
JSON.stringify({ prevSig }), JSON.stringify({ prevSig }),
@ -379,7 +703,7 @@ export const VerifyPage = () => {
setIsLoading setIsLoading
) )
if (!signedEvent) return if (!signedEvent) return Promise.resolve(null)
const exportSignature = JSON.stringify(signedEvent, null, 2) const exportSignature = JSON.stringify(signedEvent, null, 2)
const updatedMeta = { ...meta, exportSignature } const updatedMeta = { ...meta, exportSignature }
@ -390,8 +714,8 @@ export const VerifyPage = () => {
const arrayBuffer = await zip const arrayBuffer = await zip
.generateAsync({ .generateAsync({
type: 'arraybuffer', type: ARRAY_BUFFER,
compression: 'DEFLATE', compression: DEFLATE,
compressionOptions: { compressionOptions: {
level: 6 level: 6
} }
@ -403,12 +727,9 @@ export const VerifyPage = () => {
return null return null
}) })
if (!arrayBuffer) return if (!arrayBuffer) return Promise.resolve(null)
const blob = new Blob([arrayBuffer]) return Promise.resolve(arrayBuffer)
saveAs(blob, `exported-${unixNow()}.sigit.zip`)
setIsLoading(false)
} }
return ( return (
@ -434,7 +755,10 @@ export const VerifyPage = () => {
{selectedFile && ( {selectedFile && (
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'center' }}> <Box sx={{ mt: 2, display: 'flex', justifyContent: 'center' }}>
<Button onClick={handleVerify} variant="contained"> <Button
onClick={() => handleVerify(selectedFile)}
variant="contained"
>
Verify Verify
</Button> </Button>
</Box> </Box>
@ -454,8 +778,8 @@ export const VerifyPage = () => {
)} )}
currentFile={currentFile} currentFile={currentFile}
setCurrentFile={setCurrentFile} setCurrentFile={setCurrentFile}
handleDownload={handleMarkedExport} handleExport={handleExport}
downloadLabel="Download Sigit" handleEncryptedExport={handleEncryptedExport}
/> />
) )
} }

View File

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

View File

@ -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 { hexToNpub } from '../utils'
import { Route, RouteProps } from 'react-router-dom'
export const appPrivateRoutes = { export const appPrivateRoutes = {
homePage: '/', homePage: '/',
@ -39,93 +27,3 @@ export const getProfileRoute = (hexKey: string) =>
export const getProfileSettingsRoute = (hexKey: string) => export const getProfileSettingsRoute = (hexKey: string) =>
appPrivateRoutes.profileSettings.replace(':npub', hexToNpub(hexKey)) 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
View 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 />
}
]

View File

@ -1 +1,2 @@
export * from './cache' export * from './cache'
export * from './signer'

View File

@ -0,0 +1,143 @@
import { toast } from 'react-toastify'
import { Meta, SignedEventContent } from '../../types'
import {
parseCreateSignatureEventContent,
parseNostrEvent,
SigitStatus,
SignStatus
} from '../../utils'
import { MetaParseError } from '../../types/errors/MetaParseError'
import { verifyEvent } from 'nostr-tools'
import { appPrivateRoutes, appPublicRoutes } from '../../routes'
export class SignerService {
#signers: `npub1${string}`[] = []
#nextSigner: `npub1${string}` | undefined
#signatures = new Map<`npub1${string}`, string>()
#signersStatus = new Map<`npub1${string}`, SignStatus>()
#lastSignerSig: string | undefined
constructor(source: Meta) {
this.#process(source.createSignature, source.docSignatures)
}
getNextSigner = () => {
return this.#nextSigner
}
isNextSigner = (npub: `npub1${string}`) => {
return this.#nextSigner === npub
}
isLastSigner = (npub: `npub1${string}`) => {
const lastIndex = this.#signers.length - 1
const npubIndex = this.#signers.indexOf(npub)
return npubIndex === lastIndex
}
#isFullySigned = () => {
const signedBy = Object.keys(this.#signatures) as `npub1${string}`[]
const isCompletelySigned = this.#signers.every((signer) =>
signedBy.includes(signer)
)
return isCompletelySigned
}
getSignedStatus = () => {
return this.#isFullySigned() ? SigitStatus.Complete : SigitStatus.Partial
}
getSignerStatus = (npub: `npub1${string}`) => {
return this.#signersStatus.get(npub)
}
getNavigate = (npub: `npub1${string}`) => {
return this.isNextSigner(npub)
? appPrivateRoutes.sign
: appPublicRoutes.verify
}
getLastSignerSig = () => {
return this.#lastSignerSig
}
#process = (
createSignature: string,
docSignatures: { [key: `npub1${string}`]: string }
) => {
try {
const createSignatureEvent = parseNostrEvent(createSignature)
const { signers } = parseCreateSignatureEventContent(
createSignatureEvent.content
)
const getPrevSignerSig = (npub: `npub1${string}`) => {
if (signers[0] === npub) {
return createSignatureEvent.sig
}
// Find the index of signer
const currentSignerIndex = signers.findIndex(
(signer) => signer === npub
)
// Return if could not found user in signer's list
if (currentSignerIndex === -1) return
// Find prev signer
const prevSigner = signers[currentSignerIndex - 1]
// Get the signature of prev signer
return this.#signatures.get(prevSigner)
}
this.#signers = [...signers]
for (const npub in docSignatures) {
try {
// Parse each signature event
const event = parseNostrEvent(docSignatures[npub as `npub1${string}`])
this.#signatures.set(npub as `npub1${string}`, event.sig)
const isValidSignature = verifyEvent(event)
if (isValidSignature) {
const prevSignersSig = getPrevSignerSig(npub as `npub1${string}`)
const signedEvent: SignedEventContent = JSON.parse(event.content)
if (
signedEvent.prevSig &&
prevSignersSig &&
signedEvent.prevSig === prevSignersSig
) {
this.#signersStatus.set(
npub as `npub1${string}`,
SignStatus.Signed
)
this.#lastSignerSig = event.sig
}
} else {
this.#signersStatus.set(
npub as `npub1${string}`,
SignStatus.Invalid
)
}
} catch (error) {
this.#signersStatus.set(npub as `npub1${string}`, SignStatus.Invalid)
}
}
this.#signers
.filter((s) => !this.#signatures.has(s))
.forEach((s) => this.#signersStatus.set(s, SignStatus.Pending))
// Get the first signer that hasn't signed
const nextSigner = this.#signers.find((s) => !this.#signatures.has(s))
if (nextSigner) {
this.#nextSigner = nextSigner
this.#signersStatus.set(nextSigner, SignStatus.Awaiting)
}
} catch (error) {
if (error instanceof MetaParseError) {
toast.error(error.message)
console.error(error.name, error.message, error.cause, error.context)
} else {
console.error('Unexpected error', error)
}
}
}
}

View File

@ -7,7 +7,7 @@ export const UPDATE_LOGIN_METHOD = 'UPDATE_LOGIN_METHOD'
export const UPDATE_NOSTR_LOGIN_AUTH_METHOD = 'UPDATE_NOSTR_LOGIN_AUTH_METHOD' export const UPDATE_NOSTR_LOGIN_AUTH_METHOD = 'UPDATE_NOSTR_LOGIN_AUTH_METHOD'
export const UPDATE_KEYPAIR = 'UPDATE_KEYPAIR' export const UPDATE_KEYPAIR = 'UPDATE_KEYPAIR'
export const SET_METADATA_EVENT = 'SET_METADATA_EVENT' export const SET_USER_PROFILE = 'SET_USER_PROFILE'
export const SET_USER_ROBOT_IMAGE = 'SET_USER_ROBOT_IMAGE' export const SET_USER_ROBOT_IMAGE = 'SET_USER_ROBOT_IMAGE'

View File

@ -2,7 +2,7 @@ import * as ActionTypes from './actionTypes'
import { State } from './rootReducer' import { State } from './rootReducer'
export * from './auth/action' export * from './auth/action'
export * from './metadata/action' export * from './user/action'
export * from './relays/action' export * from './relays/action'
export * from './userAppData/action' export * from './userAppData/action'

View File

@ -1,8 +0,0 @@
import * as ActionTypes from '../actionTypes'
import { SetMetadataEvent } from './types'
import { Event } from 'nostr-tools'
export const setMetadataEvent = (payload: Event): SetMetadataEvent => ({
type: ActionTypes.SET_METADATA_EVENT,
payload
})

View File

@ -1,25 +0,0 @@
import * as ActionTypes from '../actionTypes'
import { MetadataDispatchTypes } from './types'
import { Event } from 'nostr-tools'
const initialState: Event | null = null
const reducer = (
state = initialState,
action: MetadataDispatchTypes
): Event | null => {
switch (action.type) {
case ActionTypes.SET_METADATA_EVENT:
return {
...action.payload
}
case ActionTypes.RESTORE_STATE:
return action.payload.metadata || initialState
default:
return state
}
}
export default reducer

View File

@ -1,10 +0,0 @@
import * as ActionTypes from '../actionTypes'
import { Event } from 'nostr-tools'
import { RestoreState } from '../actions'
export interface SetMetadataEvent {
type: typeof ActionTypes.SET_METADATA_EVENT
payload: Event
}
export type MetadataDispatchTypes = SetMetadataEvent | RestoreState

View File

@ -1,37 +1,31 @@
import { Event } from 'nostr-tools'
import { combineReducers } from 'redux' import { combineReducers } from 'redux'
import { UserAppData } from '../types' import { UserAppData } from '../types'
import * as ActionTypes from './actionTypes' import * as ActionTypes from './actionTypes'
import authReducer from './auth/reducer' import authReducer from './auth/reducer'
import { AuthDispatchTypes, AuthState } from './auth/types' import { AuthDispatchTypes, AuthState } from './auth/types'
import metadataReducer from './metadata/reducer' import userReducer from './user/reducer'
import relaysReducer from './relays/reducer' import relaysReducer from './relays/reducer'
import { RelaysDispatchTypes, RelaysState } from './relays/types' import { RelaysDispatchTypes, RelaysState } from './relays/types'
import UserAppDataReducer from './userAppData/reducer' import UserAppDataReducer from './userAppData/reducer'
import userRobotImageReducer from './userRobotImage/reducer'
import { MetadataDispatchTypes } from './metadata/types'
import { UserAppDataDispatchTypes } from './userAppData/types' import { UserAppDataDispatchTypes } from './userAppData/types'
import { UserRobotImageDispatchTypes } from './userRobotImage/types' import { UserDispatchTypes, UserState } from './user/types'
export interface State { export interface State {
auth: AuthState auth: AuthState
metadata?: Event user: UserState
userRobotImage?: string
relays: RelaysState relays: RelaysState
userAppData?: UserAppData userAppData?: UserAppData
} }
type AppActions = type AppActions =
| AuthDispatchTypes | AuthDispatchTypes
| MetadataDispatchTypes | UserDispatchTypes
| UserRobotImageDispatchTypes
| RelaysDispatchTypes | RelaysDispatchTypes
| UserAppDataDispatchTypes | UserAppDataDispatchTypes
export const appReducer = combineReducers({ export const appReducer = combineReducers({
auth: authReducer, auth: authReducer,
metadata: metadataReducer, user: userReducer,
userRobotImage: userRobotImageReducer,
relays: relaysReducer, relays: relaysReducer,
userAppData: UserAppDataReducer userAppData: UserAppDataReducer
}) })

17
src/store/user/action.ts Normal file
View File

@ -0,0 +1,17 @@
import { NDKUserProfile } from '@nostr-dev-kit/ndk'
import * as ActionTypes from '../actionTypes'
import { SetUserProfile, SetUserRobotImage } from './types'
export const setUserRobotImage = (
payload: string | null
): SetUserRobotImage => ({
type: ActionTypes.SET_USER_ROBOT_IMAGE,
payload
})
export const setUserProfile = (
payload: NDKUserProfile | null
): SetUserProfile => ({
type: ActionTypes.SET_USER_PROFILE,
payload
})

34
src/store/user/reducer.ts Normal file
View File

@ -0,0 +1,34 @@
import * as ActionTypes from '../actionTypes'
import { UserDispatchTypes, UserState } from './types'
const initialState: UserState = {
robotImage: null,
profile: null
}
const reducer = (
state = initialState,
action: UserDispatchTypes
): UserState => {
switch (action.type) {
case ActionTypes.SET_USER_ROBOT_IMAGE:
return {
...state,
robotImage: action.payload
}
case ActionTypes.SET_USER_PROFILE:
return {
...state,
profile: action.payload
}
case ActionTypes.RESTORE_STATE:
return action.payload.user || initialState
default:
return state
}
}
export default reducer

23
src/store/user/types.ts Normal file
View File

@ -0,0 +1,23 @@
import { NDKUserProfile } from '@nostr-dev-kit/ndk'
import * as ActionTypes from '../actionTypes'
import { RestoreState } from '../actions'
export interface UserState {
robotImage: string | null
profile: NDKUserProfile | null
}
export interface SetUserRobotImage {
type: typeof ActionTypes.SET_USER_ROBOT_IMAGE
payload: string | null
}
export interface SetUserProfile {
type: typeof ActionTypes.SET_USER_PROFILE
payload: NDKUserProfile | null
}
export type UserDispatchTypes =
| SetUserRobotImage
| SetUserProfile
| RestoreState

View File

@ -1,9 +0,0 @@
import * as ActionTypes from '../actionTypes'
import { SetUserRobotImage } from './types'
export const setUserRobotImage = (
payload: string | null
): SetUserRobotImage => ({
type: ActionTypes.SET_USER_ROBOT_IMAGE,
payload
})

View File

@ -1,22 +0,0 @@
import * as ActionTypes from '../actionTypes'
import { UserRobotImageDispatchTypes } from './types'
const initialState: string | null = null
const reducer = (
state = initialState,
action: UserRobotImageDispatchTypes
): string | null | undefined => {
switch (action.type) {
case ActionTypes.SET_USER_ROBOT_IMAGE:
return action.payload
case ActionTypes.RESTORE_STATE:
return action.payload.userRobotImage || initialState
default:
return state
}
}
export default reducer

View File

@ -1,9 +0,0 @@
import * as ActionTypes from '../actionTypes'
import { RestoreState } from '../actions'
export interface SetUserRobotImage {
type: typeof ActionTypes.SET_USER_ROBOT_IMAGE
payload: string | null
}
export type UserRobotImageDispatchTypes = SetUserRobotImage | RestoreState

View File

@ -1,6 +1,7 @@
import { Mark } from './mark' import { Mark } from './mark'
import { Keys } from '../store/auth/types' import { Keys } from '../store/auth/types'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { SigitStatus, SignStatus } from '../utils'
export enum UserRole { export enum UserRole {
signer = 'Signer', signer = 'Signer',
@ -18,6 +19,7 @@ export interface Meta {
docSignatures: { [key: `npub1${string}`]: string } docSignatures: { [key: `npub1${string}`]: string }
exportSignature?: string exportSignature?: string
keys?: { sender: string; keys: { [user: `npub1${string}`]: string } } keys?: { sender: string; keys: { [user: `npub1${string}`]: string } }
timestamps?: OpenTimestamp[]
} }
export interface CreateSignatureEventContent { export interface CreateSignatureEventContent {
@ -34,9 +36,23 @@ export interface SignedEventContent {
marks: Mark[] marks: Mark[]
} }
export interface Sigit { export interface OpenTimestamp {
fileUrl: string nostrId: string
meta: Meta 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 { export interface UserAppData {
@ -63,3 +79,52 @@ export interface UserAppData {
export interface DocSignatureEvent extends Event { export interface DocSignatureEvent extends Event {
parsedContent?: SignedEventContent parsedContent?: SignedEventContent
} }
export interface SigitNotification {
metaUrl: string
keys?: { sender: string; keys: { [user: `npub1${string}`]: string } }
}
export function isSigitNotification(obj: unknown): obj is SigitNotification {
return typeof (obj as SigitNotification).metaUrl === 'string'
}
/**
* Flattened interface that combines properties `Meta`, `CreateSignatureEventContent`,
* and `Event` (event's fields are made optional and pubkey and created_at replaced with our versions)
*/
export interface FlatMeta
extends Meta,
CreateSignatureEventContent,
Partial<Omit<Event, 'pubkey' | 'created_at'>> {
submittedBy?: string
// Optional field only present on exported sigits
// Exporting adds user's pubkey
exportedBy?: string
// Remove created_at and replace with createdAt
createdAt?: number
// Validated create signature event
isValid: boolean
// Decryption
encryptionKey: string | undefined
// Parsed Document Signatures
parsedSignatureEvents: {
[signer: `npub1${string}`]: DocSignatureEvent
}
// Calculated completion time
completedAt?: number
// Calculated status fields
signedStatus: SigitStatus
signersStatus: {
[signer: `npub1${string}`]: SignStatus
}
timestamps?: OpenTimestamp[]
}

View File

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

5
src/types/event.ts Normal file
View File

@ -0,0 +1,5 @@
export enum KeyboardCode {
Escape = 'Escape',
Enter = 'Enter',
NumpadEnter = 'NumpadEnter'
}

View File

@ -1,6 +1,6 @@
export * from './cache' export * from './cache'
export * from './core' export * from './core'
export * from './nostr' export * from './nostr'
export * from './profile'
export * from './relay' export * from './relay'
export * from './zip' export * from './zip'
export * from './event'

View File

@ -8,13 +8,16 @@ export interface CurrentUserMark {
currentValue?: string currentValue?: string
} }
// Both PdfFileHash and FileHash currently exist.
// It enables backward compatibility for Sigits created before January 2025
export interface Mark { export interface Mark {
id: number id: number
npub: string npub: string
pdfFileHash: string
type: MarkType type: MarkType
location: MarkLocation location: MarkLocation
fileName: string fileName: string
pdfFileHash?: string
fileHash?: string
value?: string value?: string
} }

38
src/types/opentimestamps.d.ts vendored Normal file
View 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 }
}

View File

@ -1,12 +0,0 @@
export interface ProfileMetadata {
name?: string
display_name?: string
/** @deprecated use name instead */
username?: string
picture?: string
banner?: string
about?: string
website?: string
nip05?: string
lud16?: string
}

View File

@ -1,3 +1,9 @@
export enum UserRelaysType {
Read = 'readRelayUrls',
Write = 'writeRelayUrls',
Both = 'bothRelayUrls'
}
export interface RelaySet { export interface RelaySet {
read: string[] read: string[]
write: string[] write: string[]

View File

@ -3,5 +3,6 @@ import type { WindowNostr } from 'nostr-tools/nip07'
declare global { declare global {
interface Window { interface Window {
nostr?: WindowNostr nostr?: WindowNostr
OpenTimestamps: OpenTimestamps
} }
} }

44
src/utils/auth.ts Normal file
View File

@ -0,0 +1,44 @@
import { Event } from 'nostr-tools'
import { SignedEvent } from '../types'
import { saveAuthToken } from './localStorage'
export const base64EncodeSignedEvent = (event: SignedEvent) => {
try {
const authEventSerialized = JSON.stringify(event)
const token = btoa(authEventSerialized)
return token
} catch (error) {
throw new Error('An error occurred in JSON.stringify of signedAuthEvent')
}
}
export const base64DecodeAuthToken = (authToken: string): SignedEvent => {
const decodedToken = atob(authToken)
try {
const signedEvent = JSON.parse(decodedToken)
return signedEvent
} catch (error) {
throw new Error('An error occurred in JSON.parse of the auth token')
}
}
export const createAndSaveAuthToken = (signedAuthEvent: SignedEvent) => {
const base64Encoded = base64EncodeSignedEvent(signedAuthEvent)
// save newly created auth token (base64 nostr signed event) in local storage along with expiry time
saveAuthToken(base64Encoded)
return base64Encoded
}
export const getEmptyMetadataEvent = (pubkey?: string): Event => {
return {
content: '',
created_at: new Date().valueOf(),
id: '',
kind: 0,
pubkey: pubkey || '',
sig: '',
tags: []
}
}

View File

@ -112,3 +112,13 @@ export const MOST_COMMON_MEDIA_TYPES = new Map([
['3g2', 'video/3gpp2'], // 3GPP2 audio/video container ['3g2', 'video/3gpp2'], // 3GPP2 audio/video container
['7z', 'application/x-7z-compressed'] // 7-zip archive ['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
}

View File

@ -1,228 +0,0 @@
import { EventTemplate, Filter, kinds, nip19 } from 'nostr-tools'
import { compareObjects, queryNip05, unixNow } from '.'
import {
MetadataController,
NostrController,
relayController
} from '../controllers'
import { NostrJoiningBlock, RelayInfoObject } from '../types'
import NDK, { NDKEvent, NDKSubscription } from '@nostr-dev-kit/ndk'
import store from '../store/store'
import { setRelayInfoAction } from '../store/actions'
export const getNostrJoiningBlockNumber = async (
hexKey: string
): Promise<NostrJoiningBlock | null> => {
const metadataController = MetadataController.getInstance()
const relaySet = await metadataController.findRelayListMetadata(hexKey)
const userRelays: string[] = []
// find user's relays
if (relaySet.write.length > 0) {
userRelays.push(...relaySet.write)
} else {
const metadata = await metadataController.findMetadata(hexKey)
if (!metadata) return null
const metadataContent =
metadataController.extractProfileMetadataContent(metadata)
if (metadataContent?.nip05) {
const nip05Profile = await queryNip05(metadataContent.nip05)
if (nip05Profile && nip05Profile.pubkey === hexKey) {
userRelays.push(...nip05Profile.relays)
}
}
}
if (userRelays.length === 0) return null
// filter for finding user's first kind 0 event
const eventFilter: Filter = {
kinds: [kinds.Metadata],
authors: [hexKey]
}
// find user's kind 0 event published on user's relays
const event = await relayController.fetchEvent(eventFilter, userRelays)
if (event) {
const { created_at } = event
// initialize job request
const jobEventTemplate: EventTemplate = {
content: '',
created_at: unixNow(),
kind: 68001,
tags: [
['i', `${created_at * 1000}`],
['j', 'blockChain-block-number']
]
}
const nostrController = NostrController.getInstance()
// sign job request event
const jobSignedEvent = await nostrController.signEvent(jobEventTemplate)
const relays = [
'wss://relay.damus.io',
'wss://relay.primal.net',
'wss://relayable.org'
]
await relayController.publish(jobSignedEvent, relays).catch((err) => {
console.error(
'Error occurred in publish blockChain-block-number DVM job',
err
)
})
const subscribeWithTimeout = (
subscription: NDKSubscription,
timeoutMs: number
): Promise<string> => {
return new Promise((resolve, reject) => {
const eventHandler = (event: NDKEvent) => {
subscription.stop()
resolve(event.content)
}
subscription.on('event', eventHandler)
// Set up a timeout to stop the subscription after a specified time
const timeout = setTimeout(() => {
subscription.stop() // Stop the subscription
reject(new Error('Subscription timed out')) // Reject the promise with a timeout error
}, timeoutMs)
// Handle subscription close event
subscription.on('close', () => clearTimeout(timeout))
})
}
const dvmNDK = new NDK({
explicitRelayUrls: relays
})
await dvmNDK.connect(2000)
// filter for getting DVM job's result
const sub = dvmNDK.subscribe({
kinds: [68002 as number],
'#e': [jobSignedEvent.id],
'#p': [jobSignedEvent.pubkey]
})
// asynchronously get block number from dvm job with 20 seconds timeout
const dvmJobResult = await subscribeWithTimeout(sub, 20000)
const encodedEventPointer = nip19.neventEncode({
id: event.id,
relays: userRelays,
author: event.pubkey,
kind: event.kind
})
return {
block: parseInt(dvmJobResult),
encodedEventPointer
}
}
return null
}
/**
* Sets information about relays into relays.info app state.
* @param relayURIs - relay URIs to get information about
*/
export const getRelayInfo = async (relayURIs: string[]) => {
// initialize job request
const jobEventTemplate: EventTemplate = {
content: '',
created_at: unixNow(),
kind: 68001,
tags: [
['i', `${JSON.stringify(relayURIs)}`],
['j', 'relay-info']
]
}
const nostrController = NostrController.getInstance()
// sign job request event
const jobSignedEvent = await nostrController.signEvent(jobEventTemplate)
const relays = [
'wss://relay.damus.io',
'wss://relay.primal.net',
'wss://relayable.org'
]
// publish job request
await relayController.publish(jobSignedEvent, relays)
console.log('jobSignedEvent :>> ', jobSignedEvent)
const subscribeWithTimeout = (
subscription: NDKSubscription,
timeoutMs: number
): Promise<string> => {
return new Promise((resolve, reject) => {
const eventHandler = (event: NDKEvent) => {
subscription.stop()
resolve(event.content)
}
subscription.on('event', eventHandler)
// Set up a timeout to stop the subscription after a specified time
const timeout = setTimeout(() => {
subscription.stop() // Stop the subscription
reject(new Error('Subscription timed out')) // Reject the promise with a timeout error
}, timeoutMs)
// Handle subscription close event
subscription.on('close', () => clearTimeout(timeout))
})
}
const dvmNDK = new NDK({
explicitRelayUrls: relays
})
await dvmNDK.connect(2000)
// filter for getting DVM job's result
const sub = dvmNDK.subscribe({
kinds: [68002 as number],
'#e': [jobSignedEvent.id],
'#p': [jobSignedEvent.pubkey]
})
// asynchronously get block number from dvm job with 20 seconds timeout
const dvmJobResult = await subscribeWithTimeout(sub, 20000)
if (!dvmJobResult) {
return Promise.reject(`Relay(s) information wasn't received`)
}
let relaysInfo: RelayInfoObject
try {
relaysInfo = JSON.parse(dvmJobResult)
} catch (error) {
return Promise.reject(`Invalid relay(s) information.`)
}
if (
relaysInfo &&
!compareObjects(store.getState().relays?.info, relaysInfo)
) {
store.dispatch(setRelayInfoAction(relaysInfo))
}
}

View File

@ -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 { Meta } from '../types'
import { PdfPage } from '../types/drawing.ts' import { PdfPage } from '../types/drawing.ts'
import { MOST_COMMON_MEDIA_TYPES } from './const.ts' import { MOST_COMMON_MEDIA_TYPES } from './const.ts'
import { extractMarksFromSignedMeta } from './mark.ts' import { extractMarksFromSignedMeta } from './mark.ts'
import { hexToNpub } from './nostr.ts'
import { import {
addMarks, addMarks,
groupMarksByFileNamePage, groupMarksByFileNamePage,
@ -20,8 +24,50 @@ export const getZipWithFiles = async (
for (const [fileName, file] of Object.entries(files)) { for (const [fileName, file] of Object.entries(files)) {
// Handle PDF Files, add marks // Handle PDF Files, add marks
if (file.isPdf) { 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) zip.file(`marked/${fileName}`, blob)
} }

View File

@ -1,5 +1,6 @@
export * from './auth'
export * from './const'
export * from './crypto' export * from './crypto'
export * from './dvm'
export * from './hash' export * from './hash'
export * from './localStorage' export * from './localStorage'
export * from './mark' export * from './mark'

View File

@ -24,6 +24,7 @@ import {
faStamp, faStamp,
faTableCellsLarge faTableCellsLarge
} from '@fortawesome/free-solid-svg-icons' } 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. * Takes in an array of Marks already filtered by User.
@ -122,9 +123,10 @@ const isLast = <T>(index: number, arr: T[]) => index === arr.length - 1
const isCurrentValueLast = ( const isCurrentValueLast = (
currentUserMarks: CurrentUserMark[], currentUserMarks: CurrentUserMark[],
selectedMark: CurrentUserMark, selectedMark: CurrentUserMark | null,
selectedMarkValue: string selectedMarkValue: string
) => { ) => {
if (selectedMark && currentUserMarks.length > 0) {
const filteredMarks = currentUserMarks.filter( const filteredMarks = currentUserMarks.filter(
(mark) => mark.id !== selectedMark.id (mark) => mark.id !== selectedMark.id
) )
@ -133,6 +135,9 @@ const isCurrentValueLast = (
) )
} }
return true
}
const getUpdatedMark = ( const getUpdatedMark = (
selectedMark: CurrentUserMark, selectedMark: CurrentUserMark,
selectedMarkValue: string selectedMarkValue: string
@ -158,6 +163,11 @@ export const DEFAULT_TOOLBOX: DrawTool[] = [
icon: faT, icon: faT,
label: 'Text' label: 'Text'
}, },
{
identifier: MarkType.SIGNATURE,
icon: faSignature,
label: 'Signature'
},
{ {
identifier: MarkType.FULLNAME, identifier: MarkType.FULLNAME,
icon: faIdCard, icon: faIdCard,
@ -170,12 +180,6 @@ export const DEFAULT_TOOLBOX: DrawTool[] = [
label: 'Job Title', label: 'Job Title',
isComingSoon: true isComingSoon: true
}, },
{
identifier: MarkType.SIGNATURE,
icon: faSignature,
label: 'Signature',
isComingSoon: true
},
{ {
identifier: MarkType.DATETIME, identifier: MarkType.DATETIME,
icon: faClock, icon: faClock,
@ -266,6 +270,29 @@ export const getToolboxLabelByMarkType = (markType: MarkType) => {
return DEFAULT_TOOLBOX.find((t) => t.identifier === markType)?.label return DEFAULT_TOOLBOX.find((t) => t.identifier === markType)?.label
} }
export const encryptAndUploadMarks = 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 { export {
getCurrentUserMarks, getCurrentUserMarks,
filterMarksByPubkey, filterMarksByPubkey,

Some files were not shown because too many files have changed in this diff Show More