Merge pull request 'staging release' (#299) from staging into main
All checks were successful
Release to Production / build_and_release (push) Successful in 1m35s

Reviewed-on: #299
This commit is contained in:
b 2025-01-07 10:10:29 +00:00
commit b361ab3d99
65 changed files with 2613 additions and 2719 deletions

137
package-lock.json generated
View File

@ -20,12 +20,14 @@
"@mui/lab": "5.0.0-alpha.166",
"@mui/material": "5.15.11",
"@noble/hashes": "^1.4.0",
"@nostr-dev-kit/ndk": "2.5.0",
"@nostr-dev-kit/ndk": "2.10.0",
"@nostr-dev-kit/ndk-cache-dexie": "2.5.1",
"@pdf-lib/fontkit": "^1.1.1",
"@reduxjs/toolkit": "2.2.1",
"axios": "^1.7.4",
"crypto-hash": "3.0.0",
"crypto-js": "^4.2.0",
"dexie": "4.0.8",
"dnd-core": "16.0.1",
"file-saver": "2.0.5",
"idb": "8.0.0",
@ -33,7 +35,7 @@
"lodash": "4.17.21",
"material-ui-popup-state": "^5.3.1",
"mui-file-input": "4.0.4",
"nostr-login": "^1.6.6",
"nostr-login": "1.6.14",
"nostr-tools": "2.7.0",
"pdf-lib": "^1.17.1",
"pdfjs-dist": "^4.4.168",
@ -49,7 +51,8 @@
"react-toastify": "10.0.4",
"redux": "5.0.1",
"signature_pad": "^5.0.4",
"tseep": "1.2.1"
"tseep": "1.2.1",
"use-immer": "^0.11.0"
},
"devDependencies": {
"@types/crypto-js": "^4.2.2",
@ -1711,65 +1714,79 @@
}
},
"node_modules/@nostr-dev-kit/ndk": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@nostr-dev-kit/ndk/-/ndk-2.5.0.tgz",
"integrity": "sha512-A2nRgjjLScDhGZGPWx8xUIJM66dJWScdWQoCn/tI1Gtwpple+C2Jp7C9t3mb0oF3bwd2nsV6qwS//wdrH8QvYQ==",
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@nostr-dev-kit/ndk/-/ndk-2.10.0.tgz",
"integrity": "sha512-TqCAAo6ylORraAXrzRkCGFN2xTMiFbdER8Y8CtUT0HwOpFG/Wn+PBNeDeDmqkl/6LaPdeyXmVwCWj2KcUjIwYA==",
"dependencies": {
"@noble/curves": "^1.4.0",
"@noble/hashes": "^1.3.1",
"@noble/secp256k1": "^2.0.0",
"@scure/base": "^1.1.1",
"debug": "^4.3.4",
"light-bolt11-decoder": "^3.0.0",
"node-fetch": "^3.3.1",
"nostr-tools": "^1.15.0",
"nostr-tools": "^2.7.1",
"tseep": "^1.1.1",
"typescript-lru-cache": "^2.0.0",
"utf8-buffer": "^1.0.0",
"websocket-polyfill": "^0.0.3"
},
"engines": {
"node": ">=16"
}
},
"node_modules/@nostr-dev-kit/ndk/node_modules/@noble/ciphers": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.2.0.tgz",
"integrity": "sha512-6YBxJDAapHSdd3bLDv6x2wRPwq4QFMUaB3HvljNBUTThDd12eSm7/3F+2lnfzx2jvM+S6Nsy0jEt9QbPqSwqRw==",
"funding": {
"url": "https://paulmillr.com/funding/"
"node_modules/@nostr-dev-kit/ndk-cache-dexie": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@nostr-dev-kit/ndk-cache-dexie/-/ndk-cache-dexie-2.5.1.tgz",
"integrity": "sha512-tUwEy68bd9GL5JVuZIjcpdwuDEBnaXen3WJ64/GRDtbyE1RB01Y6hHC7IQC9bcQ6SC7XBGyPd+2nuTyR7+Mffg==",
"dependencies": {
"@nostr-dev-kit/ndk": "2.10.0",
"debug": "^4.3.4",
"dexie": "^4.0.2",
"nostr-tools": "^2.4.0",
"typescript-lru-cache": "^2.0.0"
}
},
"node_modules/@nostr-dev-kit/ndk/node_modules/@noble/curves": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.1.0.tgz",
"integrity": "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==",
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.7.0.tgz",
"integrity": "sha512-UTMhXK9SeDhFJVrHeUJ5uZlI6ajXg10O6Ddocf9S6GjbSBVZsJo88HzKwXznNfGpMTRDyJkqMjNDPYgf0qFWnw==",
"dependencies": {
"@noble/hashes": "1.3.1"
"@noble/hashes": "1.6.0"
},
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@nostr-dev-kit/ndk/node_modules/@noble/hashes": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz",
"integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==",
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.6.0.tgz",
"integrity": "sha512-YUULf0Uk4/mAA89w+k3+yUYh6NrEvxZa5T6SY3wlMvE2chHkxFUUIDI8/XW1QSC357iA5pSnqt7XEhvFOqmDyQ==",
"engines": {
"node": ">= 16"
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@nostr-dev-kit/ndk/node_modules/nostr-tools": {
"version": "1.17.0",
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-1.17.0.tgz",
"integrity": "sha512-LZmR8GEWKZeElbFV5Xte75dOeE9EFUW/QLI1Ncn3JKn0kFddDKEfBbFN8Mu4TMs+L4HR/WTPha2l+PPuRnJcMw==",
"version": "2.10.4",
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.10.4.tgz",
"integrity": "sha512-biU7sk+jxHgVASfobg2T5ttxOGGSt69wEVBC51sHHOEaKAAdzHBLV/I2l9Rf61UzClhliZwNouYhqIso4a3HYg==",
"dependencies": {
"@noble/ciphers": "0.2.0",
"@noble/curves": "1.1.0",
"@noble/ciphers": "^0.5.1",
"@noble/curves": "1.2.0",
"@noble/hashes": "1.3.1",
"@scure/base": "1.1.1",
"@scure/bip32": "1.3.1",
"@scure/bip39": "1.2.1"
},
"optionalDependencies": {
"nostr-wasm": "0.1.0"
},
"peerDependencies": {
"typescript": ">=5.0.0"
},
@ -1779,6 +1796,39 @@
}
}
},
"node_modules/@nostr-dev-kit/ndk/node_modules/nostr-tools/node_modules/@noble/curves": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz",
"integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==",
"dependencies": {
"@noble/hashes": "1.3.2"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@nostr-dev-kit/ndk/node_modules/nostr-tools/node_modules/@noble/curves/node_modules/@noble/hashes": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz",
"integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@nostr-dev-kit/ndk/node_modules/nostr-tools/node_modules/@noble/hashes": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz",
"integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@pdf-lib/fontkit": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@pdf-lib/fontkit/-/fontkit-1.1.1.tgz",
@ -3587,10 +3637,11 @@
"dev": true
},
"node_modules/cross-spawn": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
"integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
@ -3857,6 +3908,11 @@
"node": ">=8"
}
},
"node_modules/dexie": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/dexie/-/dexie-4.0.8.tgz",
"integrity": "sha512-1G6cJevS17KMDK847V3OHvK2zei899GwpDiqfEXHP1ASvme6eWJmAp9AU4s1son2TeGkWmC0g3y8ezOBPnalgQ=="
},
"node_modules/dezalgo": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz",
@ -6254,9 +6310,9 @@
"optional": true
},
"node_modules/nanoid": {
"version": "3.3.7",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
"version": "3.3.8",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz",
"integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==",
"dev": true,
"funding": [
{
@ -6264,6 +6320,7 @@
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
@ -6442,9 +6499,9 @@
}
},
"node_modules/nostr-login": {
"version": "1.6.6",
"resolved": "https://registry.npmjs.org/nostr-login/-/nostr-login-1.6.6.tgz",
"integrity": "sha512-XOpB9nG3Qgt7iea7gA1zn4TaTfUKCKGdCHKwErqLPtMk/q1Rhkzj5cq/66iU0WqC6mSiwENfTy1p4qaM7HzMtg==",
"version": "1.6.14",
"resolved": "https://registry.npmjs.org/nostr-login/-/nostr-login-1.6.14.tgz",
"integrity": "sha512-pId1G79kjRW1B9qy6OrA8Not23JSfgmS2VegcKf7Qm9VMC7wYGXg1Ry3FMEAB8p11WoboQ8oJi2TqUGiOf61OQ==",
"license": "MIT",
"dependencies": {
"@nostr-dev-kit/ndk": "^2.3.1",
@ -8589,6 +8646,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/use-immer": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/use-immer/-/use-immer-0.11.0.tgz",
"integrity": "sha512-RNAqi3GqsWJ4bcCd4LMBgdzvPmTABam24DUaFiKfX9s3MSorNRz9RDZYJkllJoMHUxVLMDetwAuCDeyWNrp1yA==",
"license": "MIT",
"peerDependencies": {
"immer": ">=8.0.0",
"react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz",

View File

@ -30,12 +30,14 @@
"@mui/lab": "5.0.0-alpha.166",
"@mui/material": "5.15.11",
"@noble/hashes": "^1.4.0",
"@nostr-dev-kit/ndk": "2.5.0",
"@nostr-dev-kit/ndk": "2.10.0",
"@nostr-dev-kit/ndk-cache-dexie": "2.5.1",
"@pdf-lib/fontkit": "^1.1.1",
"@reduxjs/toolkit": "2.2.1",
"axios": "^1.7.4",
"crypto-hash": "3.0.0",
"crypto-js": "^4.2.0",
"dexie": "4.0.8",
"dnd-core": "16.0.1",
"file-saver": "2.0.5",
"idb": "8.0.0",
@ -43,7 +45,7 @@
"lodash": "4.17.21",
"material-ui-popup-state": "^5.3.1",
"mui-file-input": "4.0.4",
"nostr-login": "^1.6.6",
"nostr-login": "1.6.14",
"nostr-tools": "2.7.0",
"pdf-lib": "^1.17.1",
"pdfjs-dist": "^4.4.168",
@ -59,7 +61,8 @@
"react-toastify": "10.0.4",
"redux": "5.0.1",
"signature_pad": "^5.0.4",
"tseep": "1.2.1"
"tseep": "1.2.1",
"use-immer": "^0.11.0"
},
"devDependencies": {
"@types/crypto-js": "^4.2.2",

View File

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

View File

@ -37,30 +37,19 @@ export const AppBar = () => {
const [anchorElUser, setAnchorElUser] = useState<null | HTMLElement>(null)
const authState = useAppSelector((state) => state.auth)
const metadataState = useAppSelector((state) => state.metadata)
const userRobotImage = useAppSelector((state) => state.userRobotImage)
const userProfile = useAppSelector((state) => state.user.profile)
const userRobotImage = useAppSelector((state) => state.user.robotImage)
useEffect(() => {
if (metadataState) {
if (metadataState.content) {
const profileMetadata = JSON.parse(metadataState.content)
const { picture } = profileMetadata
if (picture || userRobotImage) {
setUserAvatar(picture || userRobotImage)
}
const npub = authState.usersPubkey
? hexToNpub(authState.usersPubkey)
: ''
setUsername(getProfileUsername(npub, profileMetadata))
const npub = authState.usersPubkey ? hexToNpub(authState.usersPubkey) : ''
if (userProfile) {
setUserAvatar(userProfile.image || userRobotImage || '')
setUsername(getProfileUsername(npub, userProfile))
} else {
setUserAvatar(userRobotImage || '')
setUsername('')
setUserAvatar('')
setUsername(getProfileUsername(npub))
}
}
}, [metadataState, userRobotImage, authState.usersPubkey])
}, [userRobotImage, authState.usersPubkey, userProfile])
const handleOpenUserMenu = (event: React.MouseEvent<HTMLElement>) => {
setAnchorElUser(event.currentTarget)

View File

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

View File

@ -8,24 +8,26 @@ import {
Select
} from '@mui/material'
import styles from './style.module.scss'
import React, { useEffect, useState } from 'react'
import { ProfileMetadata, User, UserRole, KeyboardCode } from '../../types'
import { MouseState, PdfPage, DrawnField, DrawTool } from '../../types/drawing'
import React, { useCallback, useEffect, useState } from 'react'
import { User, UserRole, KeyboardCode } from '../../types'
import { MouseState, DrawnField, DrawTool } from '../../types/drawing'
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 { UserAvatar } from '../UserAvatar'
import _ from 'lodash'
import { Updater } from 'use-immer'
import { FileItem } from './internal/FileItem'
import { FileDivider } from '../FileDivider'
import { Counterpart } from './internal/Counterpart'
const MINIMUM_RECT_SIZE = {
width: 21,
height: 21
width: 10,
height: 10
} as const
import { NDKUserProfile } from '@nostr-dev-kit/ndk'
const DEFAULT_START_SIZE = {
width: 140,
@ -36,16 +38,25 @@ interface HideSignersForDrawnField {
[key: number]: boolean
}
interface Props {
type PageIndexer = [file: number, page: number]
type FieldIndexer = [...PageIndexer, field: number]
interface DrawPdfFieldsProps {
users: User[]
metadata: { [key: string]: ProfileMetadata }
userProfiles: { [key: string]: NDKUserProfile }
sigitFiles: SigitFile[]
setSigitFiles: React.Dispatch<React.SetStateAction<SigitFile[]>>
updateSigitFiles: Updater<SigitFile[]>
selectedTool?: DrawTool
}
export const DrawPDFFields = (props: Props) => {
const { selectedTool, sigitFiles, setSigitFiles, users } = props
export const DrawPDFFields = ({
selectedTool,
userProfiles,
sigitFiles,
updateSigitFiles,
users
}: DrawPdfFieldsProps) => {
const { to, from } = useScale()
const signers = users.filter((u) => u.role === UserRole.signer)
const defaultSignerNpub = signers.length ? hexToNpub(signers[0].pubkey) : ''
@ -58,158 +69,124 @@ export const DrawPDFFields = (props: Props) => {
* @param pubkeys
* @returns available pubkey or empty string
*/
const getAvailableSigner = (...pubkeys: string[]) => {
const getAvailableSigner = useCallback(
(...pubkeys: string[]) => {
const availableSigner: string | undefined = pubkeys.find((pubkey) =>
signers.some((s) => s.pubkey === npubToHex(pubkey))
)
return availableSigner || ''
},
[signers]
)
const [mouseState, setMouseState] = useState<MouseState>({})
const [indexer, setIndexer] = useState<PageIndexer | FieldIndexer>()
const [field, setField] = useState<
DrawnField & {
x: number
y: number
}
const { to, from } = useScale()
const [mouseState, setMouseState] = useState<MouseState>({
clicked: false
})
const [activeDrawnField, setActiveDrawnField] = useState<{
fileIndex: number
pageIndex: number
drawnFieldIndex: number
}>()
>()
const [lastIndexer, setLastIndexer] = useState<FieldIndexer>()
const isActiveDrawnField = (
fileIndex: number,
pageIndex: number,
drawnFieldIndex: number
) =>
activeDrawnField?.fileIndex === fileIndex &&
activeDrawnField?.pageIndex === pageIndex &&
activeDrawnField?.drawnFieldIndex === drawnFieldIndex
lastIndexer &&
lastIndexer[0] === fileIndex &&
lastIndexer[1] === pageIndex &&
lastIndexer[2] === drawnFieldIndex
/**
* Drawing events
* Gets the pointer coordinates relative to a element in the `event` param
* @param event PointerEvent
* @param customTarget coordinates relative to this element, if not provided
* event.target will be used
*/
useEffect(() => {
window.addEventListener('pointerup', handlePointerUp)
window.addEventListener('pointercancel', handlePointerUp)
const getPointerCoordinates = (
event: React.PointerEvent,
customTarget?: HTMLElement | null
) => {
const target = customTarget ? customTarget : event.currentTarget
const rect = target.getBoundingClientRect()
return () => {
window.removeEventListener('pointerup', handlePointerUp)
window.removeEventListener('pointercancel', handlePointerUp)
// Clamp X Y within the target
const x = Math.max(0, Math.min(event.clientX, rect.right) - rect.left) //x position within the element.
const y = Math.max(0, Math.min(event.clientY, rect.bottom) - rect.top) //y position within the element.
return {
x,
y,
rect
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const refreshPdfFiles = () => {
setSigitFiles([...sigitFiles])
}
/**
* Fired only on when left (primary pointer interaction) clicking page image
* Creates new drawnElement and pushes in the array
* It is re rendered and visible right away
* Creates new drawnElement
*
* @param event Pointer event
* @param page PdfPage where press happened
* @param pageIndexer File and page index
* @param pageWidth pdf value used to scale pointer coordinates
*/
const handlePointerDown = (
const handlePointerDown = useCallback(
(
event: React.PointerEvent,
page: PdfPage,
fileIndex: number,
pageIndex: number
pageIndexer: PageIndexer,
pageWidth: number
) => {
// Proceed only if left click
if (event.button !== 0) return
if (!selectedTool) {
return
}
if (!selectedTool) return
event.currentTarget.setPointerCapture(event.pointerId)
const counterpart = getAvailableSigner(lastSigner, defaultSignerNpub)
const { x, y } = getPointerCoordinates(event)
const newField: DrawnField = {
left: to(page.width, x),
top: to(page.width, y),
setIndexer(pageIndexer)
setField({
x: to(pageWidth, x),
y: to(pageWidth, y),
left: to(pageWidth, x),
top: to(pageWidth, y),
width: event.pointerType === 'mouse' ? 0 : DEFAULT_START_SIZE.width,
height: event.pointerType === 'mouse' ? 0 : DEFAULT_START_SIZE.height,
counterpart: getAvailableSigner(lastSigner, defaultSignerNpub),
type: selectedTool.identifier
}
page.drawnFields.push(newField)
setActiveDrawnField({
fileIndex,
pageIndex,
drawnFieldIndex: page.drawnFields.length - 1
type: selectedTool.identifier,
counterpart
})
setMouseState((prev) => {
return {
...prev,
setMouseState({
clicked: true
}
})
}
},
[defaultSignerNpub, getAvailableSigner, lastSigner, selectedTool, to]
)
/**
* Drawing is finished, resets all the variables used to draw
* After {@link handlePointerDown} creates an drawing element, this function
* alters the newly created drawing element, resizing it while pointer moves
* @param event Pointer event
* @param pageWidth pdf value used to scale pointer coordinates
*/
const handlePointerUp = () => {
sigitFiles.forEach((s) => {
s.pages?.forEach((p) => {
// Remove drawn fields below the MINIMUM_RECT_SIZE threshhold
p.drawnFields = p.drawnFields.filter(
(f) =>
!(
f.width < MINIMUM_RECT_SIZE.width ||
f.height < MINIMUM_RECT_SIZE.height
)
)
})
})
setMouseState((prev) => {
return {
...prev,
clicked: false,
dragging: false,
resizing: false
}
})
refreshPdfFiles()
}
/**
* After {@link handlePointerDown} create an drawing element, this function gets called on every pixel moved
* which alters the newly created drawing element, resizing it while pointer moves
* @param event Pointer event
* @param page PdfPage where moving is happening
*/
const handlePointerMove = (event: React.PointerEvent, page: PdfPage) => {
const handlePointerMove = useCallback(
(event: React.PointerEvent, pageWidth: number) => {
if (mouseState.clicked && selectedTool && event.pointerType === 'mouse') {
const lastElementIndex = page.drawnFields.length - 1
const lastDrawnField = page.drawnFields[lastElementIndex]
// Return early if we don't have lastDrawnField
// Issue noticed in the console when dragging out of bounds
// to the page below (without releaseing mouse click)
if (!lastDrawnField) return
const { x, y } = getPointerCoordinates(event)
const pageX = to(pageWidth, x)
const pageY = to(pageWidth, y)
const width = to(page.width, x) - lastDrawnField.left
const height = to(page.width, y) - lastDrawnField.top
// Calculate left, top, width, and height based on direction
setField((prev) => {
const left = pageX < prev!.x ? pageX : prev!.x
const top = pageY < prev!.y ? pageY : prev!.y
lastDrawnField.width = width
lastDrawnField.height = height
const currentDrawnFields = page.drawnFields
currentDrawnFields[lastElementIndex] = lastDrawnField
refreshPdfFiles()
}
const width = Math.abs(pageX - prev!.x)
const height = Math.abs(pageY - prev!.y)
return { ...prev!, left, top, width, height }
})
}
},
[mouseState.clicked, selectedTool, to]
)
/**
* Fired when event happens on the drawn element which will be moved
@ -219,22 +196,30 @@ export const DrawPDFFields = (props: Props) => {
* y - offsetY
*
* @param event Pointer event
* @param drawnFieldIndex Which we are moving
* @param fieldIndexer Which field we are moving
*/
const handleDrawnFieldPointerDown = (
event: React.PointerEvent,
fileIndex: number,
pageIndex: number,
drawnFieldIndex: number
fieldIndexer: FieldIndexer
) => {
event.stopPropagation()
// Proceed only if left click
if (event.button !== 0) return
event.currentTarget.setPointerCapture(event.pointerId)
const drawingRectangleCoords = getPointerCoordinates(event)
const [fileIndex, pageIndex, drawnFieldIndex] = fieldIndexer
const page = sigitFiles[fileIndex].pages![pageIndex]
const drawnField = page.drawnFields[drawnFieldIndex]
setActiveDrawnField({ fileIndex, pageIndex, drawnFieldIndex })
setIndexer(fieldIndexer)
setField({
...drawnField,
x: to(page.width, drawingRectangleCoords.x),
y: to(page.width, drawingRectangleCoords.y)
})
setLastIndexer(fieldIndexer)
setMouseState({
dragging: true,
clicked: false,
@ -244,7 +229,7 @@ export const DrawPDFFields = (props: Props) => {
}
})
// make signers dropdown visible
// Make signers dropdown visible
setHideSignersForDrawnField((prev) => ({
...prev,
[drawnFieldIndex]: false
@ -254,12 +239,10 @@ export const DrawPDFFields = (props: Props) => {
/**
* Moves the drawnElement by the pointer position (pointer can grab anywhere on the drawn element)
* @param event Pointer event
* @param drawnField which we are moving
* @param pageWidth pdf value which is used to calculate scaled offset
* @param pageWidth pdf value used to scale pointer coordinates
*/
const handleDrawnFieldPointerMove = (
event: React.PointerEvent,
drawnField: DrawnField,
pageWidth: number
) => {
if (mouseState.dragging) {
@ -273,18 +256,21 @@ export const DrawPDFFields = (props: Props) => {
let left = to(pageWidth, x - coordsOffset.x)
let top = to(pageWidth, y - coordsOffset.y)
const rightLimit = to(pageWidth, rect.width) - drawnField.width
const bottomLimit = to(pageWidth, rect.height) - drawnField.height
setField((prev) => {
const rightLimit = to(pageWidth, rect.width) - prev!.width
const bottomLimit = to(pageWidth, rect.height) - prev!.height
if (left < 0) left = 0
if (top < 0) top = 0
if (left > rightLimit) left = rightLimit
if (top > bottomLimit) top = bottomLimit
drawnField.left = left
drawnField.top = top
refreshPdfFiles()
return {
...prev!,
left,
top
}
})
}
}
}
@ -292,73 +278,85 @@ export const DrawPDFFields = (props: Props) => {
/**
* Fired when clicked on the resize handle, sets the state for a resize action
* @param event Pointer event
* @param drawnFieldIndex which we are resizing
* @param fieldIndexer which field we are resizing
*/
const handleResizePointerDown = (
event: React.PointerEvent,
fileIndex: number,
pageIndex: number,
drawnFieldIndex: number
) => {
const handleResizePointerDown = useCallback(
(event: React.PointerEvent, fieldIndexer: FieldIndexer) => {
// Proceed only if left click
if (event.button !== 0) return
event.stopPropagation()
setActiveDrawnField({ fileIndex, pageIndex, drawnFieldIndex })
event.currentTarget.setPointerCapture(event.pointerId)
const [fileIndex, pageIndex, drawnFieldIndex] = fieldIndexer
const page = sigitFiles[fileIndex].pages![pageIndex]
const drawnField = page.drawnFields[drawnFieldIndex]
setIndexer(fieldIndexer)
setField({
...drawnField,
x: drawnField.left,
y: drawnField.top
})
setLastIndexer(fieldIndexer)
setMouseState({
resizing: true
})
}
},
[sigitFiles]
)
/**
* Resizes the drawn element by the mouse position
* @param event Pointer event
* @param drawnField which we are resizing
* @param pageWidth pdf value which is used to calculate scaled offset
* @param pageWidth pdf value used to scale pointer coordinates
*/
const handleResizePointerMove = (
event: React.PointerEvent,
drawnField: DrawnField,
pageWidth: number
) => {
const handleResizePointerMove = useCallback(
(event: React.PointerEvent, pageWidth: number) => {
if (mouseState.resizing) {
const { x, y } = getPointerCoordinates(
event,
// currentTarget = span handle
// 1st parent = drawnField
// 2nd parent = img
const { x, y } = getPointerCoordinates(
event,
event.currentTarget.parentElement?.parentElement
)
const width = to(pageWidth, x) - drawnField.left
const height = to(pageWidth, y) - drawnField.top
const pageX = to(pageWidth, x)
const pageY = to(pageWidth, y)
drawnField.width = width
drawnField.height = height
setField((prev) => {
const left = pageX < prev!.x ? pageX : prev!.x
const top = pageY < prev!.y ? pageY : prev!.y
refreshPdfFiles()
}
const width = Math.abs(pageX - prev!.x)
const height = Math.abs(pageY - prev!.y)
return { ...prev!, left, top, width, height }
})
}
},
[mouseState.resizing, to]
)
const handlePointerUpReleaseCapture = useCallback(
(event: React.PointerEvent) => {
event.currentTarget.releasePointerCapture(event.pointerId)
},
[]
)
/**
* Removes the drawn element using the indexes in the params
* @param event Pointer event
* @param pdfFileIndex pdf file index
* @param pdfPageIndex pdf page index
* @param drawnFileIndex drawn file index
* @param fieldIIndexer [file index, page index, field index]
*/
const handleRemovePointerDown = (
event: React.PointerEvent,
pdfFileIndex: number,
pdfPageIndex: number,
drawnFileIndex: number
[fileIndex, pageIndex, fieldIndex]: FieldIndexer
) => {
event.stopPropagation()
const pages = sigitFiles[pdfFileIndex]?.pages
if (pages) {
pages[pdfPageIndex]?.drawnFields?.splice(drawnFileIndex, 1)
}
updateSigitFiles((draft) => {
draft[fileIndex]?.pages![pageIndex]?.drawnFields?.splice(fieldIndex, 1)
})
}
/**
@ -397,28 +395,72 @@ export const DrawPDFFields = (props: Props) => {
}
/**
* Gets the pointer coordinates relative to a element in the `event` param
* @param event PointerEvent
* @param customTarget coordinates relative to this element, if not provided
* event.target will be used
* Drawing is finished, resets all the variables used to draw
*/
const getPointerCoordinates = (
event: React.PointerEvent,
customTarget?: HTMLElement | null
) => {
const target = customTarget ? customTarget : event.currentTarget
const rect = target.getBoundingClientRect()
const handlePointerUp = useCallback(() => {
// Proceed if we have selected something
if (indexer) {
// Check if we have the "preview" field state
if (field) {
// Cancel update if preview field is below the MINIMUM_RECT_SIZE threshhold
if (
field.width < MINIMUM_RECT_SIZE.width ||
field.height < MINIMUM_RECT_SIZE.height
) {
setIndexer(undefined)
setMouseState({})
return
}
// Clamp X Y within the target
const x = Math.min(event.clientX, rect.right) - rect.left //x position within the element.
const y = Math.min(event.clientY, rect.bottom) - rect.top //y position within the element.
const [fileIndex, pageIndex, fieldIndex] = indexer
return {
x,
y,
rect
// Add new drawn field to the files
if (mouseState.clicked) {
updateSigitFiles((draft) => {
draft[fileIndex].pages![pageIndex].drawnFields.push(field)
})
}
// Move
if (mouseState.dragging) {
updateSigitFiles((draft) => {
draft[fileIndex].pages![pageIndex].drawnFields[fieldIndex!] = field
})
}
// Resize
if (mouseState.resizing) {
updateSigitFiles((draft) => {
draft[fileIndex].pages![pageIndex].drawnFields[fieldIndex!] = field
})
}
// Clear indexer after applying the update
setIndexer(undefined)
}
}
setMouseState({})
}, [
field,
indexer,
mouseState.clicked,
mouseState.dragging,
mouseState.resizing,
updateSigitFiles
])
/**
* Drawing events
*/
useEffect(() => {
window.addEventListener('pointerup', handlePointerUp)
window.addEventListener('pointercancel', handlePointerUp)
return () => {
window.removeEventListener('pointerup', handlePointerUp)
window.removeEventListener('pointercancel', handlePointerUp)
}
}, [handlePointerUp])
/**
* Renders the pdf pages and drawing elements
@ -430,6 +472,13 @@ export const DrawPDFFields = (props: Props) => {
return (
<>
{file.pages?.map((page, pageIndex: number) => {
let isPageIndexerActive = false
if (indexer) {
const [fi, pi, di] = indexer
isPageIndexerActive =
fi === fileIndex && pi === pageIndex && typeof di === 'undefined'
}
return (
<div
key={pageIndex}
@ -438,32 +487,68 @@ export const DrawPDFFields = (props: Props) => {
onKeyDown={(event) => handleEscapeButtonDown(event)}
>
<img
onPointerMove={(event) => {
handlePointerMove(event, page)
}}
onPointerDown={(event) => {
handlePointerDown(event, page, fileIndex, pageIndex)
handlePointerDown(event, [fileIndex, pageIndex], page.width)
}}
onPointerMove={(event) => {
handlePointerMove(event, page.width)
}}
onPointerUp={handlePointerUpReleaseCapture}
draggable="false"
src={page.image}
alt={`page ${pageIndex + 1} of ${file.name}`}
/>
{isPageIndexerActive && field && (
<div
className={styles.drawingRectanglePreview}
style={{
backgroundColor: field.counterpart
? `#${npubToHex(field.counterpart)?.substring(0, 6)}4b`
: undefined,
outlineColor: field.counterpart
? `#${npubToHex(field.counterpart)?.substring(0, 6)}`
: undefined,
left: inPx(from(page.width, field.left)),
top: inPx(from(page.width, field.top)),
width: inPx(from(page.width, field.width)),
height: inPx(from(page.width, field.height))
}}
>
<div
className={`file-mark ${styles.placeholder}`}
style={{
fontFamily: FONT_TYPE,
fontSize: inPx(from(page.width, FONT_SIZE))
}}
>
{getToolboxLabelByMarkType(field.type) || 'placeholder'}
</div>
</div>
)}
{page.drawnFields.map((drawnField, drawnFieldIndex: number) => {
let isFieldIndexerActive = false
if (indexer) {
const [fi, pi, di] = indexer
isFieldIndexerActive =
fi === fileIndex &&
pi === pageIndex &&
di === drawnFieldIndex
}
return (
<div
key={drawnFieldIndex}
onPointerDown={(event) =>
handleDrawnFieldPointerDown(
event,
handleDrawnFieldPointerDown(event, [
fileIndex,
pageIndex,
drawnFieldIndex
)
])
}
onPointerMove={(event) => {
handleDrawnFieldPointerMove(event, drawnField, page.width)
handleDrawnFieldPointerMove(event, page.width)
}}
onPointerUp={handlePointerUpReleaseCapture}
className={styles.drawingRectangle}
style={{
backgroundColor: drawnField.counterpart
@ -472,12 +557,29 @@ export const DrawPDFFields = (props: Props) => {
outlineColor: drawnField.counterpart
? `#${npubToHex(drawnField.counterpart)?.substring(0, 6)}`
: undefined,
...(isFieldIndexerActive && field
? {
left: inPx(from(page.width, field.left)),
top: inPx(from(page.width, field.top)),
width: inPx(from(page.width, field.width)),
height: inPx(from(page.width, field.height))
}
: {
left: inPx(from(page.width, drawnField.left)),
top: inPx(from(page.width, drawnField.top)),
width: inPx(from(page.width, drawnField.width)),
height: inPx(from(page.width, drawnField.height)),
height: inPx(from(page.width, drawnField.height))
}),
pointerEvents: mouseState.clicked ? 'none' : 'all',
touchAction: 'none',
zIndex: isActiveDrawnField(
fileIndex,
pageIndex,
drawnFieldIndex
)
? 60
: undefined,
opacity:
mouseState.dragging &&
isActiveDrawnField(
@ -501,37 +603,36 @@ export const DrawPDFFields = (props: Props) => {
</div>
<span
onPointerDown={(event) =>
handleResizePointerDown(
event,
handleResizePointerDown(event, [
fileIndex,
pageIndex,
drawnFieldIndex
)
])
}
onPointerMove={(event) => {
handleResizePointerMove(event, drawnField, page.width)
handleResizePointerMove(event, page.width)
}}
onPointerUp={handlePointerUpReleaseCapture}
className={styles.resizeHandle}
style={{
background:
mouseState.resizing &&
...(mouseState.resizing &&
isActiveDrawnField(
fileIndex,
pageIndex,
drawnFieldIndex
)
? 'var(--primary-main)'
: undefined
) && {
cursor: 'grabbing',
opacity: 0.1
})
}}
></span>
<span
onPointerDown={(event) => {
handleRemovePointerDown(
event,
handleRemovePointerDown(event, [
fileIndex,
pageIndex,
drawnFieldIndex
)
])
}}
className={styles.removeHandle}
>
@ -569,23 +670,26 @@ export const DrawPDFFields = (props: Props) => {
onChange={(event) => {
drawnField.counterpart = event.target.value
setLastSigner(event.target.value)
refreshPdfFiles()
}}
labelId="counterparts"
label="Counterparts"
sx={{
background: 'white'
}}
renderValue={(value) =>
renderCounterpartValue(value)
}
renderValue={(value) => (
<Counterpart
npub={value}
userProfiles={userProfiles}
signers={signers}
/>
)}
>
{signers.map((signer, index) => {
const npub = hexToNpub(signer.pubkey)
const metadata = props.metadata[signer.pubkey]
const profile = userProfiles[signer.pubkey]
const displayValue = getProfileUsername(
npub,
metadata
profile
)
// make current signers dropdown visible
if (
@ -604,7 +708,7 @@ export const DrawPDFFields = (props: Props) => {
<MenuItem key={index} value={npub}>
<ListItemIcon>
<AvatarIconButton
src={metadata?.picture}
src={profile?.image}
hexKey={signer.pubkey}
aria-label={`account of user ${displayValue}`}
color="inherit"
@ -635,58 +739,24 @@ export const DrawPDFFields = (props: Props) => {
)
}
const renderCounterpartValue = (npub: string) => {
let displayValue = _.truncate(npub, { length: 16 })
const signer = signers.find((u) => u.pubkey === npubToHex(npub))
if (signer) {
const metadata = props.metadata[signer.pubkey]
displayValue = getProfileUsername(npub, metadata)
return (
<div className={styles.counterpartSelectValue}>
<AvatarIconButton
src={props.metadata[signer.pubkey]?.picture}
hexKey={signer.pubkey || undefined}
sx={{
padding: 0,
marginRight: '6px',
'> img': {
width: '21px',
height: '21px'
}
}}
/>
{displayValue}
</div>
)
}
return displayValue
}
return (
<div className="files-wrapper">
{sigitFiles.map((file, i) => {
return (
{sigitFiles.length > 0 &&
sigitFiles
.map<React.ReactNode>((file, i) =>
file.isPdf ? (
<React.Fragment key={file.name}>
<div className="file-wrapper" id={`file-${file.name}`}>
{file.isPdf && getPdfPages(file, i)}
{file.isImage && (
<img
className="file-image"
src={file.objectUrl}
alt={file.name}
/>
)}
{!(file.isPdf || file.isImage) && (
<ExtensionFileBox extension={file.extension} />
)}
</div>
{i < sigitFiles.length - 1 && <FileDivider />}
{getPdfPages(file, i)}
</React.Fragment>
) : (
<FileItem key={file.name} file={file} />
)
})}
)
.reduce((prev, curr, i) => [
prev,
<FileDivider key={`separator-${i}`} />,
curr
])}
</div>
)
}

View File

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

View File

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

View File

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

View File

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

View File

@ -47,7 +47,7 @@
background-color: #fff;
border: 1px solid rgb(160, 160, 160);
border-radius: 50%;
cursor: nwse-resize;
cursor: grab;
// Increase the area a bit so it's easier to click
&::after {
@ -85,10 +85,6 @@
}
}
.counterpartSelectValue {
display: flex;
}
.counterpartAvatar {
img {
width: 21px;
@ -107,3 +103,16 @@
outline: 1px dotted #01aaad;
}
}
.drawingRectanglePreview {
position: absolute;
outline: 1px solid;
z-index: 50;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
pointer-events: none;
touch-action: none;
opacity: 0.8;
}

View File

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

View File

@ -1,5 +1,4 @@
import { CurrentUserMark } from '../../types/mark.ts'
import styles from './style.module.scss'
import {
findNextIncompleteCurrentUserMark,
getToolboxLabelByMarkType,
@ -10,13 +9,15 @@ import React, { useState } from 'react'
import { MarkInput } from '../MarkTypeStrategy/MarkInput.tsx'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faCheck } from '@fortawesome/free-solid-svg-icons'
import { Button } from '@mui/material'
import styles from './style.module.scss'
interface MarkFormFieldProps {
currentUserMarks: CurrentUserMark[]
handleCurrentUserMarkChange: (mark: CurrentUserMark) => void
handleSelectedMarkValueChange: (value: string) => void
handleSubmit: (event: React.MouseEvent<HTMLButtonElement>) => void
selectedMark: CurrentUserMark
selectedMark: CurrentUserMark | null
selectedMarkValue: string
}
@ -33,26 +34,23 @@ const MarkFormField = ({
}: MarkFormFieldProps) => {
const [displayActions, setDisplayActions] = useState(true)
const [complete, setComplete] = useState(false)
const isReadyToSign = () =>
isCurrentUserMarksComplete(currentUserMarks) ||
isCurrentValueLast(currentUserMarks, selectedMark, selectedMarkValue)
const isCurrent = (currentMark: CurrentUserMark) =>
currentMark.id === selectedMark.id && !complete
currentMark.id === selectedMark?.id && !complete
const isDone = (currentMark: CurrentUserMark) =>
isCurrent(currentMark) ? !!selectedMarkValue : currentMark.isCompleted
const findNext = () => {
return (
currentUserMarks[selectedMark.id] ||
currentUserMarks[selectedMark!.id] ||
findNextIncompleteCurrentUserMark(currentUserMarks)
)
}
const handleFormSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
console.log('handle form submit runs...')
// Without this line, we lose mark values when switching
handleCurrentUserMarkChange(selectedMark)
handleCurrentUserMarkChange(selectedMark!)
if (!complete) {
isReadyToSign()
@ -62,15 +60,16 @@ const MarkFormField = ({
}
const toggleActions = () => setDisplayActions(!displayActions)
const markLabel = getToolboxLabelByMarkType(selectedMark.mark.type)
const markLabel = selectedMark
? getToolboxLabelByMarkType(selectedMark.mark.type)
: ''
const handleCurrentUserMarkClick = (mark: CurrentUserMark) => {
setComplete(false)
handleCurrentUserMarkChange(mark)
}
const handleSelectCompleteMark = () => {
handleCurrentUserMarkChange(selectedMark)
if (currentUserMarks.length) handleCurrentUserMarkChange(selectedMark!)
setComplete(true)
}
@ -105,14 +104,15 @@ const MarkFormField = ({
<div className={styles.actionsWrapper}>
<div className={styles.actionsTop}>
<div className={styles.actionsTopInfo}>
{!complete && (
{!complete && selectedMark ? (
<p className={styles.actionsTopInfoText}>Add {markLabel}</p>
) : (
<p className={styles.actionsTopInfoText}>Finish</p>
)}
{complete && <p className={styles.actionsTopInfoText}>Finish</p>}
</div>
</div>
<div className={styles.inputWrapper}>
{!complete && (
{!complete && selectedMark ? (
<form onSubmit={(e) => handleFormSubmit(e)}>
<MarkInput
markType={selectedMark.mark.type}
@ -123,22 +123,23 @@ const MarkFormField = ({
userMark={selectedMark}
/>
<div className={styles.actionsBottom}>
<button type="submit" className={styles.submitButton}>
<Button type="submit" className={styles.submitButton}>
NEXT
</button>
</Button>
</div>
</form>
)}
{complete && (
) : (
<div className={styles.actionsBottom}>
<button
<Button
onClick={handleSignAndComplete}
className={styles.submitButton}
className={[styles.submitButton, styles.completeButton].join(
' '
)}
disabled={!isReadyToSign()}
autoFocus
>
SIGN AND COMPLETE
</button>
</Button>
</div>
)}
@ -148,6 +149,7 @@ const MarkFormField = ({
return (
<div className={styles.pagination} key={index}>
<button
type="button"
className={`${styles.paginationButton} ${isDone(mark) ? styles.paginationButtonDone : ''}`}
onClick={() => handleCurrentUserMarkClick(mark)}
>
@ -161,8 +163,10 @@ const MarkFormField = ({
})}
<div className={styles.pagination}>
<button
type="button"
className={`${styles.paginationButton} ${isReadyToSign() ? styles.paginationButtonDone : ''}`}
onClick={handleSelectCompleteMark}
title="Complete"
>
<FontAwesomeIcon
className={styles.finishPage}

View File

@ -70,6 +70,11 @@
margin-top: 10px;
}
.completeButton {
font-size: 18px;
padding: 10px 20px;
}
.paginationButton {
font-size: 12px;
padding: 5px 10px;
@ -78,7 +83,8 @@
color: rgba(0, 0, 0, 0.5);
}
.paginationButton:hover {
.paginationButton:hover,
.paginationButton:focus {
background: #447592;
color: rgba(255, 255, 255, 0.5);
}

View File

@ -101,8 +101,7 @@ const PdfMarking = (props: PdfMarkingProps) => {
*/
const handleSubmit = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault()
if (!selectedMarkValue || !selectedMark) return
if (selectedMarkValue && selectedMark) {
const updatedMark: CurrentUserMark = getUpdatedMark(
selectedMark,
selectedMarkValue
@ -116,6 +115,8 @@ const PdfMarking = (props: PdfMarkingProps) => {
setCurrentUserMarks(updatedCurrentUserMarks)
setSelectedMark(null)
setUpdatedMarks(updatedMark.mark)
}
handleSign()
}
@ -152,7 +153,6 @@ const PdfMarking = (props: PdfMarkingProps) => {
centerIcon={faPen}
rightIcon={faCircleInfo}
>
{currentUserMarks?.length > 0 && (
<PdfView
currentFile={currentFile}
files={files}
@ -162,9 +162,7 @@ const PdfMarking = (props: PdfMarkingProps) => {
currentUserMarks={currentUserMarks}
otherUserMarks={otherUserMarks}
/>
)}
</StickySideColumns>
{selectedMark !== null && (
<MarkFormField
handleSubmit={handleSubmit}
handleSelectedMarkValueChange={handleChange}
@ -173,7 +171,6 @@ const PdfMarking = (props: PdfMarkingProps) => {
currentUserMarks={currentUserMarks}
handleCurrentUserMarkChange={handleCurrentUserMarkChange}
/>
)}
</Container>
</>
)

View File

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

View File

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

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

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

View File

@ -1,153 +0,0 @@
import { EventTemplate } from 'nostr-tools'
import { MetadataController, NostrController } from '.'
import { appPrivateRoutes } from '../routes'
import {
setAuthState,
setMetadataEvent,
setRelayMapAction
} from '../store/actions'
import store from '../store/store'
import { SignedEvent } from '../types'
import {
base64DecodeAuthToken,
base64EncodeSignedEvent,
compareObjects,
getAuthToken,
getRelayMap,
saveAuthToken,
unixNow
} from '../utils'
export class AuthController {
private nostrController: NostrController
private metadataController: MetadataController
constructor() {
this.nostrController = NostrController.getInstance()
this.metadataController = MetadataController.getInstance()
}
/**
* Function will authenticate user by signing an auth event
* which is done by calling the sign() function, where appropriate
* method will be chosen (extension or keys)
*
* @param pubkey of the user trying to login
* @returns url to redirect if authentication successfull
* or error if otherwise
*/
async authAndGetMetadataAndRelaysMap(pubkey: string) {
const emptyMetadata = this.metadataController.getEmptyMetadataEvent()
this.metadataController
.findMetadata(pubkey)
.then((event) => {
if (event) {
store.dispatch(setMetadataEvent(event))
} else {
store.dispatch(setMetadataEvent(emptyMetadata))
}
})
.catch((err) => {
console.warn('Error occurred while finding metadata', err)
store.dispatch(setMetadataEvent(emptyMetadata))
})
// Nostr uses unix timestamps
const timestamp = unixNow()
const { href } = window.location
const authEvent: EventTemplate = {
kind: 27235,
tags: [
['u', href],
['method', 'GET']
],
content: '',
created_at: timestamp
}
const signedAuthEvent = await this.nostrController.signEvent(authEvent)
this.createAndSaveAuthToken(signedAuthEvent)
store.dispatch(
setAuthState({
loggedIn: true,
usersPubkey: pubkey
})
)
const relayMap = await getRelayMap(pubkey)
if (Object.keys(relayMap).length < 1) {
// Navigate user to relays page if relay map is empty
return Promise.resolve(appPrivateRoutes.relays)
}
if (store.getState().auth.loggedIn) {
if (!compareObjects(store.getState().relays?.map, relayMap.map))
store.dispatch(setRelayMapAction(relayMap.map))
}
/**
* This block was added before we started using the `nostr-login` package
* At this point it seems it's not needed anymore and it's even blocking the flow (reloading on /verify)
* TODO to remove this if app works fine
*/
// const currentLocation = window.location.hash.replace('#', '')
// if (!Object.values(appPrivateRoutes).includes(currentLocation)) {
// // Since verify is both public and private route, we don't use the `visitedLink`
// // value for it. Otherwise, when linking to /verify/:id we get redirected
// // to the root `/`
// if (currentLocation.includes(appPublicRoutes.verify)) {
// return Promise.resolve(currentLocation)
// }
//
// // User did change the location to one of the private routes
// const visitedLink = getVisitedLink()
//
// if (visitedLink) {
// const { pathname, search } = visitedLink
//
// return Promise.resolve(`${pathname}${search}`)
// } else {
// // Navigate user in
// return Promise.resolve(appPrivateRoutes.homePage)
// }
// }
}
checkSession() {
const savedAuthToken = getAuthToken()
if (savedAuthToken) {
const signedEvent = base64DecodeAuthToken(savedAuthToken)
store.dispatch(
setAuthState({
loggedIn: true,
usersPubkey: signedEvent.pubkey
})
)
return
}
store.dispatch(
setAuthState({
loggedIn: false,
usersPubkey: undefined
})
)
}
private createAndSaveAuthToken(signedAuthEvent: SignedEvent) {
const base64Encoded = base64EncodeSignedEvent(signedAuthEvent)
// save newly created auth token (base64 nostr singed event) in local storage along with expiry time
saveAuthToken(base64Encoded)
return base64Encoded
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

View File

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

View File

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

View File

@ -45,7 +45,7 @@ export interface FlatMeta
isValid: boolean
// Decryption
encryptionKey: string | null
encryptionKey: string | undefined
// Parsed Document Signatures
parsedSignatureEvents: {
@ -101,7 +101,7 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
[signer: `npub1${string}`]: SignStatus
}>({})
const [encryptionKey, setEncryptionKey] = useState<string | null>(null)
const [encryptionKey, setEncryptionKey] = useState<string | undefined>()
useEffect(() => {
if (!meta) return
@ -143,7 +143,7 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
setMarkConfig(markConfig)
setZipUrl(zipUrl)
let encryptionKey: string | null = null
let encryptionKey: string | undefined
if (meta.keys) {
const { sender, keys } = meta.keys
// Retrieve the user's public key from the state
@ -161,7 +161,7 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
'An error occurred in decrypting encryption key',
err
)
return null
return undefined
})
encryptionKey = decrypted

View File

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

View File

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

View File

@ -10,7 +10,6 @@ import {
import type { Identifier, XYCoord } from 'dnd-core'
import saveAs from 'file-saver'
import JSZip from 'jszip'
import { Event, kinds } from 'nostr-tools'
import { useCallback, useEffect, useRef, useState } from 'react'
import { DndProvider, useDrag, useDrop } from 'react-dnd'
import { MultiBackend } from 'react-dnd-multi-backend'
@ -20,19 +19,16 @@ 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,
RelayController
} from '../../controllers'
import { appPrivateRoutes } from '../../routes'
import { NostrController } from '../../controllers'
import { appPrivateRoutes, appPublicRoutes } from '../../routes'
import {
CreateSignatureEventContent,
KeyboardCode,
Meta,
ProfileMetadata,
SigitNotification,
SignedEvent,
User,
UserRelaysType,
UserRole
} from '../../types'
import {
@ -47,12 +43,11 @@ import {
unixNow,
npubToHex,
queryNip05,
sendNotification,
signEventForMetaFile,
updateUsersAppData,
uploadToFileStorage,
DEFAULT_TOOLBOX,
settleAllFullfilfedPromises
settleAllFullfilfedPromises,
uploadMetaToFileStorage
} from '../../utils'
import { Container } from '../../components/Container'
import fileListStyles from '../../components/FileList/style.module.scss'
@ -76,16 +71,23 @@ import {
} from '@fortawesome/free-solid-svg-icons'
import { getSigitFile, SigitFile } from '../../utils/file.ts'
import { generateTimestamp } from '../../utils/opentimestamps.ts'
import { Autocomplete } from '@mui/lab'
import { Autocomplete } from '@mui/material'
import _, { truncate } from 'lodash'
import * as React from 'react'
import { AvatarIconButton } from '../../components/UserAvatarIconButton'
import { NDKUserProfile, NostrEvent } from '@nostr-dev-kit/ndk'
import { useNDKContext } from '../../hooks/useNDKContext.ts'
import { useNDK } from '../../hooks/useNDK.ts'
import { useImmer } from 'use-immer'
type FoundUser = Event & { npub: string }
type FoundUser = NostrEvent & { npub: string }
export const CreatePage = () => {
const navigate = useNavigate()
const location = useLocation()
const { findMetadata, fetchEventsFromUserRelays } = useNDKContext()
const { updateUsersAppData, sendNotification } = useNDK()
const { uploadedFiles } = location.state || {}
const [currentFile, setCurrentFile] = useState<File>()
const isActive = (file: File) => file.name === currentFile?.name
@ -95,7 +97,7 @@ export const CreatePage = () => {
const [title, setTitle] = useState(`sigit_${formatTimestamp(Date.now())}`)
const [selectedFiles, setSelectedFiles] = useState<File[]>([])
const [selectedFiles, setSelectedFiles] = useState<File[]>([...uploadedFiles])
const fileInputRef = useRef<HTMLInputElement>(null)
const handleUploadButtonClick = () => {
if (fileInputRef.current) {
@ -117,10 +119,11 @@ export const CreatePage = () => {
const nostrController = NostrController.getInstance()
const [metadata, setMetadata] = useState<{ [key: string]: ProfileMetadata }>(
{}
)
const [drawnFiles, setDrawnFiles] = useState<SigitFile[]>([])
const [userProfiles, setUserProfiles] = useState<{
[key: string]: NDKUserProfile
}>({})
const [drawnFiles, updateDrawnFiles] = useImmer<SigitFile[]>([])
const [parsingPdf, setIsParsing] = useState<boolean>(false)
const searchFieldRef = useRef<HTMLInputElement>(null)
@ -166,24 +169,20 @@ export const CreatePage = () => {
setSearchUsersLoading(true)
const relayController = RelayController.getInstance()
const metadataController = MetadataController.getInstance()
const relaySet = await metadataController.findRelayListMetadata(usersPubkey)
const searchTerm = searchString.trim()
relayController
.fetchEvents(
fetchEventsFromUserRelays(
{
kinds: [0],
search: searchTerm
},
[...relaySet.write]
usersPubkey,
UserRelaysType.Write
)
.then((events) => {
console.log('events', events)
const nostrEvents = events.map((event) => event.rawEvent())
const fineFilteredEvents: FoundUser[] = events
const fineFilteredEvents = nostrEvents
.filter((event) => {
const lowercaseContent = event.content.toLowerCase()
@ -200,15 +199,15 @@ export const CreatePage = () => {
lowercaseContent.includes(`"nip05":"${searchTerm.toLowerCase()}"`)
)
})
.reduce((uniqueEvents: FoundUser[], event: Event) => {
if (!uniqueEvents.some((e: Event) => e.pubkey === event.pubkey)) {
.reduce((uniqueEvents, event) => {
if (!uniqueEvents.some((e) => e.pubkey === event.pubkey)) {
uniqueEvents.push({
...event,
npub: hexToNpub(event.pubkey)
})
}
return uniqueEvents
}, [])
}, [] as FoundUser[])
console.info('fineFilteredEvents', fineFilteredEvents)
setFoundUsers(fineFilteredEvents)
@ -284,8 +283,28 @@ export const CreatePage = () => {
selectedFiles,
getSigitFile
)
updateDrawnFiles((draft) => {
// Existing files are untouched
setDrawnFiles(files)
// Handle removed files
// Remove in reverse to avoid index issues
for (let i = draft.length - 1; i >= 0; i--) {
if (
!files.some(
(f) => f.name === draft[i].name && f.size === draft[i].size
)
) {
draft.splice(i, 1)
}
}
// Add new files
files.forEach((f) => {
if (!draft.some((d) => d.name === f.name && d.size === f.size)) {
draft.push(f)
}
})
})
}
setIsParsing(true)
@ -294,7 +313,7 @@ export const CreatePage = () => {
setIsParsing(false)
})
}
}, [selectedFiles])
}, [selectedFiles, updateDrawnFiles])
/**
* Changes the drawing tool
@ -312,29 +331,15 @@ export const CreatePage = () => {
useEffect(() => {
users.forEach((user) => {
if (!(user.pubkey in metadata)) {
const metadataController = MetadataController.getInstance()
const handleMetadataEvent = (event: Event) => {
const metadataContent =
metadataController.extractProfileMetadataContent(event)
if (metadataContent)
setMetadata((prev) => ({
if (!(user.pubkey in userProfiles)) {
findMetadata(user.pubkey)
.then((profile) => {
if (profile) {
setUserProfiles((prev) => ({
...prev,
[user.pubkey]: metadataContent
[user.pubkey]: profile
}))
}
metadataController.on(user.pubkey, (kind: number, event: Event) => {
if (kind === kinds.Metadata) {
handleMetadataEvent(event)
}
})
metadataController
.findMetadata(user.pubkey)
.then((metadataEvent) => {
if (metadataEvent) handleMetadataEvent(metadataEvent)
})
.catch((err) => {
console.error(
@ -344,13 +349,7 @@ export const CreatePage = () => {
})
}
})
}, [metadata, users])
useEffect(() => {
if (uploadedFiles) {
setSelectedFiles([...uploadedFiles])
}
}, [uploadedFiles])
}, [userProfiles, users, findMetadata])
useEffect(() => {
if (usersPubkey) {
@ -505,7 +504,7 @@ export const CreatePage = () => {
})
})
})
setDrawnFiles(drawnFilesCopy)
updateDrawnFiles(drawnFilesCopy)
}
/**
@ -529,11 +528,16 @@ export const CreatePage = () => {
const files = Array.from(event.target.files)
// Remove duplicates based on the file.name
setSelectedFiles((p) =>
[...p, ...files].filter(
setSelectedFiles((p) => {
const unique = [...p, ...files].filter(
(file, i, array) => i === array.findIndex((t) => t.name === file.name)
)
)
navigate('.', {
state: { uploadedFiles: unique },
replace: true
})
return unique
})
}
}
@ -547,9 +551,14 @@ export const CreatePage = () => {
) => {
event.stopPropagation()
setSelectedFiles((prevFiles) =>
prevFiles.filter((file) => file.name !== fileToRemove.name)
)
setSelectedFiles((prevFiles) => {
const files = prevFiles.filter((file) => file.name !== fileToRemove.name)
navigate('.', {
state: { uploadedFiles: files },
replace: true
})
return files
})
}
// Validate inputs before proceeding
@ -811,7 +820,7 @@ export const CreatePage = () => {
}
// Send notifications to signers and viewers
const sendNotifications = (meta: Meta) => {
const sendNotifications = (notification: SigitNotification) => {
// no need to send notification to self so remove it from the list
const receivers = (
signers.length > 0
@ -819,7 +828,7 @@ export const CreatePage = () => {
: viewers.map((viewer) => viewer.pubkey)
).filter((receiver) => receiver !== usersPubkey)
return receivers.map((receiver) => sendNotification(receiver, meta))
return receivers.map((receiver) => sendNotification(receiver, notification))
}
const extractNostrId = (stringifiedEvent: string): string => {
@ -894,11 +903,17 @@ export const CreatePage = () => {
}
setLoadingSpinnerDesc('Updating user app data')
const event = await updateUsersAppData(meta)
const event = await updateUsersAppData([meta])
if (!event) return
const metaUrl = await uploadMetaToFileStorage(meta, encryptionKey)
setLoadingSpinnerDesc('Sending notifications to counterparties')
const promises = sendNotifications(meta)
const promises = sendNotifications({
metaUrl,
keys: meta.keys
})
await Promise.all(promises)
.then(() => {
@ -908,7 +923,14 @@ export const CreatePage = () => {
toast.error('Failed to publish notifications')
})
const isFirstSigner = signers[0].pubkey === usersPubkey
if (isFirstSigner) {
navigate(appPrivateRoutes.sign, { state: { meta } })
} else {
const createSignatureJson = JSON.parse(createSignature)
navigate(`${appPublicRoutes.verify}/${createSignatureJson.id}`)
}
} else {
const zip = new JSZip()
@ -996,18 +1018,16 @@ export const CreatePage = () => {
setUserSearchInput(value)
}
const parseContent = (event: Event) => {
const parseContent = (event: NostrEvent) => {
try {
return JSON.parse(event.content)
} catch (e) {
return undefined
console.error(e)
}
}
return (
<>
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
<Container className={styles.container}>
<StickySideColumns
left={
@ -1107,7 +1127,7 @@ export const CreatePage = () => {
key={option.pubkey}
>
<AvatarIconButton
src={contentJson.picture}
src={contentJson.picture || contentJson.image}
hexKey={option.pubkey}
color="inherit"
sx={{
@ -1220,19 +1240,17 @@ export const CreatePage = () => {
centerIcon={faFile}
rightIcon={faToolbox}
>
{parsingPdf ? (
<LoadingSpinner variant="small" />
) : (
<DrawPDFFields
users={users}
metadata={metadata}
userProfiles={userProfiles}
selectedTool={selectedTool}
sigitFiles={drawnFiles}
setSigitFiles={setDrawnFiles}
updateSigitFiles={updateDrawnFiles}
/>
)}
{parsingPdf && <LoadingSpinner variant="small" />}
</StickySideColumns>
</Container>
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
</>
)
}

View File

@ -58,7 +58,7 @@ export const HomePage = () => {
const usersAppData = useAppSelector((state) => state.userAppData)
useEffect(() => {
if (usersAppData) {
if (usersAppData?.sigits) {
const getSigitInfo = async () => {
const parsedSigits: { [key: string]: SigitCardDisplayInfo } = {}
for (const key in usersAppData.sigits) {
@ -80,7 +80,7 @@ export const HomePage = () => {
setSigits(usersAppData.sigits)
getSigitInfo()
}
}, [usersAppData])
}, [usersAppData?.sigits])
const onDrop = useCallback(
async (acceptedFiles: File[]) => {

View File

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

View File

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

View File

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

View File

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

View File

@ -32,12 +32,11 @@ import {
parseJson,
processMarks,
readContentOfZipEntry,
sendNotification,
signEventForMetaFile,
timeout,
unixNow,
updateMarks,
updateUsersAppData
uploadMetaToFileStorage
} from '../../utils'
import { CurrentUserMark, Mark } from '../../types/mark.ts'
import PdfMarking from '../../components/PDFView/PdfMarking.tsx'
@ -48,12 +47,14 @@ import {
} from '../../utils/file.ts'
import { generateTimestamp } from '../../utils/opentimestamps.ts'
import { MARK_TYPE_CONFIG } from '../../components/MarkTypeStrategy/MarkStrategy.tsx'
import { useNDK } from '../../hooks/useNDK.ts'
import { getLastSignersSig } from '../../utils/sign.ts'
export const SignPage = () => {
const navigate = useNavigate()
const location = useLocation()
const params = useParams()
const { updateUsersAppData, sendNotification } = useNDK()
const usersAppData = useAppSelector((state) => state.userAppData)
@ -519,7 +520,7 @@ export const SignPage = () => {
}
if (await isOnline()) {
await handleOnlineFlow(updatedMeta)
await handleOnlineFlow(updatedMeta, encryptionKey)
} else {
setMeta(updatedMeta)
setIsLoading(false)
@ -639,15 +640,30 @@ export const SignPage = () => {
return null
}
// Handle the online flow: update users' app data and send notifications
const handleOnlineFlow = async (meta: Meta) => {
// Handle the online flow: update users app data and send notifications
const handleOnlineFlow = async (
meta: Meta,
encryptionKey: string | undefined
) => {
setLoadingSpinnerDesc('Updating users app data')
const updatedEvent = await updateUsersAppData(meta)
const updatedEvent = await updateUsersAppData([meta])
if (!updatedEvent) {
setIsLoading(false)
return
}
let metaUrl: string
try {
metaUrl = await uploadMetaToFileStorage(meta, encryptionKey)
} catch (error) {
if (error instanceof Error) {
toast.error(error.message)
}
console.error(error)
setIsLoading(false)
return
}
const userSet = new Set<`npub1${string}`>()
if (submittedBy && submittedBy !== usersPubkey) {
userSet.add(hexToNpub(submittedBy))
@ -680,7 +696,7 @@ export const SignPage = () => {
setLoadingSpinnerDesc('Sending notifications')
const users = Array.from(userSet)
const promises = users.map((user) =>
sendNotification(npubToHex(user)!, meta)
sendNotification(npubToHex(user)!, { metaUrl, keys: meta.keys })
)
await Promise.all(promises)
.then(() => {

View File

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

View File

@ -21,20 +21,19 @@ import {
readContentOfZipEntry,
signEventForMetaFile,
getCurrentUserFiles,
updateUsersAppData,
npubToHex,
sendNotification,
generateEncryptionKey,
encryptArrayBuffer,
generateKeysFile,
ARRAY_BUFFER,
DEFLATE
DEFLATE,
uploadMetaToFileStorage
} from '../../utils'
import styles from './style.module.scss'
import { useLocation, useParams } from 'react-router-dom'
import axios from 'axios'
import { FONT_SIZE, FONT_TYPE, inPx } from '../../utils/pdf.ts'
import { useAppSelector } from '../../hooks'
import { useAppSelector, useNDK } from '../../hooks'
import { getLastSignersSig } from '../../utils/sign.ts'
import { saveAs } from 'file-saver'
import { Container } from '../../components/Container'
@ -171,6 +170,7 @@ const SlimPdfView = ({
export const VerifyPage = () => {
const location = useLocation()
const params = useParams()
const { updateUsersAppData, sendNotification } = useNDK()
const usersAppData = useAppSelector((state) => state.userAppData)
const usersPubkey = useAppSelector((state) => state.auth.usersPubkey)
@ -353,9 +353,14 @@ export const VerifyPage = () => {
updatedMeta.timestamps = [...finalTimestamps]
updatedMeta.modifiedAt = unixNow()
const updatedEvent = await updateUsersAppData(updatedMeta)
const updatedEvent = await updateUsersAppData([updatedMeta])
if (!updatedEvent) return
const metaUrl = await uploadMetaToFileStorage(
updatedMeta,
encryptionKey
)
const userSet = new Set<`npub1${string}`>()
signers.forEach((signer) => {
if (signer !== usersPubkey) {
@ -369,7 +374,10 @@ export const VerifyPage = () => {
const users = Array.from(userSet)
const promises = users.map((user) =>
sendNotification(npubToHex(user)!, updatedMeta)
sendNotification(npubToHex(user)!, {
metaUrl,
keys: meta.keys!
})
)
await Promise.all(promises)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

@ -83,3 +83,12 @@ export interface UserAppData {
export interface DocSignatureEvent extends Event {
parsedContent?: SignedEventContent
}
export interface SigitNotification {
metaUrl: string
keys?: { sender: string; keys: { [user: `npub1${string}`]: string } }
}
export function isSigitNotification(obj: unknown): obj is SigitNotification {
return typeof (obj as SigitNotification).metaUrl === 'string'
}

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

@ -1,5 +1,6 @@
export * from './auth'
export * from './const'
export * from './crypto'
export * from './dvm'
export * from './hash'
export * from './localStorage'
export * from './mark'
@ -11,4 +12,3 @@ export * from './string'
export * from './url'
export * from './utils'
export * from './zip'
export * from './const'

View File

@ -123,15 +123,19 @@ const isLast = <T>(index: number, arr: T[]) => index === arr.length - 1
const isCurrentValueLast = (
currentUserMarks: CurrentUserMark[],
selectedMark: CurrentUserMark,
selectedMark: CurrentUserMark | null,
selectedMarkValue: string
) => {
if (selectedMark && currentUserMarks.length > 0) {
const filteredMarks = currentUserMarks.filter(
(mark) => mark.id !== selectedMark.id
)
return (
isCurrentUserMarksComplete(filteredMarks) && selectedMarkValue.length > 0
)
}
return true
}
const getUpdatedMark = (

View File

@ -1,5 +1,12 @@
import { CreateSignatureEventContent, Meta } from '../types'
import { fromUnixTimestamp, parseJson } from '.'
import {
decryptArrayBuffer,
encryptArrayBuffer,
fromUnixTimestamp,
getHash,
parseJson,
uploadToFileStorage
} from '.'
import { Event, verifyEvent } from 'nostr-tools'
import { toast } from 'react-toastify'
import { extractFileExtensions } from './file'
@ -8,6 +15,11 @@ import {
MetaParseError,
MetaParseErrorType
} from '../types/errors/MetaParseError'
import axios from 'axios'
import {
MetaStorageError,
MetaStorageErrorType
} from '../types/errors/MetaStorageError'
export enum SignStatus {
Signed = 'Signed',
@ -126,3 +138,76 @@ export const extractSigitCardDisplayInfo = async (meta: Meta) => {
}
}
}
export const uploadMetaToFileStorage = async (
meta: Meta,
encryptionKey: string | undefined
) => {
// Value is the stringified meta object
const value = JSON.stringify(meta)
const encoder = new TextEncoder()
// Encode it to the arrayBuffer
const uint8Array = encoder.encode(value)
if (!encryptionKey) {
throw new MetaStorageError(MetaStorageErrorType.ENCRYPTION_KEY_REQUIRED)
}
// Encrypt the file contents with the same encryption key from the create signature
const encryptedArrayBuffer = await encryptArrayBuffer(
uint8Array,
encryptionKey
)
const hash = await getHash(encryptedArrayBuffer)
if (!hash) {
throw new MetaStorageError(MetaStorageErrorType.HASHING_FAILED)
}
// Create the encrypted json file from array buffer and hash
const file = new File([encryptedArrayBuffer], `${hash}.json`)
const url = await uploadToFileStorage(file)
return url
}
export const fetchMetaFromFileStorage = async (
url: string,
encryptionKey: string | undefined
) => {
if (!encryptionKey) {
throw new MetaStorageError(MetaStorageErrorType.ENCRYPTION_KEY_REQUIRED)
}
const encryptedArrayBuffer = await axios.get(url, {
responseType: 'arraybuffer'
})
// Verify hash
const parts = url.split('/')
const urlHash = parts[parts.length - 1]
const hash = await getHash(encryptedArrayBuffer.data)
if (hash !== urlHash) {
throw new MetaStorageError(MetaStorageErrorType.HASH_VERIFICATION_FAILED)
}
const arrayBuffer = await decryptArrayBuffer(
encryptedArrayBuffer.data,
encryptionKey
).catch((err) => {
throw new MetaStorageError(MetaStorageErrorType.DECRYPTION_FAILED, {
cause: err
})
})
if (arrayBuffer) {
// Decode meta.json and parse
const decoder = new TextDecoder()
const json = decoder.decode(arrayBuffer)
const meta = await parseJson<Meta>(json)
return meta
}
throw new MetaStorageError(MetaStorageErrorType.UNHANDLED_FETCH_ERROR)
}

View File

@ -1,16 +1,15 @@
import { bytesToHex, hexToBytes } from '@noble/hashes/utils'
import { hexToBytes } from '@noble/hashes/utils'
import { NDKEvent, NDKUserProfile } from '@nostr-dev-kit/ndk'
import axios from 'axios'
import _, { truncate } from 'lodash'
import { truncate } from 'lodash'
import {
Event,
EventTemplate,
Filter,
UnsignedEvent,
finalizeEvent,
generateSecretKey,
getEventHash,
getPublicKey,
kinds,
nip04,
nip19,
nip44,
@ -18,28 +17,16 @@ import {
} from 'nostr-tools'
import { toast } from 'react-toastify'
import { NIP05_REGEX } from '../constants'
import {
MetadataController,
NostrController,
relayController
} from '../controllers'
import {
updateProcessedGiftWraps,
updateUserAppData as updateUserAppDataAction
} from '../store/actions'
import { Keys } from '../store/auth/types'
import store from '../store/store'
import { Meta, ProfileMetadata, SignedEvent, UserAppData } from '../types'
import { getDefaultRelayMap } from './relays'
import { parseJson, removeLeadingSlash } from './string'
import { timeout } from './utils'
import { getHash } from './hash'
import { Meta, SignedEvent } from '../types'
import { SIGIT_BLOSSOM } from './const.ts'
import { getHash } from './hash'
import { parseJson, removeLeadingSlash } from './string'
/**
* Generates a `d` tag for userAppData
*/
const getDTagForUserAppData = async (): Promise<string | null> => {
export const getDTagForUserAppData = async (): Promise<string | null> => {
const isLoggedIn = store.getState().auth.loggedIn
const pubkey = store.getState().auth?.usersPubkey
@ -198,27 +185,6 @@ export const queryNip05 = async (
}
}
export const base64EncodeSignedEvent = (event: SignedEvent) => {
try {
const authEventSerialized = JSON.stringify(event)
const token = btoa(authEventSerialized)
return token
} catch (error) {
throw new Error('An error occurred in JSON.stringify of signedAuthEvent')
}
}
export const base64DecodeAuthToken = (authToken: string): SignedEvent => {
const decodedToken = atob(authToken)
try {
const signedEvent = JSON.parse(decodedToken)
return signedEvent
} catch (error) {
throw new Error('An error occurred in JSON.parse of the auth token')
}
}
/**
* @param pubkey in hex or npub format
* @returns robohash.org url for the avatar
@ -349,317 +315,7 @@ export const createWrap = (unsignedEvent: UnsignedEvent, receiver: string) => {
}
}
/**
* Fetches user application data based on user's public key and stored metadata.
*
* @returns The user application data or null if an error occurs or no data is found.
*/
export const getUsersAppData = async (): Promise<UserAppData | null> => {
// Initialize an array to hold relay URLs
const relays: string[] = []
// Retrieve the user's public key and relay map from the Redux store
const usersPubkey = store.getState().auth.usersPubkey!
const relayMap = store.getState().relays?.map
// Check if relayMap is undefined in the Redux store
if (!relayMap) {
// If relayMap is not present, fetch relay list metadata
const metadataController = MetadataController.getInstance()
const relaySet = await metadataController
.findRelayListMetadata(usersPubkey)
.catch((err) => {
// Log error and return null if fetching metadata fails
console.log(
`An error occurred while finding relay list metadata for ${hexToNpub(usersPubkey)}`,
err
)
return null
})
// Return null if metadata retrieval failed
if (!relaySet) return null
// Ensure that the relay list is not empty
if (relaySet.write.length === 0) return null
// Add write relays to the relays array
relays.push(...relaySet.write)
} else {
// If relayMap exists, filter and add write relays from the stored map
const writeRelays = Object.keys(relayMap).filter(
(key) => relayMap[key].write
)
relays.push(...writeRelays)
}
// Generate an identifier for the user's nip78
const dTag = await getDTagForUserAppData()
if (!dTag) return null
// Define a filter for fetching events
const filter: Filter = {
kinds: [kinds.Application],
'#d': [dTag]
}
const encryptedContent = await relayController
.fetchEvent(filter, relays)
.then((event) => {
if (event) return event.content
// If no event is found, return an empty stringified object
return '{}'
})
.catch((err) => {
// Log error and show a toast notification if fetching event fails
console.log(`An error occurred in finding kind 30078 event`, err)
toast.error(
'An error occurred in finding kind 30078 event for data storage'
)
return null
})
// Return null if encrypted content retrieval fails
if (!encryptedContent) return null
// Handle case where the encrypted content is an empty object
if (encryptedContent === '{}') {
// Generate ephemeral key pair
const secret = generateSecretKey()
const pubKey = getPublicKey(secret)
return {
sigits: {},
processedGiftWraps: [],
blossomUrls: [],
keyPair: {
private: bytesToHex(secret),
public: pubKey
}
}
}
// Get an instance of the NostrController
const nostrController = NostrController.getInstance()
// Decrypt the encrypted content
const decrypted = await nostrController
.nip04Decrypt(usersPubkey, encryptedContent)
.catch((err) => {
// Log error and show a toast notification if decryption fails
console.log('An error occurred while decrypting app data', err)
toast.error('An error occurred while decrypting app data')
return null
})
// Return null if decryption fails
if (!decrypted) return null
// Parse the decrypted content
const parsedContent = await parseJson<{
blossomUrls: string[]
keyPair: Keys
}>(decrypted).catch((err) => {
// Log error and show a toast notification if parsing fails
console.log(
'An error occurred in parsing the content of kind 30078 event',
err
)
toast.error('An error occurred in parsing the content of kind 30078 event')
return null
})
// Return null if parsing fails
if (!parsedContent) return null
const { blossomUrls, keyPair } = parsedContent
// Return null if no blossom URLs are found
if (blossomUrls.length === 0) return null
// Fetch additional user app data from the first blossom URL
const dataFromBlossom = await getUserAppDataFromBlossom(
blossomUrls[0],
keyPair.private
)
// Return null if fetching data from blossom fails
if (!dataFromBlossom) return null
const { sigits, processedGiftWraps } = dataFromBlossom
// Return the final user application data
return {
blossomUrls,
keyPair,
sigits,
processedGiftWraps
}
}
export const updateUsersAppData = async (meta: Meta) => {
const appData = store.getState().userAppData
if (!appData || !appData.keyPair) return null
const sigits = _.cloneDeep(appData.sigits)
const createSignatureEvent = await parseJson<Event>(
meta.createSignature
).catch((err) => {
console.log('err in parsing the createSignature event:>> ', err)
toast.error(
err.message || 'error occurred in parsing the create signature event'
)
return null
})
if (!createSignatureEvent) return null
const id = createSignatureEvent.id
let isUpdated = false
// check if sigit already exists
if (id in sigits) {
// update meta only if incoming meta is more recent
// than already existing one
const existingMeta = sigits[id]
if (existingMeta.modifiedAt < meta.modifiedAt) {
sigits[id] = meta
isUpdated = true
}
} else {
sigits[id] = meta
isUpdated = true
}
if (!isUpdated) return null
const blossomUrls = [...appData.blossomUrls]
const newBlossomUrl = await uploadUserAppDataToBlossom(
sigits,
appData.processedGiftWraps,
appData.keyPair.private
).catch((err) => {
console.log(
'An error occurred in uploading user app data file to blossom server',
err
)
toast.error(
'An error occurred in uploading user app data file to blossom server'
)
return null
})
if (!newBlossomUrl) return null
// insert new blossom url at the start of the array
blossomUrls.unshift(newBlossomUrl)
// only keep last 10 blossom urls, delete older ones
if (blossomUrls.length > 10) {
const filesToDelete = blossomUrls.splice(10)
filesToDelete.forEach((url) => {
deleteBlossomFile(url, appData.keyPair!.private).catch((err) => {
console.log(
'An error occurred in removing old file of user app data from blossom server',
err
)
})
})
}
const usersPubkey = store.getState().auth.usersPubkey!
// encrypt content for storing in kind 30078 event
const nostrController = NostrController.getInstance()
const encryptedContent = await nostrController
.nip04Encrypt(
usersPubkey,
JSON.stringify({
blossomUrls,
keyPair: appData.keyPair
})
)
.catch((err) => {
console.log(
'An error occurred in encryption of content for app data',
err
)
toast.error(
err.message || 'An error occurred in encryption of content for app data'
)
return null
})
if (!encryptedContent) return null
// generate the identifier for user's appData event
const dTag = await getDTagForUserAppData()
if (!dTag) return null
const updatedEvent: UnsignedEvent = {
kind: kinds.Application,
pubkey: usersPubkey!,
created_at: unixNow(),
tags: [['d', dTag]],
content: encryptedContent
}
const signedEvent = await nostrController
.signEvent(updatedEvent)
.catch((err) => {
console.log('An error occurred in signing event', err)
toast.error(err.message || 'An error occurred in signing event')
return null
})
if (!signedEvent) return null
const relayMap = store.getState().relays.map || getDefaultRelayMap()
const writeRelays = Object.keys(relayMap).filter((key) => relayMap[key].write)
const publishResult = await Promise.race([
relayController.publish(signedEvent, writeRelays),
timeout(40 * 1000)
]).catch((err) => {
console.log('err :>> ', err)
if (err.message === 'Timeout') {
toast.error('Timeout occurred in publishing updated app data')
} else if (Array.isArray(err)) {
err.forEach((errResult) => {
toast.error(
`Publishing to ${errResult.relay} caused the following error: ${errResult.error}`
)
})
} else {
toast.error(
'An unexpected error occurred in publishing updated app data '
)
}
return null
})
if (!publishResult) return null
// update redux store
store.dispatch(
updateUserAppDataAction({
sigits,
blossomUrls,
processedGiftWraps: [...appData.processedGiftWraps],
keyPair: {
...appData.keyPair
}
})
)
return signedEvent
}
const deleteBlossomFile = async (url: string, privateKey: string) => {
export const deleteBlossomFile = async (url: string, privateKey: string) => {
const pathname = new URL(url).pathname
const hash = removeLeadingSlash(pathname)
@ -694,7 +350,7 @@ const deleteBlossomFile = async (url: string, privateKey: string) => {
* @param privateKey - The private key used for encryption.
* @returns A promise that resolves to the URL of the uploaded file.
*/
const uploadUserAppDataToBlossom = async (
export const uploadUserAppDataToBlossom = async (
sigits: { [key: string]: Meta },
processedGiftWraps: string[],
privateKey: string
@ -762,7 +418,10 @@ const uploadUserAppDataToBlossom = async (
* @param privateKey - The private key used for decryption.
* @returns A promise that resolves to the decrypted and parsed user application data.
*/
const getUserAppDataFromBlossom = async (url: string, privateKey: string) => {
export const getUserAppDataFromBlossom = async (
url: string,
privateKey: string
) => {
// Initialize errorCode to track HTTP error codes
let errorCode = 0
@ -831,152 +490,6 @@ const getUserAppDataFromBlossom = async (url: string, privateKey: string) => {
return parsedContent
}
/**
* Function to subscribe to sigits notifications for a specified public key.
* @param pubkey - The public key to subscribe to.
* @returns A promise that resolves when the subscription is successful.
*/
export const subscribeForSigits = async (pubkey: string) => {
// Instantiate the MetadataController to retrieve relay list metadata
const metadataController = MetadataController.getInstance()
const relaySet = await metadataController
.findRelayListMetadata(pubkey)
.catch((err) => {
// Log an error if retrieving relay list metadata fails
console.log(
`An error occurred while finding relay list metadata for ${hexToNpub(pubkey)}`,
err
)
return null
})
// Return if metadata retrieval failed
if (!relaySet) return
// Ensure relay list is not empty
if (relaySet.read.length === 0) return
// Define the filter for the subscription
const filter: Filter = {
kinds: [1059],
'#p': [pubkey]
}
// Process the received event synchronously
const events = await relayController.fetchEvents(filter, relaySet.read)
for (const e of events) {
await processReceivedEvent(e)
}
// Async processing of the events has a race condition
// relayController.subscribeForEvents(filter, relaySet.read, (event) => {
// processReceivedEvent(event)
// })
}
const processReceivedEvent = async (event: Event, difficulty: number = 5) => {
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]))
// validate PoW
// Count the number of leading zero bits in the hash
const leadingZeroes = countLeadingZeroes(event.id)
if (leadingZeroes < difficulty) return
// decrypt the content of gift wrap event
const nostrController = NostrController.getInstance()
const decrypted = await nostrController.nip44Decrypt(
event.pubkey,
event.content
)
const internalUnsignedEvent = await parseJson<UnsignedEvent>(decrypted).catch(
(err) => {
console.log(
'An error occurred in parsing the internal unsigned event',
err
)
return null
}
)
if (!internalUnsignedEvent || internalUnsignedEvent.kind !== 938) return
const meta = await parseJson<Meta>(internalUnsignedEvent.content).catch(
(err) => {
console.log(
'An error occurred in parsing the internal unsigned event',
err
)
return null
}
)
if (!meta) return
await updateUsersAppData(meta)
}
/**
* Function to send a notification to a specified receiver.
* @param receiver - The recipient's public key.
* @param meta - Metadata associated with the notification.
*/
export const sendNotification = async (receiver: string, meta: Meta) => {
// Retrieve the user's public key from the state
const usersPubkey = store.getState().auth.usersPubkey!
// Create an unsigned event object with the provided metadata
const unsignedEvent: UnsignedEvent = {
kind: 938,
pubkey: usersPubkey,
content: JSON.stringify(meta),
tags: [],
created_at: unixNow()
}
// Wrap the unsigned event with the receiver's information
const wrappedEvent = createWrap(unsignedEvent, receiver)
// Instantiate the MetadataController to retrieve relay list metadata
const metadataController = MetadataController.getInstance()
const relaySet = await metadataController
.findRelayListMetadata(receiver)
.catch((err) => {
// Log an error if retrieving relay list metadata fails
console.log(
`An error occurred while finding relay list metadata for ${hexToNpub(receiver)}`,
err
)
return null
})
// Return if metadata retrieval failed
if (!relaySet) return
// Ensure relay list is not empty
if (relaySet.read.length === 0) return
// Publish the notification event to the recipient's read relays
await Promise.race([
relayController.publish(wrappedEvent, relaySet.read),
timeout(40 * 1000)
]).catch((err) => {
// Log an error if publishing the notification event fails
console.log(
`An error occurred while publishing notification event for ${hexToNpub(receiver)}`,
err
)
throw err
})
}
/**
* 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)
@ -984,8 +497,29 @@ export const sendNotification = async (receiver: string, meta: Meta) => {
*/
export const getProfileUsername = (
npub: `npub1${string}` | string,
profile?: ProfileMetadata
profile?: NDKUserProfile
) =>
truncate(profile?.display_name || profile?.name || hexToNpub(npub), {
truncate(profile?.displayName || profile?.name || hexToNpub(npub), {
length: 16
})
/**
* Orders an array of NDKEvent objects chronologically based on their `created_at` property.
*
* @param events - The array of NDKEvent objects to be sorted.
* @param reverse - Optional flag to reverse the sorting order.
* If true, sorts in ascending order (oldest first), otherwise sorts in descending order (newest first).
*
* @returns The sorted array of events.
*/
export function orderEventsChronologically(
events: NDKEvent[],
reverse: boolean = false
): NDKEvent[] {
events.sort((e1: NDKEvent, e2: NDKEvent) => {
if (reverse) return e1.created_at! - e2.created_at!
else return e2.created_at! - e1.created_at!
})
return events
}

View File

@ -1,171 +1,43 @@
import { Event, Filter, kinds, UnsignedEvent } from 'nostr-tools'
import { RelayList } from 'nostr-tools/kinds'
import { getRelayInfo, unixNow } from '.'
import { NostrController, relayController } from '../controllers'
import { localCache } from '../services'
import { RelayMap, RelaySet } from '../types'
import {
DEFAULT_LOOK_UP_RELAY_LIST,
ONE_DAY_IN_MS,
ONE_WEEK_IN_MS,
SIGIT_RELAY
} from './const'
import NDK, { NDKEvent, NDKRelayList } from '@nostr-dev-kit/ndk'
import { kinds, UnsignedEvent } from 'nostr-tools'
import { normalizeWebSocketURL, unixNow } from '.'
import { NostrController } from '../controllers'
import { RelayMap } from '../types'
import { SIGIT_RELAY } from './const'
const READ_MARKER = 'read'
const WRITE_MARKER = 'write'
export const getRelayMapFromNDKRelayList = (ndkRelayList: NDKRelayList) => {
const relayMap: RelayMap = {}
/**
* Attempts to find a relay list from the provided lookUpRelays.
* If the relay list is found, it will be added to the user relay list metadata.
* @param lookUpRelays
* @param hexKey
* @return found relay list or null
*/
const findRelayListAndUpdateCache = async (
lookUpRelays: string[],
hexKey: string
): Promise<Event | null> => {
try {
const eventFilter: Filter = {
kinds: [RelayList],
authors: [hexKey]
}
ndkRelayList.readRelayUrls.forEach((relayUrl) => {
const normalizedUrl = normalizeWebSocketURL(relayUrl)
const event = await relayController.fetchEvent(eventFilter, lookUpRelays)
if (event) {
await localCache.addUserRelayListMetadata(event)
relayMap[normalizedUrl] = {
read: true,
write: false
}
return event
} catch (error) {
console.error(error)
return null
})
ndkRelayList.writeRelayUrls.forEach((relayUrl) => {
const normalizedUrl = normalizeWebSocketURL(relayUrl)
const existing = relayMap[normalizedUrl]
if (existing) {
existing.write = true
} else {
relayMap[normalizedUrl] = {
read: false,
write: true
}
}
})
return relayMap
}
/**
* Attempts to find a relay list in cache. If it is present, it will check that the cached event is not
* older than one week.
* @param hexKey
* @return RelayList event if it's not older than a week; otherwise null
*/
const findRelayListInCache = async (hexKey: string): Promise<Event | null> => {
try {
// Attempt to retrieve the metadata event from the local cache
const cachedRelayListMetadataEvent =
await localCache.getUserRelayListMetadata(hexKey)
// Check if the cached event is not older than one week
if (
cachedRelayListMetadataEvent &&
!isOlderThanOneWeek(cachedRelayListMetadataEvent.cachedAt)
) {
return cachedRelayListMetadataEvent.event
}
return null
} catch (error) {
console.error(error)
return null
}
}
/**
* Transforms a list of relay tags from a Nostr Event to a RelaySet.
* @param tags
*/
const getUserRelaySet = (tags: string[][]): RelaySet => {
return tags
.filter(isRelayTag)
.reduce<RelaySet>(toRelaySet, getDefaultRelaySet())
}
const getDefaultRelaySet = (): RelaySet => ({
read: [SIGIT_RELAY],
write: [SIGIT_RELAY]
})
const getDefaultRelayMap = (): RelayMap => ({
export const getDefaultRelayMap = (): RelayMap => ({
[SIGIT_RELAY]: { write: true, read: true }
})
const isOlderThanOneWeek = (cachedAt: number) => {
return Date.now() - cachedAt > ONE_WEEK_IN_MS
}
const isOlderThanOneDay = (cachedAt: number) => {
return Date.now() - cachedAt > ONE_DAY_IN_MS
}
const isRelayTag = (tag: string[]): boolean => tag[0] === 'r'
const toRelaySet = (obj: RelaySet, tag: string[]): RelaySet => {
if (tag.length >= 3) {
const marker = tag[2]
if (marker === READ_MARKER) {
obj.read.push(tag[1])
} else if (marker === WRITE_MARKER) {
obj.write.push(tag[1])
}
}
if (tag.length === 2) {
obj.read.push(tag[1])
obj.write.push(tag[1])
}
return obj
}
/**
* Provides relay map.
* @param npub - user's npub
* @returns - promise that resolves into relay map and a timestamp when it has been updated.
*/
const getRelayMap = async (
npub: string
): Promise<{ map: RelayMap; mapUpdated?: number }> => {
// More info about this kind of event available https://github.com/nostr-protocol/nips/blob/master/65.md
const eventFilter: Filter = {
kinds: [kinds.RelayList],
authors: [npub]
}
const event = await relayController
.fetchEvent(eventFilter, DEFAULT_LOOK_UP_RELAY_LIST)
.catch((err) => {
return Promise.reject(err)
})
if (event) {
// Handle founded 10002 event
const relaysMap: RelayMap = {}
// 'r' stands for 'relay'
const relayTags = event.tags.filter((tag) => tag[0] === 'r')
relayTags.forEach((tag) => {
const uri = tag[1]
const relayType = tag[2]
// if 3rd element of relay tag is undefined, relay is WRITE and READ
relaysMap[uri] = {
write: relayType ? relayType === 'write' : true,
read: relayType ? relayType === 'read' : true
}
})
Object.keys(relaysMap).forEach((relayUrl) =>
relayController.connectRelay(relayUrl)
)
getRelayInfo(Object.keys(relaysMap))
return Promise.resolve({ map: relaysMap, mapUpdated: event.created_at })
} else {
return Promise.resolve({ map: getDefaultRelayMap() })
}
}
/**
* Publishes relay map.
* @param relayMap - relay map.
@ -173,10 +45,11 @@ const getRelayMap = async (
* @param extraRelaysToPublish - optional relays to publish relay map.
* @returns - promise that resolves into a string representing publishing result.
*/
const publishRelayMap = async (
export const publishRelayMap = async (
relayMap: RelayMap,
npub: string,
extraRelaysToPublish?: string[]
ndk: NDK,
publish: (event: NDKEvent) => Promise<string[]>
): Promise<string> => {
const timestamp = unixNow()
const relayURIs = Object.keys(relayMap)
@ -205,21 +78,8 @@ const publishRelayMap = async (
const nostrController = NostrController.getInstance()
const signedEvent = await nostrController.signEvent(newRelayMapEvent)
let relaysToPublish = relayURIs
// Add extra relays if provided
if (extraRelaysToPublish) {
relaysToPublish = [...relaysToPublish, ...extraRelaysToPublish]
}
// If relay map is empty, use most popular relay URIs
if (!relaysToPublish.length) {
relaysToPublish = DEFAULT_LOOK_UP_RELAY_LIST
}
const publishResult = await relayController.publish(
signedEvent,
relaysToPublish
)
const ndkEvent = new NDKEvent(ndk, signedEvent)
const publishResult = await publish(ndkEvent)
if (publishResult && publishResult.length) {
return Promise.resolve(
@ -229,15 +89,3 @@ const publishRelayMap = async (
return Promise.reject('Publishing updated relay map was unsuccessful.')
}
export {
findRelayListAndUpdateCache,
findRelayListInCache,
getDefaultRelayMap,
getDefaultRelaySet,
getRelayMap,
getUserRelaySet,
isOlderThanOneDay,
isOlderThanOneWeek,
publishRelayMap
}