Compare commits
121 Commits
hotfix-nos
...
staging
Author | SHA1 | Date | |
---|---|---|---|
4cb6f07a68 | |||
|
5b1654c341 | ||
|
02f651acc7 | ||
|
cd0e4523e1 | ||
|
76b1fa792c | ||
|
3a94fbc0ae | ||
|
e37f90d6db | ||
cc059f6cb4 | |||
|
de44370a96 | ||
|
dfa2832e8d | ||
|
9286e4304f | ||
aae11589a4 | |||
69f67fc812 | |||
38cd88fd86 | |||
dbcd54cec0 | |||
2d0212fd6c | |||
19b815e528 | |||
33e7fc7771 | |||
97d9857bef | |||
|
4465b8c3ac | ||
54047740f9 | |||
7f411f09a7 | |||
849e47da00 | |||
bb323be87c | |||
fd2f179273 | |||
4559f16d86 | |||
d6f92accb0 | |||
ee03cc545e | |||
70e525357c | |||
3eed2964a0 | |||
3a0f155010 | |||
1d1986f082 | |||
25764c7ab4 | |||
cc382f0726 | |||
9dd190d65b | |||
c3dacbe111 | |||
897daaa1fa | |||
ed90168e5d | |||
7f5fd4534f | |||
7f172178a1 | |||
1116867224 | |||
db9cf9d20c | |||
58c457b62c | |||
b6846c0006 | |||
8deb5bd7cd | |||
7b7f23a779 | |||
db4a202363 | |||
3a507246ca | |||
f09d9b2378 | |||
fe9f282984 | |||
aa4637dd0d | |||
23a04faad8 | |||
ad2ec070be | |||
d610c79cad | |||
b7bd922af3 | |||
f12aaf1c2b | |||
6c5ed3a69c | |||
862012e405 | |||
8689c7f753 | |||
a3c45b504e | |||
da30dba368 | |||
|
51e2ab6f8a | ||
|
9091bbc251 | ||
331759de5c | |||
995c7ce293 | |||
3d5006a715 | |||
f38344b9ac | |||
2b630c94b6 | |||
edeb22fb37 | |||
a2138f1de1 | |||
85bf907f54 | |||
3b447dcf6a | |||
532cdaed8e | |||
67d545de2f | |||
637e26bf35 | |||
110621a125 | |||
59e153595a | |||
0b79ebd909 | |||
e2dbed2b03 | |||
7c26edf84e | |||
2d7bb234f4 | |||
c4d50293ff | |||
89971fb176 | |||
acad24dc06 | |||
|
55abe814c9 | ||
21aa25a42a | |||
e33996c1f9 | |||
edbe708b65 | |||
7056ad3cd3 | |||
7dffe75bd7 | |||
8da2510a18 | |||
b92790ceed | |||
7f00f9e8bf | |||
07f1a15aa1 | |||
85bcfac2e0 | |||
edfe9a2954 | |||
633c23e459 | |||
2e1d48168a | |||
e05d3e53a2 | |||
d8d51be603 | |||
5f92906032 | |||
70cca9dd10 | |||
9bae5b9ba2 | |||
a1bf88d243 | |||
67c3c74515 | |||
f81f2b0523 | |||
182ef40d8d | |||
39934f59c3 | |||
dd97dfbaf0 | |||
dfdcb8419d | |||
f8a4480994 | |||
6ba3b6ec89 | |||
aa8214d015 | |||
e48a396990 | |||
79e14d45a1 | |||
64e8ebba85 | |||
5dc8d53503 | |||
86a16c13ce | |||
7c027825cd | |||
8e71592d88 | |||
75a715d002 |
@ -6,7 +6,7 @@ module.exports = {
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended'
|
||||
],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs', 'licenseChecker.cjs'],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs', 'licenseChecker.cjs', "*.min.js"],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['react-refresh'],
|
||||
rules: {
|
||||
|
@ -8,6 +8,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script src="/opentimestamps.min.js"></script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
2130
package-lock.json
generated
2130
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "sigit",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "0.0.0-beta",
|
||||
"type": "module",
|
||||
"homepage": "https://sigit.io/",
|
||||
"license": "AGPL-3.0-or-later ",
|
||||
@ -42,6 +42,7 @@
|
||||
"jszip": "3.10.1",
|
||||
"lodash": "4.17.21",
|
||||
"mui-file-input": "4.0.4",
|
||||
"nostr-login": "^1.6.6",
|
||||
"nostr-tools": "2.7.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfjs-dist": "^4.4.168",
|
||||
@ -56,6 +57,7 @@
|
||||
"react-singleton-hook": "^4.0.1",
|
||||
"react-toastify": "10.0.4",
|
||||
"redux": "5.0.1",
|
||||
"svgo": "^3.3.2",
|
||||
"tseep": "1.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -65,6 +67,7 @@
|
||||
"@types/pdfjs-dist": "^2.10.378",
|
||||
"@types/react": "^18.2.56",
|
||||
"@types/react-dom": "^18.2.19",
|
||||
"@types/svgo": "^3.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.0.2",
|
||||
"@typescript-eslint/parser": "^7.0.2",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
@ -77,6 +80,7 @@
|
||||
"ts-css-modules-vite-plugin": "1.0.20",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.1.4",
|
||||
"vite-plugin-node-polyfills": "^0.22.0",
|
||||
"vite-tsconfig-paths": "4.3.2"
|
||||
},
|
||||
"lint-staged": {
|
||||
|
@ -1,15 +1,15 @@
|
||||
{
|
||||
"names": {
|
||||
"_": "6bf1024c6336093b632db2da21b65a44c7d82830454fb4d75634ba281e161c90"
|
||||
},
|
||||
"relays": {
|
||||
"6bf1024c6336093b632db2da21b65a44c7d82830454fb4d75634ba281e161c90": [
|
||||
"wss://brb.io",
|
||||
"wss://nostr.v0l.io",
|
||||
"wss://nostr.coinos.io",
|
||||
"wss://rsslay.nostr.net",
|
||||
"wss://relay.current.fyi",
|
||||
"wss://nos.io"
|
||||
]
|
||||
}
|
||||
"names": {
|
||||
"_": "6bf1024c6336093b632db2da21b65a44c7d82830454fb4d75634ba281e161c90"
|
||||
},
|
||||
"relays": {
|
||||
"6bf1024c6336093b632db2da21b65a44c7d82830454fb4d75634ba281e161c90": [
|
||||
"wss://brb.io",
|
||||
"wss://nostr.v0l.io",
|
||||
"wss://nostr.coinos.io",
|
||||
"wss://rsslay.nostr.net",
|
||||
"wss://relay.current.fyi",
|
||||
"wss://nos.io"
|
||||
]
|
||||
}
|
||||
}
|
2
public/opentimestamps.min.js
vendored
Normal file
2
public/opentimestamps.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
21
src/App.tsx
21
src/App.tsx
@ -1,7 +1,7 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { useAppSelector } from './hooks/store'
|
||||
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { AuthController, NostrController } from './controllers'
|
||||
import { AuthController } from './controllers'
|
||||
import { MainLayout } from './layouts/Main'
|
||||
import {
|
||||
appPrivateRoutes,
|
||||
@ -10,12 +10,10 @@ import {
|
||||
publicRoutes,
|
||||
recursiveRouteRenderer
|
||||
} from './routes'
|
||||
import { State } from './store/rootReducer'
|
||||
import { getNsecBunkerDelegatedKey, saveNsecBunkerDelegatedKey } from './utils'
|
||||
import './App.scss'
|
||||
|
||||
const App = () => {
|
||||
const authState = useSelector((state: State) => state.auth)
|
||||
const authState = useAppSelector((state) => state.auth)
|
||||
|
||||
useEffect(() => {
|
||||
if (window.location.hostname === '0.0.0.0') {
|
||||
@ -25,23 +23,10 @@ const App = () => {
|
||||
window.location.hostname = 'localhost'
|
||||
}
|
||||
|
||||
generateBunkerDelegatedKey()
|
||||
|
||||
const authController = new AuthController()
|
||||
authController.checkSession()
|
||||
}, [])
|
||||
|
||||
const generateBunkerDelegatedKey = () => {
|
||||
const existingKey = getNsecBunkerDelegatedKey()
|
||||
|
||||
if (!existingKey) {
|
||||
const nostrController = NostrController.getInstance()
|
||||
const newDelegatedKey = nostrController.generateDelegatedKey()
|
||||
|
||||
saveNsecBunkerDelegatedKey(newDelegatedKey)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRootRedirect = () => {
|
||||
if (authState.loggedIn) return appPrivateRoutes.homePage
|
||||
const callbackPathEncoded = btoa(
|
||||
|
@ -9,58 +9,42 @@ import {
|
||||
} from '@mui/material'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import {
|
||||
setAuthState,
|
||||
setMetadataEvent,
|
||||
userLogOutAction
|
||||
} from '../../store/actions'
|
||||
import { State } from '../../store/rootReducer'
|
||||
import { Dispatch } from '../../store/store'
|
||||
import { useAppSelector } from '../../hooks/store'
|
||||
import Username from '../username'
|
||||
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { MetadataController, NostrController } from '../../controllers'
|
||||
import {
|
||||
appPublicRoutes,
|
||||
appPrivateRoutes,
|
||||
getProfileRoute
|
||||
} from '../../routes'
|
||||
import {
|
||||
clearAuthToken,
|
||||
hexToNpub,
|
||||
saveNsecBunkerDelegatedKey,
|
||||
shorten
|
||||
} from '../../utils'
|
||||
import { getProfileUsername, hexToNpub } from '../../utils'
|
||||
import styles from './style.module.scss'
|
||||
import { setUserRobotImage } from '../../store/userRobotImage/action'
|
||||
import { Container } from '../Container'
|
||||
import { ButtonIcon } from '../ButtonIcon'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faClose } from '@fortawesome/free-solid-svg-icons'
|
||||
import useMediaQuery from '@mui/material/useMediaQuery'
|
||||
import { useLogout } from '../../hooks/useLogout'
|
||||
|
||||
const metadataController = MetadataController.getInstance()
|
||||
import { launch as launchNostrLoginDialog } from 'nostr-login'
|
||||
|
||||
export const AppBar = () => {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const dispatch: Dispatch = useDispatch()
|
||||
|
||||
const logout = useLogout()
|
||||
const [username, setUsername] = useState('')
|
||||
const [userAvatar, setUserAvatar] = useState('')
|
||||
const [anchorElUser, setAnchorElUser] = useState<null | HTMLElement>(null)
|
||||
|
||||
const authState = useSelector((state: State) => state.auth)
|
||||
const metadataState = useSelector((state: State) => state.metadata)
|
||||
const userRobotImage = useSelector((state: State) => state.userRobotImage)
|
||||
const authState = useAppSelector((state) => state.auth)
|
||||
const metadataState = useAppSelector((state) => state.metadata)
|
||||
const userRobotImage = useAppSelector((state) => state.userRobotImage)
|
||||
|
||||
useEffect(() => {
|
||||
if (metadataState) {
|
||||
if (metadataState.content) {
|
||||
const { picture, display_name, name } = JSON.parse(
|
||||
metadataState.content
|
||||
)
|
||||
const profileMetadata = JSON.parse(metadataState.content)
|
||||
const { picture } = profileMetadata
|
||||
|
||||
if (picture || userRobotImage) {
|
||||
setUserAvatar(picture || userRobotImage)
|
||||
@ -70,7 +54,7 @@ export const AppBar = () => {
|
||||
? hexToNpub(authState.usersPubkey)
|
||||
: ''
|
||||
|
||||
setUsername(shorten(display_name || name || npub, 7))
|
||||
setUsername(getProfileUsername(npub, profileMetadata))
|
||||
} else {
|
||||
setUserAvatar(userRobotImage || '')
|
||||
setUsername('')
|
||||
@ -95,28 +79,7 @@ export const AppBar = () => {
|
||||
|
||||
const handleLogout = () => {
|
||||
handleCloseUserMenu()
|
||||
dispatch(
|
||||
setAuthState({
|
||||
keyPair: undefined,
|
||||
loggedIn: false,
|
||||
usersPubkey: undefined,
|
||||
loginMethod: undefined,
|
||||
nsecBunkerPubkey: undefined
|
||||
})
|
||||
)
|
||||
dispatch(setMetadataEvent(metadataController.getEmptyMetadataEvent()))
|
||||
dispatch(setUserRobotImage(null))
|
||||
|
||||
// clear authToken saved in local storage
|
||||
clearAuthToken()
|
||||
|
||||
dispatch(userLogOutAction())
|
||||
|
||||
// update nsecBunker delegated key after logout
|
||||
const nostrController = NostrController.getInstance()
|
||||
const newDelegatedKey = nostrController.generateDelegatedKey()
|
||||
saveNsecBunkerDelegatedKey(newDelegatedKey)
|
||||
|
||||
logout()
|
||||
navigate('/')
|
||||
}
|
||||
const isAuthenticated = authState?.loggedIn === true
|
||||
@ -133,8 +96,10 @@ export const AppBar = () => {
|
||||
<div className={styles.banner}>
|
||||
<Container>
|
||||
<div className={styles.bannerInner}>
|
||||
SIGit is currently Alpha software (available for internal
|
||||
testing), use at your own risk!
|
||||
<p className={styles.bannerText}>
|
||||
SIGit is currently Beta software (available for user experience
|
||||
testing), use at your own risk!
|
||||
</p>
|
||||
<Button
|
||||
aria-label={`close banner`}
|
||||
variant="text"
|
||||
@ -164,7 +129,7 @@ export const AppBar = () => {
|
||||
<Button
|
||||
startIcon={<ButtonIcon />}
|
||||
onClick={() => {
|
||||
navigate(appPublicRoutes.nostr)
|
||||
launchNostrLoginDialog()
|
||||
}}
|
||||
variant="contained"
|
||||
>
|
||||
|
@ -67,3 +67,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bannerText {
|
||||
margin-left: 54px;
|
||||
flex-grow: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
@ -22,11 +22,16 @@ import { useSigitMeta } from '../../hooks/useSigitMeta'
|
||||
import { extractFileExtensions } from '../../utils/file'
|
||||
|
||||
type SigitProps = {
|
||||
sigitCreateId: string
|
||||
meta: Meta
|
||||
parsedMeta: SigitCardDisplayInfo
|
||||
}
|
||||
|
||||
export const DisplaySigit = ({ meta, parsedMeta }: SigitProps) => {
|
||||
export const DisplaySigit = ({
|
||||
meta,
|
||||
parsedMeta,
|
||||
sigitCreateId: sigitCreateId
|
||||
}: SigitProps) => {
|
||||
const { title, createdAt, submittedBy, signers, signedStatus, isValid } =
|
||||
parsedMeta
|
||||
|
||||
@ -35,15 +40,19 @@ export const DisplaySigit = ({ meta, parsedMeta }: SigitProps) => {
|
||||
|
||||
return (
|
||||
<div className={styles.itemWrapper}>
|
||||
<Link
|
||||
to={
|
||||
signedStatus === SigitStatus.Complete
|
||||
? appPublicRoutes.verify
|
||||
: appPrivateRoutes.sign
|
||||
}
|
||||
state={{ meta }}
|
||||
className={styles.insetLink}
|
||||
></Link>
|
||||
{signedStatus === SigitStatus.Complete && (
|
||||
<Link
|
||||
to={appPublicRoutes.verify}
|
||||
state={{ meta }}
|
||||
className={styles.insetLink}
|
||||
></Link>
|
||||
)}
|
||||
{signedStatus !== SigitStatus.Complete && (
|
||||
<Link
|
||||
to={`${appPrivateRoutes.sign}/${sigitCreateId}`}
|
||||
className={styles.insetLink}
|
||||
></Link>
|
||||
)}
|
||||
<p className={`line-clamp-2 ${styles.title}`}>{title}</p>
|
||||
<div className={styles.users}>
|
||||
{submittedBy && (
|
||||
|
@ -9,71 +9,76 @@ import {
|
||||
} from '@mui/material'
|
||||
import styles from './style.module.scss'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { ProfileMetadata, User, UserRole } from '../../types'
|
||||
import { ProfileMetadata, User, UserRole, KeyboardCode } from '../../types'
|
||||
import { MouseState, PdfPage, DrawnField, DrawTool } from '../../types/drawing'
|
||||
import { truncate } from 'lodash'
|
||||
import { settleAllFullfilfedPromises, hexToNpub, npubToHex } from '../../utils'
|
||||
import { getSigitFile, SigitFile } from '../../utils/file'
|
||||
import { hexToNpub, npubToHex, getProfileUsername } from '../../utils'
|
||||
import { SigitFile } from '../../utils/file'
|
||||
import { getToolboxLabelByMarkType } from '../../utils/mark'
|
||||
import { FileDivider } from '../FileDivider'
|
||||
import { ExtensionFileBox } from '../ExtensionFileBox'
|
||||
import { FONT_SIZE, FONT_TYPE, inPx } from '../../utils/pdf'
|
||||
import { useScale } from '../../hooks/useScale'
|
||||
import { AvatarIconButton } from '../UserAvatarIconButton'
|
||||
import { LoadingSpinner } from '../LoadingSpinner'
|
||||
import { UserAvatar } from '../UserAvatar'
|
||||
import _ from 'lodash'
|
||||
|
||||
const DEFAULT_START_SIZE = {
|
||||
width: 140,
|
||||
height: 40
|
||||
} as const
|
||||
|
||||
interface HideSignersForDrawnField {
|
||||
[key: number]: boolean
|
||||
}
|
||||
|
||||
interface Props {
|
||||
selectedFiles: File[]
|
||||
users: User[]
|
||||
metadata: { [key: string]: ProfileMetadata }
|
||||
onDrawFieldsChange: (sigitFiles: SigitFile[]) => void
|
||||
sigitFiles: SigitFile[]
|
||||
setSigitFiles: React.Dispatch<React.SetStateAction<SigitFile[]>>
|
||||
selectedTool?: DrawTool
|
||||
}
|
||||
|
||||
export const DrawPDFFields = (props: Props) => {
|
||||
const { selectedFiles, selectedTool, onDrawFieldsChange, users } = props
|
||||
const { to, from } = useScale()
|
||||
const { selectedTool, sigitFiles, setSigitFiles, users } = props
|
||||
|
||||
const [sigitFiles, setSigitFiles] = useState<SigitFile[]>([])
|
||||
const [parsingPdf, setIsParsing] = useState<boolean>(false)
|
||||
const signers = users.filter((u) => u.role === UserRole.signer)
|
||||
const defaultSignerNpub = signers.length ? hexToNpub(signers[0].pubkey) : ''
|
||||
const [lastSigner, setLastSigner] = useState(defaultSignerNpub)
|
||||
const [hideSignersForDrawnField, setHideSignersForDrawnField] =
|
||||
useState<HideSignersForDrawnField>({})
|
||||
|
||||
/**
|
||||
* Return first pubkey that is present in the signers list
|
||||
* @param pubkeys
|
||||
* @returns available pubkey or empty string
|
||||
*/
|
||||
const getAvailableSigner = (...pubkeys: string[]) => {
|
||||
const availableSigner: string | undefined = pubkeys.find((pubkey) =>
|
||||
signers.some((s) => s.pubkey === npubToHex(pubkey))
|
||||
)
|
||||
return availableSigner || ''
|
||||
}
|
||||
|
||||
const { to, from } = useScale()
|
||||
|
||||
const [mouseState, setMouseState] = useState<MouseState>({
|
||||
clicked: false
|
||||
})
|
||||
|
||||
const [activeDrawField, setActiveDrawField] = useState<number>()
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedFiles) {
|
||||
/**
|
||||
* Reads the binary files and converts to internal file type
|
||||
* and sets to a state (adds images if it's a PDF)
|
||||
*/
|
||||
const parsePages = async () => {
|
||||
const files = await settleAllFullfilfedPromises(
|
||||
selectedFiles,
|
||||
getSigitFile
|
||||
)
|
||||
|
||||
setSigitFiles(files)
|
||||
}
|
||||
|
||||
setIsParsing(true)
|
||||
|
||||
parsePages().finally(() => {
|
||||
setIsParsing(false)
|
||||
})
|
||||
}
|
||||
}, [selectedFiles])
|
||||
|
||||
useEffect(() => {
|
||||
if (sigitFiles) onDrawFieldsChange(sigitFiles)
|
||||
}, [onDrawFieldsChange, sigitFiles])
|
||||
const [activeDrawnField, setActiveDrawnField] = useState<{
|
||||
fileIndex: number
|
||||
pageIndex: number
|
||||
drawnFieldIndex: number
|
||||
}>()
|
||||
const isActiveDrawnField = (
|
||||
fileIndex: number,
|
||||
pageIndex: number,
|
||||
drawnFieldIndex: number
|
||||
) =>
|
||||
activeDrawnField?.fileIndex === fileIndex &&
|
||||
activeDrawnField?.pageIndex === pageIndex &&
|
||||
activeDrawnField?.drawnFieldIndex === drawnFieldIndex
|
||||
|
||||
/**
|
||||
* Drawing events
|
||||
@ -100,7 +105,12 @@ export const DrawPDFFields = (props: Props) => {
|
||||
* @param event Pointer event
|
||||
* @param page PdfPage where press happened
|
||||
*/
|
||||
const handlePointerDown = (event: React.PointerEvent, page: PdfPage) => {
|
||||
const handlePointerDown = (
|
||||
event: React.PointerEvent,
|
||||
page: PdfPage,
|
||||
fileIndex: number,
|
||||
pageIndex: number
|
||||
) => {
|
||||
// Proceed only if left click
|
||||
if (event.button !== 0) return
|
||||
|
||||
@ -115,12 +125,17 @@ export const DrawPDFFields = (props: Props) => {
|
||||
top: to(page.width, y),
|
||||
width: event.pointerType === 'mouse' ? 0 : DEFAULT_START_SIZE.width,
|
||||
height: event.pointerType === 'mouse' ? 0 : DEFAULT_START_SIZE.height,
|
||||
counterpart: '',
|
||||
counterpart: getAvailableSigner(lastSigner, defaultSignerNpub),
|
||||
type: selectedTool.identifier
|
||||
}
|
||||
|
||||
page.drawnFields.push(newField)
|
||||
|
||||
setActiveDrawnField({
|
||||
fileIndex,
|
||||
pageIndex,
|
||||
drawnFieldIndex: page.drawnFields.length - 1
|
||||
})
|
||||
setMouseState((prev) => {
|
||||
return {
|
||||
...prev,
|
||||
@ -189,6 +204,8 @@ export const DrawPDFFields = (props: Props) => {
|
||||
*/
|
||||
const handleDrawnFieldPointerDown = (
|
||||
event: React.PointerEvent,
|
||||
fileIndex: number,
|
||||
pageIndex: number,
|
||||
drawnFieldIndex: number
|
||||
) => {
|
||||
event.stopPropagation()
|
||||
@ -198,7 +215,7 @@ export const DrawPDFFields = (props: Props) => {
|
||||
|
||||
const drawingRectangleCoords = getPointerCoordinates(event)
|
||||
|
||||
setActiveDrawField(drawnFieldIndex)
|
||||
setActiveDrawnField({ fileIndex, pageIndex, drawnFieldIndex })
|
||||
setMouseState({
|
||||
dragging: true,
|
||||
clicked: false,
|
||||
@ -207,6 +224,12 @@ export const DrawPDFFields = (props: Props) => {
|
||||
y: drawingRectangleCoords.y
|
||||
}
|
||||
})
|
||||
|
||||
// make signers dropdown visible
|
||||
setHideSignersForDrawnField((prev) => ({
|
||||
...prev,
|
||||
[drawnFieldIndex]: false
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
@ -254,13 +277,15 @@ export const DrawPDFFields = (props: Props) => {
|
||||
*/
|
||||
const handleResizePointerDown = (
|
||||
event: React.PointerEvent,
|
||||
fileIndex: number,
|
||||
pageIndex: number,
|
||||
drawnFieldIndex: number
|
||||
) => {
|
||||
// Proceed only if left click
|
||||
if (event.button !== 0) return
|
||||
event.stopPropagation()
|
||||
|
||||
setActiveDrawField(drawnFieldIndex)
|
||||
setActiveDrawnField({ fileIndex, pageIndex, drawnFieldIndex })
|
||||
setMouseState({
|
||||
resizing: true
|
||||
})
|
||||
@ -326,6 +351,32 @@ export const DrawPDFFields = (props: Props) => {
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles Escape button-down event and hides all signers dropdowns
|
||||
* @param event SyntheticEvent event
|
||||
*/
|
||||
const handleEscapeButtonDown = (event: React.SyntheticEvent) => {
|
||||
// get native event
|
||||
const { nativeEvent } = event
|
||||
|
||||
//check if event is a keyboard event
|
||||
if (nativeEvent instanceof KeyboardEvent) {
|
||||
// check if event code is Escape
|
||||
if (nativeEvent.code === KeyboardCode.Escape) {
|
||||
// hide all signers dropdowns
|
||||
const newHideSignersForDrawnField: HideSignersForDrawnField = {}
|
||||
|
||||
Object.keys(hideSignersForDrawnField).forEach((key) => {
|
||||
// Object.keys always returns an array of strings,
|
||||
// that is why unknown type is used below
|
||||
newHideSignersForDrawnField[key as unknown as number] = true
|
||||
})
|
||||
|
||||
setHideSignersForDrawnField(newHideSignersForDrawnField)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the pointer coordinates relative to a element in the `event` param
|
||||
* @param event PointerEvent
|
||||
@ -349,6 +400,7 @@ export const DrawPDFFields = (props: Props) => {
|
||||
rect
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the pdf pages and drawing elements
|
||||
*/
|
||||
@ -363,13 +415,15 @@ export const DrawPDFFields = (props: Props) => {
|
||||
<div
|
||||
key={pageIndex}
|
||||
className={`image-wrapper ${styles.pdfImageWrapper} ${selectedTool ? styles.drawing : ''}`}
|
||||
tabIndex={-1}
|
||||
onKeyDown={(event) => handleEscapeButtonDown(event)}
|
||||
>
|
||||
<img
|
||||
onPointerMove={(event) => {
|
||||
handlePointerMove(event, page)
|
||||
}}
|
||||
onPointerDown={(event) => {
|
||||
handlePointerDown(event, page)
|
||||
handlePointerDown(event, page, fileIndex, pageIndex)
|
||||
}}
|
||||
draggable="false"
|
||||
src={page.image}
|
||||
@ -381,7 +435,12 @@ export const DrawPDFFields = (props: Props) => {
|
||||
<div
|
||||
key={drawnFieldIndex}
|
||||
onPointerDown={(event) =>
|
||||
handleDrawnFieldPointerDown(event, drawnFieldIndex)
|
||||
handleDrawnFieldPointerDown(
|
||||
event,
|
||||
fileIndex,
|
||||
pageIndex,
|
||||
drawnFieldIndex
|
||||
)
|
||||
}
|
||||
onPointerMove={(event) => {
|
||||
handleDrawnFieldPointerMove(event, drawnField, page.width)
|
||||
@ -402,7 +461,11 @@ export const DrawPDFFields = (props: Props) => {
|
||||
touchAction: 'none',
|
||||
opacity:
|
||||
mouseState.dragging &&
|
||||
activeDrawField === drawnFieldIndex
|
||||
isActiveDrawnField(
|
||||
fileIndex,
|
||||
pageIndex,
|
||||
drawnFieldIndex
|
||||
)
|
||||
? 0.8
|
||||
: undefined
|
||||
}}
|
||||
@ -419,7 +482,12 @@ export const DrawPDFFields = (props: Props) => {
|
||||
</div>
|
||||
<span
|
||||
onPointerDown={(event) =>
|
||||
handleResizePointerDown(event, drawnFieldIndex)
|
||||
handleResizePointerDown(
|
||||
event,
|
||||
fileIndex,
|
||||
pageIndex,
|
||||
drawnFieldIndex
|
||||
)
|
||||
}
|
||||
onPointerMove={(event) => {
|
||||
handleResizePointerMove(event, drawnField, page.width)
|
||||
@ -428,7 +496,11 @@ export const DrawPDFFields = (props: Props) => {
|
||||
style={{
|
||||
background:
|
||||
mouseState.resizing &&
|
||||
activeDrawField === drawnFieldIndex
|
||||
isActiveDrawnField(
|
||||
fileIndex,
|
||||
pageIndex,
|
||||
drawnFieldIndex
|
||||
)
|
||||
? 'var(--primary-main)'
|
||||
: undefined
|
||||
}}
|
||||
@ -446,74 +518,94 @@ export const DrawPDFFields = (props: Props) => {
|
||||
>
|
||||
<Close fontSize="small" />
|
||||
</span>
|
||||
<div
|
||||
onPointerDown={handleUserSelectPointerDown}
|
||||
className={styles.userSelect}
|
||||
>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel id="counterparts">Counterpart</InputLabel>
|
||||
<Select
|
||||
value={drawnField.counterpart}
|
||||
onChange={(event) => {
|
||||
drawnField.counterpart = event.target.value
|
||||
refreshPdfFiles()
|
||||
}}
|
||||
labelId="counterparts"
|
||||
label="Counterparts"
|
||||
sx={{
|
||||
background: 'white'
|
||||
}}
|
||||
renderValue={(value) => renderCounterpartValue(value)}
|
||||
{!isActiveDrawnField(
|
||||
fileIndex,
|
||||
pageIndex,
|
||||
drawnFieldIndex
|
||||
) &&
|
||||
!!drawnField.counterpart && (
|
||||
<div className={styles.counterpartAvatar}>
|
||||
<UserAvatar
|
||||
pubkey={npubToHex(drawnField.counterpart)!}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isActiveDrawnField(
|
||||
fileIndex,
|
||||
pageIndex,
|
||||
drawnFieldIndex
|
||||
) &&
|
||||
(!hideSignersForDrawnField ||
|
||||
!hideSignersForDrawnField[drawnFieldIndex]) && (
|
||||
<div
|
||||
onPointerDown={handleUserSelectPointerDown}
|
||||
className={styles.userSelect}
|
||||
>
|
||||
{users
|
||||
.filter((u) => u.role === UserRole.signer)
|
||||
.map((user, index) => {
|
||||
const npub = hexToNpub(user.pubkey)
|
||||
let displayValue = truncate(npub, {
|
||||
length: 16
|
||||
})
|
||||
|
||||
const metadata = props.metadata[user.pubkey]
|
||||
|
||||
if (metadata) {
|
||||
displayValue = truncate(
|
||||
metadata.name ||
|
||||
metadata.display_name ||
|
||||
metadata.username ||
|
||||
npub,
|
||||
{
|
||||
length: 16
|
||||
}
|
||||
)
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel id="counterparts">
|
||||
Counterpart
|
||||
</InputLabel>
|
||||
<Select
|
||||
value={getAvailableSigner(drawnField.counterpart)}
|
||||
onChange={(event) => {
|
||||
drawnField.counterpart = event.target.value
|
||||
setLastSigner(event.target.value)
|
||||
refreshPdfFiles()
|
||||
}}
|
||||
labelId="counterparts"
|
||||
label="Counterparts"
|
||||
sx={{
|
||||
background: 'white'
|
||||
}}
|
||||
renderValue={(value) =>
|
||||
renderCounterpartValue(value)
|
||||
}
|
||||
>
|
||||
{signers.map((signer, index) => {
|
||||
const npub = hexToNpub(signer.pubkey)
|
||||
const metadata = props.metadata[signer.pubkey]
|
||||
const displayValue = getProfileUsername(
|
||||
npub,
|
||||
metadata
|
||||
)
|
||||
// make current signers dropdown visible
|
||||
if (
|
||||
hideSignersForDrawnField[drawnFieldIndex] ===
|
||||
undefined ||
|
||||
hideSignersForDrawnField[drawnFieldIndex] ===
|
||||
true
|
||||
) {
|
||||
setHideSignersForDrawnField((prev) => ({
|
||||
...prev,
|
||||
[drawnFieldIndex]: false
|
||||
}))
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
key={index}
|
||||
value={hexToNpub(user.pubkey)}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<AvatarIconButton
|
||||
src={metadata?.picture}
|
||||
hexKey={user.pubkey}
|
||||
aria-label={`account of user ${displayValue}`}
|
||||
color="inherit"
|
||||
sx={{
|
||||
padding: 0,
|
||||
'> img': {
|
||||
width: '30px',
|
||||
height: '30px'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ListItemIcon>
|
||||
<ListItemText>{displayValue}</ListItemText>
|
||||
</MenuItem>
|
||||
)
|
||||
})}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</div>
|
||||
return (
|
||||
<MenuItem key={index} value={npub}>
|
||||
<ListItemIcon>
|
||||
<AvatarIconButton
|
||||
src={metadata?.picture}
|
||||
hexKey={signer.pubkey}
|
||||
aria-label={`account of user ${displayValue}`}
|
||||
color="inherit"
|
||||
sx={{
|
||||
padding: 0,
|
||||
'> img': {
|
||||
width: '30px',
|
||||
height: '30px'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ListItemIcon>
|
||||
<ListItemText>{displayValue}</ListItemText>
|
||||
</MenuItem>
|
||||
)
|
||||
})}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@ -524,28 +616,19 @@ export const DrawPDFFields = (props: Props) => {
|
||||
)
|
||||
}
|
||||
|
||||
const renderCounterpartValue = (value: string) => {
|
||||
const user = users.find((u) => u.pubkey === npubToHex(value))
|
||||
if (user) {
|
||||
let displayValue = truncate(value, {
|
||||
length: 16
|
||||
})
|
||||
const renderCounterpartValue = (npub: string) => {
|
||||
let displayValue = _.truncate(npub, { length: 16 })
|
||||
|
||||
const metadata = props.metadata[user.pubkey]
|
||||
const signer = signers.find((u) => u.pubkey === npubToHex(npub))
|
||||
if (signer) {
|
||||
const metadata = props.metadata[signer.pubkey]
|
||||
displayValue = getProfileUsername(npub, metadata)
|
||||
|
||||
if (metadata) {
|
||||
displayValue = truncate(
|
||||
metadata.name || metadata.display_name || metadata.username || value,
|
||||
{
|
||||
length: 16
|
||||
}
|
||||
)
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className={styles.counterpartSelectValue}>
|
||||
<AvatarIconButton
|
||||
src={props.metadata[user.pubkey]?.picture}
|
||||
hexKey={npubToHex(user.pubkey) || undefined}
|
||||
src={props.metadata[signer.pubkey]?.picture}
|
||||
hexKey={signer.pubkey || undefined}
|
||||
sx={{
|
||||
padding: 0,
|
||||
marginRight: '6px',
|
||||
@ -556,19 +639,11 @@ export const DrawPDFFields = (props: Props) => {
|
||||
}}
|
||||
/>
|
||||
{displayValue}
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
if (parsingPdf) {
|
||||
return <LoadingSpinner variant="small" />
|
||||
}
|
||||
|
||||
if (!sigitFiles.length) {
|
||||
return ''
|
||||
return displayValue
|
||||
}
|
||||
|
||||
return (
|
||||
@ -589,7 +664,7 @@ export const DrawPDFFields = (props: Props) => {
|
||||
<ExtensionFileBox extension={file.extension} />
|
||||
)}
|
||||
</div>
|
||||
{i < selectedFiles.length - 1 && <FileDivider />}
|
||||
{i < sigitFiles.length - 1 && <FileDivider />}
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
|
@ -13,6 +13,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
.pdfImageWrapper:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
position: absolute;
|
||||
opacity: 0.5;
|
||||
@ -22,7 +26,7 @@
|
||||
.drawingRectangle {
|
||||
position: absolute;
|
||||
outline: 1px solid #01aaad;
|
||||
z-index: 50;
|
||||
z-index: 40;
|
||||
background-color: #01aaad4b;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
@ -84,3 +88,14 @@
|
||||
padding: 5px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.counterpartSelectValue {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.counterpartAvatar {
|
||||
img {
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
}
|
||||
}
|
||||
|
@ -121,7 +121,7 @@ export const Footer = () =>
|
||||
</Container>
|
||||
<div className={`${styles.borderTop} ${styles.credits}`}>
|
||||
Built by
|
||||
<a href="https://nostrdev.com/" target="_blank">
|
||||
<a rel="noopener" href="https://nostrdev.com/" target="_blank">
|
||||
Nostr Dev
|
||||
</a>{' '}
|
||||
2024.
|
||||
|
@ -7,7 +7,8 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: 9999;
|
||||
z-index: 50;
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
|
@ -1,20 +1,18 @@
|
||||
import { CurrentUserMark } from '../../types/mark.ts'
|
||||
import styles from './style.module.scss'
|
||||
|
||||
import { MARK_TYPE_TRANSLATION, NEXT, SIGN } from '../../utils/const.ts'
|
||||
import {
|
||||
findNextIncompleteCurrentUserMark,
|
||||
getToolboxLabelByMarkType,
|
||||
isCurrentUserMarksComplete,
|
||||
isCurrentValueLast
|
||||
} from '../../utils'
|
||||
import React, { useState } from 'react'
|
||||
import { MARK_TYPE_CONFIG } from '../getMarkComponents.tsx'
|
||||
|
||||
interface MarkFormFieldProps {
|
||||
currentUserMarks: CurrentUserMark[]
|
||||
handleCurrentUserMarkChange: (mark: CurrentUserMark) => void
|
||||
handleSelectedMarkValueChange: (
|
||||
event: React.ChangeEvent<HTMLInputElement>
|
||||
) => void
|
||||
handleSelectedMarkValueChange: (value: string) => void
|
||||
handleSubmit: (event: React.FormEvent<HTMLFormElement>) => void
|
||||
selectedMark: CurrentUserMark
|
||||
selectedMarkValue: string
|
||||
@ -32,7 +30,6 @@ const MarkFormField = ({
|
||||
handleCurrentUserMarkChange
|
||||
}: MarkFormFieldProps) => {
|
||||
const [displayActions, setDisplayActions] = useState(true)
|
||||
const getSubmitButtonText = () => (isReadyToSign() ? SIGN : NEXT)
|
||||
const isReadyToSign = () =>
|
||||
isCurrentUserMarksComplete(currentUserMarks) ||
|
||||
isCurrentValueLast(currentUserMarks, selectedMark, selectedMarkValue)
|
||||
@ -54,6 +51,9 @@ const MarkFormField = ({
|
||||
: handleCurrentUserMarkChange(findNext()!)
|
||||
}
|
||||
const toggleActions = () => setDisplayActions(!displayActions)
|
||||
const markLabel = getToolboxLabelByMarkType(selectedMark.mark.type)
|
||||
const { input: MarkInputComponent } =
|
||||
MARK_TYPE_CONFIG[selectedMark.mark.type] || {}
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.trigger}>
|
||||
@ -61,6 +61,7 @@ const MarkFormField = ({
|
||||
onClick={toggleActions}
|
||||
className={styles.triggerBtn}
|
||||
type="button"
|
||||
title="Toggle"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@ -78,22 +79,22 @@ const MarkFormField = ({
|
||||
<div className={styles.actionsWrapper}>
|
||||
<div className={styles.actionsTop}>
|
||||
<div className={styles.actionsTopInfo}>
|
||||
<p className={styles.actionsTopInfoText}>Add your signature</p>
|
||||
<p className={styles.actionsTopInfoText}>Add {markLabel}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.inputWrapper}>
|
||||
<form onSubmit={(e) => handleFormSubmit(e)}>
|
||||
<input
|
||||
className={styles.input}
|
||||
placeholder={
|
||||
MARK_TYPE_TRANSLATION[selectedMark.mark.type.valueOf()]
|
||||
}
|
||||
onChange={handleSelectedMarkValueChange}
|
||||
value={selectedMarkValue}
|
||||
/>
|
||||
{typeof MarkInputComponent !== 'undefined' && (
|
||||
<MarkInputComponent
|
||||
value={selectedMarkValue}
|
||||
placeholder={markLabel}
|
||||
handler={handleSelectedMarkValueChange}
|
||||
userMark={selectedMark}
|
||||
/>
|
||||
)}
|
||||
<div className={styles.actionsBottom}>
|
||||
<button type="submit" className={styles.submitButton}>
|
||||
{getSubmitButtonText()}
|
||||
NEXT
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
@ -15,7 +15,7 @@
|
||||
left: 5px;
|
||||
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
z-index: 40;
|
||||
|
||||
button {
|
||||
transition: ease 0.2s;
|
||||
|
44
src/components/MarkInputs/Signature.module.scss
Normal file
44
src/components/MarkInputs/Signature.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;
|
||||
}
|
||||
|
||||
.canvas {
|
||||
outline: 1px solid black;
|
||||
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/MarkInputs/Signature.tsx
Normal file
101
src/components/MarkInputs/Signature.tsx
Normal file
@ -0,0 +1,101 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { MarkInputProps } from '../../types/mark'
|
||||
import { getOptimizedPaths, optimizeSVG } from '../../utils'
|
||||
import { faEraser } from '@fortawesome/free-solid-svg-icons'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import styles from './Signature.module.scss'
|
||||
import { MarkRenderSignature } from '../MarkRender/Signature'
|
||||
|
||||
export const MarkInputSignature = ({
|
||||
value,
|
||||
handler,
|
||||
userMark
|
||||
}: MarkInputProps) => {
|
||||
const location = userMark?.mark.location
|
||||
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const [drawing, setDrawing] = useState(false)
|
||||
const [paths, setPaths] = useState<string[]>(value ? JSON.parse(value) : [])
|
||||
|
||||
function update() {
|
||||
if (location && paths) {
|
||||
if (paths.length) {
|
||||
const optimizedSvg = optimizeSVG(location, paths)
|
||||
const extractedPaths = getOptimizedPaths(optimizedSvg)
|
||||
handler(JSON.stringify(extractedPaths))
|
||||
} else {
|
||||
handler('')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handlePointerDown = (event: React.PointerEvent) => {
|
||||
const rect = event.currentTarget.getBoundingClientRect()
|
||||
const x = event.clientX - rect.left
|
||||
const y = event.clientY - rect.top
|
||||
|
||||
const ctx = canvasRef.current?.getContext('2d')
|
||||
ctx?.beginPath()
|
||||
ctx?.moveTo(x, y)
|
||||
setPaths([...paths, `M ${x} ${y}`])
|
||||
setDrawing(true)
|
||||
}
|
||||
const handlePointerUp = () => {
|
||||
setDrawing(false)
|
||||
update()
|
||||
const ctx = canvasRef.current?.getContext('2d')
|
||||
ctx?.clearRect(0, 0, canvasRef.current!.width, canvasRef.current!.height)
|
||||
}
|
||||
const handlePointerMove = (event: React.PointerEvent) => {
|
||||
if (!drawing) return
|
||||
const ctx = canvasRef.current?.getContext('2d')
|
||||
const rect = canvasRef.current?.getBoundingClientRect()
|
||||
const x = event.clientX - rect!.left
|
||||
const y = event.clientY - rect!.top
|
||||
|
||||
ctx?.lineTo(x, y)
|
||||
ctx?.stroke()
|
||||
|
||||
// Collect the path data
|
||||
setPaths((prevPaths) => {
|
||||
const newPaths = [...prevPaths]
|
||||
newPaths[newPaths.length - 1] += ` L ${x} ${y}`
|
||||
return newPaths
|
||||
})
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
setPaths([])
|
||||
setDrawing(false)
|
||||
update()
|
||||
const ctx = canvasRef.current?.getContext('2d')
|
||||
ctx?.clearRect(0, 0, canvasRef.current!.width, canvasRef.current!.height)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.relative}>
|
||||
<canvas
|
||||
height={location?.height}
|
||||
width={location?.width}
|
||||
ref={canvasRef}
|
||||
className={styles.canvas}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerOut={handlePointerUp}
|
||||
></canvas>
|
||||
{typeof userMark?.mark !== 'undefined' && (
|
||||
<div className={styles.absolute}>
|
||||
<MarkRenderSignature value={value} mark={userMark.mark} />
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.reset}>
|
||||
<FontAwesomeIcon size="sm" icon={faEraser} onClick={handleReset} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
19
src/components/MarkInputs/Text.tsx
Normal file
19
src/components/MarkInputs/Text.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import { MarkInputProps } from '../../types/mark'
|
||||
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}
|
||||
/>
|
||||
)
|
||||
}
|
13
src/components/MarkRender/Signature.tsx
Normal file
13
src/components/MarkRender/Signature.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { MarkRenderProps } from '../../types/mark'
|
||||
|
||||
export const MarkRenderSignature = ({ value, mark }: MarkRenderProps) => {
|
||||
const paths = value ? JSON.parse(value) : []
|
||||
|
||||
return (
|
||||
<svg viewBox={`0 0 ${mark.location.width} ${mark.location.height}`}>
|
||||
{paths.map((path: string) => (
|
||||
<path d={path} stroke="black" fill="none" />
|
||||
))}
|
||||
</svg>
|
||||
)
|
||||
}
|
@ -4,6 +4,7 @@ import { FONT_SIZE, FONT_TYPE, inPx } from '../../utils/pdf.ts'
|
||||
import { useScale } from '../../hooks/useScale.tsx'
|
||||
import { forwardRef } from 'react'
|
||||
import { npubToHex } from '../../utils/nostr.ts'
|
||||
import { MARK_TYPE_CONFIG } from '../getMarkComponents.tsx'
|
||||
|
||||
interface PdfMarkItemProps {
|
||||
userMark: CurrentUserMark
|
||||
@ -27,6 +28,8 @@ const PdfMarkItem = forwardRef<HTMLDivElement, PdfMarkItemProps>(
|
||||
const getMarkValue = () =>
|
||||
isEdited() ? selectedMarkValue : userMark.currentValue
|
||||
const { from } = useScale()
|
||||
const { render: MarkRenderComponent } =
|
||||
MARK_TYPE_CONFIG[userMark.mark.type] || {}
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
@ -47,7 +50,9 @@ const PdfMarkItem = forwardRef<HTMLDivElement, PdfMarkItemProps>(
|
||||
fontSize: inPx(from(pageWidth, FONT_SIZE))
|
||||
}}
|
||||
>
|
||||
{getMarkValue()}
|
||||
{typeof MarkRenderComponent !== 'undefined' && (
|
||||
<MarkRenderComponent value={getMarkValue()} mark={userMark.mark} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -117,8 +117,7 @@ const PdfMarking = (props: PdfMarkingProps) => {
|
||||
// setCurrentUserMarks(updatedCurrentUserMarks)
|
||||
// }
|
||||
|
||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setSelectedMarkValue(event.target.value)
|
||||
const handleChange = (value: string) => setSelectedMarkValue(value)
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -6,7 +6,7 @@
|
||||
|
||||
.otherUserMarksDisplay {
|
||||
position: absolute;
|
||||
z-index: 50;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
@ -5,7 +5,7 @@ import { AvatarIconButton } from '../UserAvatarIconButton'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useProfileMetadata } from '../../hooks/useProfileMetadata'
|
||||
import { Tooltip } from '@mui/material'
|
||||
import { shorten } from '../../utils'
|
||||
import { getProfileUsername } from '../../utils'
|
||||
import { TooltipChild } from '../TooltipChild'
|
||||
|
||||
interface UserAvatarProps {
|
||||
@ -22,7 +22,7 @@ export const UserAvatar = ({
|
||||
isNameVisible = false
|
||||
}: UserAvatarProps) => {
|
||||
const profile = useProfileMetadata(pubkey)
|
||||
const name = profile?.display_name || profile?.name || shorten(pubkey)
|
||||
const name = getProfileUsername(pubkey, profile)
|
||||
const image = profile?.picture
|
||||
|
||||
return (
|
||||
|
@ -4,6 +4,7 @@ import {
|
||||
fromUnixTimestamp,
|
||||
hexToNpub,
|
||||
npubToHex,
|
||||
SigitStatus,
|
||||
SignStatus
|
||||
} from '../../utils'
|
||||
import { useSigitMeta } from '../../hooks/useSigitMeta'
|
||||
@ -15,15 +16,16 @@ import {
|
||||
faCalendar,
|
||||
faCalendarCheck,
|
||||
faCalendarPlus,
|
||||
faCheck,
|
||||
faClock,
|
||||
faEye,
|
||||
faFile,
|
||||
faFileCircleExclamation
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
import { getExtensionIconLabel } from '../getExtensionIconLabel'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { State } from '../../store/rootReducer'
|
||||
import { useAppSelector } from '../../hooks/store'
|
||||
import { DisplaySigner } from '../DisplaySigner'
|
||||
import { Meta } from '../../types'
|
||||
import { Meta, OpenTimestamp } from '../../types'
|
||||
import { extractFileExtensions } from '../../utils/file'
|
||||
import { UserAvatar } from '../UserAvatar'
|
||||
|
||||
@ -43,15 +45,61 @@ export const UsersDetails = ({ meta }: UsersDetailsProps) => {
|
||||
completedAt,
|
||||
parsedSignatureEvents,
|
||||
signedStatus,
|
||||
isValid
|
||||
isValid,
|
||||
id,
|
||||
timestamps
|
||||
} = useSigitMeta(meta)
|
||||
const { usersPubkey } = useSelector((state: State) => state.auth)
|
||||
const { usersPubkey } = useAppSelector((state) => state.auth)
|
||||
const userCanSign =
|
||||
typeof usersPubkey !== 'undefined' &&
|
||||
signers.includes(hexToNpub(usersPubkey))
|
||||
|
||||
const { extensions, isSame } = extractFileExtensions(Object.keys(fileHashes))
|
||||
|
||||
const isTimestampVerified = (
|
||||
timestamps: OpenTimestamp[],
|
||||
nostrId: string
|
||||
): boolean => {
|
||||
const matched = timestamps.find((t) => t.nostrId === nostrId)
|
||||
return !!(matched && matched.verification)
|
||||
}
|
||||
|
||||
const getOpenTimestampsInfo = (
|
||||
timestamps: OpenTimestamp[],
|
||||
nostrId: string
|
||||
) => {
|
||||
if (isTimestampVerified(timestamps, nostrId)) {
|
||||
return <FontAwesomeIcon className={styles.ticket} icon={faCheck} />
|
||||
} else {
|
||||
return <FontAwesomeIcon className={styles.ticket} icon={faClock} />
|
||||
}
|
||||
}
|
||||
|
||||
const getCompletedOpenTimestampsInfo = (timestamp: OpenTimestamp) => {
|
||||
if (timestamp.verification) {
|
||||
return <FontAwesomeIcon className={styles.ticket} icon={faCheck} />
|
||||
} else {
|
||||
return <FontAwesomeIcon className={styles.ticket} icon={faClock} />
|
||||
}
|
||||
}
|
||||
|
||||
const getTimestampTooltipTitle = (label: string, isVerified: boolean) => {
|
||||
return `${label} / Open Timestamp ${isVerified ? 'Verified' : 'Pending'}`
|
||||
}
|
||||
|
||||
const isUserSignatureTimestampVerified = () => {
|
||||
if (
|
||||
userCanSign &&
|
||||
hexToNpub(usersPubkey) in parsedSignatureEvents &&
|
||||
timestamps &&
|
||||
timestamps.length > 0
|
||||
) {
|
||||
const nostrId = parsedSignatureEvents[hexToNpub(usersPubkey)].id
|
||||
return isTimestampVerified(timestamps, nostrId)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
return submittedBy ? (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.section}>
|
||||
@ -116,19 +164,35 @@ export const UsersDetails = ({ meta }: UsersDetailsProps) => {
|
||||
<p>Details</p>
|
||||
|
||||
<Tooltip
|
||||
title={'Publication date'}
|
||||
title={getTimestampTooltipTitle(
|
||||
'Publication date',
|
||||
!!(timestamps && id && isTimestampVerified(timestamps, id))
|
||||
)}
|
||||
placement="top"
|
||||
arrow
|
||||
disableInteractive
|
||||
>
|
||||
<span className={styles.detailsItem}>
|
||||
<FontAwesomeIcon icon={faCalendarPlus} />{' '}
|
||||
{createdAt ? formatTimestamp(createdAt) : <>—</>}
|
||||
{createdAt ? formatTimestamp(createdAt) : <>—</>}{' '}
|
||||
{timestamps &&
|
||||
timestamps.length > 0 &&
|
||||
id &&
|
||||
getOpenTimestampsInfo(timestamps, id)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip
|
||||
title={'Completion date'}
|
||||
title={getTimestampTooltipTitle(
|
||||
'Completion date',
|
||||
!!(
|
||||
signedStatus === SigitStatus.Complete &&
|
||||
completedAt &&
|
||||
timestamps &&
|
||||
timestamps.length > 0 &&
|
||||
timestamps[timestamps.length - 1].verification
|
||||
)
|
||||
)}
|
||||
placement="top"
|
||||
arrow
|
||||
disableInteractive
|
||||
@ -136,13 +200,26 @@ export const UsersDetails = ({ meta }: UsersDetailsProps) => {
|
||||
<span className={styles.detailsItem}>
|
||||
<FontAwesomeIcon icon={faCalendarCheck} />{' '}
|
||||
{completedAt ? formatTimestamp(completedAt) : <>—</>}
|
||||
{signedStatus === SigitStatus.Complete &&
|
||||
completedAt &&
|
||||
timestamps &&
|
||||
timestamps.length > 0 && (
|
||||
<span className={styles.ticket}>
|
||||
{getCompletedOpenTimestampsInfo(
|
||||
timestamps[timestamps.length - 1]
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
{/* User signed date */}
|
||||
{userCanSign ? (
|
||||
<Tooltip
|
||||
title={'Your signature date'}
|
||||
title={getTimestampTooltipTitle(
|
||||
'Your signature date',
|
||||
isUserSignatureTimestampVerified()
|
||||
)}
|
||||
placement="top"
|
||||
arrow
|
||||
disableInteractive
|
||||
@ -162,6 +239,16 @@ export const UsersDetails = ({ meta }: UsersDetailsProps) => {
|
||||
) : (
|
||||
<>—</>
|
||||
)}
|
||||
{hexToNpub(usersPubkey) in parsedSignatureEvents &&
|
||||
timestamps &&
|
||||
timestamps.length > 0 && (
|
||||
<span className={styles.ticket}>
|
||||
{getOpenTimestampsInfo(
|
||||
timestamps,
|
||||
parsedSignatureEvents[hexToNpub(usersPubkey)].id
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
|
@ -31,8 +31,6 @@
|
||||
padding: 5px;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: start;
|
||||
|
||||
> :first-child {
|
||||
padding: 5px;
|
||||
@ -44,3 +42,7 @@
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.ticket {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
16
src/components/getMarkComponents.tsx
Normal file
16
src/components/getMarkComponents.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import { MarkType } from '../types/drawing'
|
||||
import { MarkConfigs } from '../types/mark'
|
||||
import { MarkInputSignature } from './MarkInputs/Signature'
|
||||
import { MarkInputText } from './MarkInputs/Text'
|
||||
import { MarkRenderSignature } from './MarkRender/Signature'
|
||||
|
||||
export const MARK_TYPE_CONFIG: MarkConfigs = {
|
||||
[MarkType.TEXT]: {
|
||||
input: MarkInputText,
|
||||
render: ({ value }) => <>{value}</>
|
||||
},
|
||||
[MarkType.SIGNATURE]: {
|
||||
input: MarkInputSignature,
|
||||
render: MarkRenderSignature
|
||||
}
|
||||
}
|
@ -1,6 +1,5 @@
|
||||
import { Typography } from '@mui/material'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { State } from '../store/rootReducer'
|
||||
import { useAppSelector } from '../hooks/store'
|
||||
|
||||
import styles from './username.module.scss'
|
||||
import { AvatarIconButton } from './UserAvatarIconButton'
|
||||
@ -16,7 +15,7 @@ type Props = {
|
||||
* Clicking will open the menu.
|
||||
*/
|
||||
const Username = ({ username, avatarContent, handleClick }: Props) => {
|
||||
const hexKey = useSelector((state: State) => state.auth.usersPubkey)
|
||||
const hexKey = useAppSelector((state) => state.auth.usersPubkey)
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
|
@ -31,7 +31,7 @@ export class AuthController {
|
||||
/**
|
||||
* Function will authenticate user by signing an auth event
|
||||
* which is done by calling the sign() function, where appropriate
|
||||
* method will be chosen (extension, nsecbunker or keys)
|
||||
* method will be chosen (extension or keys)
|
||||
*
|
||||
* @param pubkey of the user trying to login
|
||||
* @returns url to redirect if authentication successfull
|
||||
@ -57,12 +57,15 @@ export class AuthController {
|
||||
|
||||
// Nostr uses unix timestamps
|
||||
const timestamp = unixNow()
|
||||
const { hostname } = window.location
|
||||
const { href } = window.location
|
||||
|
||||
const authEvent: EventTemplate = {
|
||||
kind: 27235,
|
||||
tags: [],
|
||||
content: `${hostname}-${timestamp}`,
|
||||
tags: [
|
||||
['u', href],
|
||||
['method', 'GET']
|
||||
],
|
||||
content: '',
|
||||
created_at: timestamp
|
||||
}
|
||||
|
||||
@ -83,7 +86,7 @@ export class AuthController {
|
||||
return Promise.resolve(appPrivateRoutes.relays)
|
||||
}
|
||||
|
||||
if (store.getState().auth?.loggedIn) {
|
||||
if (store.getState().auth.loggedIn) {
|
||||
if (!compareObjects(store.getState().relays?.map, relayMap.map))
|
||||
store.dispatch(setRelayMapAction(relayMap.map))
|
||||
}
|
||||
|
@ -1,194 +1,24 @@
|
||||
import NDK, {
|
||||
NDKEvent,
|
||||
NDKNip46Signer,
|
||||
NDKPrivateKeySigner,
|
||||
NDKUser,
|
||||
NostrEvent
|
||||
} from '@nostr-dev-kit/ndk'
|
||||
import {
|
||||
Event,
|
||||
EventTemplate,
|
||||
UnsignedEvent,
|
||||
finalizeEvent,
|
||||
nip04,
|
||||
nip19,
|
||||
nip44
|
||||
} from 'nostr-tools'
|
||||
import { EventTemplate, UnsignedEvent } from 'nostr-tools'
|
||||
import { WindowNostr } from 'nostr-tools/nip07'
|
||||
import { EventEmitter } from 'tseep'
|
||||
import { updateNsecbunkerPubkey } from '../store/actions'
|
||||
import { AuthState, LoginMethods } from '../store/auth/types'
|
||||
import store from '../store/store'
|
||||
import { SignedEvent } from '../types'
|
||||
import { getNsecBunkerDelegatedKey, verifySignedEvent } from '../utils'
|
||||
import { LoginMethodContext } from '../services/LoginMethodStrategy/loginMethodContext'
|
||||
|
||||
export class NostrController extends EventEmitter {
|
||||
private static instance: NostrController
|
||||
|
||||
private bunkerNDK: NDK | undefined
|
||||
private remoteSigner: NDKNip46Signer | undefined
|
||||
|
||||
private constructor() {
|
||||
super()
|
||||
}
|
||||
|
||||
private getNostrObject = () => {
|
||||
// fix: this is not picking up type declaration from src/system/index.d.ts
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if (window.nostr) return window.nostr as any
|
||||
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 nsecBunkerInit = async (relays: string[]) => {
|
||||
// Don't reinstantiate bunker NDK if exists with same relays
|
||||
if (
|
||||
this.bunkerNDK &&
|
||||
this.bunkerNDK.explicitRelayUrls?.length === relays.length &&
|
||||
this.bunkerNDK.explicitRelayUrls?.every((relay) => relays.includes(relay))
|
||||
)
|
||||
return
|
||||
|
||||
this.bunkerNDK = new NDK({
|
||||
explicitRelayUrls: relays
|
||||
})
|
||||
|
||||
try {
|
||||
await this.bunkerNDK
|
||||
.connect(2000)
|
||||
.then(() => {
|
||||
console.log(
|
||||
`Successfully connected to the nsecBunker relays: ${relays.join(
|
||||
','
|
||||
)}`
|
||||
)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(
|
||||
`Error connecting to the nsecBunker relays: ${relays.join(
|
||||
','
|
||||
)} ${err}`
|
||||
)
|
||||
})
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates nSecBunker signer instance for the given npub
|
||||
* Or if npub omitted it will return existing signer
|
||||
* If neither, error will be thrown
|
||||
* @param npub nPub / public key in hex format
|
||||
* @returns nsecBunker Signer instance
|
||||
*/
|
||||
public createNsecBunkerSigner = async (
|
||||
npub: string | undefined
|
||||
): Promise<NDKNip46Signer> => {
|
||||
const nsecBunkerDelegatedKey = getNsecBunkerDelegatedKey()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!nsecBunkerDelegatedKey) {
|
||||
reject('nsecBunker delegated key is not found in the browser.')
|
||||
return
|
||||
}
|
||||
const localSigner = new NDKPrivateKeySigner(nsecBunkerDelegatedKey)
|
||||
|
||||
if (!npub) {
|
||||
if (this.remoteSigner) resolve(this.remoteSigner)
|
||||
|
||||
const npubFromStorage = (store.getState().auth as AuthState)
|
||||
.nsecBunkerPubkey
|
||||
|
||||
if (npubFromStorage) {
|
||||
npub = npubFromStorage
|
||||
} else {
|
||||
reject(
|
||||
'No signer instance present, no npub provided by user or found in the browser.'
|
||||
)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
store.dispatch(updateNsecbunkerPubkey(npub))
|
||||
}
|
||||
|
||||
// Pubkey of a key pair stored in nsecbunker that will be used to sign event with
|
||||
const appPubkeyOrToken = npub.includes('npub')
|
||||
? npub
|
||||
: nip19.npubEncode(npub)
|
||||
|
||||
/**
|
||||
* When creating and NDK instance we create new connection to the relay
|
||||
* To prevent too much connections and hitting rate limits, if npub against which we sign
|
||||
* we will reuse existing instance. Otherwise we will create new NDK and signer instance.
|
||||
*/
|
||||
if (!this.remoteSigner || this.remoteSigner?.remotePubkey !== npub) {
|
||||
this.remoteSigner = new NDKNip46Signer(
|
||||
this.bunkerNDK!,
|
||||
appPubkeyOrToken,
|
||||
localSigner
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* when nsecbunker-delegated-key is regenerated we have to reinitialize the remote signer
|
||||
*/
|
||||
if (this.remoteSigner.localSigner !== localSigner) {
|
||||
this.remoteSigner = new NDKNip46Signer(
|
||||
this.bunkerNDK!,
|
||||
appPubkeyOrToken,
|
||||
localSigner
|
||||
)
|
||||
}
|
||||
|
||||
resolve(this.remoteSigner)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs the nostr event and returns the sig and id or full raw nostr event
|
||||
* @param npub stored in nsecBunker to sign with
|
||||
* @param event to be signed
|
||||
* @param returnFullEvent whether to return full raw nostr event or just SIG and ID values
|
||||
*/
|
||||
public signWithNsecBunker = async (
|
||||
npub: string | undefined,
|
||||
event: NostrEvent,
|
||||
returnFullEvent = true
|
||||
): Promise<{ id: string; sig: string } | NostrEvent> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.createNsecBunkerSigner(npub)
|
||||
.then(async (signer) => {
|
||||
const ndkEvent = new NDKEvent(undefined, event)
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
reject('Timeout occurred while waiting for event signing')
|
||||
}, 60000) // 60000 ms (1 min) = 1000 * 60
|
||||
|
||||
await ndkEvent.sign(signer).catch((err) => {
|
||||
clearTimeout(timeout)
|
||||
reject(err)
|
||||
return
|
||||
})
|
||||
|
||||
clearTimeout(timeout)
|
||||
|
||||
if (returnFullEvent) {
|
||||
resolve(ndkEvent.rawEvent())
|
||||
} else {
|
||||
resolve({
|
||||
id: ndkEvent.id,
|
||||
sig: ndkEvent.sig!
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
public static getInstance(): NostrController {
|
||||
if (!NostrController.instance) {
|
||||
NostrController.instance = new NostrController()
|
||||
@ -206,60 +36,11 @@ export class NostrController extends EventEmitter {
|
||||
*/
|
||||
nip44Encrypt = async (receiver: string, content: string) => {
|
||||
// Retrieve the current login method from the application's redux state.
|
||||
const loginMethod = (store.getState().auth as AuthState).loginMethod
|
||||
const loginMethod = store.getState().auth.loginMethod
|
||||
const context = new LoginMethodContext(loginMethod)
|
||||
|
||||
// Handle encryption when the login method is via an extension.
|
||||
if (loginMethod === LoginMethods.extension) {
|
||||
const nostr = this.getNostrObject()
|
||||
|
||||
// Check if the nostr object supports NIP-44 encryption.
|
||||
if (!nostr.nip44) {
|
||||
throw new Error(
|
||||
`Your nostr extension does not support nip44 encryption & decryption`
|
||||
)
|
||||
}
|
||||
|
||||
// Encrypt the content using NIP-44 provided by the nostr extension.
|
||||
const encrypted = await nostr.nip44.encrypt(receiver, content)
|
||||
return encrypted as string
|
||||
}
|
||||
|
||||
// Handle encryption when the login method is via a private key.
|
||||
if (loginMethod === LoginMethods.privateKey) {
|
||||
const keys = (store.getState().auth as AuthState).keyPair
|
||||
|
||||
// Check if the private and public key pair is available.
|
||||
if (!keys) {
|
||||
throw new Error(
|
||||
`Login method is ${LoginMethods.privateKey} but private & public key pair is not found.`
|
||||
)
|
||||
}
|
||||
|
||||
// Decode the private key.
|
||||
const { private: nsec } = keys
|
||||
const privateKey = nip19.decode(nsec).data as Uint8Array
|
||||
|
||||
// Generate the conversation key using NIP-44 utilities.
|
||||
const nip44ConversationKey = nip44.v2.utils.getConversationKey(
|
||||
privateKey,
|
||||
receiver
|
||||
)
|
||||
|
||||
// Encrypt the content using the generated conversation key.
|
||||
const encrypted = nip44.v2.encrypt(content, nip44ConversationKey)
|
||||
|
||||
return encrypted
|
||||
}
|
||||
|
||||
// Throw an error if the login method is nsecBunker (not supported).
|
||||
if (loginMethod === LoginMethods.nsecBunker) {
|
||||
throw new Error(
|
||||
`nip44 encryption is not yet supported for login method '${LoginMethods.nsecBunker}'`
|
||||
)
|
||||
}
|
||||
|
||||
// Throw an error if the login method is undefined or unsupported.
|
||||
throw new Error('Login method is undefined')
|
||||
return await context.nip44Encrypt(receiver, content)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -272,180 +53,33 @@ export class NostrController extends EventEmitter {
|
||||
*/
|
||||
nip44Decrypt = async (sender: string, content: string) => {
|
||||
// Retrieve the current login method from the application's redux state.
|
||||
const loginMethod = (store.getState().auth as AuthState).loginMethod
|
||||
const loginMethod = store.getState().auth.loginMethod
|
||||
const context = new LoginMethodContext(loginMethod)
|
||||
|
||||
// Handle decryption when the login method is via an extension.
|
||||
if (loginMethod === LoginMethods.extension) {
|
||||
const nostr = this.getNostrObject()
|
||||
|
||||
// Check if the nostr object supports NIP-44 decryption.
|
||||
if (!nostr.nip44) {
|
||||
throw new Error(
|
||||
`Your nostr extension does not support nip44 encryption & decryption`
|
||||
)
|
||||
}
|
||||
|
||||
// Decrypt the content using NIP-44 provided by the nostr extension.
|
||||
const decrypted = await nostr.nip44.decrypt(sender, content)
|
||||
return decrypted as string
|
||||
}
|
||||
|
||||
// Handle decryption when the login method is via a private key.
|
||||
if (loginMethod === LoginMethods.privateKey) {
|
||||
const keys = (store.getState().auth as AuthState).keyPair
|
||||
|
||||
// Check if the private and public key pair is available.
|
||||
if (!keys) {
|
||||
throw new Error(
|
||||
`Login method is ${LoginMethods.privateKey} but private & public key pair is not found.`
|
||||
)
|
||||
}
|
||||
|
||||
// Decode the private key.
|
||||
const { private: nsec } = keys
|
||||
const privateKey = nip19.decode(nsec).data as Uint8Array
|
||||
|
||||
// Generate the conversation key using NIP-44 utilities.
|
||||
const nip44ConversationKey = nip44.v2.utils.getConversationKey(
|
||||
privateKey,
|
||||
sender
|
||||
)
|
||||
|
||||
// Decrypt the content using the generated conversation key.
|
||||
const decrypted = nip44.v2.decrypt(content, nip44ConversationKey)
|
||||
|
||||
return decrypted
|
||||
}
|
||||
|
||||
// Throw an error if the login method is nsecBunker (not supported).
|
||||
if (loginMethod === LoginMethods.nsecBunker) {
|
||||
throw new Error(
|
||||
`nip44 decryption is not yet supported for login method '${LoginMethods.nsecBunker}'`
|
||||
)
|
||||
}
|
||||
|
||||
// Throw an error if the login method is undefined or unsupported.
|
||||
throw new Error('Login method is undefined')
|
||||
// Handle decryption
|
||||
return await context.nip44Decrypt(sender, content)
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs an event with private key (if it is present in local storage) or
|
||||
* with browser extension (if it is present) or
|
||||
* with nSecBunker instance.
|
||||
* with browser extension (if it is present)
|
||||
* @param event - unsigned nostr event.
|
||||
* @returns - a promised that is resolved with signed nostr event.
|
||||
*/
|
||||
signEvent = async (
|
||||
event: UnsignedEvent | EventTemplate
|
||||
): Promise<SignedEvent> => {
|
||||
const loginMethod = (store.getState().auth as AuthState).loginMethod
|
||||
const loginMethod = store.getState().auth.loginMethod
|
||||
const context = new LoginMethodContext(loginMethod)
|
||||
|
||||
if (!loginMethod) {
|
||||
return Promise.reject('No login method found in the browser storage')
|
||||
}
|
||||
|
||||
if (loginMethod === LoginMethods.nsecBunker) {
|
||||
// Check if nsecBunker is available
|
||||
if (!this.bunkerNDK) {
|
||||
return Promise.reject(
|
||||
`Login method is ${loginMethod} but bunkerNDK is not created`
|
||||
)
|
||||
}
|
||||
|
||||
if (!this.remoteSigner) {
|
||||
return Promise.reject(
|
||||
`Login method is ${loginMethod} but bunkerNDK is not created`
|
||||
)
|
||||
}
|
||||
|
||||
const signedEvent = await this.signWithNsecBunker(
|
||||
'',
|
||||
event as NostrEvent
|
||||
).catch((err) => {
|
||||
throw err
|
||||
})
|
||||
|
||||
return Promise.resolve(signedEvent as SignedEvent)
|
||||
} else if (loginMethod === LoginMethods.privateKey) {
|
||||
const keys = (store.getState().auth as AuthState).keyPair
|
||||
|
||||
if (!keys) {
|
||||
return Promise.reject(
|
||||
`Login method is ${loginMethod}, but keys are not found`
|
||||
)
|
||||
}
|
||||
|
||||
const { private: nsec } = keys
|
||||
const privateKey = nip19.decode(nsec).data as Uint8Array
|
||||
|
||||
const signedEvent = finalizeEvent(event, privateKey)
|
||||
|
||||
verifySignedEvent(signedEvent)
|
||||
|
||||
return Promise.resolve(signedEvent)
|
||||
} else if (loginMethod === LoginMethods.extension) {
|
||||
const nostr = this.getNostrObject()
|
||||
|
||||
return (await nostr
|
||||
.signEvent(event as NostrEvent)
|
||||
.catch((err: unknown) => {
|
||||
console.log('Error while signing event: ', err)
|
||||
|
||||
throw err
|
||||
})) as Event
|
||||
} else {
|
||||
return Promise.reject(
|
||||
`We could not sign the event, none of the signing methods are available`
|
||||
)
|
||||
}
|
||||
return await context.signEvent(event)
|
||||
}
|
||||
|
||||
nip04Encrypt = async (receiver: string, content: string): Promise<string> => {
|
||||
const loginMethod = (store.getState().auth as AuthState).loginMethod
|
||||
const loginMethod = store.getState().auth.loginMethod
|
||||
const context = new LoginMethodContext(loginMethod)
|
||||
|
||||
if (loginMethod === LoginMethods.extension) {
|
||||
const nostr = this.getNostrObject()
|
||||
|
||||
if (!nostr.nip04) {
|
||||
throw new Error(
|
||||
`Your nostr extension does not support nip04 encryption & decryption`
|
||||
)
|
||||
}
|
||||
|
||||
const encrypted = await nostr.nip04.encrypt(receiver, content)
|
||||
return encrypted
|
||||
}
|
||||
|
||||
if (loginMethod === LoginMethods.privateKey) {
|
||||
const keys = (store.getState().auth as AuthState).keyPair
|
||||
|
||||
if (!keys) {
|
||||
throw new Error(
|
||||
`Login method is ${LoginMethods.privateKey} but private & public key pair is not found.`
|
||||
)
|
||||
}
|
||||
|
||||
const { private: nsec } = keys
|
||||
const privateKey = nip19.decode(nsec).data as Uint8Array
|
||||
|
||||
const encrypted = await nip04.encrypt(privateKey, receiver, content)
|
||||
return encrypted
|
||||
}
|
||||
|
||||
if (loginMethod === LoginMethods.nsecBunker) {
|
||||
const user = new NDKUser({ pubkey: receiver })
|
||||
|
||||
this.remoteSigner?.on('authUrl', (authUrl) => {
|
||||
this.emit('nsecbunker-auth', authUrl)
|
||||
})
|
||||
|
||||
if (!this.remoteSigner) throw new Error('Remote signer is undefined.')
|
||||
const encrypted = await this.remoteSigner.encrypt(user, content)
|
||||
|
||||
return encrypted
|
||||
}
|
||||
|
||||
throw new Error('Login method is undefined')
|
||||
return await context.nip04Encrypt(receiver, content)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -456,51 +90,10 @@ export class NostrController extends EventEmitter {
|
||||
* @returns A promise that resolves to the decrypted content.
|
||||
*/
|
||||
nip04Decrypt = async (sender: string, content: string): Promise<string> => {
|
||||
const loginMethod = (store.getState().auth as AuthState).loginMethod
|
||||
const loginMethod = store.getState().auth.loginMethod
|
||||
const context = new LoginMethodContext(loginMethod)
|
||||
|
||||
if (loginMethod === LoginMethods.extension) {
|
||||
const nostr = this.getNostrObject()
|
||||
|
||||
if (!nostr.nip04) {
|
||||
throw new Error(
|
||||
`Your nostr extension does not support nip04 encryption & decryption`
|
||||
)
|
||||
}
|
||||
|
||||
const decrypted = await nostr.nip04.decrypt(sender, content)
|
||||
return decrypted
|
||||
}
|
||||
|
||||
if (loginMethod === LoginMethods.privateKey) {
|
||||
const keys = (store.getState().auth as AuthState).keyPair
|
||||
|
||||
if (!keys) {
|
||||
throw new Error(
|
||||
`Login method is ${LoginMethods.privateKey} but private & public key pair is not found.`
|
||||
)
|
||||
}
|
||||
|
||||
const { private: nsec } = keys
|
||||
const privateKey = nip19.decode(nsec).data as Uint8Array
|
||||
|
||||
const decrypted = await nip04.decrypt(privateKey, sender, content)
|
||||
return decrypted
|
||||
}
|
||||
|
||||
if (loginMethod === LoginMethods.nsecBunker) {
|
||||
const user = new NDKUser({ pubkey: sender })
|
||||
|
||||
this.remoteSigner?.on('authUrl', (authUrl) => {
|
||||
this.emit('nsecbunker-auth', authUrl)
|
||||
})
|
||||
|
||||
if (!this.remoteSigner) throw new Error('Remote signer is undefined.')
|
||||
const decrypted = await this.remoteSigner.decrypt(user, content)
|
||||
|
||||
return decrypted
|
||||
}
|
||||
|
||||
throw new Error('Login method is undefined')
|
||||
return await context.nip04Decrypt(sender, content)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -523,12 +116,4 @@ export class NostrController extends EventEmitter {
|
||||
|
||||
return Promise.resolve(pubKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates NDK Private Signer
|
||||
* @returns nSecBunker delegated key
|
||||
*/
|
||||
generateDelegatedKey = (): string => {
|
||||
return NDKPrivateKeySigner.generate().privateKey!
|
||||
}
|
||||
}
|
||||
|
26
src/hooks/useLogout.tsx
Normal file
26
src/hooks/useLogout.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import { logout as nostrLogout } from 'nostr-login'
|
||||
import { clear } from '../utils/localStorage'
|
||||
import { userLogOutAction } from '../store/actions'
|
||||
import { LoginMethod } from '../store/auth/types'
|
||||
import { useAppDispatch, useAppSelector } from './store'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
export const useLogout = () => {
|
||||
const loginMethod = useAppSelector((state) => state.auth?.loginMethod)
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const logout = useCallback(() => {
|
||||
// Log out of the nostr-login
|
||||
if (loginMethod === LoginMethod.nostrLogin) {
|
||||
nostrLogout()
|
||||
}
|
||||
|
||||
// Reset redux state with the logout
|
||||
dispatch(userLogOutAction())
|
||||
|
||||
// Clear the local storage states
|
||||
clear()
|
||||
}, [dispatch, loginMethod])
|
||||
|
||||
return logout
|
||||
}
|
@ -3,7 +3,8 @@ import {
|
||||
CreateSignatureEventContent,
|
||||
DocSignatureEvent,
|
||||
Meta,
|
||||
SignedEventContent
|
||||
SignedEventContent,
|
||||
OpenTimestamp
|
||||
} from '../types'
|
||||
import { Mark } from '../types/mark'
|
||||
import {
|
||||
@ -18,7 +19,6 @@ import { toast } from 'react-toastify'
|
||||
import { verifyEvent } from 'nostr-tools'
|
||||
import { Event } from 'nostr-tools'
|
||||
import store from '../store/store'
|
||||
import { AuthState } from '../store/auth/types'
|
||||
import { NostrController } from '../controllers'
|
||||
import { MetaParseError } from '../types/errors/MetaParseError'
|
||||
|
||||
@ -59,6 +59,8 @@ export interface FlatMeta
|
||||
signersStatus: {
|
||||
[signer: `npub1${string}`]: SignStatus
|
||||
}
|
||||
|
||||
timestamps?: OpenTimestamp[]
|
||||
}
|
||||
|
||||
/**
|
||||
@ -143,7 +145,7 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
|
||||
if (meta.keys) {
|
||||
const { sender, keys } = meta.keys
|
||||
// Retrieve the user's public key from the state
|
||||
const usersPubkey = (store.getState().auth as AuthState).usersPubkey!
|
||||
const usersPubkey = store.getState().auth.usersPubkey!
|
||||
const usersNpub = hexToNpub(usersPubkey)
|
||||
|
||||
// Check if the user's public key is in the keys object
|
||||
@ -163,7 +165,6 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
|
||||
setEncryptionKey(decrypted)
|
||||
}
|
||||
}
|
||||
|
||||
// Temp. map to hold events and signers
|
||||
const parsedSignatureEventsMap = new Map<
|
||||
`npub1${string}`,
|
||||
@ -277,6 +278,7 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
|
||||
createSignature: meta?.createSignature,
|
||||
docSignatures: meta?.docSignatures,
|
||||
keys: meta?.keys,
|
||||
timestamps: meta?.timestamps,
|
||||
isValid,
|
||||
kind,
|
||||
tags,
|
||||
|
@ -27,7 +27,7 @@ body {
|
||||
position: fixed;
|
||||
top: 80px;
|
||||
right: 20px;
|
||||
z-index: 100;
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
#root {
|
||||
|
@ -1,87 +1,160 @@
|
||||
import { Event, kinds } from 'nostr-tools'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import { Event, getPublicKey, kinds, nip19 } from 'nostr-tools'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Outlet, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { AppBar } from '../components/AppBar/AppBar'
|
||||
import { LoadingSpinner } from '../components/LoadingSpinner'
|
||||
import { MetadataController, NostrController } from '../controllers'
|
||||
import {
|
||||
AuthController,
|
||||
MetadataController,
|
||||
NostrController
|
||||
} from '../controllers'
|
||||
import {
|
||||
restoreState,
|
||||
setAuthState,
|
||||
setMetadataEvent,
|
||||
updateKeyPair,
|
||||
updateLoginMethod,
|
||||
updateNostrLoginAuthMethod,
|
||||
updateUserAppData
|
||||
} from '../store/actions'
|
||||
import { LoginMethods } from '../store/auth/types'
|
||||
import { State } from '../store/rootReducer'
|
||||
import { Dispatch } from '../store/store'
|
||||
import { setUserRobotImage } from '../store/userRobotImage/action'
|
||||
import {
|
||||
clearAuthToken,
|
||||
clearState,
|
||||
getRoboHashPicture,
|
||||
getUsersAppData,
|
||||
loadState,
|
||||
saveNsecBunkerDelegatedKey,
|
||||
subscribeForSigits
|
||||
} from '../utils'
|
||||
import { useAppSelector } from '../hooks'
|
||||
import { useAppDispatch, useAppSelector } from '../hooks'
|
||||
import styles from './style.module.scss'
|
||||
import { useLogout } from '../hooks/useLogout'
|
||||
import { LoginMethod } from '../store/auth/types'
|
||||
import { NostrLoginAuthOptions } from 'nostr-login/dist/types'
|
||||
import { init as initNostrLogin } from 'nostr-login'
|
||||
|
||||
export const MainLayout = () => {
|
||||
const dispatch: Dispatch = useDispatch()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const dispatch = useAppDispatch()
|
||||
const logout = useLogout()
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState(`Loading App`)
|
||||
const authState = useSelector((state: State) => state.auth)
|
||||
const isLoggedIn = useAppSelector((state) => state.auth?.loggedIn)
|
||||
const authState = useAppSelector((state) => state.auth)
|
||||
const usersAppData = useAppSelector((state) => state.userAppData)
|
||||
|
||||
// Ref to track if `subscribeForSigits` has been called
|
||||
const hasSubscribed = useRef(false)
|
||||
|
||||
const navigateAfterLogin = (path: string) => {
|
||||
const callbackPath = searchParams.get('callbackPath')
|
||||
|
||||
if (callbackPath) {
|
||||
// base64 decoded path
|
||||
const path = atob(callbackPath)
|
||||
navigate(path)
|
||||
return
|
||||
}
|
||||
|
||||
navigate(path)
|
||||
}
|
||||
|
||||
const login = useCallback(async () => {
|
||||
const nostrController = NostrController.getInstance()
|
||||
const authController = new AuthController()
|
||||
const pubkey = await nostrController.capturePublicKey()
|
||||
|
||||
dispatch(updateLoginMethod(LoginMethod.nostrLogin))
|
||||
|
||||
const redirectPath =
|
||||
await authController.authAndGetMetadataAndRelaysMap(pubkey)
|
||||
|
||||
if (redirectPath) {
|
||||
navigateAfterLogin(redirectPath)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dispatch])
|
||||
|
||||
useEffect(() => {
|
||||
// Developer login with ?nsec= (not recommended)
|
||||
const nsec = searchParams.get('nsec')
|
||||
if (!nsec) return
|
||||
|
||||
// Clear nsec from the url immediately
|
||||
searchParams.delete('nsec')
|
||||
setSearchParams(searchParams)
|
||||
|
||||
if (!authState?.loggedIn) {
|
||||
if (!nsec.startsWith('nsec')) {
|
||||
console.error('Invalid format, use private key (nsec)')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const privateKey = nip19.decode(nsec).data as Uint8Array
|
||||
if (!privateKey) {
|
||||
console.error('Failed to convert the private key.')
|
||||
return
|
||||
}
|
||||
|
||||
const publickey = getPublicKey(privateKey)
|
||||
|
||||
dispatch(
|
||||
updateKeyPair({
|
||||
private: nsec,
|
||||
public: publickey
|
||||
})
|
||||
)
|
||||
dispatch(updateLoginMethod(LoginMethod.privateKey))
|
||||
|
||||
const authController = new AuthController()
|
||||
authController
|
||||
.authAndGetMetadataAndRelaysMap(publickey)
|
||||
.catch((err) => {
|
||||
console.error('Error occurred in authentication: ' + err)
|
||||
return null
|
||||
})
|
||||
} catch (err) {
|
||||
console.error(`Error decoding the nsec. ${err}`)
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dispatch, searchParams])
|
||||
|
||||
useEffect(() => {
|
||||
const handleNostrAuth = (_: string, opts: NostrLoginAuthOptions) => {
|
||||
if (opts.type === 'login' || opts.type === 'signup') {
|
||||
dispatch(updateNostrLoginAuthMethod(opts.method))
|
||||
login()
|
||||
} else if (opts.type === 'logout') {
|
||||
// Clear `subscribeForSigits` as called after the logout
|
||||
hasSubscribed.current = false
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize the nostr-login
|
||||
initNostrLogin({
|
||||
methods: ['connect', 'extension', 'local'],
|
||||
noBanner: true,
|
||||
onAuth: handleNostrAuth
|
||||
}).catch((error) => {
|
||||
console.error('Failed to initialize Nostr-Login', error)
|
||||
})
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dispatch])
|
||||
|
||||
useEffect(() => {
|
||||
const metadataController = MetadataController.getInstance()
|
||||
|
||||
const logout = () => {
|
||||
dispatch(
|
||||
setAuthState({
|
||||
keyPair: undefined,
|
||||
loggedIn: false,
|
||||
usersPubkey: undefined,
|
||||
loginMethod: undefined,
|
||||
nsecBunkerPubkey: undefined
|
||||
})
|
||||
)
|
||||
|
||||
dispatch(setMetadataEvent(metadataController.getEmptyMetadataEvent()))
|
||||
|
||||
// clear authToken saved in local storage
|
||||
clearAuthToken()
|
||||
clearState()
|
||||
|
||||
// update nsecBunker delegated key
|
||||
const newDelegatedKey =
|
||||
NostrController.getInstance().generateDelegatedKey()
|
||||
saveNsecBunkerDelegatedKey(newDelegatedKey)
|
||||
}
|
||||
|
||||
const restoredState = loadState()
|
||||
if (restoredState) {
|
||||
dispatch(restoreState(restoredState))
|
||||
|
||||
const { loggedIn, loginMethod, usersPubkey, nsecBunkerRelays } =
|
||||
restoredState.auth
|
||||
const { loggedIn, loginMethod, usersPubkey } = restoredState.auth
|
||||
|
||||
if (loggedIn) {
|
||||
if (!loginMethod || !usersPubkey) return logout()
|
||||
|
||||
if (loginMethod === LoginMethods.nsecBunker) {
|
||||
if (!nsecBunkerRelays) return logout()
|
||||
|
||||
const nostrController = NostrController.getInstance()
|
||||
nostrController.nsecBunkerInit(nsecBunkerRelays).then(() => {
|
||||
nostrController.createNsecBunkerSigner(usersPubkey)
|
||||
})
|
||||
}
|
||||
|
||||
// Update user profile metadata, old state might be outdated
|
||||
const handleMetadataEvent = (event: Event) => {
|
||||
dispatch(setMetadataEvent(event))
|
||||
}
|
||||
@ -101,10 +174,14 @@ export const MainLayout = () => {
|
||||
} else {
|
||||
setIsLoading(false)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dispatch])
|
||||
|
||||
/**
|
||||
* Subscribe for the sigits
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (authState.loggedIn && usersAppData) {
|
||||
if (authState && isLoggedIn && usersAppData) {
|
||||
const pubkey = authState.usersPubkey || authState.keyPair?.public
|
||||
|
||||
if (pubkey && !hasSubscribed.current) {
|
||||
@ -116,7 +193,7 @@ export const MainLayout = () => {
|
||||
hasSubscribed.current = true
|
||||
}
|
||||
}
|
||||
}, [authState, usersAppData])
|
||||
}, [authState, isLoggedIn, usersAppData])
|
||||
|
||||
/**
|
||||
* When authState change user logged in / or app reloaded
|
||||
@ -124,7 +201,7 @@ export const MainLayout = () => {
|
||||
* so that avatar will be consistent across the app when kind 0 is empty
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (authState && authState.loggedIn) {
|
||||
if (authState && isLoggedIn) {
|
||||
const pubkey = authState.usersPubkey || authState.keyPair?.public
|
||||
|
||||
if (pubkey) {
|
||||
@ -141,7 +218,8 @@ export const MainLayout = () => {
|
||||
})
|
||||
.finally(() => setIsLoading(false))
|
||||
}
|
||||
}, [authState, dispatch])
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dispatch, isLoggedIn])
|
||||
|
||||
if (isLoading) return <LoadingSpinner desc={loadingSpinnerDesc} />
|
||||
|
||||
|
@ -33,7 +33,7 @@ export const Modal = () => {
|
||||
{ to: appPublicRoutes.register, title: 'Register', label: 'Register' },
|
||||
{
|
||||
to: appPublicRoutes.nostr,
|
||||
title: 'Login',
|
||||
title: 'Nostr Login',
|
||||
sx: { padding: '10px' },
|
||||
label: <img src={nostrImage} width="25" alt="nostr logo" height="25" />
|
||||
}
|
||||
|
@ -8,20 +8,21 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import { DndProvider, useDrag, useDrop } from 'react-dnd'
|
||||
import { MultiBackend } from 'react-dnd-multi-backend'
|
||||
import { HTML5toTouch } from 'rdndmb-html5-to-touch'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { useAppSelector } from '../../hooks/store'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { toast } from 'react-toastify'
|
||||
import { LoadingSpinner } from '../../components/LoadingSpinner'
|
||||
import { UserAvatar } from '../../components/UserAvatar'
|
||||
import { MetadataController, NostrController } from '../../controllers'
|
||||
import { appPrivateRoutes } from '../../routes'
|
||||
import { State } from '../../store/rootReducer'
|
||||
import {
|
||||
CreateSignatureEventContent,
|
||||
Meta,
|
||||
ProfileMetadata,
|
||||
SignedEvent,
|
||||
User,
|
||||
UserRole
|
||||
UserRole,
|
||||
KeyboardCode
|
||||
} from '../../types'
|
||||
import {
|
||||
encryptArrayBuffer,
|
||||
@ -39,7 +40,8 @@ import {
|
||||
signEventForMetaFile,
|
||||
updateUsersAppData,
|
||||
uploadToFileStorage,
|
||||
DEFAULT_TOOLBOX
|
||||
DEFAULT_TOOLBOX,
|
||||
settleAllFullfilfedPromises
|
||||
} from '../../utils'
|
||||
import { Container } from '../../components/Container'
|
||||
import fileListStyles from '../../components/FileList/style.module.scss'
|
||||
@ -60,7 +62,9 @@ import {
|
||||
faTrash,
|
||||
faUpload
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
import { SigitFile } from '../../utils/file.ts'
|
||||
import { getSigitFile, SigitFile } from '../../utils/file.ts'
|
||||
import _ from 'lodash'
|
||||
import { generateTimestamp } from '../../utils/opentimestamps.ts'
|
||||
|
||||
export const CreatePage = () => {
|
||||
const navigate = useNavigate()
|
||||
@ -72,8 +76,6 @@ export const CreatePage = () => {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
|
||||
|
||||
const [authUrl, setAuthUrl] = useState<string>()
|
||||
|
||||
const [title, setTitle] = useState(`sigit_${formatTimestamp(Date.now())}`)
|
||||
|
||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([])
|
||||
@ -86,7 +88,10 @@ export const CreatePage = () => {
|
||||
|
||||
const [userInput, setUserInput] = useState('')
|
||||
const handleInputKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.code === 'Enter' || event.code === 'NumpadEnter') {
|
||||
if (
|
||||
event.code === KeyboardCode.Enter ||
|
||||
event.code === KeyboardCode.NumpadEnter
|
||||
) {
|
||||
event.preventDefault()
|
||||
handleAddUser()
|
||||
}
|
||||
@ -98,7 +103,7 @@ export const CreatePage = () => {
|
||||
const signers = users.filter((u) => u.role === UserRole.signer)
|
||||
const viewers = users.filter((u) => u.role === UserRole.viewer)
|
||||
|
||||
const usersPubkey = useSelector((state: State) => state.auth.usersPubkey)
|
||||
const usersPubkey = useAppSelector((state) => state.auth.usersPubkey)
|
||||
|
||||
const nostrController = NostrController.getInstance()
|
||||
|
||||
@ -106,9 +111,31 @@ export const CreatePage = () => {
|
||||
{}
|
||||
)
|
||||
const [drawnFiles, setDrawnFiles] = useState<SigitFile[]>([])
|
||||
const [parsingPdf, setIsParsing] = useState<boolean>(false)
|
||||
useEffect(() => {
|
||||
if (selectedFiles) {
|
||||
/**
|
||||
* Reads the binary files and converts to internal file type
|
||||
* and sets to a state (adds images if it's a PDF)
|
||||
*/
|
||||
const parsePages = async () => {
|
||||
const files = await settleAllFullfilfedPromises(
|
||||
selectedFiles,
|
||||
getSigitFile
|
||||
)
|
||||
|
||||
setDrawnFiles(files)
|
||||
}
|
||||
|
||||
setIsParsing(true)
|
||||
|
||||
parsePages().finally(() => {
|
||||
setIsParsing(false)
|
||||
})
|
||||
}
|
||||
}, [selectedFiles])
|
||||
|
||||
const [selectedTool, setSelectedTool] = useState<DrawTool>()
|
||||
const [toolbox] = useState<DrawTool[]>(DEFAULT_TOOLBOX)
|
||||
|
||||
/**
|
||||
* Changes the drawing tool
|
||||
@ -159,10 +186,6 @@ export const CreatePage = () => {
|
||||
}
|
||||
})
|
||||
}, [metadata, users])
|
||||
// Set up event listener for authentication event
|
||||
nostrController.on('nsecbunker-auth', (url) => {
|
||||
setAuthUrl(url)
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (uploadedFiles) {
|
||||
@ -229,17 +252,22 @@ export const CreatePage = () => {
|
||||
const input = userInput.toLowerCase()
|
||||
|
||||
if (input.startsWith('npub')) {
|
||||
const pubkey = npubToHex(input)
|
||||
if (pubkey) {
|
||||
addUser(pubkey)
|
||||
setUserInput('')
|
||||
} else {
|
||||
setError('Provided npub is not valid. Please enter correct npub.')
|
||||
}
|
||||
return
|
||||
return handleAddNpubUser(input)
|
||||
}
|
||||
|
||||
if (input.includes('@')) {
|
||||
return await handleAddNip05User(input)
|
||||
}
|
||||
|
||||
// If the user enters the domain (w/o @) assume it's the "root" and append _@
|
||||
// https://github.com/nostr-protocol/nips/blob/master/05.md#showing-just-the-domain-as-an-identifier
|
||||
if (input.includes('.')) {
|
||||
return await handleAddNip05User(`_@${input}`)
|
||||
}
|
||||
|
||||
setError('Invalid input! Make sure to provide correct npub or nip05.')
|
||||
|
||||
async function handleAddNip05User(input: string) {
|
||||
setIsLoading(true)
|
||||
setLoadingSpinnerDesc('Querying for nip05')
|
||||
const nip05Profile = await queryNip05(input)
|
||||
@ -262,7 +290,16 @@ export const CreatePage = () => {
|
||||
return
|
||||
}
|
||||
|
||||
setError('Invalid input! Make sure to provide correct npub or nip05.')
|
||||
function handleAddNpubUser(input: string) {
|
||||
const pubkey = npubToHex(input)
|
||||
if (pubkey) {
|
||||
addUser(pubkey)
|
||||
setUserInput('')
|
||||
} else {
|
||||
setError('Provided npub is not valid. Please enter correct npub.')
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const handleUserRoleChange = (role: UserRole, pubkey: string) => {
|
||||
@ -282,6 +319,19 @@ export const CreatePage = () => {
|
||||
|
||||
const handleRemoveUser = (pubkey: string) => {
|
||||
setUsers((prev) => prev.filter((user) => user.pubkey !== pubkey))
|
||||
|
||||
// Set counterpart to ''
|
||||
const drawnFilesCopy = _.cloneDeep(drawnFiles)
|
||||
drawnFilesCopy.forEach((s) => {
|
||||
s.pages?.forEach((p) => {
|
||||
p.drawnFields.forEach((d) => {
|
||||
if (d.counterpart === hexToNpub(pubkey)) {
|
||||
d.counterpart = ''
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
setDrawnFiles(drawnFilesCopy)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -598,6 +648,11 @@ export const CreatePage = () => {
|
||||
return receivers.map((receiver) => sendNotification(receiver, meta))
|
||||
}
|
||||
|
||||
const extractNostrId = (stringifiedEvent: string): string => {
|
||||
const e = JSON.parse(stringifiedEvent) as SignedEvent
|
||||
return e.id
|
||||
}
|
||||
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
if (!validateInputs()) return
|
||||
@ -647,6 +702,12 @@ export const CreatePage = () => {
|
||||
const keys = await generateKeys(pubkeys, encryptionKey)
|
||||
if (!keys) return
|
||||
|
||||
setLoadingSpinnerDesc('Generating an open timestamp.')
|
||||
|
||||
const timestamp = await generateTimestamp(
|
||||
extractNostrId(createSignature)
|
||||
)
|
||||
|
||||
const meta: Meta = {
|
||||
createSignature,
|
||||
keys,
|
||||
@ -654,6 +715,10 @@ export const CreatePage = () => {
|
||||
docSignatures: {}
|
||||
}
|
||||
|
||||
if (timestamp) {
|
||||
meta.timestamps = [timestamp]
|
||||
}
|
||||
|
||||
setLoadingSpinnerDesc('Updating user app data')
|
||||
const event = await updateUsersAppData(meta)
|
||||
if (!event) return
|
||||
@ -724,21 +789,6 @@ export const CreatePage = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const onDrawFieldsChange = (sigitFiles: SigitFile[]) => {
|
||||
setDrawnFiles(sigitFiles)
|
||||
}
|
||||
|
||||
if (authUrl) {
|
||||
return (
|
||||
<iframe
|
||||
title="Nsecbunker auth"
|
||||
src={authUrl}
|
||||
width="100%"
|
||||
height="500px"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
|
||||
@ -841,28 +891,36 @@ export const CreatePage = () => {
|
||||
</div>
|
||||
|
||||
<div className={`${styles.paperGroup} ${styles.toolbox}`}>
|
||||
{toolbox.map((drawTool: DrawTool, index: number) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
{...(drawTool.active && {
|
||||
onClick: () => handleToolSelect(drawTool)
|
||||
})}
|
||||
className={`${styles.toolItem} ${selectedTool?.identifier === drawTool.identifier ? styles.selected : ''} ${!drawTool.active ? styles.comingSoon : ''}
|
||||
{DEFAULT_TOOLBOX.filter((drawTool) => !drawTool.isHidden).map(
|
||||
(drawTool: DrawTool, index: number) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
{...(!drawTool.isComingSoon && {
|
||||
onClick: () => handleToolSelect(drawTool)
|
||||
})}
|
||||
className={`${styles.toolItem} ${selectedTool?.identifier === drawTool.identifier ? styles.selected : ''} ${drawTool.isComingSoon ? styles.comingSoon : ''}
|
||||
`}
|
||||
>
|
||||
<FontAwesomeIcon fontSize={'15px'} icon={drawTool.icon} />
|
||||
{drawTool.label}
|
||||
{drawTool.active ? (
|
||||
<FontAwesomeIcon fontSize={'15px'} icon={faEllipsis} />
|
||||
) : (
|
||||
<span className={styles.comingSoonPlaceholder}>
|
||||
Coming soon
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
fontSize={'15px'}
|
||||
icon={drawTool.icon}
|
||||
/>
|
||||
{drawTool.label}
|
||||
{!drawTool.isComingSoon ? (
|
||||
<FontAwesomeIcon
|
||||
fontSize={'15px'}
|
||||
icon={faEllipsis}
|
||||
/>
|
||||
) : (
|
||||
<span className={styles.comingSoonPlaceholder}>
|
||||
Coming soon
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button onClick={handleCreate} variant="contained">
|
||||
@ -878,13 +936,17 @@ export const CreatePage = () => {
|
||||
centerIcon={faFile}
|
||||
rightIcon={faToolbox}
|
||||
>
|
||||
<DrawPDFFields
|
||||
metadata={metadata}
|
||||
users={users}
|
||||
selectedFiles={selectedFiles}
|
||||
onDrawFieldsChange={onDrawFieldsChange}
|
||||
selectedTool={selectedTool}
|
||||
/>
|
||||
{parsingPdf ? (
|
||||
<LoadingSpinner variant="small" />
|
||||
) : (
|
||||
<DrawPDFFields
|
||||
users={users}
|
||||
metadata={metadata}
|
||||
selectedTool={selectedTool}
|
||||
sigitFiles={drawnFiles}
|
||||
setSigitFiles={setDrawnFiles}
|
||||
/>
|
||||
)}
|
||||
</StickySideColumns>
|
||||
</Container>
|
||||
</>
|
||||
|
@ -257,6 +257,7 @@ export const HomePage = () => {
|
||||
.map((key) => (
|
||||
<DisplaySigit
|
||||
key={`sigit-${key}`}
|
||||
sigitCreateId={key}
|
||||
parsedMeta={parsedSigits[key]}
|
||||
meta={sigits[key]}
|
||||
/>
|
||||
|
@ -1,7 +1,6 @@
|
||||
import { Box, Button } from '@mui/material'
|
||||
import { useEffect } from 'react'
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { appPublicRoutes } from '../../routes'
|
||||
import { Outlet, useLocation } from 'react-router-dom'
|
||||
import { saveVisitedLink } from '../../utils'
|
||||
import { CardComponent } from '../../components/Landing/CardComponent/CardComponent'
|
||||
import { Container } from '../../components/Container'
|
||||
@ -20,13 +19,13 @@ import {
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
import { FontAwesomeIconStack } from '../../components/FontAwesomeIconStack'
|
||||
import { Footer } from '../../components/Footer/Footer'
|
||||
import { launch as launchNostrLoginDialog } from 'nostr-login'
|
||||
|
||||
export const LandingPage = () => {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
const onSignInClick = async () => {
|
||||
navigate(appPublicRoutes.nostr)
|
||||
launchNostrLoginDialog()
|
||||
}
|
||||
|
||||
const cards = [
|
||||
@ -35,7 +34,7 @@ export const LandingPage = () => {
|
||||
title: <>Open Source</>,
|
||||
description: (
|
||||
<>
|
||||
Code is MIT licenced and available at{' '}
|
||||
Code is AGPL licenced and available at{' '}
|
||||
<a href="https://git.nostrdev.com/sigit/sigit.io">
|
||||
https://git.nostrdev.com/sigit/sigit.io
|
||||
</a>
|
||||
@ -70,8 +69,8 @@ export const LandingPage = () => {
|
||||
title: <>Verifiable</>,
|
||||
description: (
|
||||
<>
|
||||
Thanks to Schnorr Signatures and Web of Trust, SIGit is far more
|
||||
auditable than traditional server-based offerings.
|
||||
SIGit Agreements can be directly verified - unlike traditional,
|
||||
server-based offerings.
|
||||
</>
|
||||
)
|
||||
},
|
||||
@ -85,8 +84,8 @@ export const LandingPage = () => {
|
||||
title: <>Works Offline</>,
|
||||
description: (
|
||||
<>
|
||||
Presuming you have a hardware signing device, it is possible to
|
||||
complete a SIGit round without an internet connection.
|
||||
It is possible to complete a SIGit round without an internet
|
||||
connection.
|
||||
</>
|
||||
)
|
||||
},
|
||||
@ -95,8 +94,8 @@ export const LandingPage = () => {
|
||||
title: <>Multi-Party Signing</>,
|
||||
description: (
|
||||
<>
|
||||
Choose any number of Signers and Viewers, track the signature status,
|
||||
send reminders, get notifications on completion.
|
||||
Choose any number of Signers and Viewers, track status, get
|
||||
notifications on completion.
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -120,9 +119,7 @@ export const LandingPage = () => {
|
||||
<Container className={styles.container}>
|
||||
<img className={styles.logo} src="/logo.svg" alt="Logo" width={300} />
|
||||
<div className={styles.titleSection}>
|
||||
<h1 className={styles.title}>
|
||||
Secure & Private Document Signing
|
||||
</h1>
|
||||
<h1 className={styles.title}>Secure & Private Agreements</h1>
|
||||
<p className={styles.subTitle}>
|
||||
An open-source and self-hostable solution for secure document
|
||||
signing and verification.
|
||||
|
@ -12,6 +12,11 @@ export const Login = () => {
|
||||
margin="dense"
|
||||
autoComplete="username"
|
||||
disabled
|
||||
sx={{
|
||||
input: {
|
||||
cursor: 'not-allowed'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
label="Password"
|
||||
@ -20,6 +25,11 @@ export const Login = () => {
|
||||
margin="dense"
|
||||
autoComplete="current-password"
|
||||
disabled
|
||||
sx={{
|
||||
input: {
|
||||
cursor: 'not-allowed'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button variant="contained" fullWidth disabled>
|
||||
|
@ -1,67 +1,31 @@
|
||||
import { launch as launchNostrLoginDialog } from 'nostr-login'
|
||||
|
||||
import { Button, Divider, TextField } from '@mui/material'
|
||||
import { getPublicKey, nip19 } from 'nostr-tools'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useDispatch } from 'react-redux'
|
||||
import { useAppDispatch } from '../../hooks/store'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { toast } from 'react-toastify'
|
||||
import { LoadingSpinner } from '../../components/LoadingSpinner'
|
||||
import {
|
||||
AuthController,
|
||||
MetadataController,
|
||||
NostrController
|
||||
} from '../../controllers'
|
||||
import {
|
||||
updateKeyPair,
|
||||
updateLoginMethod,
|
||||
updateNsecbunkerPubkey,
|
||||
updateNsecbunkerRelays
|
||||
} from '../../store/actions'
|
||||
import { LoginMethods } from '../../store/auth/types'
|
||||
import { Dispatch } from '../../store/store'
|
||||
import { npubToHex, queryNip05, timeout } from '../../utils'
|
||||
import { AuthController } from '../../controllers'
|
||||
import { updateKeyPair, updateLoginMethod } from '../../store/actions'
|
||||
import { KeyboardCode } from '../../types'
|
||||
import { LoginMethod } from '../../store/auth/types'
|
||||
import { hexToBytes } from '@noble/hashes/utils'
|
||||
import { NIP05_REGEX } from '../../constants'
|
||||
|
||||
import styles from './styles.module.scss'
|
||||
|
||||
import { TimeoutError } from '../../types/errors/TimeoutError'
|
||||
const EXTENSION_LOGIN_DELAY_SECONDS = 5
|
||||
const EXTENSION_LOGIN_TIMEOUT_SECONDS = EXTENSION_LOGIN_DELAY_SECONDS + 55
|
||||
|
||||
export const Nostr = () => {
|
||||
const [searchParams] = useSearchParams()
|
||||
|
||||
const dispatch: Dispatch = useDispatch()
|
||||
const dispatch = useAppDispatch()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const authController = new AuthController()
|
||||
const metadataController = MetadataController.getInstance()
|
||||
const nostrController = NostrController.getInstance()
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
|
||||
const [isExtensionSlow, setIsExtensionSlow] = useState(false)
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const [authUrl, setAuthUrl] = useState<string>()
|
||||
|
||||
const [isNostrExtensionAvailable, setIsNostrExtensionAvailable] =
|
||||
useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
setIsNostrExtensionAvailable(!!window.nostr)
|
||||
}, 500)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Call login function when enter is pressed
|
||||
*/
|
||||
const handleInputKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.code === 'Enter' || event.code === 'NumpadEnter') {
|
||||
event.preventDefault()
|
||||
login()
|
||||
}
|
||||
}
|
||||
|
||||
const navigateAfterLogin = (path: string) => {
|
||||
const callbackPath = searchParams.get('callbackPath')
|
||||
@ -76,43 +40,25 @@ export const Nostr = () => {
|
||||
navigate(path)
|
||||
}
|
||||
|
||||
const loginWithExtension = async () => {
|
||||
let waitTimeout: number | undefined
|
||||
try {
|
||||
// Wait EXTENSION_LOGIN_DELAY_SECONDS before showing extension delay message
|
||||
waitTimeout = window.setTimeout(() => {
|
||||
setIsExtensionSlow(true)
|
||||
}, EXTENSION_LOGIN_DELAY_SECONDS * 1000)
|
||||
const [isNostrExtensionAvailable, setIsNostrExtensionAvailable] =
|
||||
useState(false)
|
||||
|
||||
setIsLoading(true)
|
||||
setLoadingSpinnerDesc('Capturing pubkey from nostr extension')
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
setIsNostrExtensionAvailable(!!window.nostr)
|
||||
}, 500)
|
||||
}, [])
|
||||
|
||||
const pubkey = await nostrController.capturePublicKey()
|
||||
dispatch(updateLoginMethod(LoginMethods.extension))
|
||||
|
||||
setLoadingSpinnerDesc('Authenticating and finding metadata')
|
||||
const redirectPath = await Promise.race([
|
||||
authController.authAndGetMetadataAndRelaysMap(pubkey),
|
||||
timeout(EXTENSION_LOGIN_TIMEOUT_SECONDS * 1000)
|
||||
])
|
||||
|
||||
if (redirectPath) {
|
||||
navigateAfterLogin(redirectPath)
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof TimeoutError) {
|
||||
// Just log the error, no toast, user has already been notified with the loading screen
|
||||
console.error("Extension didn't respond in time")
|
||||
} else {
|
||||
toast.error('Error capturing public key from nostr extension: ' + error)
|
||||
}
|
||||
} finally {
|
||||
// Clear the wait timeout so we don't change the state unnecessarily
|
||||
window.clearTimeout(waitTimeout)
|
||||
|
||||
setIsLoading(false)
|
||||
setLoadingSpinnerDesc('')
|
||||
setIsExtensionSlow(false)
|
||||
/**
|
||||
* Call login function when enter is pressed
|
||||
*/
|
||||
const handleInputKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (
|
||||
event.code === KeyboardCode.Enter ||
|
||||
event.code === KeyboardCode.NumpadEnter
|
||||
) {
|
||||
event.preventDefault()
|
||||
login()
|
||||
}
|
||||
}
|
||||
|
||||
@ -151,7 +97,7 @@ export const Nostr = () => {
|
||||
public: publickey
|
||||
})
|
||||
)
|
||||
dispatch(updateLoginMethod(LoginMethods.privateKey))
|
||||
dispatch(updateLoginMethod(LoginMethod.privateKey))
|
||||
|
||||
setIsLoading(true)
|
||||
setLoadingSpinnerDesc('Authenticating and finding metadata')
|
||||
@ -169,182 +115,10 @@ export const Nostr = () => {
|
||||
setLoadingSpinnerDesc('')
|
||||
}
|
||||
|
||||
const loginWithNsecBunker = async () => {
|
||||
let relays: string[] | undefined
|
||||
let pubkey: string | undefined
|
||||
|
||||
setIsLoading(true)
|
||||
|
||||
const displayError = (message: string) => {
|
||||
toast.error(message)
|
||||
setIsLoading(false)
|
||||
setLoadingSpinnerDesc('')
|
||||
}
|
||||
|
||||
if (inputValue.match(NIP05_REGEX)) {
|
||||
const nip05Profile = await queryNip05(inputValue).catch((err) => {
|
||||
toast.error('An error occurred while querying nip05 profile: ' + err)
|
||||
return null
|
||||
})
|
||||
|
||||
if (nip05Profile) {
|
||||
pubkey = nip05Profile.pubkey
|
||||
relays = nip05Profile.relays
|
||||
}
|
||||
} else if (inputValue.startsWith('npub')) {
|
||||
pubkey = nip19.decode(inputValue).data as string
|
||||
const metadataEvent = await metadataController
|
||||
.findMetadata(pubkey)
|
||||
.catch(() => {
|
||||
return null
|
||||
})
|
||||
|
||||
if (!metadataEvent) {
|
||||
return displayError('metadata not found!')
|
||||
}
|
||||
|
||||
const metadataContent =
|
||||
metadataController.extractProfileMetadataContent(metadataEvent)
|
||||
|
||||
if (!metadataContent?.nip05) {
|
||||
return displayError('nip05 not present in metadata')
|
||||
}
|
||||
|
||||
const nip05Profile = await queryNip05(inputValue).catch((err) => {
|
||||
toast.error('An error occurred while querying nip05 profile: ' + err)
|
||||
return null
|
||||
})
|
||||
|
||||
if (nip05Profile) {
|
||||
if (nip05Profile.pubkey !== pubkey) {
|
||||
return displayError(
|
||||
'pubkey in nip05 does not match with provided npub'
|
||||
)
|
||||
}
|
||||
|
||||
relays = nip05Profile.relays
|
||||
}
|
||||
}
|
||||
|
||||
if (!relays || relays.length === 0) {
|
||||
return displayError('No relay found for nsecbunker')
|
||||
}
|
||||
|
||||
if (!pubkey) {
|
||||
return displayError('pubkey not found')
|
||||
}
|
||||
|
||||
setLoadingSpinnerDesc('Initializing nsecBunker')
|
||||
await nostrController.nsecBunkerInit(relays)
|
||||
|
||||
setLoadingSpinnerDesc('Creating nsecbunker singer')
|
||||
await nostrController
|
||||
.createNsecBunkerSigner(pubkey)
|
||||
.then(async (signer) => {
|
||||
signer.on('authUrl', (url: string) => {
|
||||
setAuthUrl(url)
|
||||
})
|
||||
|
||||
dispatch(updateLoginMethod(LoginMethods.nsecBunker))
|
||||
dispatch(updateNsecbunkerPubkey(pubkey))
|
||||
dispatch(updateNsecbunkerRelays(relays))
|
||||
|
||||
setLoadingSpinnerDesc('Authenticating and finding metadata')
|
||||
|
||||
const redirectPath = await authController
|
||||
.authAndGetMetadataAndRelaysMap(pubkey!)
|
||||
.catch((err) => {
|
||||
toast.error('Error occurred in authentication: ' + err)
|
||||
return null
|
||||
})
|
||||
|
||||
if (redirectPath) navigateAfterLogin(redirectPath)
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(
|
||||
'An error occurred while creating nsecbunker signer: ' + err
|
||||
)
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false)
|
||||
setLoadingSpinnerDesc('')
|
||||
})
|
||||
}
|
||||
|
||||
const loginWithBunkerConnectionString = async () => {
|
||||
// Extract the key
|
||||
const keyStartIndex = inputValue.indexOf('bunker://') + 'bunker://'.length
|
||||
const keyEndIndex = inputValue.indexOf('?relay=')
|
||||
const key = inputValue.substring(keyStartIndex, keyEndIndex)
|
||||
|
||||
const pubkey = npubToHex(key)
|
||||
|
||||
if (!pubkey) {
|
||||
toast.error('Invalid pubkey in bunker connection string.')
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract the relay value
|
||||
const relayIndex = inputValue.indexOf('relay=')
|
||||
const relay = inputValue.substring(
|
||||
relayIndex + 'relay='.length,
|
||||
inputValue.length
|
||||
)
|
||||
|
||||
setIsLoading(true)
|
||||
setLoadingSpinnerDesc('Initializing bunker NDK')
|
||||
|
||||
await nostrController.nsecBunkerInit([relay])
|
||||
|
||||
setLoadingSpinnerDesc('Creating remote signer')
|
||||
await nostrController
|
||||
.createNsecBunkerSigner(pubkey)
|
||||
.then(async (signer) => {
|
||||
signer.on('authUrl', (url: string) => {
|
||||
setAuthUrl(url)
|
||||
})
|
||||
|
||||
dispatch(updateLoginMethod(LoginMethods.nsecBunker))
|
||||
dispatch(updateNsecbunkerPubkey(pubkey))
|
||||
dispatch(updateNsecbunkerRelays([relay]))
|
||||
|
||||
setLoadingSpinnerDesc('Authenticating and finding metadata')
|
||||
|
||||
const redirectPath = await authController
|
||||
.authAndGetMetadataAndRelaysMap(pubkey!)
|
||||
.catch((err) => {
|
||||
toast.error('Error occurred in authentication: ' + err)
|
||||
return null
|
||||
})
|
||||
|
||||
if (redirectPath) navigateAfterLogin(redirectPath)
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(
|
||||
'An error occurred while creating nsecbunker signer: ' + err
|
||||
)
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false)
|
||||
setLoadingSpinnerDesc('')
|
||||
})
|
||||
}
|
||||
|
||||
const login = () => {
|
||||
if (inputValue.startsWith('bunker://')) {
|
||||
return loginWithBunkerConnectionString()
|
||||
}
|
||||
|
||||
if (inputValue.startsWith('nsec')) {
|
||||
return loginWithNsec()
|
||||
}
|
||||
if (inputValue.startsWith('npub')) {
|
||||
return loginWithNsecBunker()
|
||||
}
|
||||
if (inputValue.match(NIP05_REGEX)) {
|
||||
return loginWithNsecBunker()
|
||||
}
|
||||
|
||||
// Check if maybe hex nsec
|
||||
try {
|
||||
@ -356,64 +130,33 @@ export const Nostr = () => {
|
||||
console.warn('err', err)
|
||||
}
|
||||
|
||||
toast.error(
|
||||
'Invalid format, please use: private key (hex), nsec..., bunker:// or nip05 format.'
|
||||
)
|
||||
toast.error('Invalid format, please use: private key (hex or nsec)')
|
||||
return
|
||||
}
|
||||
|
||||
if (authUrl) {
|
||||
return (
|
||||
<iframe
|
||||
title="Nsecbunker auth"
|
||||
src={authUrl}
|
||||
width="100%"
|
||||
height="500px"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoading && (
|
||||
<LoadingSpinner desc={loadingSpinnerDesc}>
|
||||
{isExtensionSlow && (
|
||||
<>
|
||||
<p>
|
||||
Your nostr extension is not responding. Check these
|
||||
alternatives:{' '}
|
||||
<a href="https://github.com/aljazceru/awesome-nostr?tab=readme-ov-file#nip-07-browser-extensions">
|
||||
https://github.com/aljazceru/awesome-nostr
|
||||
</a>
|
||||
</p>
|
||||
<br />
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
onClick={() => {
|
||||
setLoadingSpinnerDesc('')
|
||||
setIsLoading(false)
|
||||
setIsExtensionSlow(false)
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</LoadingSpinner>
|
||||
)}
|
||||
|
||||
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
|
||||
{isNostrExtensionAvailable && (
|
||||
<>
|
||||
<label className={styles.label} htmlFor="extension-login">
|
||||
Login by using a browser extension
|
||||
Login by using a{' '}
|
||||
<a
|
||||
rel="noopener"
|
||||
href="https://github.com/nostrband/nostr-login"
|
||||
target="_blank"
|
||||
>
|
||||
nostr-login
|
||||
</a>
|
||||
</label>
|
||||
<Button
|
||||
id="extension-login"
|
||||
onClick={loginWithExtension}
|
||||
id="nostr-login"
|
||||
variant="contained"
|
||||
onClick={() => {
|
||||
launchNostrLoginDialog()
|
||||
}}
|
||||
>
|
||||
Extension Login
|
||||
Nostr Login
|
||||
</Button>
|
||||
<Divider
|
||||
sx={{
|
||||
@ -424,16 +167,18 @@ export const Nostr = () => {
|
||||
</Divider>
|
||||
</>
|
||||
)}
|
||||
<TextField
|
||||
onKeyDown={handleInputKeyDown}
|
||||
label="nip05 login / nip46 bunker string"
|
||||
helperText="Private key (Not recommended)"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
fullWidth
|
||||
margin="dense"
|
||||
/>
|
||||
|
||||
<form autoComplete="off">
|
||||
<TextField
|
||||
onKeyDown={handleInputKeyDown}
|
||||
label="Private key (Not recommended)"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
fullWidth
|
||||
margin="dense"
|
||||
/>
|
||||
</form>
|
||||
<Button
|
||||
disabled={!inputValue}
|
||||
onClick={login}
|
||||
|
@ -1,19 +1,18 @@
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
|
||||
import EditIcon from '@mui/icons-material/Edit'
|
||||
import { Box, IconButton, SxProps, Theme, Typography } from '@mui/material'
|
||||
import { truncate } from 'lodash'
|
||||
import { Event, VerifiedEvent, kinds, nip19 } from 'nostr-tools'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { useAppSelector } from '../../hooks/store'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import { toast } from 'react-toastify'
|
||||
import { LoadingSpinner } from '../../components/LoadingSpinner'
|
||||
import { MetadataController } from '../../controllers'
|
||||
import { getProfileSettingsRoute } from '../../routes'
|
||||
import { State } from '../../store/rootReducer'
|
||||
import { NostrJoiningBlock, ProfileMetadata } from '../../types'
|
||||
import {
|
||||
getNostrJoiningBlockNumber,
|
||||
getProfileUsername,
|
||||
getRoboHashPicture,
|
||||
hexToNpub,
|
||||
shorten
|
||||
@ -33,24 +32,16 @@ export const ProfilePage = () => {
|
||||
const [nostrJoiningBlock, setNostrJoiningBlock] =
|
||||
useState<NostrJoiningBlock | null>(null)
|
||||
const [profileMetadata, setProfileMetadata] = useState<ProfileMetadata>()
|
||||
const metadataState = useSelector((state: State) => state.metadata)
|
||||
const { usersPubkey } = useSelector((state: State) => state.auth)
|
||||
const userRobotImage = useSelector((state: State) => state.userRobotImage)
|
||||
const metadataState = useAppSelector((state) => state.metadata)
|
||||
const { usersPubkey } = useAppSelector((state) => state.auth)
|
||||
const userRobotImage = useAppSelector((state) => state.userRobotImage)
|
||||
|
||||
const [isUsersOwnProfile, setIsUsersOwnProfile] = useState(false)
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [loadingSpinnerDesc] = useState('Fetching metadata')
|
||||
|
||||
const profileName =
|
||||
pubkey &&
|
||||
profileMetadata &&
|
||||
truncate(
|
||||
profileMetadata.display_name || profileMetadata.name || hexToNpub(pubkey),
|
||||
{
|
||||
length: 16
|
||||
}
|
||||
)
|
||||
const profileName = pubkey && getProfileUsername(pubkey, profileMetadata)
|
||||
|
||||
useEffect(() => {
|
||||
if (npub) {
|
||||
|
@ -24,7 +24,7 @@
|
||||
}
|
||||
|
||||
.container {
|
||||
color: black
|
||||
color: black;
|
||||
}
|
||||
|
||||
.left {
|
||||
@ -51,7 +51,8 @@
|
||||
}
|
||||
|
||||
.image-placeholder {
|
||||
width: 150px;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.link {
|
||||
@ -99,4 +100,4 @@
|
||||
margin-left: 5px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -12,6 +12,11 @@ export const Register = () => {
|
||||
margin="dense"
|
||||
autoComplete="username"
|
||||
disabled
|
||||
sx={{
|
||||
input: {
|
||||
cursor: 'not-allowed'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
label="Password"
|
||||
@ -21,6 +26,11 @@ export const Register = () => {
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
disabled
|
||||
sx={{
|
||||
input: {
|
||||
cursor: 'not-allowed'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
label="Confirm password"
|
||||
@ -30,6 +40,11 @@ export const Register = () => {
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
disabled
|
||||
sx={{
|
||||
input: {
|
||||
cursor: 'not-allowed'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button variant="contained" fullWidth disabled>
|
||||
|
@ -2,25 +2,22 @@ import AccountCircleIcon from '@mui/icons-material/AccountCircle'
|
||||
import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos'
|
||||
import CachedIcon from '@mui/icons-material/Cached'
|
||||
import RouterIcon from '@mui/icons-material/Router'
|
||||
import { useTheme } from '@mui/material'
|
||||
import { ListItem, useTheme } from '@mui/material'
|
||||
import List from '@mui/material/List'
|
||||
import ListItemButton from '@mui/material/ListItemButton'
|
||||
import ListItemIcon from '@mui/material/ListItemIcon'
|
||||
import ListItemText from '@mui/material/ListItemText'
|
||||
import ListSubheader from '@mui/material/ListSubheader'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAppSelector } from '../../hooks/store'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { appPrivateRoutes, getProfileSettingsRoute } from '../../routes'
|
||||
import { State } from '../../store/rootReducer'
|
||||
import { Container } from '../../components/Container'
|
||||
import { Footer } from '../../components/Footer/Footer'
|
||||
import ExtensionIcon from '@mui/icons-material/Extension'
|
||||
import { LoginMethod } from '../../store/auth/types'
|
||||
|
||||
export const SettingsPage = () => {
|
||||
const theme = useTheme()
|
||||
|
||||
const navigate = useNavigate()
|
||||
const usersPubkey = useSelector((state: State) => state.auth.usersPubkey)
|
||||
|
||||
const { usersPubkey, loginMethod } = useAppSelector((state) => state.auth)
|
||||
const listItem = (label: string, disabled = false) => {
|
||||
return (
|
||||
<>
|
||||
@ -57,43 +54,40 @@ export const SettingsPage = () => {
|
||||
fontSize: '1.5rem',
|
||||
borderBottom: '0.5px solid',
|
||||
paddingBottom: 2,
|
||||
paddingTop: 2
|
||||
paddingTop: 2,
|
||||
zIndex: 2
|
||||
}}
|
||||
>
|
||||
Settings
|
||||
</ListSubheader>
|
||||
}
|
||||
>
|
||||
<ListItemButton
|
||||
onClick={() => {
|
||||
navigate(getProfileSettingsRoute(usersPubkey!))
|
||||
}}
|
||||
>
|
||||
<ListItem component={Link} to={getProfileSettingsRoute(usersPubkey!)}>
|
||||
<ListItemIcon>
|
||||
<AccountCircleIcon />
|
||||
</ListItemIcon>
|
||||
{listItem('Profile')}
|
||||
</ListItemButton>
|
||||
<ListItemButton
|
||||
onClick={() => {
|
||||
navigate(appPrivateRoutes.relays)
|
||||
}}
|
||||
>
|
||||
</ListItem>
|
||||
<ListItem component={Link} to={appPrivateRoutes.relays}>
|
||||
<ListItemIcon>
|
||||
<RouterIcon />
|
||||
</ListItemIcon>
|
||||
{listItem('Relays')}
|
||||
</ListItemButton>
|
||||
<ListItemButton
|
||||
onClick={() => {
|
||||
navigate(appPrivateRoutes.cacheSettings)
|
||||
}}
|
||||
>
|
||||
</ListItem>
|
||||
<ListItem component={Link} to={appPrivateRoutes.cacheSettings}>
|
||||
<ListItemIcon>
|
||||
<CachedIcon />
|
||||
</ListItemIcon>
|
||||
{listItem('Local Cache')}
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
{loginMethod === LoginMethod.nostrLogin && (
|
||||
<ListItem component={Link} to={appPrivateRoutes.nostrLogin}>
|
||||
<ListItemIcon>
|
||||
<ExtensionIcon />
|
||||
</ListItemIcon>
|
||||
{listItem('Nostr Login')}
|
||||
</ListItem>
|
||||
)}
|
||||
</List>
|
||||
</Container>
|
||||
<Footer />
|
||||
|
3
src/pages/settings/cache/index.tsx
vendored
3
src/pages/settings/cache/index.tsx
vendored
@ -66,7 +66,8 @@ export const CacheSettingsPage = () => {
|
||||
fontSize: '1.5rem',
|
||||
borderBottom: '0.5px solid',
|
||||
paddingBottom: 2,
|
||||
paddingTop: 2
|
||||
paddingTop: 2,
|
||||
zIndex: 2
|
||||
}}
|
||||
>
|
||||
Cache Setting
|
||||
|
78
src/pages/settings/nostrLogin/index.tsx
Normal file
78
src/pages/settings/nostrLogin/index.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
import {
|
||||
List,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
ListSubheader,
|
||||
useTheme
|
||||
} from '@mui/material'
|
||||
import { launch as launchNostrLoginDialog } from 'nostr-login'
|
||||
import { Container } from '../../../components/Container'
|
||||
import PeopleIcon from '@mui/icons-material/People'
|
||||
import ImportExportIcon from '@mui/icons-material/ImportExport'
|
||||
import { useAppSelector } from '../../../hooks/store'
|
||||
import { NostrLoginAuthMethod } from '../../../store/auth/types'
|
||||
|
||||
export const NostrLoginPage = () => {
|
||||
const theme = useTheme()
|
||||
const nostrLoginAuthMethod = useAppSelector(
|
||||
(state) => state.auth?.nostrLoginAuthMethod
|
||||
)
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<List
|
||||
sx={{
|
||||
width: '100%',
|
||||
bgcolor: 'background.paper'
|
||||
}}
|
||||
subheader={
|
||||
<ListSubheader
|
||||
sx={{
|
||||
fontSize: '1.5rem',
|
||||
borderBottom: '0.5px solid',
|
||||
paddingBottom: 2,
|
||||
paddingTop: 2,
|
||||
zIndex: 2
|
||||
}}
|
||||
>
|
||||
Nostr Settings
|
||||
</ListSubheader>
|
||||
}
|
||||
>
|
||||
<ListItemButton
|
||||
onClick={() => {
|
||||
launchNostrLoginDialog('switch-account')
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<PeopleIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={'Nostr Login Accounts'}
|
||||
sx={{
|
||||
color: theme.palette.text.primary
|
||||
}}
|
||||
/>
|
||||
</ListItemButton>
|
||||
{nostrLoginAuthMethod === NostrLoginAuthMethod.Local && (
|
||||
<ListItemButton
|
||||
onClick={() => {
|
||||
launchNostrLoginDialog('import')
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<ImportExportIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={'Import / Export Keys'}
|
||||
sx={{
|
||||
color: theme.palette.text.primary
|
||||
}}
|
||||
/>
|
||||
</ListItemButton>
|
||||
)}
|
||||
</List>
|
||||
</Container>
|
||||
)
|
||||
}
|
@ -18,13 +18,13 @@ import { toast } from 'react-toastify'
|
||||
import { MetadataController, NostrController } from '../../../controllers'
|
||||
import { NostrJoiningBlock, ProfileMetadata } from '../../../types'
|
||||
import styles from './style.module.scss'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import { State } from '../../../store/rootReducer'
|
||||
import { useAppDispatch, useAppSelector } from '../../../hooks/store'
|
||||
|
||||
import { LoadingButton } from '@mui/lab'
|
||||
import { Dispatch } from '../../../store/store'
|
||||
import { setMetadataEvent } from '../../../store/actions'
|
||||
import { LoadingSpinner } from '../../../components/LoadingSpinner'
|
||||
import { LoginMethods } from '../../../store/auth/types'
|
||||
import { LoginMethod, NostrLoginAuthMethod } from '../../../store/auth/types'
|
||||
import { SmartToy } from '@mui/icons-material'
|
||||
import {
|
||||
getNostrJoiningBlockNumber,
|
||||
@ -33,13 +33,15 @@ import {
|
||||
} from '../../../utils'
|
||||
import { Container } from '../../../components/Container'
|
||||
import { Footer } from '../../../components/Footer/Footer'
|
||||
import LaunchIcon from '@mui/icons-material/Launch'
|
||||
import { launch as launchNostrLoginDialog } from 'nostr-login'
|
||||
|
||||
export const ProfileSettingsPage = () => {
|
||||
const theme = useTheme()
|
||||
|
||||
const { npub } = useParams()
|
||||
|
||||
const dispatch: Dispatch = useDispatch()
|
||||
const dispatch: Dispatch = useAppDispatch()
|
||||
|
||||
const metadataController = MetadataController.getInstance()
|
||||
const nostrController = NostrController.getInstance()
|
||||
@ -49,10 +51,12 @@ export const ProfileSettingsPage = () => {
|
||||
useState<NostrJoiningBlock | null>(null)
|
||||
const [profileMetadata, setProfileMetadata] = useState<ProfileMetadata>()
|
||||
const [savingProfileMetadata, setSavingProfileMetadata] = useState(false)
|
||||
const metadataState = useSelector((state: State) => state.metadata)
|
||||
const keys = useSelector((state: State) => state.auth?.keyPair)
|
||||
const { usersPubkey, loginMethod } = useSelector((state: State) => state.auth)
|
||||
const userRobotImage = useSelector((state: State) => state.userRobotImage)
|
||||
const metadataState = useAppSelector((state) => state.metadata)
|
||||
const keys = useAppSelector((state) => state.auth?.keyPair)
|
||||
const { usersPubkey, loginMethod, nostrLoginAuthMethod } = useAppSelector(
|
||||
(state) => state.auth
|
||||
)
|
||||
const userRobotImage = useAppSelector((state) => state.userRobotImage)
|
||||
|
||||
const [isUsersOwnProfile, setIsUsersOwnProfile] = useState(false)
|
||||
|
||||
@ -287,7 +291,8 @@ export const ProfileSettingsPage = () => {
|
||||
sx={{
|
||||
paddingBottom: 1,
|
||||
paddingTop: 1,
|
||||
fontSize: '1.5rem'
|
||||
fontSize: '1.5rem',
|
||||
zIndex: 2
|
||||
}}
|
||||
className={styles.subHeader}
|
||||
>
|
||||
@ -363,7 +368,7 @@ export const ProfileSettingsPage = () => {
|
||||
<>
|
||||
{usersPubkey &&
|
||||
copyItem(nip19.npubEncode(usersPubkey), 'Public Key')}
|
||||
{loginMethod === LoginMethods.privateKey &&
|
||||
{loginMethod === LoginMethod.privateKey &&
|
||||
keys &&
|
||||
keys.private &&
|
||||
copyItem(
|
||||
@ -373,6 +378,33 @@ export const ProfileSettingsPage = () => {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{isUsersOwnProfile && (
|
||||
<>
|
||||
{loginMethod === LoginMethod.nostrLogin &&
|
||||
nostrLoginAuthMethod === NostrLoginAuthMethod.Local && (
|
||||
<ListItem
|
||||
sx={{ marginTop: 1 }}
|
||||
onClick={() => {
|
||||
launchNostrLoginDialog('import')
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
label="Private Key (nostr-login)"
|
||||
defaultValue="••••••••••••••••••••••••••••••••••••••••••••••••••"
|
||||
size="small"
|
||||
className={styles.textField}
|
||||
disabled
|
||||
type={'password'}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<LaunchIcon className={styles.copyItem} />
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</List>
|
||||
|
@ -6,13 +6,12 @@ import _ from 'lodash'
|
||||
import { MuiFileInput } from 'mui-file-input'
|
||||
import { Event, verifyEvent } from 'nostr-tools'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { useAppSelector } from '../../hooks/store'
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||
import { toast } from 'react-toastify'
|
||||
import { LoadingSpinner } from '../../components/LoadingSpinner'
|
||||
import { NostrController } from '../../controllers'
|
||||
import { appPublicRoutes } from '../../routes'
|
||||
import { State } from '../../store/rootReducer'
|
||||
import { CreateSignatureEventContent, Meta, SignedEvent } from '../../types'
|
||||
import {
|
||||
decryptArrayBuffer,
|
||||
@ -54,6 +53,8 @@ import {
|
||||
SigitFile
|
||||
} from '../../utils/file.ts'
|
||||
import { ARRAY_BUFFER, DEFLATE } from '../../utils/const.ts'
|
||||
import { generateTimestamp } from '../../utils/opentimestamps.ts'
|
||||
|
||||
enum SignedStatus {
|
||||
Fully_Signed,
|
||||
User_Is_Next_Signer,
|
||||
@ -63,17 +64,39 @@ enum SignedStatus {
|
||||
export const SignPage = () => {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const params = useParams()
|
||||
|
||||
const usersAppData = useAppSelector((state) => state.userAppData)
|
||||
|
||||
/**
|
||||
* Received from `location.state`
|
||||
*
|
||||
* uploadedZip will be received from home page when a user uploads a sigit zip wrapper that contains keys.json
|
||||
* arrayBuffer will be received in navigation from create page in offline mode
|
||||
* meta will be received in navigation from create & home page in online mode
|
||||
* 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
|
||||
*/
|
||||
const {
|
||||
meta: metaInNavState,
|
||||
arrayBuffer: decryptedArrayBuffer,
|
||||
uploadedZip
|
||||
} = location.state || {}
|
||||
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`
|
||||
*/
|
||||
if (usersAppData) {
|
||||
const sigitCreateId = params.id
|
||||
|
||||
if (sigitCreateId) {
|
||||
const sigit = usersAppData.sigits[sigitCreateId]
|
||||
|
||||
if (sigit) {
|
||||
metaInNavState = sigit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const [displayInput, setDisplayInput] = useState(false)
|
||||
|
||||
@ -106,9 +129,8 @@ export const SignPage = () => {
|
||||
// This state variable indicates whether the logged-in user is a signer, a creator, or neither.
|
||||
const [isSignerOrCreator, setIsSignerOrCreator] = useState(false)
|
||||
|
||||
const usersPubkey = useSelector((state: State) => state.auth.usersPubkey)
|
||||
const usersPubkey = useAppSelector((state) => state.auth.usersPubkey)
|
||||
|
||||
const [authUrl, setAuthUrl] = useState<string>()
|
||||
const nostrController = NostrController.getInstance()
|
||||
const [currentUserMarks, setCurrentUserMarks] = useState<CurrentUserMark[]>(
|
||||
[]
|
||||
@ -272,11 +294,6 @@ export const SignPage = () => {
|
||||
const { keys, sender } = parsedKeysJson
|
||||
|
||||
for (const key of keys) {
|
||||
// Set up event listener for authentication event
|
||||
nostrController.on('nsecbunker-auth', (url) => {
|
||||
setAuthUrl(url)
|
||||
})
|
||||
|
||||
// decrypt the encryptionKey, with timeout (duration = 60 seconds)
|
||||
const encryptionKey = await Promise.race([
|
||||
nostrController.nip04Decrypt(sender, key),
|
||||
@ -289,9 +306,6 @@ export const SignPage = () => {
|
||||
console.log('err :>> ', err)
|
||||
return null
|
||||
})
|
||||
.finally(() => {
|
||||
setAuthUrl(undefined) // Clear authentication URL
|
||||
})
|
||||
|
||||
// Return if encryption failed
|
||||
if (!encryptionKey) continue
|
||||
@ -523,7 +537,11 @@ export const SignPage = () => {
|
||||
setIsLoading(true)
|
||||
const arrayBuffer = await decrypt(selectedFile)
|
||||
|
||||
if (!arrayBuffer) return
|
||||
if (!arrayBuffer) {
|
||||
setIsLoading(false)
|
||||
toast.error('Error decrypting file')
|
||||
return
|
||||
}
|
||||
|
||||
handleDecryptedArrayBuffer(arrayBuffer)
|
||||
}
|
||||
@ -549,6 +567,14 @@ export const SignPage = () => {
|
||||
|
||||
const updatedMeta = updateMetaSignatures(meta, signedEvent)
|
||||
|
||||
setLoadingSpinnerDesc('Generating an open timestamp.')
|
||||
|
||||
const timestamp = await generateTimestamp(signedEvent.id)
|
||||
if (timestamp) {
|
||||
updatedMeta.timestamps = [...(updatedMeta.timestamps || []), timestamp]
|
||||
updatedMeta.modifiedAt = unixNow()
|
||||
}
|
||||
|
||||
if (await isOnline()) {
|
||||
await handleOnlineFlow(updatedMeta)
|
||||
} else {
|
||||
@ -758,14 +784,9 @@ export const SignPage = () => {
|
||||
2
|
||||
)
|
||||
|
||||
const zip = new JSZip()
|
||||
|
||||
const zip = await getZipWithFiles(meta, files)
|
||||
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',
|
||||
@ -791,19 +812,14 @@ export const SignPage = () => {
|
||||
navigate(appPublicRoutes.verify)
|
||||
}
|
||||
|
||||
const handleExportSigit = async () => {
|
||||
const handleEncryptedExport = async () => {
|
||||
if (Object.entries(files).length === 0 || !meta) return
|
||||
|
||||
const zip = new JSZip()
|
||||
|
||||
const stringifiedMeta = JSON.stringify(meta, null, 2)
|
||||
const zip = await getZipWithFiles(meta, files)
|
||||
|
||||
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',
|
||||
@ -865,17 +881,6 @@ export const SignPage = () => {
|
||||
}
|
||||
}
|
||||
|
||||
if (authUrl) {
|
||||
return (
|
||||
<iframe
|
||||
title="Nsecbunker auth"
|
||||
src={authUrl}
|
||||
width="100%"
|
||||
height="500px"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingSpinner desc={loadingSpinnerDesc} />
|
||||
}
|
||||
@ -941,7 +946,7 @@ export const SignPage = () => {
|
||||
{signedStatus === SignedStatus.Fully_Signed && (
|
||||
<Box sx={{ mt: 1, display: 'flex', justifyContent: 'center' }}>
|
||||
<Button onClick={handleExport} variant="contained">
|
||||
Export
|
||||
Export Sigit
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
@ -956,8 +961,8 @@ export const SignPage = () => {
|
||||
|
||||
{isSignerOrCreator && (
|
||||
<Box sx={{ mt: 1, display: 'flex', justifyContent: 'center' }}>
|
||||
<Button onClick={handleExportSigit} variant="contained">
|
||||
Export Sigit
|
||||
<Button onClick={handleEncryptedExport} variant="contained">
|
||||
Export Encrypted Sigit
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
@ -14,6 +14,7 @@
|
||||
border-bottom: 0.5px solid;
|
||||
padding: 8px 16px;
|
||||
font-size: 1.5rem;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.filesWrapper {
|
||||
@ -62,7 +63,6 @@
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
//z-index: 200;
|
||||
}
|
||||
|
||||
.fixedBottomForm input[type='text'] {
|
||||
|
@ -5,41 +5,46 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import { toast } from 'react-toastify'
|
||||
import { LoadingSpinner } from '../../components/LoadingSpinner'
|
||||
import { NostrController } from '../../controllers'
|
||||
import { DocSignatureEvent, Meta } from '../../types'
|
||||
import {
|
||||
DocSignatureEvent,
|
||||
Meta,
|
||||
SignedEvent,
|
||||
OpenTimestamp,
|
||||
OpenTimestampUpgradeVerifyResponse
|
||||
} from '../../types'
|
||||
import {
|
||||
decryptArrayBuffer,
|
||||
extractMarksFromSignedMeta,
|
||||
getHash,
|
||||
hexToNpub,
|
||||
unixNow,
|
||||
parseJson,
|
||||
readContentOfZipEntry,
|
||||
signEventForMetaFile,
|
||||
getCurrentUserFiles
|
||||
getCurrentUserFiles,
|
||||
updateUsersAppData,
|
||||
npubToHex,
|
||||
sendNotification
|
||||
} from '../../utils'
|
||||
import styles from './style.module.scss'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import {
|
||||
addMarks,
|
||||
FONT_SIZE,
|
||||
FONT_TYPE,
|
||||
groupMarksByFileNamePage,
|
||||
inPx
|
||||
} from '../../utils/pdf.ts'
|
||||
import { State } from '../../store/rootReducer.ts'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { FONT_SIZE, FONT_TYPE, inPx } from '../../utils/pdf.ts'
|
||||
import { useAppSelector } from '../../hooks/store'
|
||||
import { getLastSignersSig } from '../../utils/sign.ts'
|
||||
import { saveAs } from 'file-saver'
|
||||
import { Container } from '../../components/Container'
|
||||
import { useSigitMeta } from '../../hooks/useSigitMeta.tsx'
|
||||
import { StickySideColumns } from '../../layouts/StickySideColumns.tsx'
|
||||
import { UsersDetails } from '../../components/UsersDetails.tsx/index.tsx'
|
||||
import { UsersDetails } from '../../components/UsersDetails.tsx'
|
||||
import FileList from '../../components/FileList'
|
||||
import { CurrentUserFile } from '../../types/file.ts'
|
||||
import { Mark } from '../../types/mark.ts'
|
||||
import React from 'react'
|
||||
import { convertToSigitFile, SigitFile } from '../../utils/file.ts'
|
||||
import {
|
||||
convertToSigitFile,
|
||||
getZipWithFiles,
|
||||
SigitFile
|
||||
} from '../../utils/file.ts'
|
||||
import { FileDivider } from '../../components/FileDivider.tsx'
|
||||
import { ExtensionFileBox } from '../../components/ExtensionFileBox.tsx'
|
||||
import { useScale } from '../../hooks/useScale.tsx'
|
||||
@ -48,6 +53,9 @@ import {
|
||||
faFile,
|
||||
faFileDownload
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
import { upgradeAndVerifyTimestamp } from '../../utils/opentimestamps.ts'
|
||||
import _ from 'lodash'
|
||||
import { MARK_TYPE_CONFIG } from '../../components/getMarkComponents.tsx'
|
||||
|
||||
interface PdfViewProps {
|
||||
files: CurrentUserFile[]
|
||||
@ -107,6 +115,8 @@ const SlimPdfView = ({
|
||||
alt={`page ${i} of ${file.name}`}
|
||||
/>
|
||||
{marks.map((m) => {
|
||||
const { render: MarkRenderComponent } =
|
||||
MARK_TYPE_CONFIG[m.type] || {}
|
||||
return (
|
||||
<div
|
||||
className={`file-mark ${styles.mark}`}
|
||||
@ -122,7 +132,9 @@ const SlimPdfView = ({
|
||||
fontSize: inPx(from(page.width, FONT_SIZE))
|
||||
}}
|
||||
>
|
||||
{m.value}
|
||||
{typeof MarkRenderComponent !== 'undefined' && (
|
||||
<MarkRenderComponent value={m.value} mark={m} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@ -153,7 +165,7 @@ const SlimPdfView = ({
|
||||
|
||||
export const VerifyPage = () => {
|
||||
const location = useLocation()
|
||||
const usersPubkey = useSelector((state: State) => state.auth.usersPubkey)
|
||||
const usersPubkey = useAppSelector((state) => state.auth.usersPubkey)
|
||||
|
||||
const nostrController = NostrController.getInstance()
|
||||
|
||||
@ -180,7 +192,8 @@ export const VerifyPage = () => {
|
||||
signers,
|
||||
viewers,
|
||||
fileHashes,
|
||||
parsedSignatureEvents
|
||||
parsedSignatureEvents,
|
||||
timestamps
|
||||
} = useSigitMeta(meta)
|
||||
|
||||
const [files, setFiles] = useState<{ [filename: string]: SigitFile }>({})
|
||||
@ -190,6 +203,16 @@ export const VerifyPage = () => {
|
||||
[key: string]: string | null
|
||||
}>({})
|
||||
|
||||
const signTimestampEvent = async (signerContent: {
|
||||
timestamps: OpenTimestamp[]
|
||||
}): Promise<SignedEvent | null> => {
|
||||
return await signEventForMetaFile(
|
||||
JSON.stringify(signerContent),
|
||||
nostrController,
|
||||
setIsLoading
|
||||
)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (Object.entries(files).length > 0) {
|
||||
const tmp = getCurrentUserFiles(files, currentFileHashes, fileHashes)
|
||||
@ -197,6 +220,147 @@ export const VerifyPage = () => {
|
||||
}
|
||||
}, [currentFileHashes, fileHashes, files])
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
timestamps &&
|
||||
timestamps.length > 0 &&
|
||||
usersPubkey &&
|
||||
submittedBy &&
|
||||
parsedSignatureEvents
|
||||
) {
|
||||
if (timestamps.every((t) => !!t.verification)) {
|
||||
return
|
||||
}
|
||||
const upgradeT = async (timestamps: OpenTimestamp[]) => {
|
||||
try {
|
||||
setLoadingSpinnerDesc('Upgrading your timestamps.')
|
||||
|
||||
const findCreatorTimestamp = (timestamps: OpenTimestamp[]) => {
|
||||
if (usersPubkey === submittedBy) {
|
||||
return timestamps[0]
|
||||
}
|
||||
}
|
||||
|
||||
const findSignerTimestamp = (timestamps: OpenTimestamp[]) => {
|
||||
const parsedEvent = parsedSignatureEvents[hexToNpub(usersPubkey)]
|
||||
if (parsedEvent?.id) {
|
||||
return timestamps.find((t) => t.nostrId === parsedEvent.id)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if timestamp verification has been achieved for the first time.
|
||||
* Note that the upgrade flag is separate from verification. It is possible for a timestamp
|
||||
* to not be upgraded, but to be verified for the first time.
|
||||
* @param upgradedTimestamp
|
||||
* @param timestamps
|
||||
*/
|
||||
const isNewlyVerified = (
|
||||
upgradedTimestamp: OpenTimestampUpgradeVerifyResponse,
|
||||
timestamps: OpenTimestamp[]
|
||||
) => {
|
||||
if (!upgradedTimestamp.verified) return false
|
||||
const oldT = timestamps.find(
|
||||
(t) => t.nostrId === upgradedTimestamp.timestamp.nostrId
|
||||
)
|
||||
if (!oldT) return false
|
||||
if (!oldT.verification && upgradedTimestamp.verified) return true
|
||||
}
|
||||
|
||||
const userTimestamps: OpenTimestamp[] = []
|
||||
|
||||
const creatorTimestamp = findCreatorTimestamp(timestamps)
|
||||
if (creatorTimestamp) {
|
||||
userTimestamps.push(creatorTimestamp)
|
||||
}
|
||||
|
||||
const signerTimestamp = findSignerTimestamp(timestamps)
|
||||
if (signerTimestamp) {
|
||||
userTimestamps.push(signerTimestamp)
|
||||
}
|
||||
|
||||
if (userTimestamps.every((t) => !!t.verification)) {
|
||||
return
|
||||
}
|
||||
|
||||
const upgradedUserTimestamps = await Promise.all(
|
||||
userTimestamps.map(upgradeAndVerifyTimestamp)
|
||||
)
|
||||
|
||||
const upgradedTimestamps = upgradedUserTimestamps
|
||||
.filter((t) => t.upgraded || isNewlyVerified(t, userTimestamps))
|
||||
.map((t) => {
|
||||
const timestamp: OpenTimestamp = { ...t.timestamp }
|
||||
if (t.verified) {
|
||||
timestamp.verification = t.verification
|
||||
}
|
||||
return timestamp
|
||||
})
|
||||
|
||||
if (upgradedTimestamps.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
setLoadingSpinnerDesc('Signing a timestamp upgrade event.')
|
||||
|
||||
const signedEvent = await signTimestampEvent({
|
||||
timestamps: upgradedTimestamps
|
||||
})
|
||||
if (!signedEvent) return
|
||||
|
||||
const finalTimestamps = timestamps.map((t) => {
|
||||
const upgraded = upgradedTimestamps.find(
|
||||
(tu) => tu.nostrId === t.nostrId
|
||||
)
|
||||
if (upgraded) {
|
||||
return {
|
||||
...upgraded,
|
||||
signature: JSON.stringify(signedEvent, null, 2)
|
||||
}
|
||||
}
|
||||
return t
|
||||
})
|
||||
|
||||
const updatedMeta = _.cloneDeep(meta)
|
||||
updatedMeta.timestamps = [...finalTimestamps]
|
||||
updatedMeta.modifiedAt = unixNow()
|
||||
|
||||
const updatedEvent = await updateUsersAppData(updatedMeta)
|
||||
if (!updatedEvent) return
|
||||
|
||||
const userSet = new Set<`npub1${string}`>()
|
||||
signers.forEach((signer) => {
|
||||
if (signer !== usersPubkey) {
|
||||
userSet.add(signer)
|
||||
}
|
||||
})
|
||||
|
||||
viewers.forEach((viewer) => {
|
||||
userSet.add(viewer)
|
||||
})
|
||||
|
||||
const users = Array.from(userSet)
|
||||
const promises = users.map((user) =>
|
||||
sendNotification(npubToHex(user)!, updatedMeta)
|
||||
)
|
||||
|
||||
await Promise.all(promises)
|
||||
|
||||
toast.success('Timestamp updates have been sent successfully.')
|
||||
|
||||
setMeta(meta)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
toast.error(
|
||||
'There was an error upgrading or verifying your timestamps!'
|
||||
)
|
||||
}
|
||||
}
|
||||
upgradeT(timestamps)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [timestamps, submittedBy, parsedSignatureEvents])
|
||||
|
||||
useEffect(() => {
|
||||
if (metaInNavState && encryptionKey) {
|
||||
const processSigit = async () => {
|
||||
@ -359,7 +523,7 @@ export const VerifyPage = () => {
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
const handleExport = async () => {
|
||||
const handleMarkedExport = async () => {
|
||||
if (Object.entries(files).length === 0 || !meta || !usersPubkey) return
|
||||
|
||||
const usersNpub = hexToNpub(usersPubkey)
|
||||
@ -389,22 +553,9 @@ export const VerifyPage = () => {
|
||||
const updatedMeta = { ...meta, exportSignature }
|
||||
const stringifiedMeta = JSON.stringify(updatedMeta, null, 2)
|
||||
|
||||
const zip = new JSZip()
|
||||
const zip = await getZipWithFiles(updatedMeta, files)
|
||||
zip.file('meta.json', stringifiedMeta)
|
||||
|
||||
const marks = extractMarksFromSignedMeta(updatedMeta)
|
||||
const marksByPage = groupMarksByFileNamePage(marks)
|
||||
|
||||
for (const [fileName, file] of Object.entries(files)) {
|
||||
if (file.isPdf) {
|
||||
// Draw marks into PDF file and generate a brand new blob
|
||||
const blob = await addMarks(file, marksByPage[fileName])
|
||||
zip.file(`files/${fileName}`, blob)
|
||||
} else {
|
||||
zip.file(`files/${fileName}`, file)
|
||||
}
|
||||
}
|
||||
|
||||
const arrayBuffer = await zip
|
||||
.generateAsync({
|
||||
type: 'arraybuffer',
|
||||
@ -471,7 +622,7 @@ export const VerifyPage = () => {
|
||||
)}
|
||||
currentFile={currentFile}
|
||||
setCurrentFile={setCurrentFile}
|
||||
handleDownload={handleExport}
|
||||
handleDownload={handleMarkedExport}
|
||||
downloadLabel="Download Sigit"
|
||||
/>
|
||||
)
|
||||
|
@ -1,13 +1,10 @@
|
||||
import { Modal } from '../layouts/modal'
|
||||
import { CreatePage } from '../pages/create'
|
||||
import { HomePage } from '../pages/home'
|
||||
import { LandingPage } from '../pages/landing'
|
||||
import { Login } from '../pages/login'
|
||||
import { Nostr } from '../pages/nostr'
|
||||
import { ProfilePage } from '../pages/profile'
|
||||
import { Register } from '../pages/register'
|
||||
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'
|
||||
@ -22,7 +19,8 @@ export const appPrivateRoutes = {
|
||||
settings: '/settings',
|
||||
profileSettings: '/settings/profile/:npub',
|
||||
cacheSettings: '/settings/cache',
|
||||
relays: '/settings/relays'
|
||||
relays: '/settings/relays',
|
||||
nostrLogin: '/settings/nostrLogin'
|
||||
}
|
||||
|
||||
export const appPublicRoutes = {
|
||||
@ -85,29 +83,7 @@ export const publicRoutes: PublicRouteProps[] = [
|
||||
{
|
||||
path: appPublicRoutes.landingPage,
|
||||
hiddenWhenLoggedIn: true,
|
||||
element: <LandingPage />,
|
||||
children: [
|
||||
{
|
||||
element: <Modal />,
|
||||
children: [
|
||||
{
|
||||
path: appPublicRoutes.login,
|
||||
hiddenWhenLoggedIn: true,
|
||||
element: <Login />
|
||||
},
|
||||
{
|
||||
path: appPublicRoutes.register,
|
||||
hiddenWhenLoggedIn: true,
|
||||
element: <Register />
|
||||
},
|
||||
{
|
||||
path: appPublicRoutes.nostr,
|
||||
hiddenWhenLoggedIn: true,
|
||||
element: <Nostr />
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
element: <LandingPage />
|
||||
},
|
||||
{
|
||||
path: appPublicRoutes.profile,
|
||||
@ -129,7 +105,7 @@ export const privateRoutes = [
|
||||
element: <CreatePage />
|
||||
},
|
||||
{
|
||||
path: appPrivateRoutes.sign,
|
||||
path: `${appPrivateRoutes.sign}/:id?`,
|
||||
element: <SignPage />
|
||||
},
|
||||
{
|
||||
@ -147,5 +123,9 @@ export const privateRoutes = [
|
||||
{
|
||||
path: appPrivateRoutes.relays,
|
||||
element: <RelaysPage />
|
||||
},
|
||||
{
|
||||
path: appPrivateRoutes.nostrLogin,
|
||||
element: <NostrLoginPage />
|
||||
}
|
||||
]
|
||||
|
81
src/services/LoginMethodStrategy/NostrLoginStrategy.ts
Normal file
81
src/services/LoginMethodStrategy/NostrLoginStrategy.ts
Normal file
@ -0,0 +1,81 @@
|
||||
import { Event, UnsignedEvent, EventTemplate, NostrEvent } from 'nostr-tools'
|
||||
import { SignedEvent } from '../../types'
|
||||
import { LoginMethodStrategy } from './loginMethodStrategy'
|
||||
import { WindowNostr } from 'nostr-tools/nip07'
|
||||
|
||||
/**
|
||||
* Login Method Strategy when using nostr-login package.
|
||||
*
|
||||
* This class extends {@link LoginMethodStrategy base strategy} and implements all login method operations
|
||||
* @see {@link LoginMethodStrategy}
|
||||
*/
|
||||
export class NostrLoginStrategy extends LoginMethodStrategy {
|
||||
private nostr: WindowNostr
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
if (!window.nostr) {
|
||||
throw new Error(
|
||||
`window.nostr object not present. Make sure you have an nostr extension installed/working properly.`
|
||||
)
|
||||
}
|
||||
|
||||
this.nostr = window.nostr as WindowNostr
|
||||
}
|
||||
|
||||
async nip04Encrypt(receiver: string, content: string): Promise<string> {
|
||||
if (!this.nostr.nip04) {
|
||||
throw new Error(
|
||||
`Your nostr extension does not support nip04 encryption & decryption`
|
||||
)
|
||||
}
|
||||
|
||||
const encrypted = await this.nostr.nip04.encrypt(receiver, content)
|
||||
return encrypted
|
||||
}
|
||||
|
||||
async nip04Decrypt(sender: string, content: string): Promise<string> {
|
||||
if (!this.nostr.nip04) {
|
||||
throw new Error(
|
||||
`Your nostr extension does not support nip04 encryption & decryption`
|
||||
)
|
||||
}
|
||||
|
||||
const decrypted = await this.nostr.nip04.decrypt(sender, content)
|
||||
return decrypted
|
||||
}
|
||||
|
||||
async nip44Encrypt(receiver: string, content: string): Promise<string> {
|
||||
if (!this.nostr.nip44) {
|
||||
throw new Error(
|
||||
`Your nostr extension does not support nip44 encryption & decryption`
|
||||
)
|
||||
}
|
||||
|
||||
// Encrypt the content using NIP-44 provided by the nostr extension.
|
||||
const encrypted = await this.nostr.nip44.encrypt(receiver, content)
|
||||
return encrypted as string
|
||||
}
|
||||
|
||||
async nip44Decrypt(sender: string, content: string): Promise<string> {
|
||||
if (!this.nostr.nip44) {
|
||||
throw new Error(
|
||||
`Your nostr extension does not support nip44 encryption & decryption`
|
||||
)
|
||||
}
|
||||
|
||||
// Decrypt the content using NIP-44 provided by the nostr extension.
|
||||
const decrypted = await this.nostr.nip44.decrypt(sender, content)
|
||||
return decrypted as string
|
||||
}
|
||||
|
||||
async signEvent(event: UnsignedEvent | EventTemplate): Promise<SignedEvent> {
|
||||
return (await this.nostr
|
||||
.signEvent(event as NostrEvent)
|
||||
.catch((err: unknown) => {
|
||||
console.log('Error while signing event: ', err)
|
||||
|
||||
throw err
|
||||
})) as Event
|
||||
}
|
||||
}
|
124
src/services/LoginMethodStrategy/PrivateKeyStrategy.ts
Normal file
124
src/services/LoginMethodStrategy/PrivateKeyStrategy.ts
Normal file
@ -0,0 +1,124 @@
|
||||
import {
|
||||
UnsignedEvent,
|
||||
EventTemplate,
|
||||
nip19,
|
||||
nip44,
|
||||
finalizeEvent,
|
||||
nip04
|
||||
} from 'nostr-tools'
|
||||
import { SignedEvent } from '../../types'
|
||||
import store from '../../store/store'
|
||||
import { LoginMethod } from '../../store/auth/types'
|
||||
import { LoginMethodStrategy } from './loginMethodStrategy'
|
||||
import { verifySignedEvent } from '../../utils/nostr'
|
||||
|
||||
/**
|
||||
* Login Method Strategy when using dev private key login.
|
||||
*
|
||||
* This class extends {@link LoginMethodStrategy base strategy} and implements all login method operations
|
||||
* @see {@link LoginMethodStrategy}
|
||||
*/
|
||||
export class PrivateKeyStrategy extends LoginMethodStrategy {
|
||||
async nip04Encrypt(receiver: string, content: string): Promise<string> {
|
||||
const keys = store.getState().auth.keyPair
|
||||
|
||||
if (!keys) {
|
||||
throw new Error(
|
||||
`Login method is ${LoginMethod.privateKey} but private & public key pair is not found.`
|
||||
)
|
||||
}
|
||||
|
||||
const { private: nsec } = keys
|
||||
const privateKey = nip19.decode(nsec).data as Uint8Array
|
||||
|
||||
const encrypted = await nip04.encrypt(privateKey, receiver, content)
|
||||
return encrypted
|
||||
}
|
||||
|
||||
async nip04Decrypt(sender: string, content: string): Promise<string> {
|
||||
const keys = store.getState().auth.keyPair
|
||||
|
||||
if (!keys) {
|
||||
throw new Error(
|
||||
`Login method is ${LoginMethod.privateKey} but private & public key pair is not found.`
|
||||
)
|
||||
}
|
||||
|
||||
const { private: nsec } = keys
|
||||
const privateKey = nip19.decode(nsec).data as Uint8Array
|
||||
|
||||
const decrypted = await nip04.decrypt(privateKey, sender, content)
|
||||
return decrypted
|
||||
}
|
||||
|
||||
async nip44Encrypt(receiver: string, content: string): Promise<string> {
|
||||
const keys = store.getState().auth.keyPair
|
||||
|
||||
// Check if the private and public key pair is available.
|
||||
if (!keys) {
|
||||
throw new Error(
|
||||
`Login method is ${LoginMethod.privateKey} but private & public key pair is not found.`
|
||||
)
|
||||
}
|
||||
|
||||
// Decode the private key.
|
||||
const { private: nsec } = keys
|
||||
const privateKey = nip19.decode(nsec).data as Uint8Array
|
||||
|
||||
// Generate the conversation key using NIP-44 utilities.
|
||||
const nip44ConversationKey = nip44.v2.utils.getConversationKey(
|
||||
privateKey,
|
||||
receiver
|
||||
)
|
||||
|
||||
// Encrypt the content using the generated conversation key.
|
||||
const encrypted = nip44.v2.encrypt(content, nip44ConversationKey)
|
||||
|
||||
return encrypted
|
||||
}
|
||||
|
||||
async nip44Decrypt(sender: string, content: string): Promise<string> {
|
||||
const keys = store.getState().auth.keyPair
|
||||
|
||||
// Check if the private and public key pair is available.
|
||||
if (!keys) {
|
||||
throw new Error(
|
||||
`Login method is ${LoginMethod.privateKey} but private & public key pair is not found.`
|
||||
)
|
||||
}
|
||||
|
||||
// Decode the private key.
|
||||
const { private: nsec } = keys
|
||||
const privateKey = nip19.decode(nsec).data as Uint8Array
|
||||
|
||||
// Generate the conversation key using NIP-44 utilities.
|
||||
const nip44ConversationKey = nip44.v2.utils.getConversationKey(
|
||||
privateKey,
|
||||
sender
|
||||
)
|
||||
|
||||
// Decrypt the content using the generated conversation key.
|
||||
const decrypted = nip44.v2.decrypt(content, nip44ConversationKey)
|
||||
|
||||
return decrypted
|
||||
}
|
||||
|
||||
async signEvent(event: UnsignedEvent | EventTemplate): Promise<SignedEvent> {
|
||||
const keys = store.getState().auth.keyPair
|
||||
|
||||
if (!keys) {
|
||||
return Promise.reject(
|
||||
`Login method is ${LoginMethod.privateKey}, but keys are not found`
|
||||
)
|
||||
}
|
||||
|
||||
const { private: nsec } = keys
|
||||
const privateKey = nip19.decode(nsec).data as Uint8Array
|
||||
|
||||
const signedEvent = finalizeEvent(event, privateKey)
|
||||
|
||||
verifySignedEvent(signedEvent)
|
||||
|
||||
return Promise.resolve(signedEvent)
|
||||
}
|
||||
}
|
50
src/services/LoginMethodStrategy/loginMethodContext.ts
Normal file
50
src/services/LoginMethodStrategy/loginMethodContext.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { UnsignedEvent, EventTemplate } from 'nostr-tools'
|
||||
import { SignedEvent } from '../../types'
|
||||
import {
|
||||
LoginMethodStrategy,
|
||||
LoginMethodOperations
|
||||
} from './loginMethodStrategy'
|
||||
import { LoginMethod } from '../../store/auth/types'
|
||||
import { NostrLoginStrategy } from './NostrLoginStrategy'
|
||||
import { PrivateKeyStrategy } from './PrivateKeyStrategy'
|
||||
|
||||
/**
|
||||
* This class is a context provider and helper class. This MUST be instantiated and used as an entry point for any of the {@link LoginMethodOperations LoginMethodOperations}
|
||||
* @constructor Takes {@link LoginMethod LoginMethod} as an argument and sets the correct strategy
|
||||
*
|
||||
* @see {@link LoginMethod}
|
||||
* @see {@link LoginMethodOperations}
|
||||
*/
|
||||
export class LoginMethodContext implements LoginMethodOperations {
|
||||
private strategy: LoginMethodStrategy
|
||||
|
||||
constructor(loginMethod?: LoginMethod) {
|
||||
switch (loginMethod) {
|
||||
case LoginMethod.nostrLogin:
|
||||
this.strategy = new NostrLoginStrategy()
|
||||
break
|
||||
case LoginMethod.privateKey:
|
||||
this.strategy = new PrivateKeyStrategy()
|
||||
break
|
||||
default:
|
||||
this.strategy = new LoginMethodStrategy()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
nip04Encrypt(receiver: string, content: string): Promise<string> {
|
||||
return this.strategy.nip04Encrypt(receiver, content)
|
||||
}
|
||||
nip04Decrypt(sender: string, content: string): Promise<string> {
|
||||
return this.strategy.nip04Decrypt(sender, content)
|
||||
}
|
||||
nip44Encrypt(receiver: string, content: string): Promise<string> {
|
||||
return this.strategy.nip44Encrypt(receiver, content)
|
||||
}
|
||||
nip44Decrypt(sender: string, content: string): Promise<string> {
|
||||
return this.strategy.nip44Decrypt(sender, content)
|
||||
}
|
||||
signEvent(event: UnsignedEvent | EventTemplate): Promise<SignedEvent> {
|
||||
return this.strategy.signEvent(event)
|
||||
}
|
||||
}
|
38
src/services/LoginMethodStrategy/loginMethodStrategy.ts
Normal file
38
src/services/LoginMethodStrategy/loginMethodStrategy.ts
Normal file
@ -0,0 +1,38 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import { EventTemplate, UnsignedEvent } from 'nostr-tools'
|
||||
import { SignedEvent } from '../../types/nostr'
|
||||
|
||||
/**
|
||||
* This interface holds all operations that are dependant on the login method and is used as the basis for the login strategies.
|
||||
*/
|
||||
export interface LoginMethodOperations {
|
||||
nip04Encrypt(receiver: string, content: string): Promise<string>
|
||||
nip04Decrypt(sender: string, content: string): Promise<string>
|
||||
nip44Encrypt(receiver: string, content: string): Promise<string>
|
||||
nip44Decrypt(sender: string, content: string): Promise<string>
|
||||
signEvent(event: UnsignedEvent | EventTemplate): Promise<SignedEvent>
|
||||
}
|
||||
|
||||
/**
|
||||
* This is the fallback class that provides base implementation for the {@link LoginMethodOperations login method operations} . Only used to throw errors in case when the LoginMethod is missing (login context not set).
|
||||
* @see {@link LoginMethodOperations}
|
||||
*/
|
||||
export class LoginMethodStrategy implements LoginMethodOperations {
|
||||
async nip04Encrypt(_receiver: string, _content: string): Promise<string> {
|
||||
throw new Error('Login method strategy is undefined')
|
||||
}
|
||||
async nip04Decrypt(_sender: string, _content: string): Promise<string> {
|
||||
throw new Error('Login method strategy is undefined')
|
||||
}
|
||||
async nip44Encrypt(_receiver: string, _content: string): Promise<string> {
|
||||
throw new Error('Login method strategy is undefined')
|
||||
}
|
||||
async nip44Decrypt(_sender: string, _content: string): Promise<string> {
|
||||
throw new Error('Login method strategy is undefined')
|
||||
}
|
||||
async signEvent(_event: UnsignedEvent | EventTemplate): Promise<SignedEvent> {
|
||||
return Promise.reject(
|
||||
`We could not sign the event, none of the signing methods are available`
|
||||
)
|
||||
}
|
||||
}
|
@ -4,9 +4,8 @@ export const USER_LOGOUT = 'USER_LOGOUT'
|
||||
|
||||
export const SET_AUTH_STATE = 'SET_AUTH_STATE'
|
||||
export const UPDATE_LOGIN_METHOD = 'UPDATE_LOGIN_METHOD'
|
||||
export const UPDATE_NOSTR_LOGIN_AUTH_METHOD = 'UPDATE_NOSTR_LOGIN_AUTH_METHOD'
|
||||
export const UPDATE_KEYPAIR = 'UPDATE_KEYPAIR'
|
||||
export const UPDATE_NSECBUNKER_PUBKEY = 'UPDATE_NSECBUNKER_PUBKEY'
|
||||
export const UPDATE_NSECBUNKER_RELAYS = 'UPDATE_NSECBUNKER_RELAYS'
|
||||
|
||||
export const SET_METADATA_EVENT = 'SET_METADATA_EVENT'
|
||||
|
||||
|
@ -2,12 +2,12 @@ import * as ActionTypes from '../actionTypes'
|
||||
import {
|
||||
AuthState,
|
||||
Keys,
|
||||
LoginMethods,
|
||||
LoginMethod,
|
||||
SetAuthState,
|
||||
UpdateKeyPair,
|
||||
UpdateLoginMethod,
|
||||
UpdateNsecBunkerPubkey,
|
||||
UpdateNsecBunkerRelays
|
||||
NostrLoginAuthMethod,
|
||||
UpdateNostrLoginAuthMethod
|
||||
} from './types'
|
||||
|
||||
export const setAuthState = (payload: AuthState): SetAuthState => ({
|
||||
@ -16,27 +16,20 @@ export const setAuthState = (payload: AuthState): SetAuthState => ({
|
||||
})
|
||||
|
||||
export const updateLoginMethod = (
|
||||
payload: LoginMethods | undefined
|
||||
payload: LoginMethod | undefined
|
||||
): UpdateLoginMethod => ({
|
||||
type: ActionTypes.UPDATE_LOGIN_METHOD,
|
||||
payload
|
||||
})
|
||||
|
||||
export const updateNostrLoginAuthMethod = (
|
||||
payload: NostrLoginAuthMethod | undefined
|
||||
): UpdateNostrLoginAuthMethod => ({
|
||||
type: ActionTypes.UPDATE_NOSTR_LOGIN_AUTH_METHOD,
|
||||
payload
|
||||
})
|
||||
|
||||
export const updateKeyPair = (payload: Keys | undefined): UpdateKeyPair => ({
|
||||
type: ActionTypes.UPDATE_KEYPAIR,
|
||||
payload
|
||||
})
|
||||
|
||||
export const updateNsecbunkerPubkey = (
|
||||
payload: string | undefined
|
||||
): UpdateNsecBunkerPubkey => ({
|
||||
type: ActionTypes.UPDATE_NSECBUNKER_PUBKEY,
|
||||
payload
|
||||
})
|
||||
|
||||
export const updateNsecbunkerRelays = (
|
||||
payload: string[] | undefined
|
||||
): UpdateNsecBunkerRelays => ({
|
||||
type: ActionTypes.UPDATE_NSECBUNKER_RELAYS,
|
||||
payload
|
||||
})
|
||||
|
@ -8,16 +8,15 @@ const initialState: AuthState = {
|
||||
const reducer = (
|
||||
state = initialState,
|
||||
action: AuthDispatchTypes
|
||||
): AuthState | null => {
|
||||
): AuthState => {
|
||||
switch (action.type) {
|
||||
case ActionTypes.SET_AUTH_STATE: {
|
||||
const { loginMethod, keyPair, nsecBunkerPubkey, nsecBunkerRelays } = state
|
||||
const { loginMethod, nostrLoginAuthMethod, keyPair } = state
|
||||
|
||||
return {
|
||||
loginMethod,
|
||||
nostrLoginAuthMethod,
|
||||
keyPair,
|
||||
nsecBunkerPubkey,
|
||||
nsecBunkerRelays,
|
||||
...action.payload
|
||||
}
|
||||
}
|
||||
@ -30,6 +29,15 @@ const reducer = (
|
||||
}
|
||||
}
|
||||
|
||||
case ActionTypes.UPDATE_NOSTR_LOGIN_AUTH_METHOD: {
|
||||
const { payload } = action
|
||||
|
||||
return {
|
||||
...state,
|
||||
nostrLoginAuthMethod: payload
|
||||
}
|
||||
}
|
||||
|
||||
case ActionTypes.UPDATE_KEYPAIR: {
|
||||
const { payload } = action
|
||||
|
||||
@ -39,26 +47,8 @@ const reducer = (
|
||||
}
|
||||
}
|
||||
|
||||
case ActionTypes.UPDATE_NSECBUNKER_PUBKEY: {
|
||||
const { payload } = action
|
||||
|
||||
return {
|
||||
...state,
|
||||
nsecBunkerPubkey: payload
|
||||
}
|
||||
}
|
||||
|
||||
case ActionTypes.UPDATE_NSECBUNKER_RELAYS: {
|
||||
const { payload } = action
|
||||
|
||||
return {
|
||||
...state,
|
||||
nsecBunkerRelays: payload
|
||||
}
|
||||
}
|
||||
|
||||
case ActionTypes.RESTORE_STATE:
|
||||
return action.payload.auth
|
||||
return action.payload.auth || initialState
|
||||
|
||||
default:
|
||||
return state
|
||||
|
@ -1,10 +1,17 @@
|
||||
import * as ActionTypes from '../actionTypes'
|
||||
import { RestoreState, UserLogout } from '../actions'
|
||||
|
||||
export enum LoginMethods {
|
||||
extension = 'extension',
|
||||
export enum NostrLoginAuthMethod {
|
||||
Connect = 'connect',
|
||||
ReadOnly = 'readOnly',
|
||||
Extension = 'extension',
|
||||
Local = 'local',
|
||||
OTP = 'otp'
|
||||
}
|
||||
|
||||
export enum LoginMethod {
|
||||
nostrLogin = 'nostrLogin',
|
||||
privateKey = 'privateKey',
|
||||
nsecBunker = 'nsecBunker',
|
||||
register = 'register'
|
||||
}
|
||||
|
||||
@ -16,10 +23,21 @@ export interface Keys {
|
||||
export interface AuthState {
|
||||
loggedIn: boolean
|
||||
usersPubkey?: string
|
||||
loginMethod?: LoginMethods
|
||||
/**
|
||||
* sigit login {@link LoginMethod methods }
|
||||
* @see {@link LoginMethod}
|
||||
*/
|
||||
loginMethod?: LoginMethod
|
||||
/**
|
||||
* nostr-login package specific {@link NostrLoginAuthMethod method }
|
||||
* @see {@link NostrLoginAuthMethod}
|
||||
*/
|
||||
nostrLoginAuthMethod?: NostrLoginAuthMethod
|
||||
/**
|
||||
* {@link Keys keyPair} for user auth (usually only public is available)
|
||||
* @see {@link Keys}
|
||||
*/
|
||||
keyPair?: Keys
|
||||
nsecBunkerPubkey?: string
|
||||
nsecBunkerRelays?: string[]
|
||||
}
|
||||
|
||||
export interface SetAuthState {
|
||||
@ -29,7 +47,12 @@ export interface SetAuthState {
|
||||
|
||||
export interface UpdateLoginMethod {
|
||||
type: typeof ActionTypes.UPDATE_LOGIN_METHOD
|
||||
payload: LoginMethods | undefined
|
||||
payload: LoginMethod | undefined
|
||||
}
|
||||
|
||||
export interface UpdateNostrLoginAuthMethod {
|
||||
type: typeof ActionTypes.UPDATE_NOSTR_LOGIN_AUTH_METHOD
|
||||
payload: NostrLoginAuthMethod | undefined
|
||||
}
|
||||
|
||||
export interface UpdateKeyPair {
|
||||
@ -37,21 +60,10 @@ export interface UpdateKeyPair {
|
||||
payload: Keys | undefined
|
||||
}
|
||||
|
||||
export interface UpdateNsecBunkerPubkey {
|
||||
type: typeof ActionTypes.UPDATE_NSECBUNKER_PUBKEY
|
||||
payload: string | undefined
|
||||
}
|
||||
|
||||
export interface UpdateNsecBunkerRelays {
|
||||
type: typeof ActionTypes.UPDATE_NSECBUNKER_RELAYS
|
||||
payload: string[] | undefined
|
||||
}
|
||||
|
||||
export type AuthDispatchTypes =
|
||||
| RestoreState
|
||||
| SetAuthState
|
||||
| UpdateLoginMethod
|
||||
| UpdateNostrLoginAuthMethod
|
||||
| UpdateKeyPair
|
||||
| UpdateNsecBunkerPubkey
|
||||
| UpdateNsecBunkerRelays
|
||||
| UserLogout
|
||||
|
@ -15,7 +15,7 @@ const reducer = (
|
||||
}
|
||||
|
||||
case ActionTypes.RESTORE_STATE:
|
||||
return action.payload.metadata || null
|
||||
return action.payload.metadata || initialState
|
||||
|
||||
default:
|
||||
return state
|
||||
|
@ -10,7 +10,7 @@ const initialState: RelaysState = {
|
||||
const reducer = (
|
||||
state = initialState,
|
||||
action: RelaysDispatchTypes
|
||||
): RelaysState | null => {
|
||||
): RelaysState => {
|
||||
switch (action.type) {
|
||||
case ActionTypes.SET_RELAY_MAP:
|
||||
return { ...state, map: action.payload, mapUpdated: Date.now() }
|
||||
@ -25,7 +25,7 @@ const reducer = (
|
||||
}
|
||||
|
||||
case ActionTypes.RESTORE_STATE:
|
||||
return action.payload.relays
|
||||
return action.payload.relays || initialState
|
||||
|
||||
default:
|
||||
return state
|
||||
|
@ -12,7 +12,7 @@ const reducer = (
|
||||
return action.payload
|
||||
|
||||
case ActionTypes.RESTORE_STATE:
|
||||
return action.payload.userRobotImage
|
||||
return action.payload.userRobotImage || initialState
|
||||
|
||||
default:
|
||||
return state
|
||||
|
@ -18,6 +18,7 @@ export interface Meta {
|
||||
docSignatures: { [key: `npub1${string}`]: string }
|
||||
exportSignature?: string
|
||||
keys?: { sender: string; keys: { [user: `npub1${string}`]: string } }
|
||||
timestamps?: OpenTimestamp[]
|
||||
}
|
||||
|
||||
export interface CreateSignatureEventContent {
|
||||
@ -39,11 +40,44 @@ export interface Sigit {
|
||||
meta: Meta
|
||||
}
|
||||
|
||||
export interface OpenTimestamp {
|
||||
nostrId: string
|
||||
value: string
|
||||
verification?: OpenTimestampVerification
|
||||
signature?: string
|
||||
}
|
||||
|
||||
export interface OpenTimestampVerification {
|
||||
height: number
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export interface OpenTimestampUpgradeVerifyResponse {
|
||||
timestamp: OpenTimestamp
|
||||
upgraded: boolean
|
||||
verified?: boolean
|
||||
verification?: OpenTimestampVerification
|
||||
}
|
||||
|
||||
export interface UserAppData {
|
||||
sigits: { [key: string]: Meta } // key will be id of create signature
|
||||
processedGiftWraps: string[] // an array of ids of processed gift wrapped events
|
||||
keyPair?: Keys // this key pair is used for blossom requests authentication
|
||||
blossomUrls: string[] // array for storing Urls for the files that stores all the sigits and processedGiftWraps on blossom
|
||||
/**
|
||||
* Key will be id of create signature
|
||||
*/
|
||||
sigits: { [key: string]: Meta }
|
||||
/**
|
||||
* An array of ids of processed gift wrapped events
|
||||
*/
|
||||
processedGiftWraps: string[]
|
||||
/**
|
||||
* Generated ephemeral key pair (https://docs.sigit.io/#/technical?id=storing-app-data).
|
||||
* This {@link Keys key pair} is used for blossom requests authentication.
|
||||
* @see {@link Keys}
|
||||
*/
|
||||
keyPair?: Keys
|
||||
/**
|
||||
* Array for storing Urls for the files that stores all the sigits and processedGiftWraps on blossom.
|
||||
*/
|
||||
blossomUrls: string[]
|
||||
}
|
||||
|
||||
export interface DocSignatureEvent extends Event {
|
||||
|
@ -31,7 +31,10 @@ export interface DrawTool {
|
||||
icon: IconDefinition
|
||||
defaultValue?: string
|
||||
selected?: boolean
|
||||
active?: boolean
|
||||
/** show or hide the toolbox item */
|
||||
isHidden?: boolean
|
||||
/** show or hide "coming soon" message on the toolbox item */
|
||||
isComingSoon?: boolean
|
||||
}
|
||||
|
||||
export enum MarkType {
|
||||
|
5
src/types/event.ts
Normal file
5
src/types/event.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export enum KeyboardCode {
|
||||
Escape = 'Escape',
|
||||
Enter = 'Enter',
|
||||
NumpadEnter = 'NumpadEnter'
|
||||
}
|
@ -4,3 +4,4 @@ export * from './nostr'
|
||||
export * from './profile'
|
||||
export * from './relay'
|
||||
export * from './zip'
|
||||
export * from './event'
|
||||
|
@ -28,3 +28,24 @@ export interface MarkRect {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export interface MarkInputProps {
|
||||
value: string
|
||||
handler: (value: string) => void
|
||||
placeholder?: string
|
||||
userMark?: CurrentUserMark
|
||||
}
|
||||
|
||||
export interface MarkRenderProps {
|
||||
value?: string
|
||||
mark: Mark
|
||||
}
|
||||
|
||||
export interface MarkConfig {
|
||||
input: React.FC<MarkInputProps>
|
||||
render?: React.FC<MarkRenderProps>
|
||||
}
|
||||
|
||||
export type MarkConfigs = {
|
||||
[key in MarkType]?: MarkConfig
|
||||
}
|
||||
|
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,6 +1,7 @@
|
||||
export interface ProfileMetadata {
|
||||
name?: string
|
||||
display_name?: string
|
||||
/** @deprecated use name instead */
|
||||
username?: string
|
||||
picture?: string
|
||||
banner?: 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 {
|
||||
interface Window {
|
||||
nostr?: WindowNostr
|
||||
OpenTimestamps: OpenTimestamps
|
||||
}
|
||||
}
|
||||
|
@ -1,11 +1,4 @@
|
||||
import { MarkType } from '../types/drawing.ts'
|
||||
|
||||
export const EMPTY: string = ''
|
||||
export const MARK_TYPE_TRANSLATION: { [key: string]: string } = {
|
||||
[MarkType.FULLNAME.valueOf()]: 'Full Name'
|
||||
}
|
||||
export const SIGN: string = 'Sign'
|
||||
export const NEXT: string = 'Next'
|
||||
export const ARRAY_BUFFER = 'arraybuffer'
|
||||
export const DEFLATE = 'DEFLATE'
|
||||
|
||||
|
@ -19,14 +19,14 @@ export const getZipWithFiles = async (
|
||||
const marksByFileNamePage = groupMarksByFileNamePage(marks)
|
||||
|
||||
for (const [fileName, file] of Object.entries(files)) {
|
||||
if (file.isPdf) {
|
||||
// Handle PDF Files
|
||||
// Handle PDF Files, add marks
|
||||
if (file.isPdf && fileName in marksByFileNamePage) {
|
||||
const blob = await addMarks(file, marksByFileNamePage[fileName])
|
||||
zip.file(`files/${fileName}`, blob)
|
||||
} else {
|
||||
// Handle other files
|
||||
zip.file(`files/${fileName}`, file)
|
||||
zip.file(`marked/${fileName}`, blob)
|
||||
}
|
||||
|
||||
// Save original files
|
||||
zip.file(`files/${fileName}`, file)
|
||||
}
|
||||
|
||||
return zip
|
||||
|
@ -26,14 +26,6 @@ export const clearState = () => {
|
||||
localStorage.removeItem('state')
|
||||
}
|
||||
|
||||
export const saveNsecBunkerDelegatedKey = (privateKey: string) => {
|
||||
localStorage.setItem('nsecbunker-delegated-key', privateKey)
|
||||
}
|
||||
|
||||
export const getNsecBunkerDelegatedKey = () => {
|
||||
return localStorage.getItem('nsecbunker-delegated-key')
|
||||
}
|
||||
|
||||
export const saveVisitedLink = (pathname: string, search: string) => {
|
||||
localStorage.setItem(
|
||||
'visitedLink',
|
||||
@ -69,3 +61,8 @@ export const getAuthToken = () => {
|
||||
export const clearAuthToken = () => {
|
||||
localStorage.removeItem('authToken')
|
||||
}
|
||||
|
||||
export const clear = () => {
|
||||
clearAuthToken()
|
||||
clearState()
|
||||
}
|
||||
|
@ -1,29 +1,30 @@
|
||||
import { CurrentUserMark, Mark } from '../types/mark.ts'
|
||||
import { CurrentUserMark, Mark, MarkLocation } from '../types/mark.ts'
|
||||
import { hexToNpub } from './nostr.ts'
|
||||
import { Meta, SignedEventContent } from '../types'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { EMPTY } from './const.ts'
|
||||
import { MarkType } from '../types/drawing.ts'
|
||||
import { DrawTool, MarkType } from '../types/drawing.ts'
|
||||
import {
|
||||
faT,
|
||||
faSignature,
|
||||
faBriefcase,
|
||||
faIdCard,
|
||||
faHeading,
|
||||
faClock,
|
||||
faCalendarDays,
|
||||
fa1,
|
||||
faImage,
|
||||
faSquareCheck,
|
||||
faCalendarDays,
|
||||
faCheckDouble,
|
||||
faPaperclip,
|
||||
faCircleDot,
|
||||
faSquareCaretDown,
|
||||
faTableCellsLarge,
|
||||
faStamp,
|
||||
faCreditCard,
|
||||
faPhone
|
||||
faHeading,
|
||||
faImage,
|
||||
faPaperclip,
|
||||
faPhone,
|
||||
faSquareCaretDown,
|
||||
faSquareCheck,
|
||||
faStamp,
|
||||
faTableCellsLarge
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
import { Config, optimize } from 'svgo'
|
||||
|
||||
/**
|
||||
* Takes in an array of Marks already filtered by User.
|
||||
@ -152,114 +153,112 @@ const findOtherUserMarks = (marks: Mark[], pubkey: string): Mark[] => {
|
||||
return marks.filter((mark) => mark.npub !== hexToNpub(pubkey))
|
||||
}
|
||||
|
||||
export const DEFAULT_TOOLBOX = [
|
||||
export const DEFAULT_TOOLBOX: DrawTool[] = [
|
||||
{
|
||||
identifier: MarkType.TEXT,
|
||||
icon: faT,
|
||||
label: 'Text',
|
||||
active: true
|
||||
label: 'Text'
|
||||
},
|
||||
{
|
||||
identifier: MarkType.SIGNATURE,
|
||||
icon: faSignature,
|
||||
label: 'Signature',
|
||||
active: false
|
||||
},
|
||||
{
|
||||
identifier: MarkType.JOBTITLE,
|
||||
icon: faBriefcase,
|
||||
label: 'Job Title',
|
||||
active: false
|
||||
label: 'Signature'
|
||||
},
|
||||
{
|
||||
identifier: MarkType.FULLNAME,
|
||||
icon: faIdCard,
|
||||
label: 'Full Name',
|
||||
active: false
|
||||
isComingSoon: true
|
||||
},
|
||||
{
|
||||
identifier: MarkType.INITIALS,
|
||||
icon: faHeading,
|
||||
label: 'Initials',
|
||||
active: false
|
||||
identifier: MarkType.JOBTITLE,
|
||||
icon: faBriefcase,
|
||||
label: 'Job Title',
|
||||
isComingSoon: true
|
||||
},
|
||||
{
|
||||
identifier: MarkType.DATETIME,
|
||||
icon: faClock,
|
||||
label: 'Date Time',
|
||||
active: false
|
||||
},
|
||||
{
|
||||
identifier: MarkType.DATE,
|
||||
icon: faCalendarDays,
|
||||
label: 'Date',
|
||||
active: false
|
||||
isComingSoon: true
|
||||
},
|
||||
{
|
||||
identifier: MarkType.NUMBER,
|
||||
icon: fa1,
|
||||
label: 'Number',
|
||||
active: false
|
||||
isComingSoon: true
|
||||
},
|
||||
{
|
||||
identifier: MarkType.INITIALS,
|
||||
icon: faHeading,
|
||||
label: 'Initials',
|
||||
isHidden: true
|
||||
},
|
||||
{
|
||||
identifier: MarkType.DATE,
|
||||
icon: faCalendarDays,
|
||||
label: 'Date',
|
||||
isHidden: true
|
||||
},
|
||||
{
|
||||
identifier: MarkType.IMAGES,
|
||||
icon: faImage,
|
||||
label: 'Images',
|
||||
active: false
|
||||
isHidden: true
|
||||
},
|
||||
{
|
||||
identifier: MarkType.CHECKBOX,
|
||||
icon: faSquareCheck,
|
||||
label: 'Checkbox',
|
||||
active: false
|
||||
isHidden: true
|
||||
},
|
||||
{
|
||||
identifier: MarkType.MULTIPLE,
|
||||
icon: faCheckDouble,
|
||||
label: 'Multiple',
|
||||
active: false
|
||||
isHidden: true
|
||||
},
|
||||
{
|
||||
identifier: MarkType.FILE,
|
||||
icon: faPaperclip,
|
||||
label: 'File',
|
||||
active: false
|
||||
isHidden: true
|
||||
},
|
||||
{
|
||||
identifier: MarkType.RADIO,
|
||||
icon: faCircleDot,
|
||||
label: 'Radio',
|
||||
active: false
|
||||
isHidden: true
|
||||
},
|
||||
{
|
||||
identifier: MarkType.SELECT,
|
||||
icon: faSquareCaretDown,
|
||||
label: 'Select',
|
||||
active: false
|
||||
isHidden: true
|
||||
},
|
||||
{
|
||||
identifier: MarkType.CELLS,
|
||||
icon: faTableCellsLarge,
|
||||
label: 'Cells',
|
||||
active: false
|
||||
isHidden: true
|
||||
},
|
||||
{
|
||||
identifier: MarkType.STAMP,
|
||||
icon: faStamp,
|
||||
label: 'Stamp',
|
||||
active: false
|
||||
isHidden: true
|
||||
},
|
||||
{
|
||||
identifier: MarkType.PAYMENT,
|
||||
icon: faCreditCard,
|
||||
label: 'Payment',
|
||||
active: false
|
||||
isHidden: true
|
||||
},
|
||||
{
|
||||
identifier: MarkType.PHONE,
|
||||
icon: faPhone,
|
||||
label: 'Phone',
|
||||
active: false
|
||||
isHidden: true
|
||||
}
|
||||
]
|
||||
|
||||
@ -267,6 +266,24 @@ export const getToolboxLabelByMarkType = (markType: MarkType) => {
|
||||
return DEFAULT_TOOLBOX.find((t) => t.identifier === markType)?.label
|
||||
}
|
||||
|
||||
export const optimizeSVG = (location: MarkLocation, paths: string[]) => {
|
||||
const svgContent = `<svg xmlns="http://www.w3.org/2000/svg" width="${location.width}" height="${location.height}">${paths.map((path) => `<path d="${path}" stroke="black" fill="none" />`).join('')}</svg>`
|
||||
const optimizedSVG = optimize(svgContent, {
|
||||
multipass: true, // Optimize multiple times if needed
|
||||
floatPrecision: 2 // Adjust precision
|
||||
} as Config)
|
||||
|
||||
return optimizedSVG.data
|
||||
}
|
||||
|
||||
export const getOptimizedPaths = (svgString: string) => {
|
||||
const regex = / d="([^"]*)"/g
|
||||
const matches = [...svgString.matchAll(regex)]
|
||||
const pathValues = matches.map((match) => match[1])
|
||||
|
||||
return pathValues
|
||||
}
|
||||
|
||||
export {
|
||||
getCurrentUserMarks,
|
||||
filterMarksByPubkey,
|
||||
|
@ -10,7 +10,6 @@ import {
|
||||
} from 'nostr-tools'
|
||||
import { toast } from 'react-toastify'
|
||||
import { NostrController } from '../controllers'
|
||||
import { AuthState } from '../store/auth/types'
|
||||
import store from '../store/store'
|
||||
import { CreateSignatureEventContent, Meta } from '../types'
|
||||
import { hexToNpub, unixNow } from './nostr'
|
||||
@ -232,7 +231,7 @@ export const extractZipUrlAndEncryptionKey = async (meta: Meta) => {
|
||||
const zipUrl = createSignatureContent.zipUrl
|
||||
|
||||
// Retrieve the user's public key from the state
|
||||
const usersPubkey = (store.getState().auth as AuthState).usersPubkey!
|
||||
const usersPubkey = store.getState().auth.usersPubkey!
|
||||
const usersNpub = hexToNpub(usersPubkey)
|
||||
|
||||
// Return null if the metadata does not contain keys
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { bytesToHex, hexToBytes } from '@noble/hashes/utils'
|
||||
import axios from 'axios'
|
||||
import _ from 'lodash'
|
||||
import _, { truncate } from 'lodash'
|
||||
import {
|
||||
Event,
|
||||
EventTemplate,
|
||||
@ -27,10 +27,9 @@ import {
|
||||
updateProcessedGiftWraps,
|
||||
updateUserAppData as updateUserAppDataAction
|
||||
} from '../store/actions'
|
||||
import { AuthState, Keys } from '../store/auth/types'
|
||||
import { RelaysState } from '../store/relays/types'
|
||||
import { Keys } from '../store/auth/types'
|
||||
import store from '../store/store'
|
||||
import { Meta, SignedEvent, UserAppData } from '../types'
|
||||
import { Meta, ProfileMetadata, SignedEvent, UserAppData } from '../types'
|
||||
import { getDefaultRelayMap } from './relays'
|
||||
import { parseJson, removeLeadingSlash } from './string'
|
||||
import { timeout } from './utils'
|
||||
@ -41,7 +40,7 @@ import { SIGIT_BLOSSOM } from './const.ts'
|
||||
* Generates a `d` tag for userAppData
|
||||
*/
|
||||
const getDTagForUserAppData = async (): Promise<string | null> => {
|
||||
const isLoggedIn = store.getState().auth?.loggedIn
|
||||
const isLoggedIn = store.getState().auth.loggedIn
|
||||
const pubkey = store.getState().auth?.usersPubkey
|
||||
|
||||
if (!isLoggedIn || !pubkey) {
|
||||
@ -360,7 +359,7 @@ export const getUsersAppData = async (): Promise<UserAppData | null> => {
|
||||
const relays: string[] = []
|
||||
|
||||
// Retrieve the user's public key and relay map from the Redux store
|
||||
const usersPubkey = (store.getState().auth as AuthState).usersPubkey!
|
||||
const usersPubkey = store.getState().auth.usersPubkey!
|
||||
const relayMap = store.getState().relays?.map
|
||||
|
||||
// Check if relayMap is undefined in the Redux store
|
||||
@ -426,6 +425,7 @@ export const getUsersAppData = async (): Promise<UserAppData | null> => {
|
||||
|
||||
// Handle case where the encrypted content is an empty object
|
||||
if (encryptedContent === '{}') {
|
||||
// Generate ephemeral key pair
|
||||
const secret = generateSecretKey()
|
||||
const pubKey = getPublicKey(secret)
|
||||
|
||||
@ -570,7 +570,7 @@ export const updateUsersAppData = async (meta: Meta) => {
|
||||
})
|
||||
}
|
||||
|
||||
const usersPubkey = (store.getState().auth as AuthState).usersPubkey!
|
||||
const usersPubkey = store.getState().auth.usersPubkey!
|
||||
|
||||
// encrypt content for storing in kind 30078 event
|
||||
const nostrController = NostrController.getInstance()
|
||||
@ -617,8 +617,7 @@ export const updateUsersAppData = async (meta: Meta) => {
|
||||
|
||||
if (!signedEvent) return null
|
||||
|
||||
const relayMap =
|
||||
(store.getState().relays as RelaysState).map || getDefaultRelayMap()
|
||||
const relayMap = store.getState().relays.map || getDefaultRelayMap()
|
||||
const writeRelays = Object.keys(relayMap).filter((key) => relayMap[key].write)
|
||||
|
||||
const publishResult = await Promise.race([
|
||||
@ -876,8 +875,11 @@ export const subscribeForSigits = async (pubkey: string) => {
|
||||
}
|
||||
|
||||
const processReceivedEvent = async (event: Event, difficulty: number = 5) => {
|
||||
const processedEvents = (store.getState().userAppData as UserAppData)
|
||||
.processedGiftWraps
|
||||
const processedEvents = store.getState().userAppData?.processedGiftWraps
|
||||
|
||||
// Abort processing if userAppData is undefined
|
||||
if (!processedEvents) return
|
||||
|
||||
if (processedEvents.includes(event.id)) return
|
||||
|
||||
store.dispatch(updateProcessedGiftWraps([...processedEvents, event.id]))
|
||||
@ -928,7 +930,7 @@ const processReceivedEvent = async (event: Event, difficulty: number = 5) => {
|
||||
*/
|
||||
export const sendNotification = async (receiver: string, meta: Meta) => {
|
||||
// Retrieve the user's public key from the state
|
||||
const usersPubkey = (store.getState().auth as AuthState).usersPubkey!
|
||||
const usersPubkey = store.getState().auth.usersPubkey!
|
||||
|
||||
// Create an unsigned event object with the provided metadata
|
||||
const unsignedEvent: UnsignedEvent = {
|
||||
@ -974,3 +976,16 @@ export const sendNotification = async (receiver: string, meta: Meta) => {
|
||||
throw err
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Show user's name, first available in order: display_name, name, or npub as fallback
|
||||
* @param npub User identifier, it can be either pubkey or npub1 (we only show npub)
|
||||
* @param profile User profile
|
||||
*/
|
||||
export const getProfileUsername = (
|
||||
npub: `npub1${string}` | string,
|
||||
profile?: ProfileMetadata
|
||||
) =>
|
||||
truncate(profile?.display_name || profile?.name || hexToNpub(npub), {
|
||||
length: 16
|
||||
})
|
||||
|
119
src/utils/opentimestamps.ts
Normal file
119
src/utils/opentimestamps.ts
Normal file
@ -0,0 +1,119 @@
|
||||
import { OpenTimestamp, OpenTimestampUpgradeVerifyResponse } from '../types'
|
||||
import { retry } from './retry.ts'
|
||||
import { bytesToHex } from '@noble/hashes/utils'
|
||||
import { utf8Encoder } from 'nostr-tools/utils'
|
||||
import { hexStringToUint8Array } from './string.ts'
|
||||
|
||||
/**
|
||||
* Generates a timestamp for the provided nostr event ID.
|
||||
* @returns Timestamp with its value and the nostr event ID.
|
||||
*/
|
||||
export const generateTimestamp = async (
|
||||
nostrId: string
|
||||
): Promise<OpenTimestamp | undefined> => {
|
||||
try {
|
||||
return {
|
||||
value: await retry(() => timestamp(nostrId)),
|
||||
nostrId: nostrId
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to upgrade (i.e. add Bitcoin blockchain attestations) and verify the provided timestamp.
|
||||
* Returns the same timestamp, alongside additional information required to decide if any further
|
||||
* timestamp updates are required.
|
||||
* @param timestamp
|
||||
*/
|
||||
export const upgradeAndVerifyTimestamp = async (
|
||||
timestamp: OpenTimestamp
|
||||
): Promise<OpenTimestampUpgradeVerifyResponse> => {
|
||||
const upgradedResult = await upgrade(timestamp)
|
||||
return await verify(upgradedResult)
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to upgrade a timestamp. If an upgrade is available,
|
||||
* it will add new data to detachedTimestamp.
|
||||
* The upgraded flag indicates if an upgrade has been performed.
|
||||
* @param t - timestamp
|
||||
*/
|
||||
export const upgrade = async (
|
||||
t: OpenTimestamp
|
||||
): Promise<OpenTimestampUpgradeVerifyResponse> => {
|
||||
const detachedTimestamp =
|
||||
window.OpenTimestamps.DetachedTimestampFile.deserialize(
|
||||
hexStringToUint8Array(t.value)
|
||||
)
|
||||
|
||||
const changed: boolean =
|
||||
await window.OpenTimestamps.upgrade(detachedTimestamp)
|
||||
if (changed) {
|
||||
const updated = detachedTimestamp.serializeToBytes()
|
||||
|
||||
const value = {
|
||||
...t,
|
||||
timestamp: bytesToHex(updated)
|
||||
}
|
||||
|
||||
return {
|
||||
timestamp: value,
|
||||
upgraded: true
|
||||
}
|
||||
}
|
||||
return {
|
||||
timestamp: t,
|
||||
upgraded: false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to verify a timestamp. If verification is available,
|
||||
* it will be included in the returned object.
|
||||
* @param t - timestamp
|
||||
*/
|
||||
export const verify = async (
|
||||
t: OpenTimestampUpgradeVerifyResponse
|
||||
): Promise<OpenTimestampUpgradeVerifyResponse> => {
|
||||
const detachedNostrId = window.OpenTimestamps.DetachedTimestampFile.fromBytes(
|
||||
new window.OpenTimestamps.Ops.OpSHA256(),
|
||||
utf8Encoder.encode(t.timestamp.nostrId)
|
||||
)
|
||||
|
||||
const detachedTimestamp =
|
||||
window.OpenTimestamps.DetachedTimestampFile.deserialize(
|
||||
hexStringToUint8Array(t.timestamp.value)
|
||||
)
|
||||
|
||||
const res = await window.OpenTimestamps.verify(
|
||||
detachedTimestamp,
|
||||
detachedNostrId
|
||||
)
|
||||
|
||||
return {
|
||||
...t,
|
||||
verified: !!res.bitcoin,
|
||||
verification: res?.bitcoin || null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Timestamps a nostrId.
|
||||
* @param nostrId
|
||||
*/
|
||||
const timestamp = async (nostrId: string): Promise<string> => {
|
||||
const detachedTimestamp =
|
||||
window.OpenTimestamps.DetachedTimestampFile.fromBytes(
|
||||
new window.OpenTimestamps.Ops.OpSHA256(),
|
||||
utf8Encoder.encode(nostrId)
|
||||
)
|
||||
|
||||
await window.OpenTimestamps.stamp(detachedTimestamp)
|
||||
const ctx = new window.OpenTimestamps.Context.StreamSerialization()
|
||||
detachedTimestamp.serialize(ctx)
|
||||
const timestampBytes = ctx.getOutput()
|
||||
return bytesToHex(timestampBytes)
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import { PdfPage } from '../types/drawing.ts'
|
||||
import { MarkType, PdfPage } from '../types/drawing.ts'
|
||||
import { PDFDocument, PDFFont, PDFPage, rgb } from 'pdf-lib'
|
||||
import { Mark } from '../types/mark.ts'
|
||||
import * as PDFJS from 'pdfjs-dist'
|
||||
@ -132,9 +132,17 @@ export const addMarks = async (
|
||||
|
||||
for (let i = 0; i < pages.length; i++) {
|
||||
if (marksPerPage && Object.hasOwn(marksPerPage, i)) {
|
||||
marksPerPage[i]?.forEach((mark) =>
|
||||
drawMarkText(mark, pages[i], robotoFont)
|
||||
)
|
||||
marksPerPage[i]?.forEach((mark) => {
|
||||
switch (mark.type) {
|
||||
case MarkType.SIGNATURE:
|
||||
drawSignatureText(mark, pages[i])
|
||||
break
|
||||
|
||||
default:
|
||||
drawMarkText(mark, pages[i], robotoFont)
|
||||
break
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -245,3 +253,19 @@ async function embedFont(pdf: PDFDocument) {
|
||||
const embeddedFont = await pdf.embedFont(fontBytes)
|
||||
return embeddedFont
|
||||
}
|
||||
|
||||
const drawSignatureText = (mark: Mark, page: PDFPage) => {
|
||||
const { location } = mark
|
||||
const { height } = page.getSize()
|
||||
|
||||
// Convert the mark location origin (top, left) to PDF origin (bottom, left)
|
||||
const x = location.left
|
||||
const y = height - location.top
|
||||
|
||||
if (hasValue(mark)) {
|
||||
const paths = JSON.parse(mark.value!)
|
||||
paths.forEach((d: string) => {
|
||||
page.drawSvgPath(d, { x, y })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
25
src/utils/retry.ts
Normal file
25
src/utils/retry.ts
Normal file
@ -0,0 +1,25 @@
|
||||
export const retryAll = async <T>(
|
||||
promises: (() => Promise<T>)[],
|
||||
retries: number = 3,
|
||||
delay: number = 1000
|
||||
) => {
|
||||
const wrappedPromises = promises.map((fn) => retry(fn, retries, delay))
|
||||
return Promise.allSettled(wrappedPromises)
|
||||
}
|
||||
|
||||
export const retry = async <T>(
|
||||
fn: () => Promise<T>,
|
||||
retries: number = 3,
|
||||
delay: number = 1000
|
||||
): Promise<T> => {
|
||||
try {
|
||||
return await fn()
|
||||
} catch (err) {
|
||||
if (retries === 0) {
|
||||
return Promise.reject(err)
|
||||
}
|
||||
return new Promise((resolve) =>
|
||||
setTimeout(() => resolve(retry(fn, retries - 1)), delay)
|
||||
)
|
||||
}
|
||||
}
|
@ -119,3 +119,15 @@ export const settleAllFullfilfedPromises = async <Item, FulfilledItem = Item>(
|
||||
return acc
|
||||
}, [])
|
||||
}
|
||||
|
||||
export const isPromiseFulfilled = <T>(
|
||||
result: PromiseSettledResult<T>
|
||||
): result is PromiseFulfilledResult<T> => {
|
||||
return result.status === 'fulfilled'
|
||||
}
|
||||
|
||||
export const isPromiseRejected = <T>(
|
||||
result: PromiseSettledResult<T>
|
||||
): result is PromiseRejectedResult => {
|
||||
return result.status === 'rejected'
|
||||
}
|
||||
|
@ -1,9 +1,16 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tsconfigPaths from 'vite-tsconfig-paths'
|
||||
import { nodePolyfills } from 'vite-plugin-node-polyfills'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tsconfigPaths()],
|
||||
plugins: [
|
||||
react(),
|
||||
tsconfigPaths(),
|
||||
nodePolyfills({
|
||||
include: ['os']
|
||||
})
|
||||
],
|
||||
build: {
|
||||
target: 'ES2022'
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user