Send dm on sigit sign/complete #230
.eslintrc.cjscontributing.mdindex.htmlpackage-lock.jsonpackage.json
public
src
App.tsx
assets/images
components
AppBar
ButtonUnderline
DisplaySigit
DrawPDFFields
FileList
Footer
LoadingSpinner
MarkFormField
MarkTypeStrategy
PDFView
UserAvatar
UsersDetails.tsx
contexts
controllers
data
hooks
layouts
main.tsxpages
create
home
landing
nostr
profile
settings
sign
verify
routes
services
store
types
utils
@ -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: {
|
||||||
|
@ -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
|
||||||
|
@ -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
1822
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@ -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
2
public/opentimestamps.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
22
src/App.tsx
22
src/App.tsx
@ -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
|
||||||
|
BIN
src/assets/images/nostr-logo.png
Normal file
BIN
src/assets/images/nostr-logo.png
Normal file
Binary file not shown.
Before ![]() (image error) Size: 186 KiB After ![]() (image error) Size: 186 KiB ![]() ![]() |
@ -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}>
|
||||||
|
24
src/components/ButtonUnderline/index.tsx
Normal file
24
src/components/ButtonUnderline/index.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
25
src/components/ButtonUnderline/style.module.scss
Normal file
25
src/components/ButtonUnderline/style.module.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@ -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
@ -0,0 +1,3 @@
|
|||||||
|
.counterpartSelectValue {
|
||||||
|
display: flex;
|
||||||
|
}
|
47
src/components/DrawPDFFields/internal/Counterpart.tsx
Normal file
47
src/components/DrawPDFFields/internal/Counterpart.tsx
Normal 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
|
||||||
|
}
|
||||||
|
)
|
19
src/components/DrawPDFFields/internal/FileItem.tsx
Normal file
19
src/components/DrawPDFFields/internal/FileItem.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
})
|
10
src/components/DrawPDFFields/internal/ImageItem.tsx
Normal file
10
src/components/DrawPDFFields/internal/ImageItem.tsx
Normal 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} />
|
||||||
|
})
|
@ -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;
|
||||||
|
}
|
||||||
|
@ -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}
|
||||||
|
/>
|
||||||
|
ENCRYPTED
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
{typeof handleExport === 'function' && (
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
popupState.close
|
||||||
|
handleExport()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
color={'var(--mui-palette-primary-main)'}
|
||||||
|
icon={faTriangleExclamation}
|
||||||
|
/>
|
||||||
|
UNENCRYPTED
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
</Menu>
|
||||||
|
</React.Fragment>
|
||||||
|
)}
|
||||||
|
</PopupState>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
16
src/components/MarkTypeStrategy/MarkInput.tsx
Normal file
16
src/components/MarkTypeStrategy/MarkInput.tsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { MarkType } from '../../types/drawing'
|
||||||
|
import { MARK_TYPE_CONFIG, MarkInputProps } from './MarkStrategy'
|
||||||
|
|
||||||
|
interface MarkInputComponentProps extends MarkInputProps {
|
||||||
|
markType: MarkType
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MarkInput = ({ markType, ...rest }: MarkInputComponentProps) => {
|
||||||
|
const { input: InputComponent } = MARK_TYPE_CONFIG[markType] || {}
|
||||||
|
|
||||||
|
if (typeof InputComponent !== 'undefined') {
|
||||||
|
return <InputComponent {...rest} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
20
src/components/MarkTypeStrategy/MarkRender.tsx
Normal file
20
src/components/MarkTypeStrategy/MarkRender.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { MarkType } from '../../types/drawing'
|
||||||
|
import { MARK_TYPE_CONFIG, MarkRenderProps } from './MarkStrategy'
|
||||||
|
|
||||||
|
interface MarkRenderComponentProps extends MarkRenderProps {
|
||||||
|
markType: MarkType
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MarkRender = ({ markType, ...rest }: MarkRenderComponentProps) => {
|
||||||
|
const { render: RenderComponent } = MARK_TYPE_CONFIG[markType] || {}
|
||||||
|
|
||||||
|
if (typeof RenderComponent !== 'undefined') {
|
||||||
|
return <RenderComponent {...rest} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return <DefaultRenderComponent {...rest} />
|
||||||
|
}
|
||||||
|
|
||||||
|
const DefaultRenderComponent = ({ value }: MarkRenderProps) => (
|
||||||
|
<span>{value}</span>
|
||||||
|
)
|
32
src/components/MarkTypeStrategy/MarkStrategy.tsx
Normal file
32
src/components/MarkTypeStrategy/MarkStrategy.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { MarkType } from '../../types/drawing'
|
||||||
|
import { CurrentUserMark, Mark } from '../../types/mark'
|
||||||
|
import { TextStrategy } from './Text'
|
||||||
|
import { SignatureStrategy } from './Signature'
|
||||||
|
|
||||||
|
export interface MarkInputProps {
|
||||||
|
value: string
|
||||||
|
handler: (value: string) => void
|
||||||
|
placeholder?: string
|
||||||
|
userMark?: CurrentUserMark
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarkRenderProps {
|
||||||
|
value?: string
|
||||||
|
mark: Mark
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarkStrategy {
|
||||||
|
input: React.FC<MarkInputProps>
|
||||||
|
render: React.FC<MarkRenderProps>
|
||||||
|
encryptAndUpload?: (value: string, key?: string) => Promise<string>
|
||||||
|
fetchAndDecrypt?: (value: string, key?: string) => Promise<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MarkStrategies = {
|
||||||
|
[key in MarkType]?: MarkStrategy
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MARK_TYPE_CONFIG: MarkStrategies = {
|
||||||
|
[MarkType.TEXT]: TextStrategy,
|
||||||
|
[MarkType.SIGNATURE]: SignatureStrategy
|
||||||
|
}
|
44
src/components/MarkTypeStrategy/Signature/Input.module.scss
Normal file
44
src/components/MarkTypeStrategy/Signature/Input.module.scss
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
@import '../../../styles/colors.scss';
|
||||||
|
|
||||||
|
$padding: 5px;
|
||||||
|
|
||||||
|
.wrapper {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: $padding;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relative {
|
||||||
|
position: relative;
|
||||||
|
outline: 1px solid black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas {
|
||||||
|
background-color: $body-background-color;
|
||||||
|
cursor: crosshair;
|
||||||
|
|
||||||
|
// Disable panning/zooming when touching canvas element
|
||||||
|
-ms-touch-action: none;
|
||||||
|
touch-action: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.absolute {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reset {
|
||||||
|
cursor: pointer;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: $padding;
|
||||||
|
color: $primary-main;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $primary-dark;
|
||||||
|
}
|
||||||
|
}
|
101
src/components/MarkTypeStrategy/Signature/Input.tsx
Normal file
101
src/components/MarkTypeStrategy/Signature/Input.tsx
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import { useCallback, useEffect, useRef } from 'react'
|
||||||
|
import { faEraser } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import { MarkRenderSignature } from './Render'
|
||||||
|
import SignaturePad from 'signature_pad'
|
||||||
|
import { SIGNATURE_PAD_OPTIONS, SIGNATURE_PAD_SIZE } from '../../../utils/const'
|
||||||
|
import { BasicPoint } from 'signature_pad/dist/types/point'
|
||||||
|
import { MarkInputProps } from '../MarkStrategy'
|
||||||
|
import styles from './Input.module.scss'
|
||||||
|
|
||||||
|
export const MarkInputSignature = ({
|
||||||
|
value,
|
||||||
|
handler,
|
||||||
|
userMark
|
||||||
|
}: MarkInputProps) => {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||||
|
const signaturePad = useRef<SignaturePad | null>(null)
|
||||||
|
|
||||||
|
const update = useCallback(() => {
|
||||||
|
const data = signaturePad.current?.toData()
|
||||||
|
const reduced = data?.map((pg) => pg.points)
|
||||||
|
const json = JSON.stringify(reduced)
|
||||||
|
|
||||||
|
if (signaturePad.current && !signaturePad.current?.isEmpty()) {
|
||||||
|
handler(json)
|
||||||
|
} else {
|
||||||
|
handler('')
|
||||||
|
}
|
||||||
|
}, [handler])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEndStroke = () => {
|
||||||
|
update()
|
||||||
|
}
|
||||||
|
if (canvasRef.current) {
|
||||||
|
if (signaturePad.current === null) {
|
||||||
|
signaturePad.current = new SignaturePad(
|
||||||
|
canvasRef.current,
|
||||||
|
SIGNATURE_PAD_OPTIONS
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
signaturePad.current.addEventListener('endStroke', handleEndStroke)
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('endStroke', handleEndStroke)
|
||||||
|
}
|
||||||
|
}, [update])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (signaturePad.current) {
|
||||||
|
if (value) {
|
||||||
|
signaturePad.current.fromData(
|
||||||
|
JSON.parse(value).map((p: BasicPoint[]) => ({
|
||||||
|
points: p
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
signaturePad.current?.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
update()
|
||||||
|
}, [update, value])
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
signaturePad.current?.clear()
|
||||||
|
update()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrapper}>
|
||||||
|
<div
|
||||||
|
className={styles.relative}
|
||||||
|
style={{
|
||||||
|
width: SIGNATURE_PAD_SIZE.width,
|
||||||
|
height: SIGNATURE_PAD_SIZE.height
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<canvas
|
||||||
|
width={SIGNATURE_PAD_SIZE.width}
|
||||||
|
height={SIGNATURE_PAD_SIZE.height}
|
||||||
|
ref={canvasRef}
|
||||||
|
className={styles.canvas}
|
||||||
|
></canvas>
|
||||||
|
{typeof userMark?.mark !== 'undefined' && (
|
||||||
|
<div className={styles.absolute}>
|
||||||
|
<MarkRenderSignature
|
||||||
|
key={userMark.mark.value}
|
||||||
|
value={userMark.mark.value}
|
||||||
|
mark={userMark.mark}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={styles.reset}>
|
||||||
|
<FontAwesomeIcon size="sm" icon={faEraser} onClick={handleReset} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,9 @@
|
|||||||
|
.img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
27
src/components/MarkTypeStrategy/Signature/Render.tsx
Normal file
27
src/components/MarkTypeStrategy/Signature/Render.tsx
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import SignaturePad from 'signature_pad'
|
||||||
|
import { SIGNATURE_PAD_OPTIONS, SIGNATURE_PAD_SIZE } from '../../../utils'
|
||||||
|
import { BasicPoint } from 'signature_pad/dist/types/point'
|
||||||
|
import { MarkRenderProps } from '../MarkStrategy'
|
||||||
|
import styles from './Render.module.scss'
|
||||||
|
|
||||||
|
export const MarkRenderSignature = ({ value }: MarkRenderProps) => {
|
||||||
|
const [dataUrl, setDataUrl] = useState<string | undefined>()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (value) {
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = SIGNATURE_PAD_SIZE.width
|
||||||
|
canvas.height = SIGNATURE_PAD_SIZE.height
|
||||||
|
const pad = new SignaturePad(canvas, SIGNATURE_PAD_OPTIONS)
|
||||||
|
pad.fromData(
|
||||||
|
JSON.parse(value).map((p: BasicPoint[]) => ({
|
||||||
|
points: p
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
setDataUrl(canvas.toDataURL('image/webp'))
|
||||||
|
}
|
||||||
|
}, [value])
|
||||||
|
|
||||||
|
return dataUrl ? <img src={dataUrl} className={styles.img} alt="" /> : null
|
||||||
|
}
|
89
src/components/MarkTypeStrategy/Signature/index.tsx
Normal file
89
src/components/MarkTypeStrategy/Signature/index.tsx
Normal 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
|
||||||
|
}
|
||||||
|
}
|
19
src/components/MarkTypeStrategy/Text/Input.tsx
Normal file
19
src/components/MarkTypeStrategy/Text/Input.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { MarkInputProps } from '../MarkStrategy'
|
||||||
|
import styles from '../../MarkFormField/style.module.scss'
|
||||||
|
|
||||||
|
export const MarkInputText = ({
|
||||||
|
value,
|
||||||
|
handler,
|
||||||
|
placeholder
|
||||||
|
}: MarkInputProps) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
className={styles.input}
|
||||||
|
placeholder={placeholder}
|
||||||
|
onChange={(e) => {
|
||||||
|
handler(e.currentTarget.value)
|
||||||
|
}}
|
||||||
|
value={value}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
7
src/components/MarkTypeStrategy/Text/index.tsx
Normal file
7
src/components/MarkTypeStrategy/Text/index.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { MarkStrategy } from '../MarkStrategy'
|
||||||
|
import { MarkInputText } from './Input'
|
||||||
|
|
||||||
|
export const TextStrategy: MarkStrategy = {
|
||||||
|
input: MarkInputText,
|
||||||
|
render: ({ value }) => <>{value}</>
|
||||||
|
}
|
@ -4,6 +4,7 @@ import { FONT_SIZE, FONT_TYPE, inPx } from '../../utils/pdf.ts'
|
|||||||
import { useScale } from '../../hooks/useScale.tsx'
|
import { 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
@ -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>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
@ -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" />
|
||||||
)}
|
)}
|
||||||
|
@ -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
|
||||||
|
@ -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) : <>—</>}
|
{createdAt ? formatTimestamp(createdAt) : <>—</>}{' '}
|
||||||
|
{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) : <>—</>}
|
{completedAt ? formatTimestamp(completedAt) : <>—</>}
|
||||||
|
{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) => {
|
|||||||
) : (
|
) : (
|
||||||
<>—</>
|
<>—</>
|
||||||
)}
|
)}
|
||||||
|
{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}
|
||||||
|
@ -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
299
src/contexts/NDKContext.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
@ -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: []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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()
|
|
@ -1,4 +1 @@
|
|||||||
export * from './AuthController'
|
|
||||||
export * from './MetadataController'
|
|
||||||
export * from './NostrController'
|
export * from './NostrController'
|
||||||
export * from './RelayController'
|
|
||||||
|
@ -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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
@ -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
127
src/hooks/useAuth.ts
Normal 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
98
src/hooks/useDvm.ts
Normal 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
512
src/hooks/useNDK.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
13
src/hooks/useNDKContext.ts
Normal file
13
src/hooks/useNDKContext.ts
Normal 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
|
||||||
|
}
|
@ -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
|
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
@ -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
|
||||||
|
@ -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>
|
||||||
|
@ -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} />}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
@ -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.
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
@ -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>
|
||||||
|
@ -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 &&
|
||||||
|
@ -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 (
|
||||||
|
@ -12,6 +12,7 @@
|
|||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sectionIcon {
|
.sectionIcon {
|
||||||
|
@ -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>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
@ -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
|
||||||
|
@ -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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -53,10 +53,6 @@
|
|||||||
|
|
||||||
.mark {
|
.mark {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-dev='true'] {
|
[data-dev='true'] {
|
||||||
|
@ -1,16 +1,4 @@
|
|||||||
import { CreatePage } from '../pages/create'
|
|
||||||
import { HomePage } from '../pages/home'
|
|
||||||
import { LandingPage } from '../pages/landing'
|
|
||||||
import { ProfilePage } from '../pages/profile'
|
|
||||||
import { SettingsPage } from '../pages/settings/Settings'
|
|
||||||
import { CacheSettingsPage } from '../pages/settings/cache'
|
|
||||||
import { NostrLoginPage } from '../pages/settings/nostrLogin'
|
|
||||||
import { ProfileSettingsPage } from '../pages/settings/profile'
|
|
||||||
import { RelaysPage } from '../pages/settings/relays'
|
|
||||||
import { SignPage } from '../pages/sign'
|
|
||||||
import { VerifyPage } from '../pages/verify'
|
|
||||||
import { hexToNpub } from '../utils'
|
import { 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
103
src/routes/util.tsx
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
import { Route, RouteProps } from 'react-router-dom'
|
||||||
|
import { appPrivateRoutes, appPublicRoutes } from '.'
|
||||||
|
import { CreatePage } from '../pages/create'
|
||||||
|
import { HomePage } from '../pages/home'
|
||||||
|
import { LandingPage } from '../pages/landing'
|
||||||
|
import { ProfilePage } from '../pages/profile'
|
||||||
|
import { CacheSettingsPage } from '../pages/settings/cache'
|
||||||
|
import { NostrLoginPage } from '../pages/settings/nostrLogin'
|
||||||
|
import { ProfileSettingsPage } from '../pages/settings/profile'
|
||||||
|
import { RelaysPage } from '../pages/settings/relays'
|
||||||
|
import { SettingsPage } from '../pages/settings/Settings'
|
||||||
|
import { SignPage } from '../pages/sign'
|
||||||
|
import { VerifyPage } from '../pages/verify'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper type allows for extending react-router-dom's **RouteProps** with generic type
|
||||||
|
*/
|
||||||
|
type CustomRouteProps<T> = T &
|
||||||
|
Omit<RouteProps, 'children'> & {
|
||||||
|
children?: Array<CustomRouteProps<T>>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function maps over nested routes with optional condition for rendering
|
||||||
|
* @param {CustomRouteProps<T>[]} routes - routes list
|
||||||
|
* @param {RenderConditionCallbackFn} renderConditionCallbackFn - render condition callback (default true)
|
||||||
|
*/
|
||||||
|
export function recursiveRouteRenderer<T>(
|
||||||
|
routes?: CustomRouteProps<T>[],
|
||||||
|
renderConditionCallbackFn: (route: CustomRouteProps<T>) => boolean = () =>
|
||||||
|
true
|
||||||
|
) {
|
||||||
|
if (!routes) return null
|
||||||
|
|
||||||
|
// Callback allows us to pass arbitrary conditions for each route's rendering
|
||||||
|
// Skipping the callback will by default evaluate to true (show route)
|
||||||
|
return routes.map((route, index) =>
|
||||||
|
renderConditionCallbackFn(route) ? (
|
||||||
|
<Route
|
||||||
|
key={`${route.path}${index}`}
|
||||||
|
path={route.path}
|
||||||
|
element={route.element}
|
||||||
|
>
|
||||||
|
{recursiveRouteRenderer(route.children, renderConditionCallbackFn)}
|
||||||
|
</Route>
|
||||||
|
) : null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type PublicRouteProps = CustomRouteProps<{
|
||||||
|
hiddenWhenLoggedIn?: boolean
|
||||||
|
}>
|
||||||
|
|
||||||
|
export const publicRoutes: PublicRouteProps[] = [
|
||||||
|
{
|
||||||
|
path: appPublicRoutes.landingPage,
|
||||||
|
hiddenWhenLoggedIn: true,
|
||||||
|
element: <LandingPage />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: appPublicRoutes.profile,
|
||||||
|
element: <ProfilePage />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: `${appPublicRoutes.verify}/:id?`,
|
||||||
|
element: <VerifyPage />
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export const privateRoutes = [
|
||||||
|
{
|
||||||
|
path: appPrivateRoutes.homePage,
|
||||||
|
element: <HomePage />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: appPrivateRoutes.create,
|
||||||
|
element: <CreatePage />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: `${appPrivateRoutes.sign}/:id?`,
|
||||||
|
element: <SignPage />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: appPrivateRoutes.settings,
|
||||||
|
element: <SettingsPage />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: appPrivateRoutes.profileSettings,
|
||||||
|
element: <ProfileSettingsPage />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: appPrivateRoutes.cacheSettings,
|
||||||
|
element: <CacheSettingsPage />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: appPrivateRoutes.relays,
|
||||||
|
element: <RelaysPage />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: appPrivateRoutes.nostrLogin,
|
||||||
|
element: <NostrLoginPage />
|
||||||
|
}
|
||||||
|
]
|
@ -1 +1,2 @@
|
|||||||
export * from './cache'
|
export * from './cache'
|
||||||
|
export * from './signer'
|
||||||
|
143
src/services/signer/index.ts
Normal file
143
src/services/signer/index.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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'
|
||||||
|
|
||||||
|
@ -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'
|
||||||
|
|
||||||
|
@ -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
|
|
||||||
})
|
|
@ -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
|
|
@ -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
|
|
@ -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
17
src/store/user/action.ts
Normal 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
34
src/store/user/reducer.ts
Normal 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
23
src/store/user/types.ts
Normal 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
|
@ -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
|
|
||||||
})
|
|
@ -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
|
|
@ -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
|
|
@ -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[]
|
||||||
|
}
|
||||||
|
26
src/types/errors/MetaStorageError.ts
Normal file
26
src/types/errors/MetaStorageError.ts
Normal 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
5
src/types/event.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export enum KeyboardCode {
|
||||||
|
Escape = 'Escape',
|
||||||
|
Enter = 'Enter',
|
||||||
|
NumpadEnter = 'NumpadEnter'
|
||||||
|
}
|
@ -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'
|
||||||
|
@ -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
38
src/types/opentimestamps.d.ts
vendored
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
interface OpenTimestamps {
|
||||||
|
// Create a detached timestamp file from a buffer or file hash
|
||||||
|
DetachedTimestampFile: {
|
||||||
|
fromHash(op: any, hash: Uint8Array): any
|
||||||
|
fromBytes(op: any, buffer: Uint8Array): any
|
||||||
|
deserialize(buffer: any): any
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stamp the provided timestamp file and return a Promise
|
||||||
|
stamp(file: any): Promise<void>
|
||||||
|
|
||||||
|
// Verify the provided timestamp proof file
|
||||||
|
verify(
|
||||||
|
ots: string,
|
||||||
|
file: string
|
||||||
|
): Promise<TimestampVerficiationResponse | Record<string, never>>
|
||||||
|
|
||||||
|
// Other utilities or operations (like OpSHA256, serialization)
|
||||||
|
Ops: {
|
||||||
|
OpSHA256: any
|
||||||
|
OpSHA1?: any
|
||||||
|
}
|
||||||
|
|
||||||
|
Context: {
|
||||||
|
StreamSerialization: any
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load a timestamp file from a buffer
|
||||||
|
deserialize(bytes: Uint8Array): any
|
||||||
|
|
||||||
|
// Other potential methods based on repo functions
|
||||||
|
upgrade(file: any): Promise<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TimestampVerficiationResponse {
|
||||||
|
bitcoin: { timestamp: number; height: number }
|
||||||
|
}
|
@ -1,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
|
|
||||||
}
|
|
@ -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[]
|
||||||
|
1
src/types/system/index.d.ts
vendored
1
src/types/system/index.d.ts
vendored
@ -3,5 +3,6 @@ import type { WindowNostr } from 'nostr-tools/nip07'
|
|||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
nostr?: WindowNostr
|
nostr?: WindowNostr
|
||||||
|
OpenTimestamps: OpenTimestamps
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
44
src/utils/auth.ts
Normal file
44
src/utils/auth.ts
Normal 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: []
|
||||||
|
}
|
||||||
|
}
|
@ -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
|
||||||
|
}
|
||||||
|
228
src/utils/dvm.ts
228
src/utils/dvm.ts
@ -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))
|
|
||||||
}
|
|
||||||
}
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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'
|
||||||
|
@ -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
Loading…
x
Reference in New Issue
Block a user