Compare commits
4 Commits
Author | SHA1 | Date | |
---|---|---|---|
89f6cd1dbe | |||
9cefdad3fc | |||
de7a8a868a | |||
b965623a02 |
@ -6,7 +6,7 @@ module.exports = {
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended'
|
||||
],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs', 'licenseChecker.cjs', "*.min.js"],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs', 'licenseChecker.cjs'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['react-refresh'],
|
||||
rules: {
|
||||
|
@ -26,27 +26,9 @@ jobs:
|
||||
- name: Create Build
|
||||
run: npm run build
|
||||
|
||||
- name: Deploy Build
|
||||
- name: Release Build
|
||||
run: |
|
||||
npm -g install cloudron-surfer
|
||||
surfer config --token ${{ secrets.CLOUDRON_SURFER_TOKEN }} --server sigit.io
|
||||
surfer config --token ${{ secrets.CLOUDRON_SURFER_TOKEN }} --server sigit.io
|
||||
surfer put dist/* / --all -d
|
||||
surfer put dist/.well-known / --all
|
||||
|
||||
- name: Create Empty Release (assets are posted later)
|
||||
run: |
|
||||
npm i
|
||||
npm i -g semantic-release
|
||||
# We do a semantic-release DRY RUN to make the job fail if there are no changes to release
|
||||
GITEA_TOKEN=${{ secrets.RELEASE_TOKEN }} GITEA_URL=https://git.nostrdev.com/sigit/sigit.io semantic-release --dry-run | grep -q "There are no relevant changes, so no new version is released." && exit 1
|
||||
GITEA_TOKEN=${{ secrets.RELEASE_TOKEN }} GITEA_URL=https://git.nostrdev.com/sigit/sigit.io semantic-release
|
||||
|
||||
- name: Upload assets to release
|
||||
run: |
|
||||
RELEASE_ID=`curl -k 'https://git.nostrdev.com/sigit/sigit.io/api/v1/repos/sigit/sigit.io/releases/latest?access_token=${{ secrets.RELEASE_TOKEN }}' | jq -r '.id'`
|
||||
RELEASE_BODY=`curl -k 'https://git.nostrdev.com/sigit/sigit.io/api/v1/repos/sigit/sigit.io/releases/latest?access_token=${{ secrets.RELEASE_TOKEN }}' | jq -r '.body'`
|
||||
# Update body
|
||||
curl --data '{"draft": false,"body":"'"$RELEASE_BODY\n\nFor installation instructions, please visit https://docs.sigit.io/#/"'"}' -X PATCH --header 'Content-Type: application/json' -k https://git.nostrdev.com/sigit/sigit.io/api/v1/repos/sigit/sigit.io/releases/$RELEASE_ID?access_token=${{ secrets.RELEASE_TOKEN }}
|
||||
# Upload assets
|
||||
URL="https://git.nostrdev.com/sigit/sigit.io/api/v1/repos/sigit/sigit.io/releases/$RELEASE_ID/assets?access_token=${{ secrets.RELEASE_TOKEN }}"
|
||||
curl -k $URL -F attachment=@dist.zip
|
||||
surfer put dist/.well-known / --all
|
||||
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -8,7 +8,7 @@ pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist-zip
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
|
31
.releaserc
31
.releaserc
@ -1,31 +0,0 @@
|
||||
{
|
||||
"branches": [
|
||||
"main"
|
||||
],
|
||||
"plugins": [
|
||||
"@semantic-release/commit-analyzer",
|
||||
"@semantic-release/release-notes-generator",
|
||||
"@semantic-release/changelog",
|
||||
"@semantic-release/npm",
|
||||
[
|
||||
"@semantic-release/git",
|
||||
{
|
||||
"assets": [
|
||||
"CHANGELOG.md",
|
||||
"package.json"
|
||||
]
|
||||
}
|
||||
],
|
||||
[
|
||||
"@saithodev/semantic-release-gitea",
|
||||
{
|
||||
"giteaUrl": "https://git.nostrdev.com/sigit/sigit.io",
|
||||
"assets": [
|
||||
{
|
||||
"path": "dist-zip/dist.zip"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
@ -6,19 +6,19 @@ Welcome to Sigit! We are thrilled that you are interested in contributing to thi
|
||||
|
||||
### Reporting Bugs
|
||||
|
||||
If you encounter a bug while using Sigit, please [open an issue](https://git.nostrdev.com/sigit/sigit.io/issues/new) on this repository. Provide as much detail as possible, including steps to reproduce the bug.
|
||||
If you encounter a bug while using Sigit, please [open an issue](https://git.sigit.io/g/web/issues/new) on this repository. Provide as much detail as possible, including steps to reproduce the bug.
|
||||
|
||||
### Suggesting Enhancements
|
||||
|
||||
If you have an idea for how to improve Sigit, we would love to hear from you! [Open an issue](https://git.nostrdev.com/sigit/sigit.io/issues/new) to suggest an enhancement.
|
||||
If you have an idea for how to improve Sigit, we would love to hear from you! [Open an issue](https://git.sigit.io/g/web/issues/new) to suggest an enhancement.
|
||||
|
||||
### Pull Requests
|
||||
|
||||
We welcome pull requests from contributors! To contribute code changes:
|
||||
|
||||
1. Fork the repository and create your branch from `staging`.
|
||||
1. Fork the repository and create your branch from `main`.
|
||||
2. Make your changes and ensure they pass any existing tests.
|
||||
3. Write meaningful commit messages (conventional commit standard)
|
||||
3. Write meaningful commit messages.
|
||||
4. Submit a pull request, describing your changes in detail and referencing any related issues.
|
||||
|
||||
## Development Setup
|
||||
@ -35,14 +35,4 @@ All contributions, including pull requests, undergo code review. Code review ens
|
||||
|
||||
## Contact
|
||||
|
||||
If you have questions or need further assistance, you can reach out to `npub1d0csynrrxcynkcedktdzrdj6gnras2psg48mf46kxjazs8skrjgq9uzhlq`
|
||||
|
||||
## Testing
|
||||
|
||||
The following items should be tested with each release:
|
||||
|
||||
- Create a SIGit with at least 3 signers
|
||||
- Create a SIGit where the creator is not the first signer
|
||||
- Create a SIGit where one co-signer has no marks
|
||||
- Create a SIGit using a file other than a PDF
|
||||
- Use several login mechanisms, browsers, operating systems whilst testing
|
||||
If you have questions or need further assistance, you can reach out to [maintainer's email].
|
||||
|
@ -8,7 +8,6 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script src="/opentimestamps.min.js"></script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
11122
package-lock.json
generated
11122
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
33
package.json
33
package.json
@ -1,14 +1,14 @@
|
||||
{
|
||||
"name": "sigit",
|
||||
"private": true,
|
||||
"version": "0.0.0-beta",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"homepage": "https://sigit.io/",
|
||||
"license": "AGPL-3.0-or-later ",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 2",
|
||||
"lint:fix": "eslint . --fix --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"lint:staged": "eslint --fix --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"formatter:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,html,css,sass,less,yml,md,graphql}\"",
|
||||
@ -17,8 +17,7 @@
|
||||
"preview": "vite preview",
|
||||
"preinstall": "git config core.hooksPath .git-hooks",
|
||||
"license-checker": "node licenseChecker.cjs",
|
||||
"lint-staged": "lint-staged",
|
||||
"release": "commit-and-tag-version"
|
||||
"lint-staged": "lint-staged"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "11.11.4",
|
||||
@ -31,29 +30,23 @@
|
||||
"@mui/lab": "5.0.0-alpha.166",
|
||||
"@mui/material": "5.15.11",
|
||||
"@noble/hashes": "^1.4.0",
|
||||
"@nostr-dev-kit/ndk": "2.10.0",
|
||||
"@nostr-dev-kit/ndk-cache-dexie": "2.5.1",
|
||||
"@pdf-lib/fontkit": "^1.1.1",
|
||||
"@nostr-dev-kit/ndk": "2.5.0",
|
||||
"@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",
|
||||
"jszip": "3.10.1",
|
||||
"lodash": "4.17.21",
|
||||
"material-ui-popup-state": "^5.3.1",
|
||||
"mui-file-input": "4.0.4",
|
||||
"nostr-login": "1.6.14",
|
||||
"nostr-tools": "2.7.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfjs-dist": "^4.4.168",
|
||||
"rdndmb-html5-to-touch": "^8.0.3",
|
||||
"react": "^18.2.0",
|
||||
"react-dnd": "^16.0.1",
|
||||
"react-dnd-multi-backend": "^8.0.3",
|
||||
"react-dnd": "16.0.1",
|
||||
"react-dnd-html5-backend": "16.0.1",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-redux": "9.1.0",
|
||||
@ -61,28 +54,18 @@
|
||||
"react-singleton-hook": "^4.0.1",
|
||||
"react-toastify": "10.0.4",
|
||||
"redux": "5.0.1",
|
||||
"signature_pad": "^5.0.4",
|
||||
"tseep": "1.2.1",
|
||||
"use-immer": "^0.11.0"
|
||||
"tseep": "1.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@saithodev/semantic-release-gitea": "^2.1.0",
|
||||
"@semantic-release/changelog": "^6.0.3",
|
||||
"@semantic-release/commit-analyzer": "^10.0.1",
|
||||
"@semantic-release/git": "^10.0.1",
|
||||
"@semantic-release/npm": "11.0.0",
|
||||
"@semantic-release/release-notes-generator": "^11.0.4",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/file-saver": "2.0.7",
|
||||
"@types/lodash": "4.14.202",
|
||||
"@types/pdfjs-dist": "^2.10.378",
|
||||
"@types/react": "^18.2.56",
|
||||
"@types/react-dom": "^18.2.19",
|
||||
"@types/svgo": "^3.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.0.2",
|
||||
"@typescript-eslint/parser": "^7.0.2",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"commit-and-tag-version": "^11.2.2",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.5",
|
||||
@ -92,8 +75,6 @@
|
||||
"ts-css-modules-vite-plugin": "1.0.20",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.1.4",
|
||||
"vite-plugin-node-polyfills": "^0.22.0",
|
||||
"vite-plugin-zip-pack": "^1.2.4",
|
||||
"vite-tsconfig-paths": "4.3.2"
|
||||
},
|
||||
"lint-staged": {
|
||||
|
@ -1,15 +1,15 @@
|
||||
{
|
||||
"names": {
|
||||
"_": "6bf1024c6336093b632db2da21b65a44c7d82830454fb4d75634ba281e161c90"
|
||||
},
|
||||
"relays": {
|
||||
"6bf1024c6336093b632db2da21b65a44c7d82830454fb4d75634ba281e161c90": [
|
||||
"wss://brb.io",
|
||||
"wss://nostr.v0l.io",
|
||||
"wss://nostr.coinos.io",
|
||||
"wss://rsslay.nostr.net",
|
||||
"wss://relay.current.fyi",
|
||||
"wss://nos.io"
|
||||
]
|
||||
}
|
||||
"names": {
|
||||
"_": "6bf1024c6336093b632db2da21b65a44c7d82830454fb4d75634ba281e161c90"
|
||||
},
|
||||
"relays": {
|
||||
"6bf1024c6336093b632db2da21b65a44c7d82830454fb4d75634ba281e161c90": [
|
||||
"wss://brb.io",
|
||||
"wss://nostr.v0l.io",
|
||||
"wss://nostr.coinos.io",
|
||||
"wss://rsslay.nostr.net",
|
||||
"wss://relay.current.fyi",
|
||||
"wss://nos.io"
|
||||
]
|
||||
}
|
||||
}
|
2
public/opentimestamps.min.js
vendored
2
public/opentimestamps.min.js
vendored
File diff suppressed because one or more lines are too long
33
src/App.scss
33
src/App.scss
@ -41,7 +41,6 @@ p {
|
||||
|
||||
body {
|
||||
color: $text-color;
|
||||
background: $body-background-color;
|
||||
font-family: $font-familiy;
|
||||
letter-spacing: $letter-spacing;
|
||||
font-size: $body-font-size;
|
||||
@ -71,18 +70,6 @@ input {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style-type: none; /* Removes bullet points */
|
||||
margin: 0; /* Removes default margin */
|
||||
padding: 0; /* Removes default padding */
|
||||
}
|
||||
|
||||
li {
|
||||
list-style-type: none; /* Removes the bullets */
|
||||
margin: 0; /* Removes any default margin */
|
||||
padding: 0; /* Removes any default padding */
|
||||
}
|
||||
|
||||
// Shared styles for center content (Create, Sign, Verify)
|
||||
.files-wrapper {
|
||||
display: flex;
|
||||
@ -100,10 +87,10 @@ li {
|
||||
// - first-child Header height, default body padding, and center content border (10px) and padding (10px)
|
||||
// - others We don't include border and padding and scroll to the top of the image
|
||||
&:first-child {
|
||||
scroll-margin-top: $body-vertical-padding + 20px;
|
||||
scroll-margin-top: $header-height + $body-vertical-padding + 20px;
|
||||
}
|
||||
&:not(:first-child) {
|
||||
scroll-margin-top: $body-vertical-padding;
|
||||
scroll-margin-top: $header-height + $body-vertical-padding;
|
||||
}
|
||||
}
|
||||
|
||||
@ -135,20 +122,12 @@ li {
|
||||
// Consistent styling for every file mark
|
||||
// Reverts some of the design defaults for font
|
||||
.file-mark {
|
||||
font-family: 'Roboto';
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
letter-spacing: normal;
|
||||
line-height: 1;
|
||||
|
||||
font-family: Arial;
|
||||
font-size: 16px;
|
||||
font-weight: normal;
|
||||
color: black;
|
||||
outline: 1px solid transparent;
|
||||
|
||||
justify-content: start;
|
||||
align-items: start;
|
||||
|
||||
scroll-margin-top: $body-vertical-padding;
|
||||
letter-spacing: normal;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
[data-dev='true'] {
|
||||
|
39
src/App.tsx
39
src/App.tsx
@ -1,22 +1,21 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||
|
||||
import { useAppSelector, useAuth } from './hooks'
|
||||
|
||||
import { AuthController, NostrController } from './controllers'
|
||||
import { MainLayout } from './layouts/Main'
|
||||
|
||||
import { appPrivateRoutes, appPublicRoutes } from './routes'
|
||||
import {
|
||||
appPrivateRoutes,
|
||||
appPublicRoutes,
|
||||
privateRoutes,
|
||||
publicRoutes,
|
||||
recursiveRouteRenderer
|
||||
} from './routes/util'
|
||||
|
||||
} from './routes'
|
||||
import { State } from './store/rootReducer'
|
||||
import { getNsecBunkerDelegatedKey, saveNsecBunkerDelegatedKey } from './utils'
|
||||
import './App.scss'
|
||||
|
||||
const App = () => {
|
||||
const { checkSession } = useAuth()
|
||||
const authState = useAppSelector((state) => state.auth)
|
||||
const authState = useSelector((state: State) => state.auth)
|
||||
|
||||
useEffect(() => {
|
||||
if (window.location.hostname === '0.0.0.0') {
|
||||
@ -26,17 +25,29 @@ const App = () => {
|
||||
window.location.hostname = 'localhost'
|
||||
}
|
||||
|
||||
checkSession()
|
||||
}, [checkSession])
|
||||
generateBunkerDelegatedKey()
|
||||
|
||||
const authController = new AuthController()
|
||||
authController.checkSession()
|
||||
}, [])
|
||||
|
||||
const generateBunkerDelegatedKey = () => {
|
||||
const existingKey = getNsecBunkerDelegatedKey()
|
||||
|
||||
if (!existingKey) {
|
||||
const nostrController = NostrController.getInstance()
|
||||
const newDelegatedKey = nostrController.generateDelegatedKey()
|
||||
|
||||
saveNsecBunkerDelegatedKey(newDelegatedKey)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRootRedirect = () => {
|
||||
if (authState.loggedIn) return appPrivateRoutes.homePage
|
||||
|
||||
const callbackPathEncoded = btoa(
|
||||
window.location.href.split(`${window.location.origin}/#`)[1]
|
||||
)
|
||||
|
||||
return `${appPublicRoutes.landingPage}?callbackPath=${callbackPathEncoded}`
|
||||
return `${appPublicRoutes.login}?callbackPath=${callbackPathEncoded}`
|
||||
}
|
||||
|
||||
// Hide route only if loggedIn and r.hiddenWhenLoggedIn are both true
|
||||
|
Binary file not shown.
Binary file not shown.
Before Width: | Height: | Size: 186 KiB |
@ -9,47 +9,71 @@ import {
|
||||
} from '@mui/material'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useAppSelector } from '../../hooks/store'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import {
|
||||
setAuthState,
|
||||
setMetadataEvent,
|
||||
userLogOutAction
|
||||
} from '../../store/actions'
|
||||
import { State } from '../../store/rootReducer'
|
||||
import { Dispatch } from '../../store/store'
|
||||
import Username from '../username'
|
||||
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { MetadataController, NostrController } from '../../controllers'
|
||||
import {
|
||||
appPublicRoutes,
|
||||
appPrivateRoutes,
|
||||
getProfileRoute
|
||||
} from '../../routes'
|
||||
import { getProfileUsername, hexToNpub } from '../../utils'
|
||||
import {
|
||||
clearAuthToken,
|
||||
hexToNpub,
|
||||
saveNsecBunkerDelegatedKey,
|
||||
shorten
|
||||
} from '../../utils'
|
||||
import styles from './style.module.scss'
|
||||
import { setUserRobotImage } from '../../store/userRobotImage/action'
|
||||
import { Container } from '../Container'
|
||||
import { ButtonIcon } from '../ButtonIcon'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faClose } from '@fortawesome/free-solid-svg-icons'
|
||||
import useMediaQuery from '@mui/material/useMediaQuery'
|
||||
import { useLogout } from '../../hooks/useLogout'
|
||||
|
||||
import { launch as launchNostrLoginDialog } from 'nostr-login'
|
||||
const metadataController = new MetadataController()
|
||||
|
||||
export const AppBar = () => {
|
||||
const navigate = useNavigate()
|
||||
const logout = useLogout()
|
||||
|
||||
const dispatch: Dispatch = useDispatch()
|
||||
|
||||
const [username, setUsername] = useState('')
|
||||
const [userAvatar, setUserAvatar] = useState('')
|
||||
const [anchorElUser, setAnchorElUser] = useState<null | HTMLElement>(null)
|
||||
|
||||
const authState = useAppSelector((state) => state.auth)
|
||||
const userProfile = useAppSelector((state) => state.user.profile)
|
||||
const userRobotImage = useAppSelector((state) => state.user.robotImage)
|
||||
const authState = useSelector((state: State) => state.auth)
|
||||
const metadataState = useSelector((state: State) => state.metadata)
|
||||
const userRobotImage = useSelector((state: State) => state.userRobotImage)
|
||||
|
||||
useEffect(() => {
|
||||
const npub = authState.usersPubkey ? hexToNpub(authState.usersPubkey) : ''
|
||||
if (userProfile) {
|
||||
setUserAvatar(userProfile.image || userRobotImage || '')
|
||||
setUsername(getProfileUsername(npub, userProfile))
|
||||
} else {
|
||||
setUserAvatar('')
|
||||
setUsername(getProfileUsername(npub))
|
||||
if (metadataState) {
|
||||
if (metadataState.content) {
|
||||
const { picture, display_name, name } = JSON.parse(
|
||||
metadataState.content
|
||||
)
|
||||
|
||||
if (picture || userRobotImage) {
|
||||
setUserAvatar(picture || userRobotImage)
|
||||
}
|
||||
|
||||
const npub = authState.usersPubkey
|
||||
? hexToNpub(authState.usersPubkey)
|
||||
: ''
|
||||
|
||||
setUsername(shorten(display_name || name || npub, 7))
|
||||
} else {
|
||||
setUserAvatar(userRobotImage || '')
|
||||
setUsername('')
|
||||
}
|
||||
}
|
||||
}, [userRobotImage, authState.usersPubkey, userProfile])
|
||||
}, [metadataState, userRobotImage, authState.usersPubkey])
|
||||
|
||||
const handleOpenUserMenu = (event: React.MouseEvent<HTMLElement>) => {
|
||||
setAnchorElUser(event.currentTarget)
|
||||
@ -68,167 +92,151 @@ export const AppBar = () => {
|
||||
|
||||
const handleLogout = () => {
|
||||
handleCloseUserMenu()
|
||||
logout()
|
||||
dispatch(
|
||||
setAuthState({
|
||||
keyPair: undefined,
|
||||
loggedIn: false,
|
||||
usersPubkey: undefined,
|
||||
loginMethod: undefined,
|
||||
nsecBunkerPubkey: undefined
|
||||
})
|
||||
)
|
||||
dispatch(setMetadataEvent(metadataController.getEmptyMetadataEvent()))
|
||||
dispatch(setUserRobotImage(null))
|
||||
|
||||
// clear authToken saved in local storage
|
||||
clearAuthToken()
|
||||
|
||||
dispatch(userLogOutAction())
|
||||
|
||||
// update nsecBunker delegated key after logout
|
||||
const nostrController = NostrController.getInstance()
|
||||
const newDelegatedKey = nostrController.generateDelegatedKey()
|
||||
saveNsecBunkerDelegatedKey(newDelegatedKey)
|
||||
|
||||
navigate('/')
|
||||
}
|
||||
const isAuthenticated = authState?.loggedIn === true
|
||||
|
||||
const matches = useMediaQuery('(max-width:767px)')
|
||||
const [isBannerVisible, setIsBannerVisible] = useState(true)
|
||||
const handleBannerHide = () => {
|
||||
setIsBannerVisible(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isAuthenticated && isBannerVisible && (
|
||||
<div className={styles.banner}>
|
||||
<Container>
|
||||
<div className={styles.bannerInner}>
|
||||
<p className={styles.bannerText}>
|
||||
SIGit is currently Beta software (available for user experience
|
||||
testing), use at your own risk!
|
||||
</p>
|
||||
<AppBarMui
|
||||
position="fixed"
|
||||
className={styles.AppBar}
|
||||
sx={{
|
||||
boxShadow: 'none'
|
||||
}}
|
||||
>
|
||||
<Container>
|
||||
<Toolbar className={styles.toolbar} disableGutters={true}>
|
||||
<Box className={styles.logoWrapper}>
|
||||
<img src="/logo.svg" alt="Logo" onClick={() => navigate('/')} />
|
||||
</Box>
|
||||
|
||||
<Box className={styles.rightSideBox}>
|
||||
{!isAuthenticated && (
|
||||
<Button
|
||||
aria-label={`close banner`}
|
||||
variant="text"
|
||||
onClick={handleBannerHide}
|
||||
>
|
||||
<FontAwesomeIcon icon={faClose} />
|
||||
</Button>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
)}
|
||||
<AppBarMui
|
||||
position={matches ? 'sticky' : 'static'}
|
||||
className={styles.AppBar}
|
||||
sx={{
|
||||
boxShadow: 'none'
|
||||
}}
|
||||
>
|
||||
<Container>
|
||||
<Toolbar className={styles.toolbar} disableGutters={true}>
|
||||
<Box className={styles.logoWrapper}>
|
||||
<img
|
||||
src="/logo.svg"
|
||||
alt="Logo"
|
||||
startIcon={<ButtonIcon />}
|
||||
onClick={() => {
|
||||
if (['', '#/'].includes(window.location.hash)) {
|
||||
location.reload()
|
||||
} else {
|
||||
navigate('/')
|
||||
}
|
||||
navigate(appPublicRoutes.nostr)
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
variant="contained"
|
||||
>
|
||||
LOGIN
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Box className={styles.rightSideBox}>
|
||||
{!isAuthenticated && (
|
||||
<Button
|
||||
startIcon={<ButtonIcon />}
|
||||
onClick={() => {
|
||||
launchNostrLoginDialog()
|
||||
{isAuthenticated && (
|
||||
<>
|
||||
<Username
|
||||
username={username}
|
||||
avatarContent={userAvatar}
|
||||
handleClick={handleOpenUserMenu}
|
||||
/>
|
||||
<Menu
|
||||
id="menu-appbar"
|
||||
anchorEl={anchorElUser}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'center'
|
||||
}}
|
||||
variant="contained"
|
||||
keepMounted
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'center'
|
||||
}}
|
||||
open={!!anchorElUser}
|
||||
onClose={handleCloseUserMenu}
|
||||
>
|
||||
LOGIN
|
||||
</Button>
|
||||
)}
|
||||
<MenuItem
|
||||
sx={{
|
||||
justifyContent: 'center',
|
||||
display: { md: 'none' },
|
||||
fontWeight: 500,
|
||||
fontSize: '14px',
|
||||
color: 'var(--text-color)'
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6">{username}</Typography>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={handleProfile}
|
||||
sx={{
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
Profile
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setAnchorElUser(null)
|
||||
|
||||
{isAuthenticated && (
|
||||
<>
|
||||
<Username
|
||||
username={username}
|
||||
avatarContent={userAvatar}
|
||||
handleClick={handleOpenUserMenu}
|
||||
/>
|
||||
<Menu
|
||||
id="menu-appbar"
|
||||
anchorEl={anchorElUser}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'center'
|
||||
navigate(appPrivateRoutes.settings)
|
||||
}}
|
||||
keepMounted
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'center'
|
||||
sx={{
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
open={!!anchorElUser}
|
||||
onClose={handleCloseUserMenu}
|
||||
>
|
||||
Settings
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setAnchorElUser(null)
|
||||
|
||||
navigate(appPublicRoutes.verify)
|
||||
}}
|
||||
sx={{
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
Verify
|
||||
</MenuItem>
|
||||
<Link
|
||||
to={appPublicRoutes.source}
|
||||
target="_blank"
|
||||
style={{ color: 'inherit', textDecoration: 'inherit' }}
|
||||
>
|
||||
<MenuItem
|
||||
sx={{
|
||||
justifyContent: 'center',
|
||||
display: { md: 'none' },
|
||||
fontWeight: 500,
|
||||
fontSize: '14px',
|
||||
color: 'var(--text-color)'
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6">{username}</Typography>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={handleProfile}
|
||||
sx={{
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
Profile
|
||||
Source
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setAnchorElUser(null)
|
||||
|
||||
navigate(appPrivateRoutes.settings)
|
||||
}}
|
||||
sx={{
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
Settings
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setAnchorElUser(null)
|
||||
|
||||
navigate(appPublicRoutes.verify)
|
||||
}}
|
||||
sx={{
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
Verify
|
||||
</MenuItem>
|
||||
<Link
|
||||
to={appPublicRoutes.source}
|
||||
target="_blank"
|
||||
style={{ color: 'inherit', textDecoration: 'inherit' }}
|
||||
>
|
||||
<MenuItem
|
||||
sx={{
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
Source
|
||||
</MenuItem>
|
||||
</Link>
|
||||
<MenuItem
|
||||
onClick={handleLogout}
|
||||
sx={{
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
Logout
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Toolbar>
|
||||
</Container>
|
||||
</AppBarMui>
|
||||
</>
|
||||
</Link>
|
||||
<MenuItem
|
||||
onClick={handleLogout}
|
||||
sx={{
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
Logout
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Toolbar>
|
||||
</Container>
|
||||
</AppBarMui>
|
||||
)
|
||||
}
|
||||
|
@ -34,42 +34,3 @@
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.banner {
|
||||
color: #ffffff;
|
||||
background-color: $primary-main;
|
||||
}
|
||||
|
||||
.bannerInner {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding-block: 10px;
|
||||
z-index: 1;
|
||||
|
||||
width: 100%;
|
||||
|
||||
justify-content: space-between;
|
||||
flex-direction: row;
|
||||
|
||||
button {
|
||||
min-width: 44px;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&.active,
|
||||
&:focus-within {
|
||||
background: $primary-main;
|
||||
color: inherit;
|
||||
|
||||
button {
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bannerText {
|
||||
margin-left: 54px;
|
||||
flex-grow: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
@ -1,24 +0,0 @@
|
||||
import { PropsWithChildren } from 'react'
|
||||
import styles from './style.module.scss'
|
||||
|
||||
interface ButtonUnderlineProps {
|
||||
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void
|
||||
disabled?: boolean | undefined
|
||||
}
|
||||
|
||||
export const ButtonUnderline = ({
|
||||
onClick,
|
||||
disabled = false,
|
||||
children
|
||||
}: PropsWithChildren<ButtonUnderlineProps>) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.button}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
@ -1,25 +0,0 @@
|
||||
@import '../../styles/colors.scss';
|
||||
|
||||
.button {
|
||||
color: $primary-main !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
|
||||
width: max-content;
|
||||
margin: 0 auto;
|
||||
|
||||
// Override default styling
|
||||
border: none !important;
|
||||
outline: none !important;
|
||||
|
||||
// Override leaky css in sign page
|
||||
background: transparent !important;
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
text-decoration-color: inherit;
|
||||
}
|
||||
}
|
@ -1,12 +1,7 @@
|
||||
import { Meta } from '../../types'
|
||||
import {
|
||||
hexToNpub,
|
||||
SigitCardDisplayInfo,
|
||||
SigitStatus,
|
||||
SignStatus
|
||||
} from '../../utils'
|
||||
import { SigitCardDisplayInfo, SigitStatus, SignStatus } from '../../utils'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { formatTimestamp, npubToHex } from '../../utils'
|
||||
import { formatTimestamp, hexToNpub, npubToHex, shorten } from '../../utils'
|
||||
import { appPublicRoutes, appPrivateRoutes } from '../../routes'
|
||||
import { Button, Divider, Tooltip } from '@mui/material'
|
||||
import { DisplaySigner } from '../DisplaySigner'
|
||||
@ -22,73 +17,99 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { UserAvatarGroup } from '../UserAvatarGroup'
|
||||
|
||||
import styles from './style.module.scss'
|
||||
import { TooltipChild } from '../TooltipChild'
|
||||
import { getExtensionIconLabel } from '../getExtensionIconLabel'
|
||||
import { useSigitProfiles } from '../../hooks/useSigitProfiles'
|
||||
import { useSigitMeta } from '../../hooks/useSigitMeta'
|
||||
import { extractFileExtensions } from '../../utils/file'
|
||||
import { useAppSelector } from '../../hooks'
|
||||
|
||||
type SigitProps = {
|
||||
sigitCreateId: string
|
||||
meta: Meta
|
||||
parsedMeta: SigitCardDisplayInfo
|
||||
}
|
||||
|
||||
export const DisplaySigit = ({
|
||||
meta,
|
||||
parsedMeta,
|
||||
sigitCreateId: sigitCreateId
|
||||
}: SigitProps) => {
|
||||
const { usersPubkey } = useAppSelector((state) => state.auth)
|
||||
|
||||
export const DisplaySigit = ({ meta, parsedMeta }: SigitProps) => {
|
||||
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
|
||||
const profiles = useSigitProfiles([
|
||||
...(submittedBy ? [submittedBy] : []),
|
||||
...signers
|
||||
])
|
||||
|
||||
const { extensions, isSame } = extractFileExtensions(Object.keys(fileHashes))
|
||||
|
||||
return (
|
||||
<div className={styles.itemWrapper}>
|
||||
{signedStatus === SigitStatus.Complete || !currentUserNextSigner ? (
|
||||
<Link
|
||||
to={`${appPublicRoutes.verify}/${sigitCreateId}`}
|
||||
className={styles.insetLink}
|
||||
></Link>
|
||||
) : (
|
||||
<Link
|
||||
to={`${appPrivateRoutes.sign}/${sigitCreateId}`}
|
||||
className={styles.insetLink}
|
||||
></Link>
|
||||
)}
|
||||
|
||||
<Link
|
||||
to={
|
||||
signedStatus === SigitStatus.Complete
|
||||
? appPublicRoutes.verify
|
||||
: appPrivateRoutes.sign
|
||||
}
|
||||
state={{ meta }}
|
||||
className={styles.insetLink}
|
||||
></Link>
|
||||
<p className={`line-clamp-2 ${styles.title}`}>{title}</p>
|
||||
<div className={styles.users}>
|
||||
{submittedBy && (
|
||||
<DisplaySigner
|
||||
status={isValid ? SignStatus.Signed : SignStatus.Invalid}
|
||||
pubkey={submittedBy}
|
||||
/>
|
||||
)}
|
||||
{submittedBy &&
|
||||
(function () {
|
||||
const profile = profiles[submittedBy]
|
||||
return (
|
||||
<Tooltip
|
||||
key={submittedBy}
|
||||
title={
|
||||
profile?.display_name ||
|
||||
profile?.name ||
|
||||
shorten(hexToNpub(submittedBy))
|
||||
}
|
||||
placement="top"
|
||||
arrow
|
||||
disableInteractive
|
||||
>
|
||||
<TooltipChild>
|
||||
<DisplaySigner
|
||||
status={isValid ? SignStatus.Signed : SignStatus.Invalid}
|
||||
profile={profile}
|
||||
pubkey={submittedBy}
|
||||
/>
|
||||
</TooltipChild>
|
||||
</Tooltip>
|
||||
)
|
||||
})()}
|
||||
{submittedBy && signers.length ? (
|
||||
<Divider orientation="vertical" flexItem />
|
||||
) : null}
|
||||
<UserAvatarGroup max={7}>
|
||||
{signers.map((signer) => {
|
||||
const pubkey = npubToHex(signer)!
|
||||
const profile = profiles[pubkey]
|
||||
|
||||
return (
|
||||
<DisplaySigner
|
||||
key={pubkey}
|
||||
status={signersStatus[signer]}
|
||||
pubkey={pubkey}
|
||||
/>
|
||||
<Tooltip
|
||||
key={signer}
|
||||
title={
|
||||
profile?.display_name || profile?.name || shorten(pubkey)
|
||||
}
|
||||
placement="top"
|
||||
arrow
|
||||
disableInteractive
|
||||
>
|
||||
<TooltipChild>
|
||||
<DisplaySigner
|
||||
status={signersStatus[signer]}
|
||||
profile={profile}
|
||||
pubkey={pubkey}
|
||||
/>
|
||||
</TooltipChild>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
</UserAvatarGroup>
|
||||
</div>
|
||||
<div className={`${styles.details} ${styles.iconLabel}`}>
|
||||
<div className={`${styles.details} ${styles.date} ${styles.iconLabel}`}>
|
||||
<FontAwesomeIcon icon={faCalendar} />
|
||||
{createdAt ? formatTimestamp(createdAt) : null}
|
||||
</div>
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { Badge } from '@mui/material'
|
||||
import { ProfileMetadata } from '../../types'
|
||||
import styles from './style.module.scss'
|
||||
import { UserAvatar } from '../UserAvatar'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
@ -14,33 +15,38 @@ import { SignStatus } from '../../utils'
|
||||
import { Spinner } from '../Spinner'
|
||||
|
||||
type DisplaySignerProps = {
|
||||
profile: ProfileMetadata
|
||||
pubkey: string
|
||||
status: SignStatus
|
||||
}
|
||||
|
||||
const getStatusIcon = (status: SignStatus) => {
|
||||
switch (status) {
|
||||
case SignStatus.Signed:
|
||||
return <FontAwesomeIcon icon={faCheck} />
|
||||
case SignStatus.Awaiting:
|
||||
return (
|
||||
<Spinner>
|
||||
<FontAwesomeIcon icon={faHourglass} />
|
||||
</Spinner>
|
||||
)
|
||||
case SignStatus.Pending:
|
||||
return <FontAwesomeIcon icon={faEllipsis} />
|
||||
case SignStatus.Invalid:
|
||||
return <FontAwesomeIcon icon={faExclamation} />
|
||||
case SignStatus.Viewer:
|
||||
return <FontAwesomeIcon icon={faEye} />
|
||||
export const DisplaySigner = ({
|
||||
status,
|
||||
profile,
|
||||
pubkey
|
||||
}: DisplaySignerProps) => {
|
||||
const getStatusIcon = (status: SignStatus) => {
|
||||
switch (status) {
|
||||
case SignStatus.Signed:
|
||||
return <FontAwesomeIcon icon={faCheck} />
|
||||
case SignStatus.Awaiting:
|
||||
return (
|
||||
<Spinner>
|
||||
<FontAwesomeIcon icon={faHourglass} />
|
||||
</Spinner>
|
||||
)
|
||||
case SignStatus.Pending:
|
||||
return <FontAwesomeIcon icon={faEllipsis} />
|
||||
case SignStatus.Invalid:
|
||||
return <FontAwesomeIcon icon={faExclamation} />
|
||||
case SignStatus.Viewer:
|
||||
return <FontAwesomeIcon icon={faEye} />
|
||||
|
||||
default:
|
||||
return <FontAwesomeIcon icon={faQuestion} />
|
||||
default:
|
||||
return <FontAwesomeIcon icon={faQuestion} />
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const DisplaySigner = ({ status, pubkey }: DisplaySignerProps) => {
|
||||
return (
|
||||
<Badge
|
||||
className={styles.signer}
|
||||
@ -50,7 +56,7 @@ export const DisplaySigner = ({ status, pubkey }: DisplaySignerProps) => {
|
||||
<div className={styles.statusBadge}>{getStatusIcon(status)}</div>
|
||||
}
|
||||
>
|
||||
<UserAvatar pubkey={pubkey} />
|
||||
<UserAvatar pubkey={pubkey} image={profile?.picture} />
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -1,3 +0,0 @@
|
||||
.counterpartSelectValue {
|
||||
display: flex;
|
||||
}
|
@ -1,47 +0,0 @@
|
||||
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
|
||||
}
|
||||
)
|
@ -1,19 +0,0 @@
|
||||
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>
|
||||
)
|
||||
})
|
@ -1,10 +0,0 @@
|
||||
import React from 'react'
|
||||
import { SigitFile } from '../../../utils/file'
|
||||
|
||||
interface ImageItemProps {
|
||||
file: SigitFile
|
||||
}
|
||||
|
||||
export const ImageItem = React.memo(({ file }: ImageItemProps) => {
|
||||
return <img className="file-image" src={file.objectUrl} alt={file.name} />
|
||||
})
|
@ -13,20 +13,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
.pdfImageWrapper:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
position: absolute;
|
||||
opacity: 0.5;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.drawingRectangle {
|
||||
position: absolute;
|
||||
outline: 1px solid #01aaad;
|
||||
z-index: 40;
|
||||
border: 1px solid #01aaad;
|
||||
z-index: 50;
|
||||
background-color: #01aaad4b;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
@ -38,6 +28,10 @@
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
&.edited {
|
||||
border: 1px dotted #01aaad;
|
||||
}
|
||||
|
||||
.resizeHandle {
|
||||
position: absolute;
|
||||
right: -5px;
|
||||
@ -47,7 +41,7 @@
|
||||
background-color: #fff;
|
||||
border: 1px solid rgb(160, 160, 160);
|
||||
border-radius: 50%;
|
||||
cursor: grab;
|
||||
cursor: nwse-resize;
|
||||
|
||||
// Increase the area a bit so it's easier to click
|
||||
&::after {
|
||||
@ -84,35 +78,3 @@
|
||||
padding: 5px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.counterpartAvatar {
|
||||
img {
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
}
|
||||
}
|
||||
|
||||
.signingRectangle {
|
||||
position: absolute;
|
||||
outline: 1px solid #01aaad;
|
||||
z-index: 40;
|
||||
background-color: #01aaad4b;
|
||||
cursor: pointer;
|
||||
|
||||
&.edited {
|
||||
outline: 1px dotted #01aaad;
|
||||
}
|
||||
}
|
||||
|
||||
.drawingRectanglePreview {
|
||||
position: absolute;
|
||||
outline: 1px solid;
|
||||
z-index: 50;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
pointer-events: none;
|
||||
touch-action: none;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
@ -1,96 +1,54 @@
|
||||
import React from 'react'
|
||||
import { Button, Menu, MenuItem } from '@mui/material'
|
||||
import PopupState, { bindTrigger, bindMenu } from 'material-ui-popup-state'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import {
|
||||
faCheck,
|
||||
faLock,
|
||||
faTriangleExclamation
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
import { CurrentUserFile } from '../../types/file.ts'
|
||||
import styles from './style.module.scss'
|
||||
import { Button } from '@mui/material'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faCheck } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
interface FileListProps {
|
||||
files: CurrentUserFile[]
|
||||
currentFile: CurrentUserFile
|
||||
setCurrentFile: (file: CurrentUserFile) => void
|
||||
handleExport?: () => void
|
||||
handleEncryptedExport?: () => void
|
||||
handleDownload: () => void
|
||||
downloadLabel?: string
|
||||
}
|
||||
|
||||
const FileList = ({
|
||||
files,
|
||||
currentFile,
|
||||
setCurrentFile,
|
||||
handleExport,
|
||||
handleEncryptedExport
|
||||
handleDownload,
|
||||
downloadLabel
|
||||
}: FileListProps) => {
|
||||
const isActive = (file: CurrentUserFile) => file.id === currentFile.id
|
||||
return (
|
||||
<div className={styles.wrap}>
|
||||
<ul className={styles.files}>
|
||||
{files.map((currentUserFile: CurrentUserFile) => (
|
||||
<li
|
||||
key={currentUserFile.id}
|
||||
className={`${styles.fileItem} ${isActive(currentUserFile) && styles.active}`}
|
||||
onClick={() => setCurrentFile(currentUserFile)}
|
||||
>
|
||||
<div className={styles.fileNumber}>{currentUserFile.id}</div>
|
||||
<div className={styles.fileInfo}>
|
||||
<div className={styles.fileName}>{currentUserFile.file.name}</div>
|
||||
</div>
|
||||
<div className={styles.container}>
|
||||
<ul className={styles.files}>
|
||||
{files.map((currentUserFile: CurrentUserFile) => (
|
||||
<li
|
||||
key={currentUserFile.id}
|
||||
className={`${styles.fileItem} ${isActive(currentUserFile) && styles.active}`}
|
||||
onClick={() => setCurrentFile(currentUserFile)}
|
||||
>
|
||||
<div className={styles.fileNumber}>{currentUserFile.id}</div>
|
||||
<div className={styles.fileInfo}>
|
||||
<div className={styles.fileName}>
|
||||
{currentUserFile.file.name}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.fileVisual}>
|
||||
{currentUserFile.isHashValid && (
|
||||
<FontAwesomeIcon icon={faCheck} />
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{(typeof handleExport === 'function' ||
|
||||
typeof handleEncryptedExport === 'function') && (
|
||||
<PopupState variant="popover" popupId="download-popup-menu">
|
||||
{(popupState) => (
|
||||
<React.Fragment>
|
||||
<Button variant="contained" {...bindTrigger(popupState)}>
|
||||
Export files
|
||||
</Button>
|
||||
<Menu {...bindMenu(popupState)}>
|
||||
{typeof handleEncryptedExport === 'function' && (
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
popupState.close
|
||||
handleEncryptedExport()
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
color={'var(--mui-palette-primary-main)'}
|
||||
icon={faLock}
|
||||
/>
|
||||
ENCRYPTED
|
||||
</MenuItem>
|
||||
<div className={styles.fileVisual}>
|
||||
{currentUserFile.isHashValid && (
|
||||
<FontAwesomeIcon icon={faCheck} />
|
||||
)}
|
||||
{typeof handleExport === 'function' && (
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
popupState.close
|
||||
handleExport()
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
color={'var(--mui-palette-primary-main)'}
|
||||
icon={faTriangleExclamation}
|
||||
/>
|
||||
UNENCRYPTED
|
||||
</MenuItem>
|
||||
)}
|
||||
</Menu>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</PopupState>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<Button variant="contained" fullWidth onClick={handleDownload}>
|
||||
{downloadLabel || 'Download Files'}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -1,3 +1,12 @@
|
||||
.container {
|
||||
border-radius: 4px;
|
||||
background: white;
|
||||
padding: 15px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
grid-gap: 0px;
|
||||
}
|
||||
|
||||
.filesPageContainer {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
@ -6,6 +15,18 @@
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style-type: none; /* Removes bullet points */
|
||||
margin: 0; /* Removes default margin */
|
||||
padding: 0; /* Removes default padding */
|
||||
}
|
||||
|
||||
li {
|
||||
list-style-type: none; /* Removes the bullets */
|
||||
margin: 0; /* Removes any default margin */
|
||||
padding: 0; /* Removes any default padding */
|
||||
}
|
||||
|
||||
.wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -13,16 +34,14 @@
|
||||
}
|
||||
|
||||
.files {
|
||||
border-radius: 4px;
|
||||
background: white;
|
||||
padding: 15px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
grid-gap: 15px;
|
||||
overflow-y: auto;
|
||||
overflow-x: none;
|
||||
max-height: 350px;
|
||||
overflow: auto;
|
||||
padding: 0 5px 0 0;
|
||||
margin: 0 -5px 0 0;
|
||||
}
|
||||
|
||||
.files::-webkit-scrollbar {
|
||||
|
@ -4,134 +4,125 @@ import styles from './style.module.scss'
|
||||
import { Container } from '../Container'
|
||||
import nostrImage from '../../assets/images/nostr.gif'
|
||||
import { appPublicRoutes } from '../../routes'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
export const Footer = () =>
|
||||
createPortal(
|
||||
<footer className={`${styles.borderTop} ${styles.footer}`}>
|
||||
<Container
|
||||
style={{
|
||||
paddingBlock: '50px'
|
||||
export const Footer = () => (
|
||||
<footer className={`${styles.borderTop} ${styles.footer}`}>
|
||||
<Container
|
||||
style={{
|
||||
paddingBlock: '50px'
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
display={'grid'}
|
||||
sx={{
|
||||
gridTemplateColumns: {
|
||||
xs: '1fr',
|
||||
md: '0.5fr 2fr 0.5fr'
|
||||
},
|
||||
alignItems: {
|
||||
xs: 'center',
|
||||
md: 'start'
|
||||
}
|
||||
}}
|
||||
gap={'50px'}
|
||||
>
|
||||
<Box
|
||||
display={'grid'}
|
||||
<LinkMui
|
||||
sx={{
|
||||
gridTemplateColumns: {
|
||||
xs: '1fr',
|
||||
md: '0.5fr 2fr 0.5fr'
|
||||
},
|
||||
alignItems: {
|
||||
justifySelf: {
|
||||
xs: 'center',
|
||||
md: 'start'
|
||||
}
|
||||
}}
|
||||
gap={'50px'}
|
||||
component={Link}
|
||||
to={'/'}
|
||||
className={styles.logo}
|
||||
>
|
||||
<LinkMui
|
||||
<img src="/logo.svg" alt="Logo" />
|
||||
</LinkMui>
|
||||
<Box
|
||||
display={'grid'}
|
||||
sx={{
|
||||
gap: '15px',
|
||||
gridTemplateColumns: {
|
||||
xs: '1fr',
|
||||
sm: 'repeat(2, 1fr)',
|
||||
xl: 'repeat(3, 1fr)'
|
||||
},
|
||||
borderBlock: {
|
||||
xs: 'solid 1px rgba(0, 0, 0, 0.1)',
|
||||
md: 'unset'
|
||||
},
|
||||
paddingY: {
|
||||
xs: '10px',
|
||||
md: 'unset'
|
||||
}
|
||||
}}
|
||||
component={'nav'}
|
||||
className={styles.nav}
|
||||
>
|
||||
<Button
|
||||
sx={{
|
||||
justifySelf: {
|
||||
xs: 'center',
|
||||
md: 'start'
|
||||
}
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
component={Link}
|
||||
to={'/'}
|
||||
className={styles.logo}
|
||||
variant={'text'}
|
||||
>
|
||||
<img src="/logo.svg" alt="Logo" />
|
||||
</LinkMui>
|
||||
<Box
|
||||
display={'grid'}
|
||||
Home
|
||||
</Button>
|
||||
<Button
|
||||
sx={{
|
||||
gap: '15px',
|
||||
gridTemplateColumns: {
|
||||
xs: '1fr',
|
||||
sm: 'repeat(2, 1fr)',
|
||||
xl: 'repeat(3, 1fr)'
|
||||
},
|
||||
borderBlock: {
|
||||
xs: 'solid 1px rgba(0, 0, 0, 0.1)',
|
||||
md: 'unset'
|
||||
},
|
||||
paddingY: {
|
||||
xs: '10px',
|
||||
md: 'unset'
|
||||
}
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
component={'nav'}
|
||||
className={styles.nav}
|
||||
component={LinkMui}
|
||||
href={appPublicRoutes.docs}
|
||||
target="_blank"
|
||||
variant={'text'}
|
||||
>
|
||||
<Button
|
||||
sx={{
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
component={Link}
|
||||
to={'/'}
|
||||
onClick={(event) => {
|
||||
if (['', '#/'].includes(window.location.hash)) {
|
||||
event.preventDefault()
|
||||
window.scrollTo(0, 0)
|
||||
}
|
||||
}}
|
||||
variant={'text'}
|
||||
>
|
||||
Home
|
||||
</Button>
|
||||
<Button
|
||||
sx={{
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
component={LinkMui}
|
||||
href={appPublicRoutes.docs}
|
||||
target="_blank"
|
||||
variant={'text'}
|
||||
>
|
||||
Documentation
|
||||
</Button>
|
||||
<Button
|
||||
sx={{
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
component={LinkMui}
|
||||
href={appPublicRoutes.source}
|
||||
target="_blank"
|
||||
variant={'text'}
|
||||
>
|
||||
Source
|
||||
</Button>
|
||||
</Box>
|
||||
<Box
|
||||
className={styles.links}
|
||||
Documentation
|
||||
</Button>
|
||||
<Button
|
||||
sx={{
|
||||
justifySelf: {
|
||||
xs: 'center',
|
||||
md: 'end'
|
||||
}
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
component={LinkMui}
|
||||
href={appPublicRoutes.source}
|
||||
target="_blank"
|
||||
variant={'text'}
|
||||
>
|
||||
<Button
|
||||
component={LinkMui}
|
||||
href="https://snort.social/npub1yay8e9sqk94jfgdlkpgeelj2t5ddsj2eu0xwt4kh4xw5ses2rauqnstrdv"
|
||||
target="_blank"
|
||||
sx={{
|
||||
minWidth: '45px',
|
||||
padding: '10px'
|
||||
}}
|
||||
variant={'contained'}
|
||||
>
|
||||
<img src={nostrImage} width="25" alt="nostr logo" height="25" />
|
||||
</Button>
|
||||
</Box>
|
||||
Source
|
||||
</Button>
|
||||
</Box>
|
||||
</Container>
|
||||
<div className={`${styles.borderTop} ${styles.credits}`}>
|
||||
Built by
|
||||
<a rel="noopener" href="https://nostrdev.com/" target="_blank">
|
||||
Nostr Dev
|
||||
</a>{' '}
|
||||
2024.
|
||||
</div>
|
||||
</footer>,
|
||||
document.getElementById('root')!
|
||||
)
|
||||
<Box
|
||||
className={styles.links}
|
||||
sx={{
|
||||
justifySelf: {
|
||||
xs: 'center',
|
||||
md: 'end'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
component={LinkMui}
|
||||
href="https://snort.social/npub1yay8e9sqk94jfgdlkpgeelj2t5ddsj2eu0xwt4kh4xw5ses2rauqnstrdv"
|
||||
target="_blank"
|
||||
sx={{
|
||||
minWidth: '45px',
|
||||
padding: '10px'
|
||||
}}
|
||||
variant={'contained'}
|
||||
>
|
||||
<img src={nostrImage} width="25" alt="nostr logo" height="25" />
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Container>
|
||||
<div className={`${styles.borderTop} ${styles.credits}`}>
|
||||
Built by
|
||||
<a href="https://nostrdev.com/" target="_blank">
|
||||
Nostr Dev
|
||||
</a>{' '}
|
||||
2024.
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
|
@ -1,43 +1,18 @@
|
||||
import { createPortal } from 'react-dom'
|
||||
import styles from './style.module.scss'
|
||||
import { PropsWithChildren } from 'react'
|
||||
|
||||
interface Props {
|
||||
desc?: string
|
||||
variant?: 'small' | 'default'
|
||||
desc: string
|
||||
}
|
||||
|
||||
export const LoadingSpinner = (props: PropsWithChildren<Props>) => {
|
||||
const { desc, children, variant = 'default' } = props
|
||||
export const LoadingSpinner = (props: Props) => {
|
||||
const { desc } = props
|
||||
|
||||
switch (variant) {
|
||||
case 'small':
|
||||
return (
|
||||
<div
|
||||
className={`${styles.loadingSpinnerContainer}`}
|
||||
data-variant={variant}
|
||||
>
|
||||
<div className={styles.loadingSpinner}></div>
|
||||
</div>
|
||||
)
|
||||
|
||||
default:
|
||||
return createPortal(
|
||||
<div className={styles.loadingSpinnerOverlay}>
|
||||
<div
|
||||
className={styles.loadingSpinnerContainer}
|
||||
data-variant={variant}
|
||||
>
|
||||
<div className={styles.loadingSpinner}></div>
|
||||
{desc && (
|
||||
<div className={styles.loadingSpinnerDesc}>
|
||||
{desc}
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.getElementById('root')!
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className={styles.loadingSpinnerOverlay}>
|
||||
<div className={styles.loadingSpinnerContainer}>
|
||||
<div className={styles.loadingSpinner}></div>
|
||||
{desc && <span className={styles.loadingSpinnerDesc}>{desc}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -2,56 +2,37 @@
|
||||
|
||||
.loadingSpinnerOverlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: 70;
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
z-index: 9999;
|
||||
|
||||
.loadingSpinnerContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&[data-variant='default'] {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
margin: 25px 20px;
|
||||
background: $overlay-background-color;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 0 4px 0 rgb(0, 0, 0, 0.1);
|
||||
.loadingSpinnerContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
&[data-variant='small'] {
|
||||
min-height: 250px;
|
||||
}
|
||||
}
|
||||
|
||||
.loadingSpinner {
|
||||
background: url('/favicon.png') no-repeat center / cover;
|
||||
margin: 40px 25px;
|
||||
width: 65px;
|
||||
height: 65px;
|
||||
animation: spin 1s linear infinite;
|
||||
.loadingSpinner {
|
||||
background: url('/favicon.png') no-repeat center / cover;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.loadingSpinnerDesc {
|
||||
width: 100%;
|
||||
padding: 15px;
|
||||
border-top: solid 1px rgba(0, 0, 0, 0.1);
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
color: white;
|
||||
margin-top: 13px;
|
||||
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
|
@ -1,27 +1,22 @@
|
||||
import { CurrentUserMark } from '../../types/mark.ts'
|
||||
import styles from './style.module.scss'
|
||||
|
||||
import { MARK_TYPE_TRANSLATION, NEXT, SIGN } from '../../utils/const.ts'
|
||||
import {
|
||||
findNextIncompleteCurrentUserMark,
|
||||
getToolboxLabelByMarkType,
|
||||
isCurrentUserMarksComplete,
|
||||
isCurrentValueLast
|
||||
} from '../../utils'
|
||||
import React, { useState } from 'react'
|
||||
import { MarkInput } from '../MarkTypeStrategy/MarkInput.tsx'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faCheck, faDownload } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Button } from '@mui/material'
|
||||
import styles from './style.module.scss'
|
||||
import { ButtonUnderline } from '../ButtonUnderline/index.tsx'
|
||||
|
||||
interface MarkFormFieldProps {
|
||||
currentUserMarks: CurrentUserMark[]
|
||||
handleCurrentUserMarkChange: (mark: CurrentUserMark) => void
|
||||
handleSelectedMarkValueChange: (value: string) => void
|
||||
handleSubmit: (
|
||||
event: React.MouseEvent<HTMLButtonElement>,
|
||||
type: 'online' | 'offline'
|
||||
handleSelectedMarkValueChange: (
|
||||
event: React.ChangeEvent<HTMLInputElement>
|
||||
) => void
|
||||
selectedMark: CurrentUserMark | null
|
||||
handleSubmit: (event: React.FormEvent<HTMLFormElement>) => void
|
||||
selectedMark: CurrentUserMark
|
||||
selectedMarkValue: string
|
||||
}
|
||||
|
||||
@ -37,52 +32,28 @@ const MarkFormField = ({
|
||||
handleCurrentUserMarkChange
|
||||
}: MarkFormFieldProps) => {
|
||||
const [displayActions, setDisplayActions] = useState(true)
|
||||
const [complete, setComplete] = useState(false)
|
||||
const getSubmitButtonText = () => (isReadyToSign() ? SIGN : NEXT)
|
||||
const isReadyToSign = () =>
|
||||
isCurrentUserMarksComplete(currentUserMarks) ||
|
||||
isCurrentValueLast(currentUserMarks, selectedMark, selectedMarkValue)
|
||||
const isCurrent = (currentMark: CurrentUserMark) =>
|
||||
currentMark.id === selectedMark?.id && !complete
|
||||
currentMark.id === selectedMark.id
|
||||
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()
|
||||
// Without this line, we lose mark values when switching
|
||||
handleCurrentUserMarkChange(selectedMark!)
|
||||
|
||||
if (!complete) {
|
||||
isReadyToSign()
|
||||
? setComplete(true)
|
||||
: handleCurrentUserMarkChange(findNext()!)
|
||||
}
|
||||
console.log('handle form submit runs...')
|
||||
return isReadyToSign()
|
||||
? handleSubmit(event)
|
||||
: handleCurrentUserMarkChange(findNext()!)
|
||||
}
|
||||
|
||||
const toggleActions = () => setDisplayActions(!displayActions)
|
||||
const markLabel = selectedMark
|
||||
? getToolboxLabelByMarkType(selectedMark.mark.type)
|
||||
: ''
|
||||
const handleCurrentUserMarkClick = (mark: CurrentUserMark) => {
|
||||
setComplete(false)
|
||||
handleCurrentUserMarkChange(mark)
|
||||
}
|
||||
|
||||
const handleSelectCompleteMark = () => {
|
||||
if (currentUserMarks.length) handleCurrentUserMarkChange(selectedMark!)
|
||||
setComplete(true)
|
||||
}
|
||||
|
||||
const handleSignAndComplete =
|
||||
(type: 'online' | 'offline') =>
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
handleSubmit(event, type)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.trigger}>
|
||||
@ -90,7 +61,6 @@ const MarkFormField = ({
|
||||
onClick={toggleActions}
|
||||
className={styles.triggerBtn}
|
||||
type="button"
|
||||
title="Toggle"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@ -108,64 +78,33 @@ const MarkFormField = ({
|
||||
<div className={styles.actionsWrapper}>
|
||||
<div className={styles.actionsTop}>
|
||||
<div className={styles.actionsTopInfo}>
|
||||
{!complete && selectedMark ? (
|
||||
<p className={styles.actionsTopInfoText}>Add {markLabel}</p>
|
||||
) : (
|
||||
<p className={styles.actionsTopInfoText}>Finish</p>
|
||||
)}
|
||||
<p className={styles.actionsTopInfoText}>Add your signature</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.inputWrapper}>
|
||||
{!complete && selectedMark ? (
|
||||
<form onSubmit={(e) => handleFormSubmit(e)}>
|
||||
<MarkInput
|
||||
markType={selectedMark.mark.type}
|
||||
key={selectedMark.id}
|
||||
value={selectedMarkValue}
|
||||
placeholder={markLabel}
|
||||
handler={handleSelectedMarkValueChange}
|
||||
userMark={selectedMark}
|
||||
/>
|
||||
<div className={styles.actionsBottom}>
|
||||
<Button type="submit" className={styles.submitButton}>
|
||||
NEXT
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.actionsBottom}>
|
||||
<Button
|
||||
onClick={handleSignAndComplete('online')}
|
||||
className={[
|
||||
styles.submitButton,
|
||||
styles.completeButton
|
||||
].join(' ')}
|
||||
disabled={!isReadyToSign()}
|
||||
autoFocus
|
||||
>
|
||||
SIGN AND BROADCAST
|
||||
</Button>
|
||||
</div>
|
||||
<ButtonUnderline
|
||||
onClick={handleSignAndComplete('offline')}
|
||||
disabled={!isReadyToSign()}
|
||||
>
|
||||
<FontAwesomeIcon icon={faDownload} />
|
||||
Sign and export locally instead
|
||||
</ButtonUnderline>
|
||||
</>
|
||||
)}
|
||||
|
||||
<form onSubmit={(e) => handleFormSubmit(e)}>
|
||||
<input
|
||||
className={styles.input}
|
||||
placeholder={
|
||||
MARK_TYPE_TRANSLATION[selectedMark.mark.type.valueOf()]
|
||||
}
|
||||
onChange={handleSelectedMarkValueChange}
|
||||
value={selectedMarkValue}
|
||||
/>
|
||||
<div className={styles.actionsBottom}>
|
||||
<button type="submit" className={styles.submitButton}>
|
||||
{getSubmitButtonText()}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div className={styles.footerContainer}>
|
||||
<div className={styles.footer}>
|
||||
{currentUserMarks.map((mark, index) => {
|
||||
return (
|
||||
<div className={styles.pagination} key={index}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.paginationButton} ${isDone(mark) ? styles.paginationButtonDone : ''}`}
|
||||
onClick={() => handleCurrentUserMarkClick(mark)}
|
||||
className={`${styles.paginationButton} ${isDone(mark) && styles.paginationButtonDone}`}
|
||||
onClick={() => handleCurrentUserMarkChange(mark)}
|
||||
>
|
||||
{mark.id}
|
||||
</button>
|
||||
@ -175,22 +114,6 @@ const MarkFormField = ({
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className={styles.pagination}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.paginationButton} ${isReadyToSign() ? styles.paginationButtonDone : ''}`}
|
||||
onClick={handleSelectCompleteMark}
|
||||
title="Complete"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
className={styles.finishPage}
|
||||
icon={faCheck}
|
||||
/>
|
||||
</button>
|
||||
{complete && (
|
||||
<div className={styles.paginationButtonCurrent}></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,21 +1,13 @@
|
||||
@import '../../styles/sizes.scss';
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: fixed;
|
||||
|
||||
@media only screen and (min-width: 768px) {
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
}
|
||||
bottom: $tabs-height + 5px;
|
||||
right: 5px;
|
||||
left: 5px;
|
||||
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
align-items: center;
|
||||
z-index: 40;
|
||||
z-index: 1000;
|
||||
|
||||
button {
|
||||
transition: ease 0.2s;
|
||||
@ -70,11 +62,6 @@
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.completeButton {
|
||||
font-size: 18px;
|
||||
padding: 10px 20px;
|
||||
}
|
||||
|
||||
.paginationButton {
|
||||
font-size: 12px;
|
||||
padding: 5px 10px;
|
||||
@ -83,8 +70,7 @@
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.paginationButton:hover,
|
||||
.paginationButton:focus {
|
||||
.paginationButton:hover {
|
||||
background: #447592;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
@ -121,14 +107,14 @@
|
||||
.actions {
|
||||
background: white;
|
||||
width: 100%;
|
||||
border-radius: 5px;
|
||||
border-radius: 4px;
|
||||
padding: 10px 20px;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
grid-gap: 15px;
|
||||
box-shadow: 0 -2px 4px 0 rgb(0, 0, 0, 0.1);
|
||||
max-width: 450px;
|
||||
max-width: 750px;
|
||||
|
||||
&.expanded {
|
||||
display: flex;
|
||||
@ -222,7 +208,3 @@
|
||||
flex-direction: column;
|
||||
grid-gap: 5px;
|
||||
}
|
||||
|
||||
.finishPage {
|
||||
padding: 1px 0;
|
||||
}
|
||||
|
@ -1,16 +0,0 @@
|
||||
import { MarkType } from '../../types/drawing'
|
||||
import { MARK_TYPE_CONFIG, MarkInputProps } from './MarkStrategy'
|
||||
|
||||
interface MarkInputComponentProps extends MarkInputProps {
|
||||
markType: MarkType
|
||||
}
|
||||
|
||||
export const MarkInput = ({ markType, ...rest }: MarkInputComponentProps) => {
|
||||
const { input: InputComponent } = MARK_TYPE_CONFIG[markType] || {}
|
||||
|
||||
if (typeof InputComponent !== 'undefined') {
|
||||
return <InputComponent {...rest} />
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
@ -1,20 +0,0 @@
|
||||
import { MarkType } from '../../types/drawing'
|
||||
import { MARK_TYPE_CONFIG, MarkRenderProps } from './MarkStrategy'
|
||||
|
||||
interface MarkRenderComponentProps extends MarkRenderProps {
|
||||
markType: MarkType
|
||||
}
|
||||
|
||||
export const MarkRender = ({ markType, ...rest }: MarkRenderComponentProps) => {
|
||||
const { render: RenderComponent } = MARK_TYPE_CONFIG[markType] || {}
|
||||
|
||||
if (typeof RenderComponent !== 'undefined') {
|
||||
return <RenderComponent {...rest} />
|
||||
}
|
||||
|
||||
return <DefaultRenderComponent {...rest} />
|
||||
}
|
||||
|
||||
const DefaultRenderComponent = ({ value }: MarkRenderProps) => (
|
||||
<span>{value}</span>
|
||||
)
|
@ -1,32 +0,0 @@
|
||||
import { MarkType } from '../../types/drawing'
|
||||
import { CurrentUserMark, Mark } from '../../types/mark'
|
||||
import { TextStrategy } from './Text'
|
||||
import { SignatureStrategy } from './Signature'
|
||||
|
||||
export interface MarkInputProps {
|
||||
value: string
|
||||
handler: (value: string) => void
|
||||
placeholder?: string
|
||||
userMark?: CurrentUserMark
|
||||
}
|
||||
|
||||
export interface MarkRenderProps {
|
||||
value?: string
|
||||
mark: Mark
|
||||
}
|
||||
|
||||
export interface MarkStrategy {
|
||||
input: React.FC<MarkInputProps>
|
||||
render: React.FC<MarkRenderProps>
|
||||
encryptAndUpload?: (value: string, key?: string) => Promise<string>
|
||||
fetchAndDecrypt?: (value: string, key?: string) => Promise<string>
|
||||
}
|
||||
|
||||
export type MarkStrategies = {
|
||||
[key in MarkType]?: MarkStrategy
|
||||
}
|
||||
|
||||
export const MARK_TYPE_CONFIG: MarkStrategies = {
|
||||
[MarkType.TEXT]: TextStrategy,
|
||||
[MarkType.SIGNATURE]: SignatureStrategy
|
||||
}
|
@ -1,44 +0,0 @@
|
||||
@import '../../../styles/colors.scss';
|
||||
|
||||
$padding: 5px;
|
||||
|
||||
.wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: $padding;
|
||||
}
|
||||
|
||||
.relative {
|
||||
position: relative;
|
||||
outline: 1px solid black;
|
||||
}
|
||||
|
||||
.canvas {
|
||||
background-color: $body-background-color;
|
||||
cursor: crosshair;
|
||||
|
||||
// Disable panning/zooming when touching canvas element
|
||||
-ms-touch-action: none;
|
||||
touch-action: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.absolute {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.reset {
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: $padding;
|
||||
color: $primary-main;
|
||||
|
||||
&:hover {
|
||||
color: $primary-dark;
|
||||
}
|
||||
}
|
@ -1,101 +0,0 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { faEraser } from '@fortawesome/free-solid-svg-icons'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { MarkRenderSignature } from './Render'
|
||||
import SignaturePad from 'signature_pad'
|
||||
import { SIGNATURE_PAD_OPTIONS, SIGNATURE_PAD_SIZE } from '../../../utils/const'
|
||||
import { BasicPoint } from 'signature_pad/dist/types/point'
|
||||
import { MarkInputProps } from '../MarkStrategy'
|
||||
import styles from './Input.module.scss'
|
||||
|
||||
export const MarkInputSignature = ({
|
||||
value,
|
||||
handler,
|
||||
userMark
|
||||
}: MarkInputProps) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const signaturePad = useRef<SignaturePad | null>(null)
|
||||
|
||||
const update = useCallback(() => {
|
||||
const data = signaturePad.current?.toData()
|
||||
const reduced = data?.map((pg) => pg.points)
|
||||
const json = JSON.stringify(reduced)
|
||||
|
||||
if (signaturePad.current && !signaturePad.current?.isEmpty()) {
|
||||
handler(json)
|
||||
} else {
|
||||
handler('')
|
||||
}
|
||||
}, [handler])
|
||||
|
||||
useEffect(() => {
|
||||
const handleEndStroke = () => {
|
||||
update()
|
||||
}
|
||||
if (canvasRef.current) {
|
||||
if (signaturePad.current === null) {
|
||||
signaturePad.current = new SignaturePad(
|
||||
canvasRef.current,
|
||||
SIGNATURE_PAD_OPTIONS
|
||||
)
|
||||
}
|
||||
|
||||
signaturePad.current.addEventListener('endStroke', handleEndStroke)
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('endStroke', handleEndStroke)
|
||||
}
|
||||
}, [update])
|
||||
|
||||
useEffect(() => {
|
||||
if (signaturePad.current) {
|
||||
if (value) {
|
||||
signaturePad.current.fromData(
|
||||
JSON.parse(value).map((p: BasicPoint[]) => ({
|
||||
points: p
|
||||
}))
|
||||
)
|
||||
} else {
|
||||
signaturePad.current?.clear()
|
||||
}
|
||||
}
|
||||
update()
|
||||
}, [update, value])
|
||||
|
||||
const handleReset = () => {
|
||||
signaturePad.current?.clear()
|
||||
update()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<div
|
||||
className={styles.relative}
|
||||
style={{
|
||||
width: SIGNATURE_PAD_SIZE.width,
|
||||
height: SIGNATURE_PAD_SIZE.height
|
||||
}}
|
||||
>
|
||||
<canvas
|
||||
width={SIGNATURE_PAD_SIZE.width}
|
||||
height={SIGNATURE_PAD_SIZE.height}
|
||||
ref={canvasRef}
|
||||
className={styles.canvas}
|
||||
></canvas>
|
||||
{typeof userMark?.mark !== 'undefined' && (
|
||||
<div className={styles.absolute}>
|
||||
<MarkRenderSignature
|
||||
key={userMark.mark.value}
|
||||
value={userMark.mark.value}
|
||||
mark={userMark.mark}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.reset}>
|
||||
<FontAwesomeIcon size="sm" icon={faEraser} onClick={handleReset} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,9 +0,0 @@
|
||||
.img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
@ -1,27 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import SignaturePad from 'signature_pad'
|
||||
import { SIGNATURE_PAD_OPTIONS, SIGNATURE_PAD_SIZE } from '../../../utils'
|
||||
import { BasicPoint } from 'signature_pad/dist/types/point'
|
||||
import { MarkRenderProps } from '../MarkStrategy'
|
||||
import styles from './Render.module.scss'
|
||||
|
||||
export const MarkRenderSignature = ({ value }: MarkRenderProps) => {
|
||||
const [dataUrl, setDataUrl] = useState<string | undefined>()
|
||||
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = SIGNATURE_PAD_SIZE.width
|
||||
canvas.height = SIGNATURE_PAD_SIZE.height
|
||||
const pad = new SignaturePad(canvas, SIGNATURE_PAD_OPTIONS)
|
||||
pad.fromData(
|
||||
JSON.parse(value).map((p: BasicPoint[]) => ({
|
||||
points: p
|
||||
}))
|
||||
)
|
||||
setDataUrl(canvas.toDataURL('image/webp'))
|
||||
}
|
||||
}, [value])
|
||||
|
||||
return dataUrl ? <img src={dataUrl} className={styles.img} alt="" /> : null
|
||||
}
|
@ -1,89 +0,0 @@
|
||||
import axios from 'axios'
|
||||
import {
|
||||
decryptArrayBuffer,
|
||||
encryptArrayBuffer,
|
||||
getHash,
|
||||
uploadToFileStorage
|
||||
} from '../../../utils'
|
||||
import { MarkStrategy } from '../MarkStrategy'
|
||||
import { MarkInputSignature } from './Input'
|
||||
import { MarkRenderSignature } from './Render'
|
||||
|
||||
export const SignatureStrategy: MarkStrategy = {
|
||||
input: MarkInputSignature,
|
||||
render: MarkRenderSignature,
|
||||
encryptAndUpload: async (value, encryptionKey) => {
|
||||
// Value is the stringified signature object
|
||||
// Encode it to the arrayBuffer
|
||||
const encoder = new TextEncoder()
|
||||
const uint8Array = encoder.encode(value)
|
||||
|
||||
if (!encryptionKey) {
|
||||
throw new Error('Signature requires an encryption key')
|
||||
}
|
||||
|
||||
// Encrypt the file contents with the same encryption key from the create signature
|
||||
const encryptedArrayBuffer = await encryptArrayBuffer(
|
||||
uint8Array,
|
||||
encryptionKey
|
||||
)
|
||||
|
||||
const hash = await getHash(encryptedArrayBuffer)
|
||||
if (!hash) {
|
||||
throw new Error("Can't get encrypted file hash.")
|
||||
}
|
||||
|
||||
// Create the encrypted json file from array buffer and hash
|
||||
const file = new File([encryptedArrayBuffer], `${hash}.json`)
|
||||
|
||||
try {
|
||||
const url = await uploadToFileStorage(file)
|
||||
console.info(`${file.name} uploaded to file storage`)
|
||||
return url
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.error(
|
||||
`Error occurred in uploading file ${file.name}`,
|
||||
error.message
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return value
|
||||
},
|
||||
fetchAndDecrypt: async (value, encryptionKey) => {
|
||||
if (!encryptionKey) {
|
||||
throw new Error('Signature requires an encryption key')
|
||||
}
|
||||
|
||||
const encryptedArrayBuffer = await axios.get(value, {
|
||||
responseType: 'arraybuffer'
|
||||
})
|
||||
|
||||
// Verify hash
|
||||
const parts = value.split('/')
|
||||
const urlHash = parts[parts.length - 1]
|
||||
const hash = await getHash(encryptedArrayBuffer.data)
|
||||
if (hash !== urlHash) {
|
||||
// TODO: handle hash verification failing
|
||||
throw new Error('Unable to verify signature')
|
||||
}
|
||||
|
||||
const arrayBuffer = await decryptArrayBuffer(
|
||||
encryptedArrayBuffer.data,
|
||||
encryptionKey
|
||||
).catch((err) => {
|
||||
console.log('err in decryption:>> ', err)
|
||||
return null
|
||||
})
|
||||
|
||||
if (arrayBuffer) {
|
||||
// decode json
|
||||
const decoder = new TextDecoder()
|
||||
const json = decoder.decode(arrayBuffer)
|
||||
return json
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
}
|
@ -1,19 +0,0 @@
|
||||
import { MarkInputProps } from '../MarkStrategy'
|
||||
import styles from '../../MarkFormField/style.module.scss'
|
||||
|
||||
export const MarkInputText = ({
|
||||
value,
|
||||
handler,
|
||||
placeholder
|
||||
}: MarkInputProps) => {
|
||||
return (
|
||||
<input
|
||||
className={styles.input}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => {
|
||||
handler(e.currentTarget.value)
|
||||
}}
|
||||
value={value}
|
||||
/>
|
||||
)
|
||||
}
|
@ -1,7 +0,0 @@
|
||||
import { MarkStrategy } from '../MarkStrategy'
|
||||
import { MarkInputText } from './Input'
|
||||
|
||||
export const TextStrategy: MarkStrategy = {
|
||||
input: MarkInputText,
|
||||
render: ({ value }) => <>{value}</>
|
||||
}
|
@ -36,8 +36,6 @@ const PdfItem = ({
|
||||
return file.pages?.map((page, i) => {
|
||||
return (
|
||||
<PdfPageItem
|
||||
fileName={file.name}
|
||||
pageIndex={i}
|
||||
page={page}
|
||||
key={i}
|
||||
currentUserMarks={filterByPage(currentUserMarks, i)}
|
||||
|
@ -2,9 +2,6 @@ import { CurrentUserMark } from '../../types/mark.ts'
|
||||
import styles from '../DrawPDFFields/style.module.scss'
|
||||
import { FONT_SIZE, FONT_TYPE, inPx } from '../../utils/pdf.ts'
|
||||
import { useScale } from '../../hooks/useScale.tsx'
|
||||
import { forwardRef } from 'react'
|
||||
import { npubToHex } from '../../utils/nostr.ts'
|
||||
import { MarkRender } from '../MarkTypeStrategy/MarkRender.tsx'
|
||||
|
||||
interface PdfMarkItemProps {
|
||||
userMark: CurrentUserMark
|
||||
@ -17,46 +14,35 @@ interface PdfMarkItemProps {
|
||||
/**
|
||||
* Responsible for display an individual Pdf Mark.
|
||||
*/
|
||||
const PdfMarkItem = forwardRef<HTMLDivElement, PdfMarkItemProps>(
|
||||
(
|
||||
{ selectedMark, handleMarkClick, selectedMarkValue, userMark, pageWidth },
|
||||
ref
|
||||
) => {
|
||||
const { location } = userMark.mark
|
||||
const handleClick = () => handleMarkClick(userMark.mark.id)
|
||||
const isEdited = () => selectedMark?.mark.id === userMark.mark.id
|
||||
const getMarkValue = () =>
|
||||
isEdited() ? selectedMarkValue : userMark.currentValue
|
||||
const { from } = useScale()
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
onClick={handleClick}
|
||||
className={`file-mark ${styles.signingRectangle} ${isEdited() && styles.edited}`}
|
||||
style={{
|
||||
backgroundColor: selectedMark?.mark.npub
|
||||
? `#${npubToHex(selectedMark?.mark.npub)?.substring(0, 6)}4b`
|
||||
: undefined,
|
||||
outlineColor: selectedMark?.mark.npub
|
||||
? `#${npubToHex(selectedMark?.mark.npub)?.substring(0, 6)}`
|
||||
: undefined,
|
||||
left: inPx(from(pageWidth, location.left)),
|
||||
top: inPx(from(pageWidth, location.top)),
|
||||
width: inPx(from(pageWidth, location.width)),
|
||||
height: inPx(from(pageWidth, location.height)),
|
||||
fontFamily: FONT_TYPE,
|
||||
fontSize: inPx(from(pageWidth, FONT_SIZE))
|
||||
}}
|
||||
>
|
||||
<MarkRender
|
||||
key={getMarkValue()}
|
||||
markType={userMark.mark.type}
|
||||
value={getMarkValue()}
|
||||
mark={userMark.mark}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
const PdfMarkItem = ({
|
||||
selectedMark,
|
||||
handleMarkClick,
|
||||
selectedMarkValue,
|
||||
userMark,
|
||||
pageWidth
|
||||
}: PdfMarkItemProps) => {
|
||||
const { location } = userMark.mark
|
||||
const handleClick = () => handleMarkClick(userMark.mark.id)
|
||||
const isEdited = () => selectedMark?.mark.id === userMark.mark.id
|
||||
const getMarkValue = () =>
|
||||
isEdited() ? selectedMarkValue : userMark.currentValue
|
||||
const { from } = useScale()
|
||||
return (
|
||||
<div
|
||||
onClick={handleClick}
|
||||
className={`file-mark ${styles.drawingRectangle} ${isEdited() && styles.edited}`}
|
||||
style={{
|
||||
left: inPx(from(pageWidth, location.left)),
|
||||
top: inPx(from(pageWidth, location.top)),
|
||||
width: inPx(from(pageWidth, location.width)),
|
||||
height: inPx(from(pageWidth, location.height)),
|
||||
fontFamily: FONT_TYPE,
|
||||
fontSize: inPx(from(pageWidth, FONT_SIZE))
|
||||
}}
|
||||
>
|
||||
{getMarkValue()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PdfMarkItem
|
||||
|
@ -15,20 +15,15 @@ import FileList from '../FileList'
|
||||
import { StickySideColumns } from '../../layouts/StickySideColumns.tsx'
|
||||
import { UsersDetails } from '../UsersDetails.tsx'
|
||||
import { Meta } from '../../types'
|
||||
import {
|
||||
faCircleInfo,
|
||||
faFileDownload,
|
||||
faPen
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
interface PdfMarkingProps {
|
||||
currentUserMarks: CurrentUserMark[]
|
||||
files: CurrentUserFile[]
|
||||
handleSign: () => void
|
||||
handleSignOffline: () => void
|
||||
handleDownload: () => void
|
||||
meta: Meta | null
|
||||
otherUserMarks: Mark[]
|
||||
setCurrentUserMarks: (currentUserMarks: CurrentUserMark[]) => void
|
||||
setIsMarksCompleted: (isMarksCompleted: boolean) => void
|
||||
setUpdatedMarks: (markToUpdate: Mark) => void
|
||||
}
|
||||
|
||||
@ -38,16 +33,17 @@ interface PdfMarkingProps {
|
||||
* @param props
|
||||
* @constructor
|
||||
*/
|
||||
const PdfMarking = ({
|
||||
files,
|
||||
currentUserMarks,
|
||||
setCurrentUserMarks,
|
||||
setUpdatedMarks,
|
||||
handleSign,
|
||||
handleSignOffline,
|
||||
meta,
|
||||
otherUserMarks
|
||||
}: PdfMarkingProps) => {
|
||||
const PdfMarking = (props: PdfMarkingProps) => {
|
||||
const {
|
||||
files,
|
||||
currentUserMarks,
|
||||
setIsMarksCompleted,
|
||||
setCurrentUserMarks,
|
||||
setUpdatedMarks,
|
||||
handleDownload,
|
||||
meta,
|
||||
otherUserMarks
|
||||
} = props
|
||||
const [selectedMark, setSelectedMark] = useState<CurrentUserMark | null>(null)
|
||||
const [selectedMarkValue, setSelectedMarkValue] = useState<string>('')
|
||||
const [currentFile, setCurrentFile] = useState<CurrentUserFile | null>(null)
|
||||
@ -69,8 +65,8 @@ const PdfMarking = ({
|
||||
|
||||
const handleMarkClick = (id: number) => {
|
||||
const nextMark = currentUserMarks.find((mark) => mark.mark.id === id)
|
||||
|
||||
if (nextMark) handleCurrentUserMarkChange(nextMark)
|
||||
setSelectedMark(nextMark!)
|
||||
setSelectedMarkValue(nextMark?.mark.value ?? EMPTY)
|
||||
}
|
||||
|
||||
const handleCurrentUserMarkChange = (mark: CurrentUserMark) => {
|
||||
@ -85,45 +81,39 @@ const PdfMarking = ({
|
||||
updatedSelectedMark
|
||||
)
|
||||
setCurrentUserMarks(updatedCurrentUserMarks)
|
||||
|
||||
// If clicking on the same mark, don't update the value, otherwise do update
|
||||
if (mark.id !== selectedMark.id) {
|
||||
setSelectedMarkValue(mark.currentValue ?? EMPTY)
|
||||
setSelectedMark(mark)
|
||||
}
|
||||
setSelectedMarkValue(mark.currentValue ?? EMPTY)
|
||||
setSelectedMark(mark)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign and Complete
|
||||
*/
|
||||
const handleSubmit = (
|
||||
event: React.MouseEvent<HTMLButtonElement>,
|
||||
type: 'online' | 'offline'
|
||||
) => {
|
||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
if (selectedMarkValue && selectedMark) {
|
||||
const updatedMark: CurrentUserMark = getUpdatedMark(
|
||||
selectedMark,
|
||||
selectedMarkValue
|
||||
)
|
||||
if (!selectedMarkValue || !selectedMark) return
|
||||
|
||||
setSelectedMarkValue(EMPTY)
|
||||
const updatedCurrentUserMarks = updateCurrentUserMarks(
|
||||
currentUserMarks,
|
||||
updatedMark
|
||||
)
|
||||
setCurrentUserMarks(updatedCurrentUserMarks)
|
||||
setSelectedMark(null)
|
||||
setUpdatedMarks(updatedMark.mark)
|
||||
}
|
||||
const updatedMark: CurrentUserMark = getUpdatedMark(
|
||||
selectedMark,
|
||||
selectedMarkValue
|
||||
)
|
||||
|
||||
if (type === 'online') handleSign()
|
||||
else if (type === 'offline') handleSignOffline()
|
||||
setSelectedMarkValue(EMPTY)
|
||||
const updatedCurrentUserMarks = updateCurrentUserMarks(
|
||||
currentUserMarks,
|
||||
updatedMark
|
||||
)
|
||||
setCurrentUserMarks(updatedCurrentUserMarks)
|
||||
setSelectedMark(null)
|
||||
setIsMarksCompleted(true)
|
||||
setUpdatedMarks(updatedMark.mark)
|
||||
}
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
setSelectedMarkValue(value)
|
||||
}
|
||||
// const updateCurrentUserMarkValues = () => {
|
||||
// const updatedMark: CurrentUserMark = getUpdatedMark(selectedMark!, selectedMarkValue)
|
||||
// const updatedCurrentUserMarks = updateCurrentUserMarks(currentUserMarks, updatedMark)
|
||||
// setSelectedMarkValue(EMPTY)
|
||||
// setCurrentUserMarks(updatedCurrentUserMarks)
|
||||
// }
|
||||
|
||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setSelectedMarkValue(event.target.value)
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -136,33 +126,35 @@ const PdfMarking = ({
|
||||
files={files}
|
||||
currentFile={currentFile}
|
||||
setCurrentFile={setCurrentFile}
|
||||
handleDownload={handleDownload}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
right={meta !== null && <UsersDetails meta={meta} />}
|
||||
leftIcon={faFileDownload}
|
||||
centerIcon={faPen}
|
||||
rightIcon={faCircleInfo}
|
||||
>
|
||||
<PdfView
|
||||
currentFile={currentFile}
|
||||
files={files}
|
||||
handleMarkClick={handleMarkClick}
|
||||
selectedMarkValue={selectedMarkValue}
|
||||
selectedMark={selectedMark}
|
||||
currentUserMarks={currentUserMarks}
|
||||
otherUserMarks={otherUserMarks}
|
||||
/>
|
||||
{currentUserMarks?.length > 0 && (
|
||||
<PdfView
|
||||
currentFile={currentFile}
|
||||
files={files}
|
||||
handleMarkClick={handleMarkClick}
|
||||
selectedMarkValue={selectedMarkValue}
|
||||
selectedMark={selectedMark}
|
||||
currentUserMarks={currentUserMarks}
|
||||
otherUserMarks={otherUserMarks}
|
||||
/>
|
||||
)}
|
||||
</StickySideColumns>
|
||||
<MarkFormField
|
||||
handleSubmit={handleSubmit}
|
||||
handleSelectedMarkValueChange={handleChange}
|
||||
selectedMark={selectedMark}
|
||||
selectedMarkValue={selectedMarkValue}
|
||||
currentUserMarks={currentUserMarks}
|
||||
handleCurrentUserMarkChange={handleCurrentUserMarkChange}
|
||||
/>
|
||||
{selectedMark !== null && (
|
||||
<MarkFormField
|
||||
handleSubmit={handleSubmit}
|
||||
handleSelectedMarkValueChange={handleChange}
|
||||
selectedMark={selectedMark}
|
||||
selectedMarkValue={selectedMarkValue}
|
||||
currentUserMarks={currentUserMarks}
|
||||
handleCurrentUserMarkChange={handleCurrentUserMarkChange}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
</>
|
||||
)
|
||||
|
@ -6,10 +6,7 @@ import { useEffect, useRef } from 'react'
|
||||
import pdfViewStyles from './style.module.scss'
|
||||
import { FONT_SIZE, FONT_TYPE, inPx } from '../../utils/pdf.ts'
|
||||
import { useScale } from '../../hooks/useScale.tsx'
|
||||
import { MarkRender } from '../MarkTypeStrategy/MarkRender.tsx'
|
||||
interface PdfPageProps {
|
||||
fileName: string
|
||||
pageIndex: number
|
||||
currentUserMarks: CurrentUserMark[]
|
||||
handleMarkClick: (id: number) => void
|
||||
otherUserMarks: Mark[]
|
||||
@ -22,8 +19,6 @@ interface PdfPageProps {
|
||||
* Responsible for rendering a single Pdf Page and its Marks
|
||||
*/
|
||||
const PdfPageItem = ({
|
||||
fileName,
|
||||
pageIndex,
|
||||
page,
|
||||
currentUserMarks,
|
||||
handleMarkClick,
|
||||
@ -34,8 +29,7 @@ const PdfPageItem = ({
|
||||
useEffect(() => {
|
||||
if (selectedMark !== null && !!markRefs.current[selectedMark.id]) {
|
||||
markRefs.current[selectedMark.id]?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center'
|
||||
behavior: 'smooth'
|
||||
})
|
||||
}
|
||||
}, [selectedMark])
|
||||
@ -44,21 +38,18 @@ const PdfPageItem = ({
|
||||
|
||||
return (
|
||||
<div className={`image-wrapper ${styles.pdfImageWrapper}`}>
|
||||
<img
|
||||
draggable="false"
|
||||
src={page.image}
|
||||
alt={`page ${pageIndex + 1} of ${fileName}`}
|
||||
/>
|
||||
<img draggable="false" src={page.image} />
|
||||
{currentUserMarks.map((m, i) => (
|
||||
<PdfMarkItem
|
||||
key={i}
|
||||
ref={(el) => (markRefs.current[m.id] = el)}
|
||||
handleMarkClick={handleMarkClick}
|
||||
selectedMarkValue={selectedMarkValue}
|
||||
userMark={m}
|
||||
selectedMark={selectedMark}
|
||||
pageWidth={page.width}
|
||||
/>
|
||||
<div key={i} ref={(el) => (markRefs.current[m.id] = el)}>
|
||||
<PdfMarkItem
|
||||
key={i}
|
||||
handleMarkClick={handleMarkClick}
|
||||
selectedMarkValue={selectedMarkValue}
|
||||
userMark={m}
|
||||
selectedMark={selectedMark}
|
||||
pageWidth={page.width}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{otherUserMarks.map((m, i) => {
|
||||
return (
|
||||
@ -74,7 +65,7 @@ const PdfPageItem = ({
|
||||
fontSize: inPx(from(page.width, FONT_SIZE))
|
||||
}}
|
||||
>
|
||||
<MarkRender value={m.value} mark={m} markType={m.type} />
|
||||
{m.value}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
@ -4,7 +4,6 @@ import { CurrentUserFile } from '../../types/file.ts'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { FileDivider } from '../FileDivider.tsx'
|
||||
import React from 'react'
|
||||
import { LoadingSpinner } from '../LoadingSpinner/index.tsx'
|
||||
|
||||
interface PdfViewProps {
|
||||
currentFile: CurrentUserFile | null
|
||||
@ -38,51 +37,41 @@ const PdfView = ({
|
||||
currentUserMarks: CurrentUserMark[],
|
||||
hash: string
|
||||
): CurrentUserMark[] => {
|
||||
return currentUserMarks.filter((currentUserMark) =>
|
||||
currentUserMark.mark.pdfFileHash
|
||||
? currentUserMark.mark.pdfFileHash === hash
|
||||
: currentUserMark.mark.fileHash === hash
|
||||
return currentUserMarks.filter(
|
||||
(currentUserMark) => currentUserMark.mark.pdfFileHash === hash
|
||||
)
|
||||
}
|
||||
const filterMarksByFile = (marks: Mark[], hash: string): Mark[] => {
|
||||
return marks.filter((mark) =>
|
||||
mark.pdfFileHash ? mark.pdfFileHash === hash : mark.fileHash === hash
|
||||
)
|
||||
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<React.ReactNode>((currentUserFile) => {
|
||||
const { hash, file, id } = currentUserFile
|
||||
{files.map((currentUserFile, index, arr) => {
|
||||
const { hash, file, id } = currentUserFile
|
||||
|
||||
if (!hash) return
|
||||
return (
|
||||
<div
|
||||
key={`file-${file.name}`}
|
||||
id={file.name}
|
||||
className="file-wrapper"
|
||||
ref={(el) => (pdfRefs.current[id] = el)}
|
||||
>
|
||||
<PdfItem
|
||||
file={file}
|
||||
currentUserMarks={filterByFile(currentUserMarks, hash)}
|
||||
selectedMark={selectedMark}
|
||||
handleMarkClick={handleMarkClick}
|
||||
selectedMarkValue={selectedMarkValue}
|
||||
otherUserMarks={filterMarksByFile(otherUserMarks, hash)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
.reduce((prev, curr, i) => [
|
||||
prev,
|
||||
<FileDivider key={`separator-${i}`} />,
|
||||
curr
|
||||
])
|
||||
) : (
|
||||
<LoadingSpinner variant="small" />
|
||||
)}
|
||||
if (!hash) return
|
||||
return (
|
||||
<React.Fragment key={index}>
|
||||
<div
|
||||
id={file.name}
|
||||
className="file-wrapper"
|
||||
ref={(el) => (pdfRefs.current[id] = el)}
|
||||
>
|
||||
<PdfItem
|
||||
file={file}
|
||||
currentUserMarks={filterByFile(currentUserMarks, hash)}
|
||||
selectedMark={selectedMark}
|
||||
handleMarkClick={handleMarkClick}
|
||||
selectedMarkValue={selectedMarkValue}
|
||||
otherUserMarks={filterMarksByFile(otherUserMarks, hash)}
|
||||
/>
|
||||
</div>
|
||||
{isNotLastPdfFile(index, arr) && <FileDivider />}
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -6,7 +6,7 @@
|
||||
|
||||
.otherUserMarksDisplay {
|
||||
position: absolute;
|
||||
z-index: 40;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
@ -3,56 +3,34 @@ import { getProfileRoute } from '../../routes'
|
||||
import styles from './styles.module.scss'
|
||||
import { AvatarIconButton } from '../UserAvatarIconButton'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useProfileMetadata } from '../../hooks/useProfileMetadata'
|
||||
import { Tooltip } from '@mui/material'
|
||||
import { getProfileUsername } from '../../utils'
|
||||
import { TooltipChild } from '../TooltipChild'
|
||||
|
||||
interface UserAvatarProps {
|
||||
name?: string
|
||||
pubkey: string
|
||||
isNameVisible?: boolean
|
||||
image?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* This component will be used for the displaying username and profile picture.
|
||||
* Clicking will navigate to the user's profile.
|
||||
*/
|
||||
export const UserAvatar = ({
|
||||
pubkey,
|
||||
isNameVisible = false
|
||||
}: UserAvatarProps) => {
|
||||
const profile = useProfileMetadata(pubkey)
|
||||
const name = getProfileUsername(pubkey, profile)
|
||||
const image = profile?.image
|
||||
|
||||
export const UserAvatar = ({ pubkey, name, image }: UserAvatarProps) => {
|
||||
return (
|
||||
<Link
|
||||
to={getProfileRoute(pubkey)}
|
||||
className={styles.container}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<Tooltip
|
||||
key={pubkey}
|
||||
title={name}
|
||||
placement="top"
|
||||
arrow
|
||||
disableInteractive
|
||||
>
|
||||
<TooltipChild>
|
||||
<AvatarIconButton
|
||||
src={image}
|
||||
hexKey={pubkey}
|
||||
aria-label={`account of user ${name}`}
|
||||
color="inherit"
|
||||
sx={{
|
||||
padding: 0
|
||||
}}
|
||||
/>
|
||||
</TooltipChild>
|
||||
</Tooltip>
|
||||
{isNameVisible && name ? (
|
||||
<span className={styles.username}>{name}</span>
|
||||
) : null}
|
||||
<AvatarIconButton
|
||||
src={image}
|
||||
hexKey={pubkey}
|
||||
aria-label={`account of user ${name || pubkey}`}
|
||||
color="inherit"
|
||||
sx={{
|
||||
padding: 0
|
||||
}}
|
||||
/>
|
||||
{name ? <span className={styles.username}>{name}</span> : null}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
@ -1,10 +1,11 @@
|
||||
import { Divider, Tooltip } from '@mui/material'
|
||||
import { useSigitProfiles } from '../../hooks/useSigitProfiles'
|
||||
import {
|
||||
formatTimestamp,
|
||||
fromUnixTimestamp,
|
||||
hexToNpub,
|
||||
npubToHex,
|
||||
SigitStatus,
|
||||
shorten,
|
||||
SignStatus
|
||||
} from '../../utils'
|
||||
import { useSigitMeta } from '../../hooks/useSigitMeta'
|
||||
@ -16,18 +17,17 @@ import {
|
||||
faCalendar,
|
||||
faCalendarCheck,
|
||||
faCalendarPlus,
|
||||
faCheck,
|
||||
faClock,
|
||||
faEye,
|
||||
faFile,
|
||||
faFileCircleExclamation
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
import { getExtensionIconLabel } from '../getExtensionIconLabel'
|
||||
import { useAppSelector } from '../../hooks/store'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { State } from '../../store/rootReducer'
|
||||
import { TooltipChild } from '../TooltipChild'
|
||||
import { DisplaySigner } from '../DisplaySigner'
|
||||
import { Meta, OpenTimestamp } from '../../types'
|
||||
import { Meta } from '../../types'
|
||||
import { extractFileExtensions } from '../../utils/file'
|
||||
import { UserAvatar } from '../UserAvatar'
|
||||
|
||||
interface UsersDetailsProps {
|
||||
meta: Meta
|
||||
@ -36,7 +36,6 @@ interface UsersDetailsProps {
|
||||
export const UsersDetails = ({ meta }: UsersDetailsProps) => {
|
||||
const {
|
||||
submittedBy,
|
||||
exportedBy,
|
||||
signers,
|
||||
viewers,
|
||||
fileHashes,
|
||||
@ -45,72 +44,50 @@ export const UsersDetails = ({ meta }: UsersDetailsProps) => {
|
||||
completedAt,
|
||||
parsedSignatureEvents,
|
||||
signedStatus,
|
||||
isValid,
|
||||
id,
|
||||
timestamps
|
||||
isValid
|
||||
} = useSigitMeta(meta)
|
||||
const { usersPubkey } = useAppSelector((state) => state.auth)
|
||||
const { usersPubkey } = useSelector((state: State) => state.auth)
|
||||
const profiles = useSigitProfiles([
|
||||
...(submittedBy ? [submittedBy] : []),
|
||||
...signers,
|
||||
...viewers
|
||||
])
|
||||
const userCanSign =
|
||||
typeof usersPubkey !== 'undefined' &&
|
||||
signers.includes(hexToNpub(usersPubkey))
|
||||
|
||||
const { extensions, isSame } = extractFileExtensions(Object.keys(fileHashes))
|
||||
|
||||
const isTimestampVerified = (
|
||||
timestamps: OpenTimestamp[],
|
||||
nostrId: string
|
||||
): boolean => {
|
||||
const matched = timestamps.find((t) => t.nostrId === nostrId)
|
||||
return !!(matched && matched.verification)
|
||||
}
|
||||
|
||||
const getOpenTimestampsInfo = (
|
||||
timestamps: OpenTimestamp[],
|
||||
nostrId: string
|
||||
) => {
|
||||
if (isTimestampVerified(timestamps, nostrId)) {
|
||||
return <FontAwesomeIcon className={styles.ticket} icon={faCheck} />
|
||||
} else {
|
||||
return <FontAwesomeIcon className={styles.ticket} icon={faClock} />
|
||||
}
|
||||
}
|
||||
|
||||
const getCompletedOpenTimestampsInfo = (timestamp: OpenTimestamp) => {
|
||||
if (timestamp.verification) {
|
||||
return <FontAwesomeIcon className={styles.ticket} icon={faCheck} />
|
||||
} else {
|
||||
return <FontAwesomeIcon className={styles.ticket} icon={faClock} />
|
||||
}
|
||||
}
|
||||
|
||||
const getTimestampTooltipTitle = (label: string, isVerified: boolean) => {
|
||||
return `${label} / Open Timestamp ${isVerified ? 'Verified' : 'Pending'}`
|
||||
}
|
||||
|
||||
const isUserSignatureTimestampVerified = () => {
|
||||
if (
|
||||
userCanSign &&
|
||||
hexToNpub(usersPubkey) in parsedSignatureEvents &&
|
||||
timestamps &&
|
||||
timestamps.length > 0
|
||||
) {
|
||||
const nostrId = parsedSignatureEvents[hexToNpub(usersPubkey)].id
|
||||
return isTimestampVerified(timestamps, nostrId)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
return submittedBy ? (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.section}>
|
||||
<p>Signers</p>
|
||||
<div className={styles.users}>
|
||||
{submittedBy && (
|
||||
<DisplaySigner
|
||||
status={isValid ? SignStatus.Signed : SignStatus.Invalid}
|
||||
pubkey={submittedBy}
|
||||
/>
|
||||
)}
|
||||
{submittedBy &&
|
||||
(function () {
|
||||
const profile = profiles[submittedBy]
|
||||
return (
|
||||
<Tooltip
|
||||
key={submittedBy}
|
||||
title={
|
||||
profile?.display_name ||
|
||||
profile?.name ||
|
||||
shorten(hexToNpub(submittedBy))
|
||||
}
|
||||
placement="top"
|
||||
arrow
|
||||
disableInteractive
|
||||
>
|
||||
<TooltipChild>
|
||||
<DisplaySigner
|
||||
status={isValid ? SignStatus.Signed : SignStatus.Invalid}
|
||||
profile={profile}
|
||||
pubkey={submittedBy}
|
||||
/>
|
||||
</TooltipChild>
|
||||
</Tooltip>
|
||||
)
|
||||
})()}
|
||||
|
||||
{submittedBy && signers.length ? (
|
||||
<Divider orientation="vertical" flexItem />
|
||||
@ -119,80 +96,72 @@ export const UsersDetails = ({ meta }: UsersDetailsProps) => {
|
||||
<UserAvatarGroup max={20}>
|
||||
{signers.map((signer) => {
|
||||
const pubkey = npubToHex(signer)!
|
||||
const profile = profiles[pubkey]
|
||||
|
||||
return (
|
||||
<DisplaySigner
|
||||
key={pubkey}
|
||||
status={signersStatus[signer]}
|
||||
pubkey={pubkey}
|
||||
/>
|
||||
<Tooltip
|
||||
key={signer}
|
||||
title={
|
||||
profile?.display_name || profile?.name || shorten(pubkey)
|
||||
}
|
||||
placement="top"
|
||||
arrow
|
||||
disableInteractive
|
||||
>
|
||||
<TooltipChild>
|
||||
<DisplaySigner
|
||||
status={signersStatus[signer]}
|
||||
profile={profile}
|
||||
pubkey={pubkey}
|
||||
/>
|
||||
</TooltipChild>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
{viewers.map((signer) => {
|
||||
const pubkey = npubToHex(signer)!
|
||||
const profile = profiles[pubkey]
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
key={signer}
|
||||
title={
|
||||
profile?.display_name || profile?.name || shorten(pubkey)
|
||||
}
|
||||
placement="top"
|
||||
arrow
|
||||
disableInteractive
|
||||
>
|
||||
<TooltipChild>
|
||||
<DisplaySigner
|
||||
status={SignStatus.Viewer}
|
||||
profile={profile}
|
||||
pubkey={pubkey}
|
||||
/>
|
||||
</TooltipChild>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
</UserAvatarGroup>
|
||||
</div>
|
||||
|
||||
{viewers.length > 0 && (
|
||||
<>
|
||||
<p>Viewers</p>
|
||||
<div className={styles.users}>
|
||||
<UserAvatarGroup max={20}>
|
||||
{viewers.map((signer) => {
|
||||
const pubkey = npubToHex(signer)!
|
||||
|
||||
return (
|
||||
<DisplaySigner
|
||||
key={pubkey}
|
||||
status={SignStatus.Viewer}
|
||||
pubkey={pubkey}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</UserAvatarGroup>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{exportedBy && (
|
||||
<>
|
||||
<p>Exported By</p>
|
||||
<div className={styles.users}>
|
||||
<UserAvatar pubkey={exportedBy} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.section}>
|
||||
<p>Details</p>
|
||||
|
||||
<Tooltip
|
||||
title={getTimestampTooltipTitle(
|
||||
'Publication date',
|
||||
!!(timestamps && id && isTimestampVerified(timestamps, id))
|
||||
)}
|
||||
title={'Publication date'}
|
||||
placement="top"
|
||||
arrow
|
||||
disableInteractive
|
||||
>
|
||||
<span className={styles.detailsItem}>
|
||||
<FontAwesomeIcon icon={faCalendarPlus} />{' '}
|
||||
{createdAt ? formatTimestamp(createdAt) : <>—</>}{' '}
|
||||
{timestamps &&
|
||||
timestamps.length > 0 &&
|
||||
id &&
|
||||
getOpenTimestampsInfo(timestamps, id)}
|
||||
{createdAt ? formatTimestamp(createdAt) : <>—</>}
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip
|
||||
title={getTimestampTooltipTitle(
|
||||
'Completion date',
|
||||
!!(
|
||||
signedStatus === SigitStatus.Complete &&
|
||||
completedAt &&
|
||||
timestamps &&
|
||||
timestamps.length > 0 &&
|
||||
timestamps[timestamps.length - 1].verification
|
||||
)
|
||||
)}
|
||||
title={'Completion date'}
|
||||
placement="top"
|
||||
arrow
|
||||
disableInteractive
|
||||
@ -200,26 +169,13 @@ export const UsersDetails = ({ meta }: UsersDetailsProps) => {
|
||||
<span className={styles.detailsItem}>
|
||||
<FontAwesomeIcon icon={faCalendarCheck} />{' '}
|
||||
{completedAt ? formatTimestamp(completedAt) : <>—</>}
|
||||
{signedStatus === SigitStatus.Complete &&
|
||||
completedAt &&
|
||||
timestamps &&
|
||||
timestamps.length > 0 && (
|
||||
<span className={styles.ticket}>
|
||||
{getCompletedOpenTimestampsInfo(
|
||||
timestamps[timestamps.length - 1]
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
{/* User signed date */}
|
||||
{userCanSign ? (
|
||||
<Tooltip
|
||||
title={getTimestampTooltipTitle(
|
||||
'Your signature date',
|
||||
isUserSignatureTimestampVerified()
|
||||
)}
|
||||
title={'Your signature date'}
|
||||
placement="top"
|
||||
arrow
|
||||
disableInteractive
|
||||
@ -239,16 +195,6 @@ export const UsersDetails = ({ meta }: UsersDetailsProps) => {
|
||||
) : (
|
||||
<>—</>
|
||||
)}
|
||||
{hexToNpub(usersPubkey) in parsedSignatureEvents &&
|
||||
timestamps &&
|
||||
timestamps.length > 0 && (
|
||||
<span className={styles.ticket}>
|
||||
{getOpenTimestampsInfo(
|
||||
timestamps,
|
||||
parsedSignatureEvents[hexToNpub(usersPubkey)].id
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
|
@ -31,6 +31,8 @@
|
||||
padding: 5px;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: start;
|
||||
|
||||
> :first-child {
|
||||
padding: 5px;
|
||||
@ -42,7 +44,3 @@
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.ticket {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { Typography } from '@mui/material'
|
||||
import { useAppSelector } from '../hooks/store'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { State } from '../store/rootReducer'
|
||||
|
||||
import styles from './username.module.scss'
|
||||
import { AvatarIconButton } from './UserAvatarIconButton'
|
||||
@ -15,7 +16,7 @@ type Props = {
|
||||
* Clicking will open the menu.
|
||||
*/
|
||||
const Username = ({ username, avatarContent, handleClick }: Props) => {
|
||||
const hexKey = useAppSelector((state) => state.auth.usersPubkey)
|
||||
const hexKey = useSelector((state: State) => state.auth.usersPubkey)
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
|
@ -1,299 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
139
src/controllers/AuthController.ts
Normal file
139
src/controllers/AuthController.ts
Normal file
@ -0,0 +1,139 @@
|
||||
import { EventTemplate } from 'nostr-tools'
|
||||
import { MetadataController, NostrController } from '.'
|
||||
import { appPrivateRoutes } from '../routes'
|
||||
import {
|
||||
setAuthState,
|
||||
setMetadataEvent,
|
||||
setRelayMapAction
|
||||
} from '../store/actions'
|
||||
import store from '../store/store'
|
||||
import { SignedEvent } from '../types'
|
||||
import {
|
||||
base64DecodeAuthToken,
|
||||
base64EncodeSignedEvent,
|
||||
compareObjects,
|
||||
getAuthToken,
|
||||
getRelayMap,
|
||||
getVisitedLink,
|
||||
saveAuthToken,
|
||||
unixNow
|
||||
} from '../utils'
|
||||
|
||||
export class AuthController {
|
||||
private nostrController: NostrController
|
||||
private metadataController: MetadataController
|
||||
|
||||
constructor() {
|
||||
this.nostrController = NostrController.getInstance()
|
||||
this.metadataController = new MetadataController()
|
||||
}
|
||||
|
||||
/**
|
||||
* Function will authenticate user by signing an auth event
|
||||
* which is done by calling the sign() function, where appropriate
|
||||
* method will be chosen (extension, nsecbunker or keys)
|
||||
*
|
||||
* @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 { hostname } = window.location
|
||||
|
||||
const authEvent: EventTemplate = {
|
||||
kind: 27235,
|
||||
tags: [],
|
||||
content: `${hostname}-${timestamp}`,
|
||||
created_at: timestamp
|
||||
}
|
||||
|
||||
const signedAuthEvent = await this.nostrController.signEvent(authEvent)
|
||||
this.createAndSaveAuthToken(signedAuthEvent)
|
||||
|
||||
store.dispatch(
|
||||
setAuthState({
|
||||
loggedIn: true,
|
||||
usersPubkey: pubkey
|
||||
})
|
||||
)
|
||||
|
||||
const relayMap = await getRelayMap(pubkey)
|
||||
|
||||
if (Object.keys(relayMap).length < 1) {
|
||||
// Navigate user to relays page if relay map is empty
|
||||
return Promise.resolve(appPrivateRoutes.relays)
|
||||
}
|
||||
|
||||
if (store.getState().auth?.loggedIn) {
|
||||
if (!compareObjects(store.getState().relays?.map, relayMap.map))
|
||||
store.dispatch(setRelayMapAction(relayMap.map))
|
||||
}
|
||||
|
||||
const currentLocation = window.location.hash.replace('#', '')
|
||||
|
||||
if (!Object.values(appPrivateRoutes).includes(currentLocation)) {
|
||||
// User did change the location to one of the private routes
|
||||
const visitedLink = getVisitedLink()
|
||||
|
||||
if (visitedLink) {
|
||||
const { pathname, search } = visitedLink
|
||||
|
||||
return Promise.resolve(`${pathname}${search}`)
|
||||
} else {
|
||||
// Navigate user in
|
||||
return Promise.resolve(appPrivateRoutes.homePage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
checkSession() {
|
||||
const savedAuthToken = getAuthToken()
|
||||
|
||||
if (savedAuthToken) {
|
||||
const signedEvent = base64DecodeAuthToken(savedAuthToken)
|
||||
|
||||
store.dispatch(
|
||||
setAuthState({
|
||||
loggedIn: true,
|
||||
usersPubkey: signedEvent.pubkey
|
||||
})
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
store.dispatch(
|
||||
setAuthState({
|
||||
loggedIn: false,
|
||||
usersPubkey: undefined
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
private createAndSaveAuthToken(signedAuthEvent: SignedEvent) {
|
||||
const base64Encoded = base64EncodeSignedEvent(signedAuthEvent)
|
||||
|
||||
// save newly created auth token (base64 nostr singed event) in local storage along with expiry time
|
||||
saveAuthToken(base64Encoded)
|
||||
return base64Encoded
|
||||
}
|
||||
}
|
221
src/controllers/MetadataController.ts
Normal file
221
src/controllers/MetadataController.ts
Normal file
@ -0,0 +1,221 @@
|
||||
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 nostrController: NostrController
|
||||
private specialMetadataRelay = 'wss://purplepag.es'
|
||||
private pendingFetches = new Map<string, Promise<Event | null>>() // Track pending fetches
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.nostrController = NostrController.getInstance()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> => {
|
||||
try {
|
||||
const relayEvent =
|
||||
(await findRelayListInCache(hexKey)) ||
|
||||
(await findRelayListAndUpdateCache(DEFAULT_LOOK_UP_RELAY_LIST, hexKey))
|
||||
|
||||
return relayEvent
|
||||
? getUserRelaySet(relayEvent.tags)
|
||||
: getDefaultRelaySet()
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`An error occurred while finding relay list metadata for ${hexKey}`,
|
||||
{ cause: error }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public extractProfileMetadataContent = (event: Event) => {
|
||||
try {
|
||||
if (!event.content) return {}
|
||||
return JSON.parse(event.content) as ProfileMetadata
|
||||
} catch (error) {
|
||||
console.log('error in parsing metadata event content :>> ', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Function will not sign provided event if the SIG exists
|
||||
*/
|
||||
public publishMetadataEvent = async (event: Event) => {
|
||||
let signedMetadataEvent = event
|
||||
|
||||
if (event.sig.length < 1) {
|
||||
const timestamp = unixNow()
|
||||
|
||||
// Metadata event to publish to the wss://purplepag.es relay
|
||||
const newMetadataEvent: Event = {
|
||||
...event,
|
||||
created_at: timestamp
|
||||
}
|
||||
|
||||
signedMetadataEvent =
|
||||
await this.nostrController.signEvent(newMetadataEvent)
|
||||
}
|
||||
|
||||
await relayController
|
||||
.publish(signedMetadataEvent, [this.specialMetadataRelay])
|
||||
.then((relays) => {
|
||||
if (relays.length) {
|
||||
toast.success(`Metadata event published on: ${relays.join('\n')}`)
|
||||
this.handleNewMetadataEvent(signedMetadataEvent as VerifiedEvent)
|
||||
} else {
|
||||
toast.error('Could not publish metadata event to any relay!')
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message)
|
||||
})
|
||||
}
|
||||
|
||||
public validate = (event: Event) => validateEvent(event) && verifyEvent(event)
|
||||
|
||||
public getEmptyMetadataEvent = (pubkey?: string): Event => {
|
||||
return {
|
||||
content: '',
|
||||
created_at: new Date().valueOf(),
|
||||
id: '',
|
||||
kind: 0,
|
||||
pubkey: pubkey || '',
|
||||
sig: '',
|
||||
tags: []
|
||||
}
|
||||
}
|
||||
}
|
@ -1,20 +1,194 @@
|
||||
import { EventTemplate, UnsignedEvent } from 'nostr-tools'
|
||||
import NDK, {
|
||||
NDKEvent,
|
||||
NDKNip46Signer,
|
||||
NDKPrivateKeySigner,
|
||||
NDKUser,
|
||||
NostrEvent
|
||||
} from '@nostr-dev-kit/ndk'
|
||||
import {
|
||||
Event,
|
||||
EventTemplate,
|
||||
UnsignedEvent,
|
||||
finalizeEvent,
|
||||
nip04,
|
||||
nip19,
|
||||
nip44
|
||||
} from 'nostr-tools'
|
||||
import { EventEmitter } from 'tseep'
|
||||
import { updateNsecbunkerPubkey } from '../store/actions'
|
||||
import { AuthState, LoginMethods } from '../store/auth/types'
|
||||
import store from '../store/store'
|
||||
import { SignedEvent } from '../types'
|
||||
import { 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'
|
||||
import { getNsecBunkerDelegatedKey, verifySignedEvent } from '../utils'
|
||||
|
||||
export class NostrController extends EventEmitter {
|
||||
private static instance: NostrController
|
||||
|
||||
private bunkerNDK: NDK | undefined
|
||||
private remoteSigner: NDKNip46Signer | undefined
|
||||
|
||||
private constructor() {
|
||||
super()
|
||||
}
|
||||
|
||||
private getNostrObject = () => {
|
||||
// fix: this is not picking up type declaration from src/system/index.d.ts
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if (window.nostr) return window.nostr as any
|
||||
|
||||
throw new Error(
|
||||
`window.nostr object not present. Make sure you have an nostr extension installed/working properly.`
|
||||
)
|
||||
}
|
||||
|
||||
public nsecBunkerInit = async (relays: string[]) => {
|
||||
// Don't reinstantiate bunker NDK if exists with same relays
|
||||
if (
|
||||
this.bunkerNDK &&
|
||||
this.bunkerNDK.explicitRelayUrls?.length === relays.length &&
|
||||
this.bunkerNDK.explicitRelayUrls?.every((relay) => relays.includes(relay))
|
||||
)
|
||||
return
|
||||
|
||||
this.bunkerNDK = new NDK({
|
||||
explicitRelayUrls: relays
|
||||
})
|
||||
|
||||
try {
|
||||
await this.bunkerNDK
|
||||
.connect(2000)
|
||||
.then(() => {
|
||||
console.log(
|
||||
`Successfully connected to the nsecBunker relays: ${relays.join(
|
||||
','
|
||||
)}`
|
||||
)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(
|
||||
`Error connecting to the nsecBunker relays: ${relays.join(
|
||||
','
|
||||
)} ${err}`
|
||||
)
|
||||
})
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates nSecBunker signer instance for the given npub
|
||||
* Or if npub omitted it will return existing signer
|
||||
* If neither, error will be thrown
|
||||
* @param npub nPub / public key in hex format
|
||||
* @returns nsecBunker Signer instance
|
||||
*/
|
||||
public createNsecBunkerSigner = async (
|
||||
npub: string | undefined
|
||||
): Promise<NDKNip46Signer> => {
|
||||
const nsecBunkerDelegatedKey = getNsecBunkerDelegatedKey()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!nsecBunkerDelegatedKey) {
|
||||
reject('nsecBunker delegated key is not found in the browser.')
|
||||
return
|
||||
}
|
||||
const localSigner = new NDKPrivateKeySigner(nsecBunkerDelegatedKey)
|
||||
|
||||
if (!npub) {
|
||||
if (this.remoteSigner) resolve(this.remoteSigner)
|
||||
|
||||
const npubFromStorage = (store.getState().auth as AuthState)
|
||||
.nsecBunkerPubkey
|
||||
|
||||
if (npubFromStorage) {
|
||||
npub = npubFromStorage
|
||||
} else {
|
||||
reject(
|
||||
'No signer instance present, no npub provided by user or found in the browser.'
|
||||
)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
store.dispatch(updateNsecbunkerPubkey(npub))
|
||||
}
|
||||
|
||||
// Pubkey of a key pair stored in nsecbunker that will be used to sign event with
|
||||
const appPubkeyOrToken = npub.includes('npub')
|
||||
? npub
|
||||
: nip19.npubEncode(npub)
|
||||
|
||||
/**
|
||||
* When creating and NDK instance we create new connection to the relay
|
||||
* To prevent too much connections and hitting rate limits, if npub against which we sign
|
||||
* we will reuse existing instance. Otherwise we will create new NDK and signer instance.
|
||||
*/
|
||||
if (!this.remoteSigner || this.remoteSigner?.remotePubkey !== npub) {
|
||||
this.remoteSigner = new NDKNip46Signer(
|
||||
this.bunkerNDK!,
|
||||
appPubkeyOrToken,
|
||||
localSigner
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* when nsecbunker-delegated-key is regenerated we have to reinitialize the remote signer
|
||||
*/
|
||||
if (this.remoteSigner.localSigner !== localSigner) {
|
||||
this.remoteSigner = new NDKNip46Signer(
|
||||
this.bunkerNDK!,
|
||||
appPubkeyOrToken,
|
||||
localSigner
|
||||
)
|
||||
}
|
||||
|
||||
resolve(this.remoteSigner)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs the nostr event and returns the sig and id or full raw nostr event
|
||||
* @param npub stored in nsecBunker to sign with
|
||||
* @param event to be signed
|
||||
* @param returnFullEvent whether to return full raw nostr event or just SIG and ID values
|
||||
*/
|
||||
public signWithNsecBunker = async (
|
||||
npub: string | undefined,
|
||||
event: NostrEvent,
|
||||
returnFullEvent = true
|
||||
): Promise<{ id: string; sig: string } | NostrEvent> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.createNsecBunkerSigner(npub)
|
||||
.then(async (signer) => {
|
||||
const ndkEvent = new NDKEvent(undefined, event)
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
reject('Timeout occurred while waiting for event signing')
|
||||
}, 60000) // 60000 ms (1 min) = 1000 * 60
|
||||
|
||||
await ndkEvent.sign(signer).catch((err) => {
|
||||
clearTimeout(timeout)
|
||||
reject(err)
|
||||
return
|
||||
})
|
||||
|
||||
clearTimeout(timeout)
|
||||
|
||||
if (returnFullEvent) {
|
||||
resolve(ndkEvent.rawEvent())
|
||||
} else {
|
||||
resolve({
|
||||
id: ndkEvent.id,
|
||||
sig: ndkEvent.sig!
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
public static getInstance(): NostrController {
|
||||
if (!NostrController.instance) {
|
||||
NostrController.instance = new NostrController()
|
||||
@ -32,11 +206,60 @@ export class NostrController extends EventEmitter {
|
||||
*/
|
||||
nip44Encrypt = async (receiver: string, content: string) => {
|
||||
// Retrieve the current login method from the application's redux state.
|
||||
const loginMethod = store.getState().auth.loginMethod
|
||||
const context = new LoginMethodContext(loginMethod)
|
||||
const loginMethod = (store.getState().auth as AuthState).loginMethod
|
||||
|
||||
// Handle encryption when the login method is via an extension.
|
||||
return await context.nip44Encrypt(receiver, content)
|
||||
if (loginMethod === LoginMethods.extension) {
|
||||
const nostr = this.getNostrObject()
|
||||
|
||||
// Check if the nostr object supports NIP-44 encryption.
|
||||
if (!nostr.nip44) {
|
||||
throw new Error(
|
||||
`Your nostr extension does not support nip44 encryption & decryption`
|
||||
)
|
||||
}
|
||||
|
||||
// Encrypt the content using NIP-44 provided by the nostr extension.
|
||||
const encrypted = await nostr.nip44.encrypt(receiver, content)
|
||||
return encrypted as string
|
||||
}
|
||||
|
||||
// Handle encryption when the login method is via a private key.
|
||||
if (loginMethod === LoginMethods.privateKey) {
|
||||
const keys = (store.getState().auth as AuthState).keyPair
|
||||
|
||||
// Check if the private and public key pair is available.
|
||||
if (!keys) {
|
||||
throw new Error(
|
||||
`Login method is ${LoginMethods.privateKey} but private & public key pair is not found.`
|
||||
)
|
||||
}
|
||||
|
||||
// Decode the private key.
|
||||
const { private: nsec } = keys
|
||||
const privateKey = nip19.decode(nsec).data as Uint8Array
|
||||
|
||||
// Generate the conversation key using NIP-44 utilities.
|
||||
const nip44ConversationKey = nip44.v2.utils.getConversationKey(
|
||||
privateKey,
|
||||
receiver
|
||||
)
|
||||
|
||||
// Encrypt the content using the generated conversation key.
|
||||
const encrypted = nip44.v2.encrypt(content, nip44ConversationKey)
|
||||
|
||||
return encrypted
|
||||
}
|
||||
|
||||
// Throw an error if the login method is nsecBunker (not supported).
|
||||
if (loginMethod === LoginMethods.nsecBunker) {
|
||||
throw new Error(
|
||||
`nip44 encryption is not yet supported for login method '${LoginMethods.nsecBunker}'`
|
||||
)
|
||||
}
|
||||
|
||||
// Throw an error if the login method is undefined or unsupported.
|
||||
throw new Error('Login method is undefined')
|
||||
}
|
||||
|
||||
/**
|
||||
@ -49,48 +272,180 @@ export class NostrController extends EventEmitter {
|
||||
*/
|
||||
nip44Decrypt = async (sender: string, content: string) => {
|
||||
// Retrieve the current login method from the application's redux state.
|
||||
const loginMethod = store.getState().auth.loginMethod
|
||||
const context = new LoginMethodContext(loginMethod)
|
||||
const loginMethod = (store.getState().auth as AuthState).loginMethod
|
||||
|
||||
// Handle decryption
|
||||
return await context.nip44Decrypt(sender, content)
|
||||
// Handle decryption when the login method is via an extension.
|
||||
if (loginMethod === LoginMethods.extension) {
|
||||
const nostr = this.getNostrObject()
|
||||
|
||||
// Check if the nostr object supports NIP-44 decryption.
|
||||
if (!nostr.nip44) {
|
||||
throw new Error(
|
||||
`Your nostr extension does not support nip44 encryption & decryption`
|
||||
)
|
||||
}
|
||||
|
||||
// Decrypt the content using NIP-44 provided by the nostr extension.
|
||||
const decrypted = await nostr.nip44.decrypt(sender, content)
|
||||
return decrypted as string
|
||||
}
|
||||
|
||||
// Handle decryption when the login method is via a private key.
|
||||
if (loginMethod === LoginMethods.privateKey) {
|
||||
const keys = (store.getState().auth as AuthState).keyPair
|
||||
|
||||
// Check if the private and public key pair is available.
|
||||
if (!keys) {
|
||||
throw new Error(
|
||||
`Login method is ${LoginMethods.privateKey} but private & public key pair is not found.`
|
||||
)
|
||||
}
|
||||
|
||||
// Decode the private key.
|
||||
const { private: nsec } = keys
|
||||
const privateKey = nip19.decode(nsec).data as Uint8Array
|
||||
|
||||
// Generate the conversation key using NIP-44 utilities.
|
||||
const nip44ConversationKey = nip44.v2.utils.getConversationKey(
|
||||
privateKey,
|
||||
sender
|
||||
)
|
||||
|
||||
// Decrypt the content using the generated conversation key.
|
||||
const decrypted = nip44.v2.decrypt(content, nip44ConversationKey)
|
||||
|
||||
return decrypted
|
||||
}
|
||||
|
||||
// Throw an error if the login method is nsecBunker (not supported).
|
||||
if (loginMethod === LoginMethods.nsecBunker) {
|
||||
throw new Error(
|
||||
`nip44 decryption is not yet supported for login method '${LoginMethods.nsecBunker}'`
|
||||
)
|
||||
}
|
||||
|
||||
// Throw an error if the login method is undefined or unsupported.
|
||||
throw new Error('Login method is undefined')
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs an event with private key (if it is present in local storage) or
|
||||
* with browser extension (if it is present)
|
||||
* with browser extension (if it is present) or
|
||||
* with nSecBunker instance.
|
||||
* @param event - unsigned nostr event.
|
||||
* @returns - a promised that is resolved with signed nostr event.
|
||||
*/
|
||||
signEvent = async (
|
||||
event: UnsignedEvent | EventTemplate
|
||||
): Promise<SignedEvent> => {
|
||||
const loginMethod = store.getState().auth.loginMethod
|
||||
const context = new LoginMethodContext(loginMethod)
|
||||
const loginMethod = (store.getState().auth as AuthState).loginMethod
|
||||
|
||||
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.')
|
||||
if (!loginMethod) {
|
||||
return Promise.reject('No login method found in the browser storage')
|
||||
}
|
||||
|
||||
return signedEvent
|
||||
if (loginMethod === LoginMethods.nsecBunker) {
|
||||
// Check if nsecBunker is available
|
||||
if (!this.bunkerNDK) {
|
||||
return Promise.reject(
|
||||
`Login method is ${loginMethod} but bunkerNDK is not created`
|
||||
)
|
||||
}
|
||||
|
||||
if (!this.remoteSigner) {
|
||||
return Promise.reject(
|
||||
`Login method is ${loginMethod} but bunkerNDK is not created`
|
||||
)
|
||||
}
|
||||
|
||||
const signedEvent = await this.signWithNsecBunker(
|
||||
'',
|
||||
event as NostrEvent
|
||||
).catch((err) => {
|
||||
throw err
|
||||
})
|
||||
|
||||
return Promise.resolve(signedEvent as SignedEvent)
|
||||
} else if (loginMethod === LoginMethods.privateKey) {
|
||||
const keys = (store.getState().auth as AuthState).keyPair
|
||||
|
||||
if (!keys) {
|
||||
return Promise.reject(
|
||||
`Login method is ${loginMethod}, but keys are not found`
|
||||
)
|
||||
}
|
||||
|
||||
const { private: nsec } = keys
|
||||
const privateKey = nip19.decode(nsec).data as Uint8Array
|
||||
|
||||
const signedEvent = finalizeEvent(event, privateKey)
|
||||
|
||||
verifySignedEvent(signedEvent)
|
||||
|
||||
return Promise.resolve(signedEvent)
|
||||
} else if (loginMethod === LoginMethods.extension) {
|
||||
const nostr = this.getNostrObject()
|
||||
|
||||
return (await nostr
|
||||
.signEvent(event as NostrEvent)
|
||||
.catch((err: unknown) => {
|
||||
console.log('Error while signing event: ', err)
|
||||
|
||||
throw err
|
||||
})) as Event
|
||||
} else {
|
||||
return Promise.reject(
|
||||
`We could not sign the event, none of the signing methods are available`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
nip04Encrypt = async (receiver: string, content: string): Promise<string> => {
|
||||
const loginMethod = store.getState().auth.loginMethod
|
||||
const context = new LoginMethodContext(loginMethod)
|
||||
const loginMethod = (store.getState().auth as AuthState).loginMethod
|
||||
|
||||
return await context.nip04Encrypt(receiver, content)
|
||||
if (loginMethod === LoginMethods.extension) {
|
||||
const nostr = this.getNostrObject()
|
||||
|
||||
if (!nostr.nip04) {
|
||||
throw new Error(
|
||||
`Your nostr extension does not support nip04 encryption & decryption`
|
||||
)
|
||||
}
|
||||
|
||||
const encrypted = await nostr.nip04.encrypt(receiver, content)
|
||||
return encrypted
|
||||
}
|
||||
|
||||
if (loginMethod === LoginMethods.privateKey) {
|
||||
const keys = (store.getState().auth as AuthState).keyPair
|
||||
|
||||
if (!keys) {
|
||||
throw new Error(
|
||||
`Login method is ${LoginMethods.privateKey} but private & public key pair is not found.`
|
||||
)
|
||||
}
|
||||
|
||||
const { private: nsec } = keys
|
||||
const privateKey = nip19.decode(nsec).data as Uint8Array
|
||||
|
||||
const encrypted = await nip04.encrypt(privateKey, receiver, content)
|
||||
return encrypted
|
||||
}
|
||||
|
||||
if (loginMethod === LoginMethods.nsecBunker) {
|
||||
const user = new NDKUser({ pubkey: receiver })
|
||||
|
||||
this.remoteSigner?.on('authUrl', (authUrl) => {
|
||||
this.emit('nsecbunker-auth', authUrl)
|
||||
})
|
||||
|
||||
if (!this.remoteSigner) throw new Error('Remote signer is undefined.')
|
||||
const encrypted = await this.remoteSigner.encrypt(user, content)
|
||||
|
||||
return encrypted
|
||||
}
|
||||
|
||||
throw new Error('Login method is undefined')
|
||||
}
|
||||
|
||||
/**
|
||||
@ -101,44 +456,79 @@ export class NostrController extends EventEmitter {
|
||||
* @returns A promise that resolves to the decrypted content.
|
||||
*/
|
||||
nip04Decrypt = async (sender: string, content: string): Promise<string> => {
|
||||
const loginMethod = store.getState().auth.loginMethod
|
||||
const context = new LoginMethodContext(loginMethod)
|
||||
const loginMethod = (store.getState().auth as AuthState).loginMethod
|
||||
|
||||
return await context.nip04Decrypt(sender, content)
|
||||
if (loginMethod === LoginMethods.extension) {
|
||||
const nostr = this.getNostrObject()
|
||||
|
||||
if (!nostr.nip04) {
|
||||
throw new Error(
|
||||
`Your nostr extension does not support nip04 encryption & decryption`
|
||||
)
|
||||
}
|
||||
|
||||
const decrypted = await nostr.nip04.decrypt(sender, content)
|
||||
return decrypted
|
||||
}
|
||||
|
||||
if (loginMethod === LoginMethods.privateKey) {
|
||||
const keys = (store.getState().auth as AuthState).keyPair
|
||||
|
||||
if (!keys) {
|
||||
throw new Error(
|
||||
`Login method is ${LoginMethods.privateKey} but private & public key pair is not found.`
|
||||
)
|
||||
}
|
||||
|
||||
const { private: nsec } = keys
|
||||
const privateKey = nip19.decode(nsec).data as Uint8Array
|
||||
|
||||
const decrypted = await nip04.decrypt(privateKey, sender, content)
|
||||
return decrypted
|
||||
}
|
||||
|
||||
if (loginMethod === LoginMethods.nsecBunker) {
|
||||
const user = new NDKUser({ pubkey: sender })
|
||||
|
||||
this.remoteSigner?.on('authUrl', (authUrl) => {
|
||||
this.emit('nsecbunker-auth', authUrl)
|
||||
})
|
||||
|
||||
if (!this.remoteSigner) throw new Error('Remote signer is undefined.')
|
||||
const decrypted = await this.remoteSigner.decrypt(user, content)
|
||||
|
||||
return decrypted
|
||||
}
|
||||
|
||||
throw new Error('Login method is undefined')
|
||||
}
|
||||
|
||||
/**
|
||||
* Function will capture the public key from signedEvent
|
||||
* 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
|
||||
*/
|
||||
capturePublicKey = async (): Promise<string> => {
|
||||
try {
|
||||
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.signEvent(authEvent)
|
||||
const pubkey = signedAuthEvent.pubkey
|
||||
|
||||
if (!pubkey) {
|
||||
return Promise.reject('Error getting public key, user canceled')
|
||||
}
|
||||
|
||||
return Promise.resolve(pubkey)
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
return Promise.reject(error.message)
|
||||
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(error))
|
||||
return Promise.reject(JSON.stringify(err))
|
||||
}
|
||||
})
|
||||
|
||||
if (!pubKey) {
|
||||
return Promise.reject('Error getting public key, user canceled')
|
||||
}
|
||||
|
||||
return Promise.resolve(pubKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates NDK Private Signer
|
||||
* @returns nSecBunker delegated key
|
||||
*/
|
||||
generateDelegatedKey = (): string => {
|
||||
return NDKPrivateKeySigner.generate().privateKey!
|
||||
}
|
||||
}
|
||||
|
296
src/controllers/RelayController.ts
Normal file
296
src/controllers/RelayController.ts
Normal file
@ -0,0 +1,296 @@
|
||||
import { Event, Filter, Relay } from 'nostr-tools'
|
||||
import {
|
||||
settleAllFullfilfedPromises,
|
||||
normalizeWebSocketURL,
|
||||
publishToRelay,
|
||||
isPromiseFulfilled
|
||||
} 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[]> => {
|
||||
/**
|
||||
* Ensure that the default Sigit Relay is included.
|
||||
* Copy the array instead of mutating it.
|
||||
*/
|
||||
const updatedRelayUrls = !relayUrls.includes(SIGIT_RELAY)
|
||||
? [...relayUrls, SIGIT_RELAY]
|
||||
: [...relayUrls]
|
||||
|
||||
// connect to all specified relays
|
||||
const relays = await settleAllFullfilfedPromises(
|
||||
updatedRelayUrls,
|
||||
this.connectRelay
|
||||
)
|
||||
|
||||
// Check if any relays are connected
|
||||
if (relays.length === 0) {
|
||||
throw new Error('No relay is connected to publish event!')
|
||||
}
|
||||
|
||||
const settledPromises: PromiseSettledResult<string>[] =
|
||||
await Promise.allSettled(
|
||||
relays.map(async (relay) => publishToRelay(relay, event))
|
||||
)
|
||||
|
||||
// Return the list of relay URLs where the event was published
|
||||
return settledPromises
|
||||
.filter(isPromiseFulfilled)
|
||||
.map((res) => res.value) as string[]
|
||||
}
|
||||
}
|
||||
|
||||
export const relayController = RelayController.getInstance()
|
@ -1 +1,4 @@
|
||||
export * from './AuthController'
|
||||
export * from './MetadataController'
|
||||
export * from './NostrController'
|
||||
export * from './RelayController'
|
||||
|
@ -19,7 +19,7 @@
|
||||
"page": 1
|
||||
},
|
||||
"npub": "npub1x77qywdllzetv9ncnhlfpv62kshlgtt0uqlsq3v22uzzkk2xvvrsn6uyfy",
|
||||
"fileHash": "da5f857e77d3aa59c461efad804116931c059b36e6b4da0b5d9452753ec70c05"
|
||||
"pdfFileHash": "da5f857e77d3aa59c461efad804116931c059b36e6b4da0b5d9452753ec70c05"
|
||||
}
|
||||
],
|
||||
"da5f857e77d3aa59c461efad804116931c059b36e6b4da0b5d9452753ec70c05/2.png": [
|
||||
@ -34,7 +34,7 @@
|
||||
"page": 2
|
||||
},
|
||||
"npub": "npub1x77qywdllzetv9ncnhlfpv62kshlgtt0uqlsq3v22uzzkk2xvvrsn6uyfy",
|
||||
"fileHash": "da5f857e77d3aa59c461efad804116931c059b36e6b4da0b5d9452753ec70c05"
|
||||
"pdfFileHash": "da5f857e77d3aa59c461efad804116931c059b36e6b4da0b5d9452753ec70c05"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -54,7 +54,7 @@
|
||||
"page": 1
|
||||
},
|
||||
"npub": "npub1x77qywdllzetv9ncnhlfpv62kshlgtt0uqlsq3v22uzzkk2xvvrsn6uyfy",
|
||||
"fileHash": "da5f857e77d3aa59c461efad804116931c059b36e6b4da0b5d9452753ec70c05",
|
||||
"pdfFileHash": "da5f857e77d3aa59c461efad804116931c059b36e6b4da0b5d9452753ec70c05",
|
||||
"value": "Pera Peric"
|
||||
},
|
||||
{
|
||||
@ -68,7 +68,7 @@
|
||||
"page": 2
|
||||
},
|
||||
"npub": "npub1x77qywdllzetv9ncnhlfpv62kshlgtt0uqlsq3v22uzzkk2xvvrsn6uyfy",
|
||||
"fileHash": "da5f857e77d3aa59c461efad804116931c059b36e6b4da0b5d9452753ec70c05",
|
||||
"pdfFileHash": "da5f857e77d3aa59c461efad804116931c059b36e6b4da0b5d9452753ec70c05",
|
||||
"value": "Pera Peric"
|
||||
}
|
||||
]
|
||||
|
@ -1,7 +1,2 @@
|
||||
export * from './store'
|
||||
export * from './useAuth'
|
||||
export * from './useDidMount'
|
||||
export * from './useDvm'
|
||||
export * from './useLogout'
|
||||
export * from './useNDK'
|
||||
export * from './useNDKContext'
|
||||
|
@ -1,127 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
@ -1,98 +0,0 @@
|
||||
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 }
|
||||
}
|
@ -1,26 +0,0 @@
|
||||
import { logout as nostrLogout } from 'nostr-login'
|
||||
import { clear } from '../utils/localStorage'
|
||||
import { userLogOutAction } from '../store/actions'
|
||||
import { LoginMethod } from '../store/auth/types'
|
||||
import { useAppDispatch, useAppSelector } from './store'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
export const useLogout = () => {
|
||||
const loginMethod = useAppSelector((state) => state.auth?.loginMethod)
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const logout = useCallback(() => {
|
||||
// Log out of the nostr-login
|
||||
if (loginMethod === LoginMethod.nostrLogin) {
|
||||
nostrLogout()
|
||||
}
|
||||
|
||||
// Reset redux state with the logout
|
||||
dispatch(userLogOutAction())
|
||||
|
||||
// Clear the local storage states
|
||||
clear()
|
||||
}, [dispatch, loginMethod])
|
||||
|
||||
return logout
|
||||
}
|
@ -1,512 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
@ -1,13 +0,0 @@
|
||||
import { NDKContext, NDKContextType } from '../contexts/NDKContext'
|
||||
import { useContext } from 'react'
|
||||
|
||||
export const useNDKContext = () => {
|
||||
const ndkContext = useContext(NDKContext)
|
||||
|
||||
if (!ndkContext)
|
||||
throw new Error(
|
||||
'NDKContext should not be used in out component tree hierarchy'
|
||||
)
|
||||
|
||||
return { ...ndkContext } as NDKContextType
|
||||
}
|
@ -1,27 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { NDKUserProfile } from '@nostr-dev-kit/ndk'
|
||||
import { useNDKContext } from './useNDKContext'
|
||||
|
||||
export const useProfileMetadata = (pubkey: string) => {
|
||||
const { findMetadata } = useNDKContext()
|
||||
|
||||
const [userProfile, setUserProfile] = useState<NDKUserProfile>()
|
||||
|
||||
useEffect(() => {
|
||||
if (pubkey) {
|
||||
findMetadata(pubkey)
|
||||
.then((profile) => {
|
||||
if (profile) setUserProfile(profile)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(
|
||||
`error occurred in finding metadata for: ${pubkey}`,
|
||||
err
|
||||
)
|
||||
})
|
||||
}
|
||||
}, [pubkey, findMetadata])
|
||||
|
||||
return userProfile
|
||||
}
|
@ -1,5 +1,10 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { DocSignatureEvent, Meta, SignedEventContent, FlatMeta } from '../types'
|
||||
import {
|
||||
CreateSignatureEventContent,
|
||||
DocSignatureEvent,
|
||||
Meta,
|
||||
SignedEventContent
|
||||
} from '../types'
|
||||
import { Mark } from '../types/mark'
|
||||
import {
|
||||
fromUnixTimestamp,
|
||||
@ -11,10 +16,46 @@ import {
|
||||
} from '../utils'
|
||||
import { toast } from 'react-toastify'
|
||||
import { verifyEvent } from 'nostr-tools'
|
||||
import { Event } from 'nostr-tools'
|
||||
import store from '../store/store'
|
||||
import { AuthState } from '../store/auth/types'
|
||||
import { NostrController } from '../controllers'
|
||||
import { MetaParseError } from '../types/errors/MetaParseError'
|
||||
import { MARK_TYPE_CONFIG } from '../components/MarkTypeStrategy/MarkStrategy'
|
||||
|
||||
/**
|
||||
* Flattened interface that combines properties `Meta`, `CreateSignatureEventContent`,
|
||||
* and `Event` (event's fields are made optional and pubkey and created_at replaced with our versions)
|
||||
*/
|
||||
export interface FlatMeta
|
||||
extends Meta,
|
||||
CreateSignatureEventContent,
|
||||
Partial<Omit<Event, 'pubkey' | 'created_at'>> {
|
||||
// Remove pubkey and use submittedBy as `npub1${string}`
|
||||
submittedBy?: `npub1${string}`
|
||||
|
||||
// Remove created_at and replace with createdAt
|
||||
createdAt?: number
|
||||
|
||||
// Validated create signature event
|
||||
isValid: boolean
|
||||
|
||||
// Decryption
|
||||
encryptionKey: string | null
|
||||
|
||||
// Parsed Document Signatures
|
||||
parsedSignatureEvents: {
|
||||
[signer: `npub1${string}`]: DocSignatureEvent
|
||||
}
|
||||
|
||||
// Calculated completion time
|
||||
completedAt?: number
|
||||
|
||||
// Calculated status fields
|
||||
signedStatus: SigitStatus
|
||||
signersStatus: {
|
||||
[signer: `npub1${string}`]: SignStatus
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom use hook for parsing the Sigit Meta
|
||||
@ -26,8 +67,7 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
|
||||
const [kind, setKind] = useState<number>()
|
||||
const [tags, setTags] = useState<string[][]>()
|
||||
const [createdAt, setCreatedAt] = useState<number>()
|
||||
const [submittedBy, setSubmittedBy] = useState<string>() // submittedBy, pubkey from nostr event (hex)
|
||||
const [exportedBy, setExportedBy] = useState<string>() // pubkey from export signature nostr event (hex)
|
||||
const [submittedBy, setSubmittedBy] = useState<`npub1${string}`>() // submittedBy, pubkey from nostr event
|
||||
const [id, setId] = useState<string>()
|
||||
const [sig, setSig] = useState<string>()
|
||||
|
||||
@ -53,23 +93,13 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
|
||||
[signer: `npub1${string}`]: SignStatus
|
||||
}>({})
|
||||
|
||||
const [encryptionKey, setEncryptionKey] = useState<string | undefined>()
|
||||
const [encryptionKey, setEncryptionKey] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!meta) return
|
||||
;(async function () {
|
||||
try {
|
||||
if (meta.exportSignature) {
|
||||
const exportSignatureEvent = parseNostrEvent(meta.exportSignature)
|
||||
if (
|
||||
verifyEvent(exportSignatureEvent) &&
|
||||
exportSignatureEvent.pubkey
|
||||
) {
|
||||
setExportedBy(exportSignatureEvent.pubkey)
|
||||
}
|
||||
}
|
||||
|
||||
const createSignatureEvent = parseNostrEvent(meta.createSignature)
|
||||
const createSignatureEvent = await parseNostrEvent(meta.createSignature)
|
||||
|
||||
const { kind, tags, created_at, pubkey, id, sig, content } =
|
||||
createSignatureEvent
|
||||
@ -79,12 +109,12 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
|
||||
setTags(tags)
|
||||
// created_at in nostr events are stored in seconds
|
||||
setCreatedAt(fromUnixTimestamp(created_at))
|
||||
setSubmittedBy(pubkey)
|
||||
setSubmittedBy(pubkey as `npub1${string}`)
|
||||
setId(id)
|
||||
setSig(sig)
|
||||
|
||||
const { title, signers, viewers, fileHashes, markConfig, zipUrl } =
|
||||
parseCreateSignatureEventContent(content)
|
||||
await parseCreateSignatureEventContent(content)
|
||||
|
||||
setTitle(title)
|
||||
setSigners(signers)
|
||||
@ -93,11 +123,10 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
|
||||
setMarkConfig(markConfig)
|
||||
setZipUrl(zipUrl)
|
||||
|
||||
let encryptionKey: string | undefined
|
||||
if (meta.keys) {
|
||||
const { sender, keys } = meta.keys
|
||||
// Retrieve the user's public key from the state
|
||||
const usersPubkey = store.getState().auth.usersPubkey!
|
||||
const usersPubkey = (store.getState().auth as AuthState).usersPubkey!
|
||||
const usersNpub = hexToNpub(usersPubkey)
|
||||
|
||||
// Check if the user's public key is in the keys object
|
||||
@ -111,13 +140,13 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
|
||||
'An error occurred in decrypting encryption key',
|
||||
err
|
||||
)
|
||||
return undefined
|
||||
return null
|
||||
})
|
||||
|
||||
encryptionKey = decrypted
|
||||
setEncryptionKey(decrypted)
|
||||
}
|
||||
}
|
||||
|
||||
// Temp. map to hold events and signers
|
||||
const parsedSignatureEventsMap = new Map<
|
||||
`npub1${string}`,
|
||||
@ -159,40 +188,13 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
|
||||
}
|
||||
}
|
||||
|
||||
for (const [npub, event] of parsedSignatureEventsMap) {
|
||||
parsedSignatureEventsMap.forEach((event, npub) => {
|
||||
const isValidSignature = verifyEvent(event)
|
||||
if (isValidSignature) {
|
||||
// get the signature of prev signer from the content of current signers signedEvent
|
||||
const prevSignersSig = getPrevSignerSig(npub)
|
||||
|
||||
try {
|
||||
const obj: SignedEventContent = JSON.parse(event.content)
|
||||
|
||||
// Signature object can include values that need to be fetched and decrypted
|
||||
for (let i = 0; i < obj.marks.length; i++) {
|
||||
const m = obj.marks[i]
|
||||
|
||||
try {
|
||||
const { fetchAndDecrypt } = MARK_TYPE_CONFIG[m.type] || {}
|
||||
if (
|
||||
typeof fetchAndDecrypt === 'function' &&
|
||||
m.value &&
|
||||
encryptionKey
|
||||
) {
|
||||
const decrypted = await fetchAndDecrypt(
|
||||
m.value,
|
||||
encryptionKey
|
||||
)
|
||||
obj.marks[i].value = decrypted
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error during mark fetchAndDecrypt phase`,
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
parsedSignatureEventsMap.set(npub, {
|
||||
...event,
|
||||
parsedContent: obj
|
||||
@ -208,7 +210,7 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
|
||||
signerStatusMap.set(npub as `npub1${string}`, SignStatus.Invalid)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
signers
|
||||
.filter((s) => !parsedSignatureEventsMap.has(s))
|
||||
@ -258,13 +260,11 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
|
||||
createSignature: meta?.createSignature,
|
||||
docSignatures: meta?.docSignatures,
|
||||
keys: meta?.keys,
|
||||
timestamps: meta?.timestamps,
|
||||
isValid,
|
||||
kind,
|
||||
tags,
|
||||
createdAt,
|
||||
submittedBy,
|
||||
exportedBy,
|
||||
id,
|
||||
sig,
|
||||
signers,
|
||||
|
71
src/hooks/useSigitProfiles.tsx
Normal file
71
src/hooks/useSigitProfiles.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ProfileMetadata } from '../types'
|
||||
import { MetadataController } from '../controllers'
|
||||
import { npubToHex } from '../utils'
|
||||
import { Event, kinds } from 'nostr-tools'
|
||||
|
||||
/**
|
||||
* Extracts profiles from metadata events
|
||||
* @param pubkeys Array of npubs to check
|
||||
* @returns ProfileMetadata
|
||||
*/
|
||||
export const useSigitProfiles = (
|
||||
pubkeys: `npub1${string}`[]
|
||||
): { [key: string]: ProfileMetadata } => {
|
||||
const [profileMetadata, setProfileMetadata] = useState<{
|
||||
[key: string]: ProfileMetadata
|
||||
}>({})
|
||||
|
||||
useEffect(() => {
|
||||
if (pubkeys.length) {
|
||||
const metadataController = new MetadataController()
|
||||
|
||||
// Remove duplicate keys
|
||||
const users = new Set<string>([...pubkeys])
|
||||
|
||||
const handleMetadataEvent = (key: string) => (event: Event) => {
|
||||
const metadataContent =
|
||||
metadataController.extractProfileMetadataContent(event)
|
||||
|
||||
if (metadataContent) {
|
||||
setProfileMetadata((prev) => ({
|
||||
...prev,
|
||||
[key]: metadataContent
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
users.forEach((user) => {
|
||||
const hexKey = npubToHex(user)
|
||||
if (hexKey && !(hexKey in profileMetadata)) {
|
||||
metadataController.on(hexKey, (kind: number, event: Event) => {
|
||||
if (kind === kinds.Metadata) {
|
||||
handleMetadataEvent(hexKey)(event)
|
||||
}
|
||||
})
|
||||
|
||||
metadataController
|
||||
.findMetadata(hexKey)
|
||||
.then((metadataEvent) => {
|
||||
if (metadataEvent) handleMetadataEvent(hexKey)(metadataEvent)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(
|
||||
`error occurred in finding metadata for: ${user}`,
|
||||
err
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
users.forEach((key) => {
|
||||
metadataController.off(key, handleMetadataEvent(key))
|
||||
})
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pubkeys])
|
||||
|
||||
return profileMetadata
|
||||
}
|
@ -27,7 +27,7 @@ body {
|
||||
position: fixed;
|
||||
top: 80px;
|
||||
right: 20px;
|
||||
z-index: 40;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
#root {
|
||||
|
@ -1,171 +1,100 @@
|
||||
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 { Event, kinds } from 'nostr-tools'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import { AppBar } from '../components/AppBar/AppBar'
|
||||
import { LoadingSpinner } from '../components/LoadingSpinner'
|
||||
|
||||
import { NostrController } from '../controllers'
|
||||
|
||||
import {
|
||||
useAppDispatch,
|
||||
useAppSelector,
|
||||
useAuth,
|
||||
useLogout,
|
||||
useNDK,
|
||||
useNDKContext
|
||||
} from '../hooks'
|
||||
|
||||
import { MetadataController, NostrController } from '../controllers'
|
||||
import {
|
||||
restoreState,
|
||||
setUserProfile,
|
||||
updateKeyPair,
|
||||
updateLoginMethod,
|
||||
updateNostrLoginAuthMethod,
|
||||
updateUserAppData,
|
||||
setUserRobotImage
|
||||
setAuthState,
|
||||
setMetadataEvent,
|
||||
updateUserAppData
|
||||
} from '../store/actions'
|
||||
import { LoginMethod } from '../store/auth/types'
|
||||
|
||||
import { getRoboHashPicture, loadState } from '../utils'
|
||||
|
||||
import { LoginMethods } from '../store/auth/types'
|
||||
import { State } from '../store/rootReducer'
|
||||
import { Dispatch } from '../store/store'
|
||||
import { setUserRobotImage } from '../store/userRobotImage/action'
|
||||
import {
|
||||
clearAuthToken,
|
||||
clearState,
|
||||
getRoboHashPicture,
|
||||
getUsersAppData,
|
||||
loadState,
|
||||
saveNsecBunkerDelegatedKey,
|
||||
subscribeForSigits
|
||||
} from '../utils'
|
||||
import { useAppSelector } from '../hooks'
|
||||
import styles from './style.module.scss'
|
||||
import { Footer } from '../components/Footer/Footer'
|
||||
|
||||
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 dispatch: Dispatch = useDispatch()
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState(`Loading App`)
|
||||
const isLoggedIn = useAppSelector((state) => state.auth?.loggedIn)
|
||||
const authState = useAppSelector((state) => state.auth)
|
||||
const authState = useSelector((state: State) => state.auth)
|
||||
const usersAppData = useAppSelector((state) => state.userAppData)
|
||||
|
||||
// Ref to track if `subscribeForSigits` has been called
|
||||
const hasSubscribed = useRef(false)
|
||||
|
||||
const navigateAfterLogin = (path: string) => {
|
||||
const callbackPath = searchParams.get('callbackPath')
|
||||
|
||||
if (callbackPath) {
|
||||
// base64 decoded path
|
||||
const path = atob(callbackPath)
|
||||
navigate(path)
|
||||
return
|
||||
}
|
||||
|
||||
navigate(path)
|
||||
}
|
||||
|
||||
const login = useCallback(async () => {
|
||||
dispatch(updateLoginMethod(LoginMethod.nostrLogin))
|
||||
|
||||
const nostrController = NostrController.getInstance()
|
||||
const pubkey = await nostrController.capturePublicKey()
|
||||
|
||||
const redirectPath = await authAndGetMetadataAndRelaysMap(pubkey)
|
||||
|
||||
if (redirectPath) {
|
||||
navigateAfterLogin(redirectPath)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dispatch])
|
||||
|
||||
useEffect(() => {
|
||||
// Developer login with ?nsec= (not recommended)
|
||||
const nsec = searchParams.get('nsec')
|
||||
if (!nsec) return
|
||||
const metadataController = new MetadataController()
|
||||
|
||||
// Clear nsec from the url immediately
|
||||
searchParams.delete('nsec')
|
||||
setSearchParams(searchParams)
|
||||
|
||||
if (!authState?.loggedIn) {
|
||||
if (!nsec.startsWith('nsec')) {
|
||||
console.error('Invalid format, use private key (nsec)')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const privateKey = nip19.decode(nsec).data as Uint8Array
|
||||
if (!privateKey) {
|
||||
console.error('Failed to convert the private key.')
|
||||
return
|
||||
}
|
||||
|
||||
const publickey = getPublicKey(privateKey)
|
||||
|
||||
dispatch(
|
||||
updateKeyPair({
|
||||
private: nsec,
|
||||
public: publickey
|
||||
})
|
||||
)
|
||||
dispatch(updateLoginMethod(LoginMethod.privateKey))
|
||||
|
||||
authAndGetMetadataAndRelaysMap(publickey).catch((err) => {
|
||||
console.error('Error occurred in authentication: ' + err)
|
||||
return null
|
||||
const logout = () => {
|
||||
dispatch(
|
||||
setAuthState({
|
||||
keyPair: undefined,
|
||||
loggedIn: false,
|
||||
usersPubkey: undefined,
|
||||
loginMethod: undefined,
|
||||
nsecBunkerPubkey: undefined
|
||||
})
|
||||
} catch (err) {
|
||||
console.error(`Error decoding the nsec. ${err}`)
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dispatch, searchParams])
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const handleNostrAuth = (_: string, opts: NostrLoginAuthOptions) => {
|
||||
if (opts.type === 'login' || opts.type === 'signup') {
|
||||
dispatch(updateNostrLoginAuthMethod(opts.method))
|
||||
login()
|
||||
} else if (opts.type === 'logout') {
|
||||
// Clear `subscribeForSigits` as called after the logout
|
||||
hasSubscribed.current = false
|
||||
}
|
||||
dispatch(setMetadataEvent(metadataController.getEmptyMetadataEvent()))
|
||||
|
||||
// clear authToken saved in local storage
|
||||
clearAuthToken()
|
||||
clearState()
|
||||
|
||||
// update nsecBunker delegated key
|
||||
const newDelegatedKey =
|
||||
NostrController.getInstance().generateDelegatedKey()
|
||||
saveNsecBunkerDelegatedKey(newDelegatedKey)
|
||||
}
|
||||
|
||||
// Initialize the nostr-login
|
||||
initNostrLogin({
|
||||
methods: ['connect', 'extension', 'local'],
|
||||
noBanner: true,
|
||||
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)
|
||||
})
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dispatch])
|
||||
|
||||
useEffect(() => {
|
||||
const restoredState = loadState()
|
||||
if (restoredState) {
|
||||
dispatch(restoreState(restoredState))
|
||||
|
||||
const { loggedIn, loginMethod, usersPubkey } = restoredState.auth
|
||||
const { loggedIn, loginMethod, usersPubkey, nsecBunkerRelays } =
|
||||
restoredState.auth
|
||||
|
||||
if (loggedIn) {
|
||||
if (!loginMethod || !usersPubkey) return logout()
|
||||
|
||||
findMetadata(usersPubkey).then((profile) => {
|
||||
dispatch(setUserProfile(profile))
|
||||
if (loginMethod === LoginMethods.nsecBunker) {
|
||||
if (!nsecBunkerRelays) return logout()
|
||||
|
||||
const nostrController = NostrController.getInstance()
|
||||
nostrController.nsecBunkerInit(nsecBunkerRelays).then(() => {
|
||||
nostrController.createNsecBunkerSigner(usersPubkey)
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
} else {
|
||||
setIsLoading(false)
|
||||
@ -173,26 +102,21 @@ export const MainLayout = () => {
|
||||
} else {
|
||||
setIsLoading(false)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dispatch])
|
||||
|
||||
/**
|
||||
* Subscribe for the sigits
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (authState && isLoggedIn && usersAppData) {
|
||||
if (authState.loggedIn && usersAppData) {
|
||||
const pubkey = authState.usersPubkey || authState.keyPair?.public
|
||||
|
||||
if (pubkey && !hasSubscribed.current) {
|
||||
// Call `subscribeForSigits` only if it hasn't been called before
|
||||
// #193 disabled websocket subscribtion, until #194 is done
|
||||
subscribeForSigits(pubkey)
|
||||
|
||||
// Mark `subscribeForSigits` as called
|
||||
hasSubscribed.current = true
|
||||
}
|
||||
}
|
||||
}, [authState, isLoggedIn, usersAppData, subscribeForSigits])
|
||||
}, [authState, usersAppData])
|
||||
|
||||
/**
|
||||
* When authState change user logged in / or app reloaded
|
||||
@ -200,7 +124,7 @@ export const MainLayout = () => {
|
||||
* so that avatar will be consistent across the app when kind 0 is empty
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (authState && isLoggedIn) {
|
||||
if (authState && authState.loggedIn) {
|
||||
const pubkey = authState.usersPubkey || authState.keyPair?.public
|
||||
|
||||
if (pubkey) {
|
||||
@ -217,8 +141,7 @@ export const MainLayout = () => {
|
||||
})
|
||||
.finally(() => setIsLoading(false))
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dispatch, isLoggedIn])
|
||||
}, [authState, dispatch])
|
||||
|
||||
if (isLoading) return <LoadingSpinner desc={loadingSpinnerDesc} />
|
||||
|
||||
@ -237,6 +160,7 @@ export const MainLayout = () => {
|
||||
>
|
||||
<Outlet />
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -3,33 +3,9 @@
|
||||
|
||||
.container {
|
||||
display: grid;
|
||||
|
||||
@media only screen and (max-width: 767px) {
|
||||
gap: 20px;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: 100%;
|
||||
|
||||
// Hide Scrollbar and let's use tabs to navigate
|
||||
-ms-overflow-style: none; /* Internet Explorer 10+ */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
&::-webkit-scrollbar {
|
||||
display: none; /* Safari and Chrome */
|
||||
}
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-inline: contain;
|
||||
scroll-snap-type: inline mandatory;
|
||||
|
||||
> * {
|
||||
scroll-margin-top: $header-height + $body-vertical-padding;
|
||||
scroll-snap-align: start;
|
||||
scroll-snap-stop: always; // Touch devices will always stop on each element
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 768px) {
|
||||
grid-template-columns: 0.75fr 1.5fr 0.75fr;
|
||||
gap: 30px;
|
||||
}
|
||||
grid-template-columns: 0.75fr 1.5fr 0.75fr;
|
||||
grid-gap: 30px;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.sidesWrap {
|
||||
@ -40,65 +16,17 @@
|
||||
}
|
||||
|
||||
.sides {
|
||||
@media only screen and (min-width: 768px) {
|
||||
position: sticky;
|
||||
top: $body-vertical-padding;
|
||||
}
|
||||
> :first-child {
|
||||
// We want to keep header on smaller devices at all times
|
||||
max-height: calc(
|
||||
100dvh - $header-height - $body-vertical-padding * 2 - $tabs-height
|
||||
);
|
||||
|
||||
@media only screen and (min-width: 768px) {
|
||||
max-height: calc(100dvh - $body-vertical-padding * 2);
|
||||
}
|
||||
}
|
||||
position: sticky;
|
||||
top: $header-height + $body-vertical-padding;
|
||||
}
|
||||
|
||||
// Adjust the content scroll on smaller screens
|
||||
// Make sure only the inner tab is scrolling
|
||||
.scrollAdjust {
|
||||
@media only screen and (max-width: 767px) {
|
||||
max-height: calc(
|
||||
100svh - $header-height - $body-vertical-padding * 2 - $tabs-height
|
||||
);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.files {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
grid-gap: 15px;
|
||||
}
|
||||
|
||||
.content {
|
||||
@media only screen and (min-width: 768px) {
|
||||
padding: 10px;
|
||||
border: 10px solid $overlay-background-color;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.navTabs {
|
||||
display: none;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
height: $tabs-height;
|
||||
z-index: 2;
|
||||
background: $overlay-background-color;
|
||||
box-shadow: 0 0 4px 0 rgb(0, 0, 0, 0.1);
|
||||
|
||||
padding: 5px;
|
||||
gap: 5px;
|
||||
|
||||
@media only screen and (max-width: 767px) {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
> li {
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.active {
|
||||
background-color: $primary-main !important;
|
||||
color: white !important;
|
||||
padding: 10px;
|
||||
border: 10px solid $overlay-background-color;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
@ -1,147 +1,30 @@
|
||||
import {
|
||||
PropsWithChildren,
|
||||
ReactNode,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
import { PropsWithChildren, ReactNode } from 'react'
|
||||
|
||||
import styles from './StickySideColumns.module.scss'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { IconDefinition } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Button } from '@mui/material'
|
||||
|
||||
interface StickySideColumnsProps {
|
||||
left: ReactNode
|
||||
right: ReactNode
|
||||
leftIcon: IconDefinition
|
||||
centerIcon: IconDefinition
|
||||
rightIcon: IconDefinition
|
||||
left?: ReactNode
|
||||
right?: ReactNode
|
||||
}
|
||||
|
||||
const DEFAULT_TAB = 'nav-content'
|
||||
export const StickySideColumns = ({
|
||||
left,
|
||||
right,
|
||||
leftIcon,
|
||||
centerIcon,
|
||||
rightIcon,
|
||||
children
|
||||
}: PropsWithChildren<StickySideColumnsProps>) => {
|
||||
const [tab, setTab] = useState(DEFAULT_TAB)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const tabsRefs = useRef<{ [id: string]: HTMLDivElement | null }>({})
|
||||
const handleNavClick = (id: string) => {
|
||||
if (ref.current && tabsRefs.current) {
|
||||
const x = tabsRefs.current[id]?.offsetLeft
|
||||
ref.current.scrollTo({
|
||||
left: x,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
}
|
||||
}
|
||||
const isActive = (id: string) => id === tab
|
||||
|
||||
useEffect(() => {
|
||||
setTab(DEFAULT_TAB)
|
||||
handleNavClick(DEFAULT_TAB)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const tabs = tabsRefs.current
|
||||
// Set up the observer
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
setTab(entry.target.id)
|
||||
}
|
||||
})
|
||||
},
|
||||
{
|
||||
root: ref.current,
|
||||
threshold: 0.5,
|
||||
rootMargin: '-20px'
|
||||
}
|
||||
)
|
||||
|
||||
if (tabs) {
|
||||
Object.values(tabs).forEach((tab) => {
|
||||
if (tab) observer.observe(tab)
|
||||
})
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (tabs) {
|
||||
Object.values(tabs).forEach((tab) => {
|
||||
if (tab) observer.unobserve(tab)
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.container} ref={ref}>
|
||||
<div
|
||||
id="nav-left"
|
||||
className={styles.sidesWrap}
|
||||
ref={(tab) => (tabsRefs.current['nav-left'] = tab)}
|
||||
>
|
||||
<div className={styles.sides}>{left}</div>
|
||||
</div>
|
||||
<div
|
||||
id="nav-content"
|
||||
className={styles.scrollAdjust}
|
||||
ref={(tab) => (tabsRefs.current['nav-content'] = tab)}
|
||||
>
|
||||
<div id="content-preview" className={styles.content}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
id="nav-right"
|
||||
className={styles.sidesWrap}
|
||||
ref={(tab) => (tabsRefs.current['nav-right'] = tab)}
|
||||
>
|
||||
<div className={styles.sides}>{right}</div>
|
||||
<div className={styles.container}>
|
||||
<div className={`${styles.sidesWrap} ${styles.files}`}>
|
||||
<div className={styles.sides}>{left}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div id="content-preview" className={styles.content}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
<ul className={styles.navTabs}>
|
||||
<li>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="text"
|
||||
onClick={() => handleNavClick('nav-left')}
|
||||
className={`${isActive('nav-left') && styles.active}`}
|
||||
aria-label="nav left"
|
||||
>
|
||||
<FontAwesomeIcon icon={leftIcon} />
|
||||
</Button>
|
||||
</li>
|
||||
<li>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="text"
|
||||
onClick={() => handleNavClick('nav-content')}
|
||||
className={`${isActive('nav-content') && styles.active}`}
|
||||
aria-label="nav middle"
|
||||
>
|
||||
<FontAwesomeIcon icon={centerIcon} />
|
||||
</Button>
|
||||
</li>
|
||||
<li>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="text"
|
||||
onClick={() => handleNavClick('nav-right')}
|
||||
className={`${isActive('nav-right') && styles.active}`}
|
||||
aria-label="nav right"
|
||||
>
|
||||
<FontAwesomeIcon icon={rightIcon} />
|
||||
</Button>
|
||||
</li>
|
||||
</ul>
|
||||
</>
|
||||
<div className={styles.sidesWrap}>
|
||||
<div className={styles.sides}>{right}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -33,7 +33,7 @@ export const Modal = () => {
|
||||
{ to: appPublicRoutes.register, title: 'Register', label: 'Register' },
|
||||
{
|
||||
to: appPublicRoutes.nostr,
|
||||
title: 'Nostr Login',
|
||||
title: 'Login',
|
||||
sx: { padding: '10px' },
|
||||
label: <img src={nostrImage} width="25" alt="nostr logo" height="25" />
|
||||
}
|
||||
|
@ -3,5 +3,6 @@
|
||||
|
||||
.main {
|
||||
flex-grow: 1;
|
||||
padding: $body-vertical-padding 0 $body-vertical-padding 0;
|
||||
padding: $header-height + $body-vertical-padding 0 $body-vertical-padding 0;
|
||||
background-color: $body-background-color;
|
||||
}
|
||||
|
@ -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,
|
||||
user: store.getState().user,
|
||||
metadata: store.getState().metadata,
|
||||
userRobotImage: store.getState().userRobotImage,
|
||||
relays: store.getState().relays
|
||||
})
|
||||
}, 1000)
|
||||
@ -28,9 +28,7 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<CssVarsProvider theme={theme}>
|
||||
<HashRouter>
|
||||
<Provider store={store}>
|
||||
<NDKContextProvider>
|
||||
<App />
|
||||
</NDKContextProvider>
|
||||
<App />
|
||||
<ToastContainer />
|
||||
</Provider>
|
||||
</HashRouter>
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -4,8 +4,6 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.orderedFilesList {
|
||||
@ -42,7 +40,6 @@
|
||||
}
|
||||
|
||||
button {
|
||||
min-width: 44px;
|
||||
color: $primary-main;
|
||||
}
|
||||
|
||||
@ -70,6 +67,10 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
|
||||
// Automatic scrolling if paper-group gets large enough
|
||||
// used for files on the left and users on the right
|
||||
max-height: 350px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
@ -77,9 +78,8 @@
|
||||
.inputWrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
height: 36px;
|
||||
height: 34px;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
outline: solid 1px #dddddd;
|
||||
@ -90,43 +90,6 @@
|
||||
&:focus-within {
|
||||
outline-color: $primary-main;
|
||||
}
|
||||
|
||||
// Override default MUI input styles only inside inputWrapepr
|
||||
:global {
|
||||
.MuiInputBase-input {
|
||||
padding: 7px 14px;
|
||||
}
|
||||
.MuiOutlinedInput-notchedOutline {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.addCounterpart {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: start;
|
||||
gap: 10px;
|
||||
|
||||
> .inputWrapper {
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
button {
|
||||
min-width: 44px;
|
||||
padding: 11px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.users {
|
||||
flex-shrink: 0;
|
||||
max-height: 33vh;
|
||||
|
||||
.counterpartToggleButton {
|
||||
min-width: 44px;
|
||||
padding: 11px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.user {
|
||||
@ -141,22 +104,6 @@
|
||||
a:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
// Higher specificify to override default button styles
|
||||
.counterpartRowToggleButton {
|
||||
min-width: 34px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.counterpartRowToggleButton {
|
||||
&[data-variant='primary'] {
|
||||
color: $primary-main;
|
||||
}
|
||||
&[data-variant='secondary'] {
|
||||
color: rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
.avatar {
|
||||
@ -183,35 +130,26 @@
|
||||
|
||||
.toolbox {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@container (min-width: 204px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
@container (min-width: 309px) {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 15px;
|
||||
|
||||
max-height: 450px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.toolItem {
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
|
||||
transition: ease 0.2s;
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
border-radius: 4px;
|
||||
padding: 10px 5px 5px 5px;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
|
||||
text-align: center;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
@ -224,7 +162,7 @@
|
||||
color: white;
|
||||
}
|
||||
|
||||
&:not(.selected, .comingSoon) {
|
||||
&:not(.selected) {
|
||||
&:hover {
|
||||
background: $primary-light;
|
||||
color: white;
|
||||
@ -236,7 +174,3 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.comingSoonPlaceholder {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
@ -1,9 +1,10 @@
|
||||
import { Button, TextField } from '@mui/material'
|
||||
import JSZip from 'jszip'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { toast } from 'react-toastify'
|
||||
import { useAppSelector } from '../../hooks'
|
||||
import { appPrivateRoutes } from '../../routes'
|
||||
import { appPrivateRoutes, appPublicRoutes } from '../../routes'
|
||||
import { Meta } from '../../types'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faSearch } from '@fortawesome/free-solid-svg-icons'
|
||||
@ -14,11 +15,9 @@ import { Container } from '../../components/Container'
|
||||
import styles from './style.module.scss'
|
||||
import {
|
||||
extractSigitCardDisplayInfo,
|
||||
navigateFromZip,
|
||||
SigitCardDisplayInfo,
|
||||
SigitStatus
|
||||
} from '../../utils'
|
||||
import { Footer } from '../../components/Footer/Footer'
|
||||
|
||||
// Unsupported Filter options are commented
|
||||
const FILTERS = [
|
||||
@ -56,15 +55,14 @@ export const HomePage = () => {
|
||||
[key: string]: SigitCardDisplayInfo
|
||||
}>({})
|
||||
const usersAppData = useAppSelector((state) => state.userAppData)
|
||||
const usersPubkey = useAppSelector((state) => state.auth.usersPubkey)
|
||||
|
||||
useEffect(() => {
|
||||
if (usersAppData?.sigits) {
|
||||
if (usersAppData) {
|
||||
const getSigitInfo = async () => {
|
||||
const parsedSigits: { [key: string]: SigitCardDisplayInfo } = {}
|
||||
for (const key in usersAppData.sigits) {
|
||||
if (Object.prototype.hasOwnProperty.call(usersAppData.sigits, key)) {
|
||||
const sigitInfo = extractSigitCardDisplayInfo(
|
||||
const sigitInfo = await extractSigitCardDisplayInfo(
|
||||
usersAppData.sigits[key]
|
||||
)
|
||||
if (sigitInfo) {
|
||||
@ -81,7 +79,7 @@ export const HomePage = () => {
|
||||
setSigits(usersAppData.sigits)
|
||||
getSigitInfo()
|
||||
}
|
||||
}, [usersAppData?.sigits])
|
||||
}, [usersAppData])
|
||||
|
||||
const onDrop = useCallback(
|
||||
async (acceptedFiles: File[]) => {
|
||||
@ -93,12 +91,27 @@ export const HomePage = () => {
|
||||
const fileName = file.name
|
||||
const fileExtension = fileName.slice(-10) // ".sigit.zip" has 10 characters
|
||||
if (fileExtension === '.sigit.zip') {
|
||||
const nav = await navigateFromZip(
|
||||
file,
|
||||
usersPubkey as `npub1${string}`
|
||||
)
|
||||
const zip = await JSZip.loadAsync(file).catch((err) => {
|
||||
console.log('err in loading zip file :>> ', err)
|
||||
toast.error(err.message || 'An error occurred in loading zip file.')
|
||||
return null
|
||||
})
|
||||
|
||||
if (nav) return navigate(nav.to, nav.options)
|
||||
if (!zip) return
|
||||
|
||||
// navigate to sign page if zip contains keys.json
|
||||
if ('keys.json' in zip.files) {
|
||||
return navigate(appPrivateRoutes.sign, {
|
||||
state: { uploadedZip: file }
|
||||
})
|
||||
}
|
||||
|
||||
// navigate to verify page if zip contains meta.json
|
||||
if ('meta.json' in zip.files) {
|
||||
return navigate(appPublicRoutes.verify, {
|
||||
state: { uploadedZip: file }
|
||||
})
|
||||
}
|
||||
|
||||
toast.error('Invalid SiGit zip file')
|
||||
return
|
||||
@ -110,7 +123,7 @@ export const HomePage = () => {
|
||||
state: { uploadedFiles: acceptedFiles }
|
||||
})
|
||||
},
|
||||
[navigate, usersPubkey]
|
||||
[navigate]
|
||||
)
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive, open } = useDropzone({
|
||||
@ -121,46 +134,6 @@ export const HomePage = () => {
|
||||
const [filter, setFilter] = useState<Filter>('Show all')
|
||||
const [sort, setSort] = useState<Sort>('desc')
|
||||
|
||||
const renderSubmissions = () => {
|
||||
const submissions = Object.keys(parsedSigits)
|
||||
.filter((s) => {
|
||||
const { title, signedStatus } = parsedSigits[s]
|
||||
const isMatch = title?.toLowerCase().includes(q.toLowerCase())
|
||||
switch (filter) {
|
||||
case 'Completed':
|
||||
return signedStatus === SigitStatus.Complete && isMatch
|
||||
case 'In-progress':
|
||||
return signedStatus === SigitStatus.Partial && isMatch
|
||||
case 'Show all':
|
||||
return isMatch
|
||||
default:
|
||||
console.error('Filter case not handled.')
|
||||
}
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const x = parsedSigits[a].createdAt ?? 0
|
||||
const y = parsedSigits[b].createdAt ?? 0
|
||||
return sort === 'desc' ? y - x : x - y
|
||||
})
|
||||
|
||||
if (submissions.length) {
|
||||
return submissions.map((key) => (
|
||||
<DisplaySigit
|
||||
key={`sigit-${key}`}
|
||||
sigitCreateId={key}
|
||||
parsedMeta={parsedSigits[key]}
|
||||
meta={sigits[key]}
|
||||
/>
|
||||
))
|
||||
} else {
|
||||
return (
|
||||
<div className={styles.noResults}>
|
||||
<p>No results</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div {...getRootProps()} tabIndex={-1}>
|
||||
<Container className={styles.container}>
|
||||
@ -259,10 +232,36 @@ export const HomePage = () => {
|
||||
<label htmlFor="file-upload">Click or drag files to upload!</label>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className={styles.submissions}>{renderSubmissions()}</div>
|
||||
<div className={styles.submissions}>
|
||||
{Object.keys(parsedSigits)
|
||||
.filter((s) => {
|
||||
const { title, signedStatus } = parsedSigits[s]
|
||||
const isMatch = title?.toLowerCase().includes(q.toLowerCase())
|
||||
switch (filter) {
|
||||
case 'Completed':
|
||||
return signedStatus === SigitStatus.Complete && isMatch
|
||||
case 'In-progress':
|
||||
return signedStatus === SigitStatus.Partial && isMatch
|
||||
case 'Show all':
|
||||
return isMatch
|
||||
default:
|
||||
console.error('Filter case not handled.')
|
||||
}
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const x = parsedSigits[a].createdAt ?? 0
|
||||
const y = parsedSigits[b].createdAt ?? 0
|
||||
return sort === 'desc' ? y - x : x - y
|
||||
})
|
||||
.map((key) => (
|
||||
<DisplaySigit
|
||||
key={`sigit-${key}`}
|
||||
parsedMeta={parsedSigits[key]}
|
||||
meta={sigits[key]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Container>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -99,10 +99,3 @@
|
||||
gap: 25px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(365px, 1fr));
|
||||
}
|
||||
|
||||
.noResults {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
font-weight: normal;
|
||||
color: #a1a1a1;
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { Box, Button } from '@mui/material'
|
||||
import { useEffect } from 'react'
|
||||
import { Outlet, useLocation } from 'react-router-dom'
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { appPublicRoutes } from '../../routes'
|
||||
import { saveVisitedLink } from '../../utils'
|
||||
import { CardComponent } from '../../components/Landing/CardComponent/CardComponent'
|
||||
import { Container } from '../../components/Container'
|
||||
@ -18,14 +19,13 @@ import {
|
||||
faWifi
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
import { FontAwesomeIconStack } from '../../components/FontAwesomeIconStack'
|
||||
import { Footer } from '../../components/Footer/Footer'
|
||||
import { launch as launchNostrLoginDialog } from 'nostr-login'
|
||||
|
||||
export const LandingPage = () => {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
const onSignInClick = async () => {
|
||||
launchNostrLoginDialog()
|
||||
navigate(appPublicRoutes.nostr)
|
||||
}
|
||||
|
||||
const cards = [
|
||||
@ -34,7 +34,7 @@ export const LandingPage = () => {
|
||||
title: <>Open Source</>,
|
||||
description: (
|
||||
<>
|
||||
Code is AGPL licenced and available at{' '}
|
||||
Code is MIT licenced and available at{' '}
|
||||
<a href="https://git.nostrdev.com/sigit/sigit.io">
|
||||
https://git.nostrdev.com/sigit/sigit.io
|
||||
</a>
|
||||
@ -69,8 +69,8 @@ export const LandingPage = () => {
|
||||
title: <>Verifiable</>,
|
||||
description: (
|
||||
<>
|
||||
SIGit Agreements can be directly verified - unlike traditional,
|
||||
server-based offerings.
|
||||
Thanks to Schnorr Signatures and Web of Trust, SIGit is far more
|
||||
auditable than traditional server-based offerings.
|
||||
</>
|
||||
)
|
||||
},
|
||||
@ -84,8 +84,8 @@ export const LandingPage = () => {
|
||||
title: <>Works Offline</>,
|
||||
description: (
|
||||
<>
|
||||
It is possible to complete a SIGit round without an internet
|
||||
connection.
|
||||
Presuming you have a hardware signing device, it is possible to
|
||||
complete a SIGit round without an internet connection.
|
||||
</>
|
||||
)
|
||||
},
|
||||
@ -94,8 +94,8 @@ export const LandingPage = () => {
|
||||
title: <>Multi-Party Signing</>,
|
||||
description: (
|
||||
<>
|
||||
Choose any number of Signers and Viewers, track status, get
|
||||
notifications on completion.
|
||||
Choose any number of Signers and Viewers, track the signature status,
|
||||
send reminders, get notifications on completion.
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -119,7 +119,9 @@ export const LandingPage = () => {
|
||||
<Container className={styles.container}>
|
||||
<img className={styles.logo} src="/logo.svg" alt="Logo" width={300} />
|
||||
<div className={styles.titleSection}>
|
||||
<h1 className={styles.title}>Secure & Private Agreements</h1>
|
||||
<h1 className={styles.title}>
|
||||
Secure & Private Document Signing
|
||||
</h1>
|
||||
<p className={styles.subTitle}>
|
||||
An open-source and self-hostable solution for secure document
|
||||
signing and verification.
|
||||
@ -160,7 +162,6 @@ export const LandingPage = () => {
|
||||
|
||||
<Outlet />
|
||||
</Container>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -12,11 +12,6 @@ export const Login = () => {
|
||||
margin="dense"
|
||||
autoComplete="username"
|
||||
disabled
|
||||
sx={{
|
||||
input: {
|
||||
cursor: 'not-allowed'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
label="Password"
|
||||
@ -25,11 +20,6 @@ export const Login = () => {
|
||||
margin="dense"
|
||||
autoComplete="current-password"
|
||||
disabled
|
||||
sx={{
|
||||
input: {
|
||||
cursor: 'not-allowed'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button variant="contained" fullWidth disabled>
|
||||
|
@ -1,44 +1,43 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
|
||||
import { Button, Divider, TextField } from '@mui/material'
|
||||
import { toast } from 'react-toastify'
|
||||
|
||||
import { hexToBytes } from '@noble/hashes/utils'
|
||||
import { launch as launchNostrLoginDialog } from 'nostr-login'
|
||||
import { getPublicKey, nip19 } from 'nostr-tools'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useDispatch } from 'react-redux'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { toast } from 'react-toastify'
|
||||
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 {
|
||||
AuthController,
|
||||
MetadataController,
|
||||
NostrController
|
||||
} from '../../controllers'
|
||||
import {
|
||||
updateKeyPair,
|
||||
updateLoginMethod,
|
||||
updateNsecbunkerPubkey,
|
||||
updateNsecbunkerRelays
|
||||
} from '../../store/actions'
|
||||
import { LoginMethods } from '../../store/auth/types'
|
||||
import { Dispatch } from '../../store/store'
|
||||
import { npubToHex, queryNip05 } from '../../utils'
|
||||
import { hexToBytes } from '@noble/hashes/utils'
|
||||
import { NIP05_REGEX } from '../../constants'
|
||||
|
||||
import styles from './styles.module.scss'
|
||||
|
||||
export const Nostr = () => {
|
||||
const [searchParams] = useSearchParams()
|
||||
const { authAndGetMetadataAndRelaysMap } = useAuth()
|
||||
|
||||
const dispatch = useAppDispatch()
|
||||
const dispatch: Dispatch = useDispatch()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const authController = new AuthController()
|
||||
const metadataController = new MetadataController()
|
||||
const nostrController = NostrController.getInstance()
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
|
||||
const navigateAfterLogin = (path: string) => {
|
||||
const callbackPath = searchParams.get('callbackPath')
|
||||
|
||||
if (callbackPath) {
|
||||
// base64 decoded path
|
||||
const path = atob(callbackPath)
|
||||
navigate(path)
|
||||
return
|
||||
}
|
||||
|
||||
navigate(path)
|
||||
}
|
||||
const [authUrl, setAuthUrl] = useState<string>()
|
||||
|
||||
const [isNostrExtensionAvailable, setIsNostrExtensionAvailable] =
|
||||
useState(false)
|
||||
@ -53,15 +52,49 @@ export const Nostr = () => {
|
||||
* Call login function when enter is pressed
|
||||
*/
|
||||
const handleInputKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (
|
||||
event.code === KeyboardCode.Enter ||
|
||||
event.code === KeyboardCode.NumpadEnter
|
||||
) {
|
||||
if (event.code === 'Enter' || event.code === 'NumpadEnter') {
|
||||
event.preventDefault()
|
||||
login()
|
||||
}
|
||||
}
|
||||
|
||||
const navigateAfterLogin = (path: string) => {
|
||||
const callbackPath = searchParams.get('callbackPath')
|
||||
|
||||
if (callbackPath) {
|
||||
// base64 decoded path
|
||||
const path = atob(callbackPath)
|
||||
navigate(path)
|
||||
return
|
||||
}
|
||||
|
||||
navigate(path)
|
||||
}
|
||||
|
||||
const loginWithExtension = async () => {
|
||||
setIsLoading(true)
|
||||
setLoadingSpinnerDesc('Capturing pubkey from nostr extension')
|
||||
|
||||
nostrController
|
||||
.capturePublicKey()
|
||||
.then(async (pubkey) => {
|
||||
dispatch(updateLoginMethod(LoginMethods.extension))
|
||||
|
||||
setLoadingSpinnerDesc('Authenticating and finding metadata')
|
||||
const redirectPath =
|
||||
await authController.authAndGetMetadataAndRelaysMap(pubkey)
|
||||
|
||||
if (redirectPath) navigateAfterLogin(redirectPath)
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error('Error capturing public key from nostr extension: ' + err)
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false)
|
||||
setLoadingSpinnerDesc('')
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Login with NSEC or HEX private key
|
||||
* @param privateKey in HEX format
|
||||
@ -97,17 +130,17 @@ export const Nostr = () => {
|
||||
public: publickey
|
||||
})
|
||||
)
|
||||
dispatch(updateLoginMethod(LoginMethod.privateKey))
|
||||
dispatch(updateLoginMethod(LoginMethods.privateKey))
|
||||
|
||||
setIsLoading(true)
|
||||
setLoadingSpinnerDesc('Authenticating and finding metadata')
|
||||
|
||||
const redirectPath = await authAndGetMetadataAndRelaysMap(publickey).catch(
|
||||
(err) => {
|
||||
const redirectPath = await authController
|
||||
.authAndGetMetadataAndRelaysMap(publickey)
|
||||
.catch((err) => {
|
||||
toast.error('Error occurred in authentication: ' + err)
|
||||
return null
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
if (redirectPath) navigateAfterLogin(redirectPath)
|
||||
|
||||
@ -115,10 +148,182 @@ export const Nostr = () => {
|
||||
setLoadingSpinnerDesc('')
|
||||
}
|
||||
|
||||
const loginWithNsecBunker = async () => {
|
||||
let relays: string[] | undefined
|
||||
let pubkey: string | undefined
|
||||
|
||||
setIsLoading(true)
|
||||
|
||||
const displayError = (message: string) => {
|
||||
toast.error(message)
|
||||
setIsLoading(false)
|
||||
setLoadingSpinnerDesc('')
|
||||
}
|
||||
|
||||
if (inputValue.match(NIP05_REGEX)) {
|
||||
const nip05Profile = await queryNip05(inputValue).catch((err) => {
|
||||
toast.error('An error occurred while querying nip05 profile: ' + err)
|
||||
return null
|
||||
})
|
||||
|
||||
if (nip05Profile) {
|
||||
pubkey = nip05Profile.pubkey
|
||||
relays = nip05Profile.relays
|
||||
}
|
||||
} else if (inputValue.startsWith('npub')) {
|
||||
pubkey = nip19.decode(inputValue).data as string
|
||||
const metadataEvent = await metadataController
|
||||
.findMetadata(pubkey)
|
||||
.catch(() => {
|
||||
return null
|
||||
})
|
||||
|
||||
if (!metadataEvent) {
|
||||
return displayError('metadata not found!')
|
||||
}
|
||||
|
||||
const metadataContent =
|
||||
metadataController.extractProfileMetadataContent(metadataEvent)
|
||||
|
||||
if (!metadataContent?.nip05) {
|
||||
return displayError('nip05 not present in metadata')
|
||||
}
|
||||
|
||||
const nip05Profile = await queryNip05(inputValue).catch((err) => {
|
||||
toast.error('An error occurred while querying nip05 profile: ' + err)
|
||||
return null
|
||||
})
|
||||
|
||||
if (nip05Profile) {
|
||||
if (nip05Profile.pubkey !== pubkey) {
|
||||
return displayError(
|
||||
'pubkey in nip05 does not match with provided npub'
|
||||
)
|
||||
}
|
||||
|
||||
relays = nip05Profile.relays
|
||||
}
|
||||
}
|
||||
|
||||
if (!relays || relays.length === 0) {
|
||||
return displayError('No relay found for nsecbunker')
|
||||
}
|
||||
|
||||
if (!pubkey) {
|
||||
return displayError('pubkey not found')
|
||||
}
|
||||
|
||||
setLoadingSpinnerDesc('Initializing nsecBunker')
|
||||
await nostrController.nsecBunkerInit(relays)
|
||||
|
||||
setLoadingSpinnerDesc('Creating nsecbunker singer')
|
||||
await nostrController
|
||||
.createNsecBunkerSigner(pubkey)
|
||||
.then(async (signer) => {
|
||||
signer.on('authUrl', (url: string) => {
|
||||
setAuthUrl(url)
|
||||
})
|
||||
|
||||
dispatch(updateLoginMethod(LoginMethods.nsecBunker))
|
||||
dispatch(updateNsecbunkerPubkey(pubkey))
|
||||
dispatch(updateNsecbunkerRelays(relays))
|
||||
|
||||
setLoadingSpinnerDesc('Authenticating and finding metadata')
|
||||
|
||||
const redirectPath = await authController
|
||||
.authAndGetMetadataAndRelaysMap(pubkey!)
|
||||
.catch((err) => {
|
||||
toast.error('Error occurred in authentication: ' + err)
|
||||
return null
|
||||
})
|
||||
|
||||
if (redirectPath) navigateAfterLogin(redirectPath)
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(
|
||||
'An error occurred while creating nsecbunker signer: ' + err
|
||||
)
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false)
|
||||
setLoadingSpinnerDesc('')
|
||||
})
|
||||
}
|
||||
|
||||
const loginWithBunkerConnectionString = async () => {
|
||||
// Extract the key
|
||||
const keyStartIndex = inputValue.indexOf('bunker://') + 'bunker://'.length
|
||||
const keyEndIndex = inputValue.indexOf('?relay=')
|
||||
const key = inputValue.substring(keyStartIndex, keyEndIndex)
|
||||
|
||||
const pubkey = npubToHex(key)
|
||||
|
||||
if (!pubkey) {
|
||||
toast.error('Invalid pubkey in bunker connection string.')
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract the relay value
|
||||
const relayIndex = inputValue.indexOf('relay=')
|
||||
const relay = inputValue.substring(
|
||||
relayIndex + 'relay='.length,
|
||||
inputValue.length
|
||||
)
|
||||
|
||||
setIsLoading(true)
|
||||
setLoadingSpinnerDesc('Initializing bunker NDK')
|
||||
|
||||
await nostrController.nsecBunkerInit([relay])
|
||||
|
||||
setLoadingSpinnerDesc('Creating remote signer')
|
||||
await nostrController
|
||||
.createNsecBunkerSigner(pubkey)
|
||||
.then(async (signer) => {
|
||||
signer.on('authUrl', (url: string) => {
|
||||
setAuthUrl(url)
|
||||
})
|
||||
|
||||
dispatch(updateLoginMethod(LoginMethods.nsecBunker))
|
||||
dispatch(updateNsecbunkerPubkey(pubkey))
|
||||
dispatch(updateNsecbunkerRelays([relay]))
|
||||
|
||||
setLoadingSpinnerDesc('Authenticating and finding metadata')
|
||||
|
||||
const redirectPath = await authController
|
||||
.authAndGetMetadataAndRelaysMap(pubkey!)
|
||||
.catch((err) => {
|
||||
toast.error('Error occurred in authentication: ' + err)
|
||||
return null
|
||||
})
|
||||
|
||||
if (redirectPath) navigateAfterLogin(redirectPath)
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(
|
||||
'An error occurred while creating nsecbunker signer: ' + err
|
||||
)
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false)
|
||||
setLoadingSpinnerDesc('')
|
||||
})
|
||||
}
|
||||
|
||||
const login = () => {
|
||||
if (inputValue.startsWith('bunker://')) {
|
||||
return loginWithBunkerConnectionString()
|
||||
}
|
||||
|
||||
if (inputValue.startsWith('nsec')) {
|
||||
return loginWithNsec()
|
||||
}
|
||||
if (inputValue.startsWith('npub')) {
|
||||
return loginWithNsecBunker()
|
||||
}
|
||||
if (inputValue.match(NIP05_REGEX)) {
|
||||
return loginWithNsecBunker()
|
||||
}
|
||||
|
||||
// Check if maybe hex nsec
|
||||
try {
|
||||
@ -130,33 +335,38 @@ export const Nostr = () => {
|
||||
console.warn('err', err)
|
||||
}
|
||||
|
||||
toast.error('Invalid format, please use: private key (hex or nsec)')
|
||||
toast.error(
|
||||
'Invalid format, please use: private key (hex), nsec..., bunker:// or nip05 format.'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (authUrl) {
|
||||
return (
|
||||
<iframe
|
||||
title="Nsecbunker auth"
|
||||
src={authUrl}
|
||||
width="100%"
|
||||
height="500px"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
|
||||
|
||||
{isNostrExtensionAvailable && (
|
||||
<>
|
||||
<label className={styles.label} htmlFor="extension-login">
|
||||
Login by using a{' '}
|
||||
<a
|
||||
rel="noopener"
|
||||
href="https://github.com/nostrband/nostr-login"
|
||||
target="_blank"
|
||||
>
|
||||
nostr-login
|
||||
</a>
|
||||
Login by using a browser extension
|
||||
</label>
|
||||
<Button
|
||||
id="nostr-login"
|
||||
id="extension-login"
|
||||
onClick={loginWithExtension}
|
||||
variant="contained"
|
||||
onClick={() => {
|
||||
launchNostrLoginDialog()
|
||||
}}
|
||||
>
|
||||
Nostr Login
|
||||
Extension Login
|
||||
</Button>
|
||||
<Divider
|
||||
sx={{
|
||||
@ -167,18 +377,16 @@ export const Nostr = () => {
|
||||
</Divider>
|
||||
</>
|
||||
)}
|
||||
<form autoComplete="off">
|
||||
<TextField
|
||||
onKeyDown={handleInputKeyDown}
|
||||
label="Private key (Not recommended)"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
fullWidth
|
||||
margin="dense"
|
||||
/>
|
||||
</form>
|
||||
<TextField
|
||||
onKeyDown={handleInputKeyDown}
|
||||
label="nip05 login / nip46 bunker string"
|
||||
helperText="Private key (Not recommended)"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
fullWidth
|
||||
margin="dense"
|
||||
/>
|
||||
|
||||
<Button
|
||||
disabled={!inputValue}
|
||||
onClick={login}
|
||||
|
@ -1,43 +1,40 @@
|
||||
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 { nip19 } from 'nostr-tools'
|
||||
|
||||
import { Container } from '../../components/Container'
|
||||
import { Footer } from '../../components/Footer/Footer'
|
||||
import { truncate } from 'lodash'
|
||||
import { Event, VerifiedEvent, kinds, nip19 } from 'nostr-tools'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import { toast } from 'react-toastify'
|
||||
import { LoadingSpinner } from '../../components/LoadingSpinner'
|
||||
import { useAppSelector } from '../../hooks/store'
|
||||
|
||||
import { MetadataController } from '../../controllers'
|
||||
import { getProfileSettingsRoute } from '../../routes'
|
||||
|
||||
import { State } from '../../store/rootReducer'
|
||||
import { NostrJoiningBlock, ProfileMetadata } from '../../types'
|
||||
import {
|
||||
getProfileUsername,
|
||||
getNostrJoiningBlockNumber,
|
||||
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'
|
||||
|
||||
export const ProfilePage = () => {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const { npub } = useParams()
|
||||
const { ndk, findMetadata } = useNDKContext()
|
||||
|
||||
const metadataController = useMemo(() => new MetadataController(), [])
|
||||
|
||||
const [pubkey, setPubkey] = useState<string>()
|
||||
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 [nostrJoiningBlock, setNostrJoiningBlock] =
|
||||
useState<NostrJoiningBlock | null>(null)
|
||||
const [profileMetadata, setProfileMetadata] = useState<ProfileMetadata>()
|
||||
const metadataState = useSelector((state: State) => state.metadata)
|
||||
const { usersPubkey } = useSelector((state: State) => state.auth)
|
||||
const userRobotImage = useSelector((state: State) => state.userRobotImage)
|
||||
|
||||
const [isUsersOwnProfile, setIsUsersOwnProfile] = useState(false)
|
||||
|
||||
@ -58,26 +55,60 @@ export const ProfilePage = () => {
|
||||
}, [npub, usersPubkey])
|
||||
|
||||
useEffect(() => {
|
||||
if (isUsersOwnProfile && currentUserProfile) {
|
||||
setUserProfile(currentUserProfile)
|
||||
setIsLoading(false)
|
||||
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)
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (pubkey) {
|
||||
findMetadata(pubkey)
|
||||
.then((profile) => {
|
||||
setUserProfile(profile)
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err)
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false)
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
const metadataEvent = await metadataController
|
||||
.findMetadata(pubkey)
|
||||
.catch((err) => {
|
||||
toast.error(err)
|
||||
return null
|
||||
})
|
||||
|
||||
if (metadataEvent) handleMetadataEvent(metadataEvent)
|
||||
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
getMetadata(pubkey)
|
||||
}
|
||||
}, [ndk, isUsersOwnProfile, currentUserProfile, pubkey, findMetadata])
|
||||
}, [isUsersOwnProfile, metadataState, pubkey, metadataController])
|
||||
|
||||
/**
|
||||
* Rendering text with button which copies the provided text
|
||||
@ -113,34 +144,28 @@ export const ProfilePage = () => {
|
||||
*
|
||||
* @returns robohash image url
|
||||
*/
|
||||
const getProfileImage = (profile: NDKUserProfile | null) => {
|
||||
if (!profile) return getRoboHashPicture(npub)
|
||||
const getProfileImage = (metadata: ProfileMetadata) => {
|
||||
if (!metadata) return ''
|
||||
|
||||
if (!isUsersOwnProfile) {
|
||||
return profile.image || getRoboHashPicture(npub!)
|
||||
return metadata.picture || getRoboHashPicture(npub!)
|
||||
}
|
||||
|
||||
// userRobotImage is used only when visiting own profile
|
||||
// while kind 0 picture is not set
|
||||
return profile.image || userRobotImage || getRoboHashPicture(npub!)
|
||||
return metadata.picture || userRobotImage || getRoboHashPicture(npub!)
|
||||
}
|
||||
|
||||
const profileName =
|
||||
pubkey && getProfileUsername(pubkey, userProfile || undefined)
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
|
||||
{pubkey && (
|
||||
<Container className={styles.container}>
|
||||
<Box
|
||||
className={`${styles.banner} ${!userProfile || !userProfile.banner ? styles.noImage : ''}`}
|
||||
className={`${styles.banner} ${!profileMetadata || !profileMetadata.banner ? styles.noImage : ''}`}
|
||||
>
|
||||
{userProfile && userProfile.banner ? (
|
||||
<img
|
||||
src={userProfile.banner}
|
||||
alt={`banner image for ${profileName}`}
|
||||
/>
|
||||
{profileMetadata && profileMetadata.banner ? (
|
||||
<img src={profileMetadata.banner} />
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
@ -159,12 +184,23 @@ export const ProfilePage = () => {
|
||||
>
|
||||
<img
|
||||
className={styles['image-placeholder']}
|
||||
src={getProfileImage(userProfile)}
|
||||
alt={profileName}
|
||||
src={getProfileImage(profileMetadata!)}
|
||||
/>
|
||||
</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
|
||||
@ -182,13 +218,22 @@ export const ProfilePage = () => {
|
||||
display: 'flex'
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{ margin: '5px 0 5px 0' }}
|
||||
variant="h6"
|
||||
className={styles.bold}
|
||||
>
|
||||
{profileName}
|
||||
</Typography>
|
||||
{profileMetadata && (
|
||||
<Typography
|
||||
sx={{ margin: '5px 0 5px 0' }}
|
||||
variant="h6"
|
||||
className={styles.bold}
|
||||
>
|
||||
{truncate(
|
||||
profileMetadata.display_name ||
|
||||
profileMetadata.name ||
|
||||
hexToNpub(pubkey),
|
||||
{
|
||||
length: 16
|
||||
}
|
||||
)}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Box>
|
||||
{textElementWithCopyIcon(
|
||||
@ -198,41 +243,48 @@ export const ProfilePage = () => {
|
||||
)}
|
||||
</Box>
|
||||
<Box>
|
||||
{userProfile?.nip05 &&
|
||||
textElementWithCopyIcon(userProfile.nip05, undefined, 15)}
|
||||
{profileMetadata?.nip05 &&
|
||||
textElementWithCopyIcon(
|
||||
profileMetadata.nip05,
|
||||
undefined,
|
||||
15
|
||||
)}
|
||||
</Box>
|
||||
<Box>
|
||||
{userProfile?.lud16 &&
|
||||
textElementWithCopyIcon(userProfile.lud16, undefined, 15)}
|
||||
{profileMetadata?.lud16 &&
|
||||
textElementWithCopyIcon(
|
||||
profileMetadata.lud16,
|
||||
undefined,
|
||||
15
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
{userProfile?.website && (
|
||||
{profileMetadata?.website && (
|
||||
<Typography
|
||||
sx={{ marginTop: '10px' }}
|
||||
variant="caption"
|
||||
component={Link}
|
||||
to={userProfile.website}
|
||||
to={profileMetadata.website}
|
||||
target="_blank"
|
||||
className={`${styles.website} ${styles.link} ${styles.captionWrapper}`}
|
||||
>
|
||||
{userProfile.website}
|
||||
{profileMetadata.website}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box>
|
||||
{userProfile?.about && (
|
||||
{profileMetadata?.about && (
|
||||
<Typography mt={1} className={styles.about}>
|
||||
{userProfile.about}
|
||||
{profileMetadata.about}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Container>
|
||||
)}
|
||||
<Footer />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -24,7 +24,7 @@
|
||||
}
|
||||
|
||||
.container {
|
||||
color: black;
|
||||
color: black
|
||||
}
|
||||
|
||||
.left {
|
||||
@ -51,8 +51,7 @@
|
||||
}
|
||||
|
||||
.image-placeholder {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.link {
|
||||
@ -100,4 +99,4 @@
|
||||
margin-left: 5px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
}
|
@ -12,11 +12,6 @@ export const Register = () => {
|
||||
margin="dense"
|
||||
autoComplete="username"
|
||||
disabled
|
||||
sx={{
|
||||
input: {
|
||||
cursor: 'not-allowed'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
label="Password"
|
||||
@ -26,11 +21,6 @@ export const Register = () => {
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
disabled
|
||||
sx={{
|
||||
input: {
|
||||
cursor: 'not-allowed'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
label="Confirm password"
|
||||
@ -40,11 +30,6 @@ export const Register = () => {
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
disabled
|
||||
sx={{
|
||||
input: {
|
||||
cursor: 'not-allowed'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button variant="contained" fullWidth disabled>
|
||||
|
@ -2,22 +2,24 @@ import AccountCircleIcon from '@mui/icons-material/AccountCircle'
|
||||
import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos'
|
||||
import CachedIcon from '@mui/icons-material/Cached'
|
||||
import RouterIcon from '@mui/icons-material/Router'
|
||||
import { ListItem, useTheme } from '@mui/material'
|
||||
import { useTheme } from '@mui/material'
|
||||
import List from '@mui/material/List'
|
||||
import ListItemButton from '@mui/material/ListItemButton'
|
||||
import ListItemIcon from '@mui/material/ListItemIcon'
|
||||
import ListItemText from '@mui/material/ListItemText'
|
||||
import ListSubheader from '@mui/material/ListSubheader'
|
||||
import { useAppSelector } from '../../hooks/store'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { appPrivateRoutes, getProfileSettingsRoute } from '../../routes'
|
||||
import { State } from '../../store/rootReducer'
|
||||
import { Container } from '../../components/Container'
|
||||
import { Footer } from '../../components/Footer/Footer'
|
||||
import ExtensionIcon from '@mui/icons-material/Extension'
|
||||
import { LoginMethod } from '../../store/auth/types'
|
||||
|
||||
export const SettingsPage = () => {
|
||||
const theme = useTheme()
|
||||
const { usersPubkey, loginMethod } = useAppSelector((state) => state.auth)
|
||||
|
||||
const navigate = useNavigate()
|
||||
const usersPubkey = useSelector((state: State) => state.auth.usersPubkey)
|
||||
|
||||
const listItem = (label: string, disabled = false) => {
|
||||
return (
|
||||
<>
|
||||
@ -41,56 +43,56 @@ export const SettingsPage = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container>
|
||||
<List
|
||||
sx={{
|
||||
width: '100%',
|
||||
bgcolor: 'background.paper'
|
||||
<Container>
|
||||
<List
|
||||
sx={{
|
||||
width: '100%',
|
||||
bgcolor: 'background.paper'
|
||||
}}
|
||||
subheader={
|
||||
<ListSubheader
|
||||
sx={{
|
||||
fontSize: '1.5rem',
|
||||
borderBottom: '0.5px solid',
|
||||
paddingBottom: 2,
|
||||
paddingTop: 2
|
||||
}}
|
||||
>
|
||||
Settings
|
||||
</ListSubheader>
|
||||
}
|
||||
>
|
||||
<ListItemButton
|
||||
onClick={() => {
|
||||
navigate(getProfileSettingsRoute(usersPubkey!))
|
||||
}}
|
||||
subheader={
|
||||
<ListSubheader
|
||||
sx={{
|
||||
fontSize: '1.5rem',
|
||||
borderBottom: '0.5px solid',
|
||||
paddingBottom: 2,
|
||||
paddingTop: 2,
|
||||
zIndex: 2
|
||||
}}
|
||||
>
|
||||
Settings
|
||||
</ListSubheader>
|
||||
}
|
||||
>
|
||||
<ListItem component={Link} to={getProfileSettingsRoute(usersPubkey!)}>
|
||||
<ListItemIcon>
|
||||
<AccountCircleIcon />
|
||||
</ListItemIcon>
|
||||
{listItem('Profile')}
|
||||
</ListItem>
|
||||
<ListItem component={Link} to={appPrivateRoutes.relays}>
|
||||
<ListItemIcon>
|
||||
<RouterIcon />
|
||||
</ListItemIcon>
|
||||
{listItem('Relays')}
|
||||
</ListItem>
|
||||
<ListItem component={Link} to={appPrivateRoutes.cacheSettings}>
|
||||
<ListItemIcon>
|
||||
<CachedIcon />
|
||||
</ListItemIcon>
|
||||
{listItem('Local Cache')}
|
||||
</ListItem>
|
||||
{loginMethod === LoginMethod.nostrLogin && (
|
||||
<ListItem component={Link} to={appPrivateRoutes.nostrLogin}>
|
||||
<ListItemIcon>
|
||||
<ExtensionIcon />
|
||||
</ListItemIcon>
|
||||
{listItem('Nostr Login')}
|
||||
</ListItem>
|
||||
)}
|
||||
</List>
|
||||
</Container>
|
||||
<Footer />
|
||||
</>
|
||||
<ListItemIcon>
|
||||
<AccountCircleIcon />
|
||||
</ListItemIcon>
|
||||
{listItem('Profile')}
|
||||
</ListItemButton>
|
||||
<ListItemButton
|
||||
onClick={() => {
|
||||
navigate(appPrivateRoutes.relays)
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<RouterIcon />
|
||||
</ListItemIcon>
|
||||
{listItem('Relays')}
|
||||
</ListItemButton>
|
||||
<ListItemButton
|
||||
onClick={() => {
|
||||
navigate(appPrivateRoutes.cacheSettings)
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<CachedIcon />
|
||||
</ListItemIcon>
|
||||
{listItem('Local Cache')}
|
||||
</ListItemButton>
|
||||
</List>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
87
src/pages/settings/cache/index.tsx
vendored
87
src/pages/settings/cache/index.tsx
vendored
@ -14,7 +14,6 @@ import { toast } from 'react-toastify'
|
||||
import { localCache } from '../../../services'
|
||||
import { LoadingSpinner } from '../../../components/LoadingSpinner'
|
||||
import { Container } from '../../../components/Container'
|
||||
import { Footer } from '../../../components/Footer/Footer'
|
||||
|
||||
export const CacheSettingsPage = () => {
|
||||
const theme = useTheme()
|
||||
@ -51,52 +50,48 @@ export const CacheSettingsPage = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container>
|
||||
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
|
||||
<List
|
||||
sx={{
|
||||
width: '100%',
|
||||
bgcolor: 'background.paper',
|
||||
marginTop: 2
|
||||
}}
|
||||
subheader={
|
||||
<ListSubheader
|
||||
sx={{
|
||||
fontSize: '1.5rem',
|
||||
borderBottom: '0.5px solid',
|
||||
paddingBottom: 2,
|
||||
paddingTop: 2,
|
||||
zIndex: 2
|
||||
}}
|
||||
>
|
||||
Cache Setting
|
||||
</ListSubheader>
|
||||
}
|
||||
>
|
||||
<ListItemButton disabled>
|
||||
<ListItemIcon>
|
||||
<IosShareIcon />
|
||||
</ListItemIcon>
|
||||
{listItem('Export (coming soon)')}
|
||||
</ListItemButton>
|
||||
<Container>
|
||||
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
|
||||
<List
|
||||
sx={{
|
||||
width: '100%',
|
||||
bgcolor: 'background.paper',
|
||||
marginTop: 2
|
||||
}}
|
||||
subheader={
|
||||
<ListSubheader
|
||||
sx={{
|
||||
fontSize: '1.5rem',
|
||||
borderBottom: '0.5px solid',
|
||||
paddingBottom: 2,
|
||||
paddingTop: 2
|
||||
}}
|
||||
>
|
||||
Cache Setting
|
||||
</ListSubheader>
|
||||
}
|
||||
>
|
||||
<ListItemButton disabled>
|
||||
<ListItemIcon>
|
||||
<IosShareIcon />
|
||||
</ListItemIcon>
|
||||
{listItem('Export (coming soon)')}
|
||||
</ListItemButton>
|
||||
|
||||
<ListItemButton disabled>
|
||||
<ListItemIcon>
|
||||
<InputIcon />
|
||||
</ListItemIcon>
|
||||
{listItem('Import (coming soon)')}
|
||||
</ListItemButton>
|
||||
<ListItemButton disabled>
|
||||
<ListItemIcon>
|
||||
<InputIcon />
|
||||
</ListItemIcon>
|
||||
{listItem('Import (coming soon)')}
|
||||
</ListItemButton>
|
||||
|
||||
<ListItemButton onClick={handleClearData}>
|
||||
<ListItemIcon>
|
||||
<ClearIcon sx={{ color: theme.palette.error.main }} />
|
||||
</ListItemIcon>
|
||||
{listItem('Clear Cache')}
|
||||
</ListItemButton>
|
||||
</List>
|
||||
</Container>
|
||||
<Footer />
|
||||
</>
|
||||
<ListItemButton onClick={handleClearData}>
|
||||
<ListItemIcon>
|
||||
<ClearIcon sx={{ color: theme.palette.error.main }} />
|
||||
</ListItemIcon>
|
||||
{listItem('Clear Cache')}
|
||||
</ListItemButton>
|
||||
</List>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
@ -1,78 +0,0 @@
|
||||
import {
|
||||
List,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
ListSubheader,
|
||||
useTheme
|
||||
} from '@mui/material'
|
||||
import { launch as launchNostrLoginDialog } from 'nostr-login'
|
||||
import { Container } from '../../../components/Container'
|
||||
import PeopleIcon from '@mui/icons-material/People'
|
||||
import ImportExportIcon from '@mui/icons-material/ImportExport'
|
||||
import { useAppSelector } from '../../../hooks/store'
|
||||
import { NostrLoginAuthMethod } from '../../../store/auth/types'
|
||||
|
||||
export const NostrLoginPage = () => {
|
||||
const theme = useTheme()
|
||||
const nostrLoginAuthMethod = useAppSelector(
|
||||
(state) => state.auth?.nostrLoginAuthMethod
|
||||
)
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<List
|
||||
sx={{
|
||||
width: '100%',
|
||||
bgcolor: 'background.paper'
|
||||
}}
|
||||
subheader={
|
||||
<ListSubheader
|
||||
sx={{
|
||||
fontSize: '1.5rem',
|
||||
borderBottom: '0.5px solid',
|
||||
paddingBottom: 2,
|
||||
paddingTop: 2,
|
||||
zIndex: 2
|
||||
}}
|
||||
>
|
||||
Nostr Settings
|
||||
</ListSubheader>
|
||||
}
|
||||
>
|
||||
<ListItemButton
|
||||
onClick={() => {
|
||||
launchNostrLoginDialog('switch-account')
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<PeopleIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={'Nostr Login Accounts'}
|
||||
sx={{
|
||||
color: theme.palette.text.primary
|
||||
}}
|
||||
/>
|
||||
</ListItemButton>
|
||||
{nostrLoginAuthMethod === NostrLoginAuthMethod.Local && (
|
||||
<ListItemButton
|
||||
onClick={() => {
|
||||
launchNostrLoginDialog('import')
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<ImportExportIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={'Import / Export Keys'}
|
||||
sx={{
|
||||
color: theme.palette.text.primary
|
||||
}}
|
||||
/>
|
||||
</ListItemButton>
|
||||
)}
|
||||
</List>
|
||||
</Container>
|
||||
)
|
||||
}
|
@ -1,11 +1,4 @@
|
||||
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,
|
||||
@ -14,48 +7,54 @@ import {
|
||||
ListItem,
|
||||
ListSubheader,
|
||||
TextField,
|
||||
Tooltip
|
||||
Tooltip,
|
||||
Typography,
|
||||
useTheme
|
||||
} from '@mui/material'
|
||||
|
||||
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 { getRoboHashPicture, unixNow } from '../../../utils'
|
||||
|
||||
import { Container } from '../../../components/Container'
|
||||
import { Footer } from '../../../components/Footer/Footer'
|
||||
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 { UnsignedEvent, nip19, kinds, VerifiedEvent, Event } from 'nostr-tools'
|
||||
import React, { useEffect, useMemo, 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 { useDispatch, useSelector } from 'react-redux'
|
||||
import { State } from '../../../store/rootReducer'
|
||||
import { LoadingButton } from '@mui/lab'
|
||||
import { Dispatch } from '../../../store/store'
|
||||
import { setMetadataEvent } from '../../../store/actions'
|
||||
import { LoadingSpinner } from '../../../components/LoadingSpinner'
|
||||
import { LoginMethods } from '../../../store/auth/types'
|
||||
import { SmartToy } from '@mui/icons-material'
|
||||
import {
|
||||
getNostrJoiningBlockNumber,
|
||||
getRoboHashPicture,
|
||||
unixNow
|
||||
} from '../../../utils'
|
||||
import { Container } from '../../../components/Container'
|
||||
|
||||
export const ProfileSettingsPage = () => {
|
||||
const dispatch: Dispatch = useAppDispatch()
|
||||
const theme = useTheme()
|
||||
|
||||
const { npub } = useParams()
|
||||
const { ndk, findMetadata, publish } = useNDKContext()
|
||||
|
||||
const dispatch: Dispatch = useDispatch()
|
||||
|
||||
const metadataController = useMemo(() => new MetadataController(), [])
|
||||
const nostrController = NostrController.getInstance()
|
||||
|
||||
const [pubkey, setPubkey] = useState<string>()
|
||||
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 [nostrJoiningBlock, setNostrJoiningBlock] =
|
||||
useState<NostrJoiningBlock | null>(null)
|
||||
const [profileMetadata, setProfileMetadata] = useState<ProfileMetadata>()
|
||||
const [savingProfileMetadata, setSavingProfileMetadata] = useState(false)
|
||||
const metadataState = useSelector((state: State) => state.metadata)
|
||||
const keys = useSelector((state: State) => state.auth?.keyPair)
|
||||
const { usersPubkey, loginMethod } = useSelector((state: State) => state.auth)
|
||||
const userRobotImage = useSelector((state: State) => state.userRobotImage)
|
||||
|
||||
const [isUsersOwnProfile, setIsUsersOwnProfile] = useState(false)
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [loadingSpinnerDesc] = useState('Fetching metadata')
|
||||
|
||||
@ -75,30 +74,63 @@ export const ProfileSettingsPage = () => {
|
||||
}, [npub, usersPubkey])
|
||||
|
||||
useEffect(() => {
|
||||
if (isUsersOwnProfile && currentUserProfile) {
|
||||
setUserProfile(currentUserProfile)
|
||||
if (pubkey) {
|
||||
getNostrJoiningBlockNumber(pubkey)
|
||||
.then((res) => {
|
||||
setNostrJoiningBlock(res)
|
||||
})
|
||||
.catch((err) => {
|
||||
// todo: handle error
|
||||
console.log('err :>> ', err)
|
||||
})
|
||||
}
|
||||
|
||||
setIsLoading(false)
|
||||
if (isUsersOwnProfile && metadataState) {
|
||||
const metadataContent = metadataController.extractProfileMetadataContent(
|
||||
metadataState as VerifiedEvent
|
||||
)
|
||||
if (metadataContent) {
|
||||
setProfileMetadata(metadataContent)
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (pubkey) {
|
||||
findMetadata(pubkey)
|
||||
.then((profile) => {
|
||||
setUserProfile(profile)
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err)
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false)
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
const metadataEvent = await metadataController
|
||||
.findMetadata(pubkey)
|
||||
.catch((err) => {
|
||||
toast.error(err)
|
||||
return null
|
||||
})
|
||||
|
||||
if (metadataEvent) handleMetadataEvent(metadataEvent)
|
||||
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
getMetadata(pubkey)
|
||||
}
|
||||
}, [ndk, isUsersOwnProfile, currentUserProfile, pubkey, findMetadata])
|
||||
}, [isUsersOwnProfile, metadataState, pubkey, metadataController])
|
||||
|
||||
const editItem = (
|
||||
key: keyof NDKUserProfile,
|
||||
key: keyof ProfileMetadata,
|
||||
label: string,
|
||||
multiline = false,
|
||||
rows = 1,
|
||||
@ -108,7 +140,7 @@ export const ProfileSettingsPage = () => {
|
||||
<TextField
|
||||
label={label}
|
||||
id={label.split(' ').join('-')}
|
||||
value={userProfile![key] || ''}
|
||||
value={profileMetadata![key] || ''}
|
||||
size="small"
|
||||
multiline={multiline}
|
||||
rows={rows}
|
||||
@ -118,7 +150,7 @@ export const ProfileSettingsPage = () => {
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { value } = event.target
|
||||
|
||||
setUserProfile((prev) => ({
|
||||
setProfileMetadata((prev) => ({
|
||||
...prev,
|
||||
[key]: value
|
||||
}))
|
||||
@ -160,47 +192,34 @@ export const ProfileSettingsPage = () => {
|
||||
)
|
||||
|
||||
const handleSaveMetadata = async () => {
|
||||
if (!userProfile) return
|
||||
|
||||
setSavingProfileMetadata(true)
|
||||
|
||||
const serializedProfile = serializeProfile(userProfile)
|
||||
const content = JSON.stringify(profileMetadata)
|
||||
|
||||
const unsignedEvent: UnsignedEvent = {
|
||||
content: serializedProfile,
|
||||
// We need to omit cachedAt and create new event
|
||||
// Relay will reject if created_at is too late
|
||||
const updatedMetadataState: UnsignedEvent = {
|
||||
content: content,
|
||||
created_at: unixNow(),
|
||||
kind: kinds.Metadata,
|
||||
pubkey: pubkey!,
|
||||
tags: []
|
||||
tags: metadataState?.tags || []
|
||||
}
|
||||
|
||||
const nostrController = NostrController.getInstance()
|
||||
const signedEvent = await nostrController
|
||||
.signEvent(unsignedEvent)
|
||||
.signEvent(updatedMetadataState)
|
||||
.catch((error) => {
|
||||
toast.error(`Error saving profile metadata. ${error}`)
|
||||
return null
|
||||
})
|
||||
|
||||
if (!signedEvent) {
|
||||
setSavingProfileMetadata(false)
|
||||
return
|
||||
}
|
||||
if (signedEvent) {
|
||||
if (!metadataController.validate(signedEvent)) {
|
||||
toast.error(`Metadata is not valid.`)
|
||||
}
|
||||
|
||||
const ndkEvent = new NDKEvent(ndk, signedEvent)
|
||||
const publishedOnRelays = await publish(ndkEvent)
|
||||
await metadataController.publishMetadataEvent(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))
|
||||
dispatch(setMetadataEvent(signedEvent))
|
||||
}
|
||||
|
||||
setSavingProfileMetadata(false)
|
||||
@ -217,7 +236,7 @@ export const ProfileSettingsPage = () => {
|
||||
|
||||
const robotAvatarLink = getRoboHashPicture(npub!, robotSet.current)
|
||||
|
||||
setUserProfile((prev) => ({
|
||||
setProfileMetadata((prev) => ({
|
||||
...prev,
|
||||
picture: robotAvatarLink
|
||||
}))
|
||||
@ -243,14 +262,14 @@ export const ProfileSettingsPage = () => {
|
||||
*
|
||||
* @returns robohash image url
|
||||
*/
|
||||
const getProfileImage = (profile: NDKUserProfile) => {
|
||||
const getProfileImage = (metadata: ProfileMetadata) => {
|
||||
if (!isUsersOwnProfile) {
|
||||
return profile.image || getRoboHashPicture(npub!)
|
||||
return metadata.picture || getRoboHashPicture(npub!)
|
||||
}
|
||||
|
||||
// userRobotImage is used only when visiting own profile
|
||||
// while kind 0 picture is not set
|
||||
return profile.image || userRobotImage || getRoboHashPicture(npub!)
|
||||
return metadata.picture || userRobotImage || getRoboHashPicture(npub!)
|
||||
}
|
||||
|
||||
return (
|
||||
@ -267,8 +286,7 @@ export const ProfileSettingsPage = () => {
|
||||
sx={{
|
||||
paddingBottom: 1,
|
||||
paddingTop: 1,
|
||||
fontSize: '1.5rem',
|
||||
zIndex: 2
|
||||
fontSize: '1.5rem'
|
||||
}}
|
||||
className={styles.subHeader}
|
||||
>
|
||||
@ -276,7 +294,7 @@ export const ProfileSettingsPage = () => {
|
||||
</ListSubheader>
|
||||
}
|
||||
>
|
||||
{userProfile && (
|
||||
{profileMetadata && (
|
||||
<div>
|
||||
<ListItem
|
||||
sx={{
|
||||
@ -285,10 +303,10 @@ export const ProfileSettingsPage = () => {
|
||||
flexDirection: 'column'
|
||||
}}
|
||||
>
|
||||
{userProfile.banner ? (
|
||||
{profileMetadata.banner ? (
|
||||
<img
|
||||
className={styles.bannerImg}
|
||||
src={userProfile.banner}
|
||||
src={profileMetadata.banner}
|
||||
alt="Banner Image"
|
||||
/>
|
||||
) : (
|
||||
@ -310,17 +328,32 @@ export const ProfileSettingsPage = () => {
|
||||
event.currentTarget.src = getRoboHashPicture(npub!)
|
||||
}}
|
||||
className={styles.img}
|
||||
src={getProfileImage(userProfile)}
|
||||
src={getProfileImage(profileMetadata)}
|
||||
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('image', 'Picture URL', undefined, undefined, {
|
||||
{editItem('picture', 'Picture URL', undefined, undefined, {
|
||||
endAdornment: isUsersOwnProfile ? robohashButton() : undefined
|
||||
})}
|
||||
|
||||
{editItem('name', 'Username')}
|
||||
{editItem('displayName', 'Display Name')}
|
||||
{editItem('display_name', 'Display Name')}
|
||||
{editItem('nip05', 'Nostr Address (nip05)')}
|
||||
{editItem('lud16', 'Lightning Address (lud16)')}
|
||||
{editItem('about', 'About', true, 4)}
|
||||
@ -329,8 +362,7 @@ export const ProfileSettingsPage = () => {
|
||||
<>
|
||||
{usersPubkey &&
|
||||
copyItem(nip19.npubEncode(usersPubkey), 'Public Key')}
|
||||
|
||||
{loginMethod === LoginMethod.privateKey &&
|
||||
{loginMethod === LoginMethods.privateKey &&
|
||||
keys &&
|
||||
keys.private &&
|
||||
copyItem(
|
||||
@ -340,33 +372,6 @@ export const ProfileSettingsPage = () => {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{isUsersOwnProfile && (
|
||||
<>
|
||||
{loginMethod === LoginMethod.nostrLogin &&
|
||||
nostrLoginAuthMethod === NostrLoginAuthMethod.Local && (
|
||||
<ListItem
|
||||
sx={{ marginTop: 1 }}
|
||||
onClick={() => {
|
||||
launchNostrLoginDialog('import')
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
label="Private Key (nostr-login)"
|
||||
defaultValue="••••••••••••••••••••••••••••••••••••••••••••••••••"
|
||||
size="small"
|
||||
className={styles.textField}
|
||||
disabled
|
||||
type={'password'}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<LaunchIcon className={styles.copyItem} />
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</List>
|
||||
@ -380,7 +385,6 @@ export const ProfileSettingsPage = () => {
|
||||
</LoadingButton>
|
||||
)}
|
||||
</Container>
|
||||
<Footer />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -13,40 +13,25 @@ import Switch from '@mui/material/Switch'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'react-toastify'
|
||||
import { Container } from '../../../components/Container'
|
||||
import {
|
||||
useAppDispatch,
|
||||
useAppSelector,
|
||||
useDidMount,
|
||||
useDvm,
|
||||
useNDKContext
|
||||
} from '../../../hooks'
|
||||
import { relayController } from '../../../controllers'
|
||||
import { useAppDispatch, useAppSelector, useDidMount } from '../../../hooks'
|
||||
import { setRelayMapAction } from '../../../store/actions'
|
||||
import { RelayConnectionState, RelayFee, RelayInfo } from '../../../types'
|
||||
import {
|
||||
capitalizeFirstLetter,
|
||||
compareObjects,
|
||||
getRelayMapFromNDKRelayList,
|
||||
getRelayInfo,
|
||||
getRelayMap,
|
||||
hexToNpub,
|
||||
publishRelayMap,
|
||||
shorten,
|
||||
timeout
|
||||
shorten
|
||||
} 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 [ndkRelayList, setNDKRelayList] = useState<NDKRelayList | null>(null)
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const [newRelayURI, setNewRelayURI] = useState<string>()
|
||||
const [newRelayURIerror, setNewRelayURIerror] = useState<string>()
|
||||
@ -54,56 +39,24 @@ export const RelaysPage = () => {
|
||||
const relayMap = useAppSelector((state) => state.relays?.map)
|
||||
const relaysInfo = useAppSelector((state) => state.relays?.info)
|
||||
|
||||
const webSocketPrefix =
|
||||
newRelayURI?.startsWith('wss://') || newRelayURI?.startsWith('ws://')
|
||||
? ''
|
||||
: 'wss://'
|
||||
const webSocketPrefix = 'wss://'
|
||||
|
||||
// fetch relay list from relays
|
||||
useEffect(() => {
|
||||
useDidMount(() => {
|
||||
if (usersPubkey) {
|
||||
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))
|
||||
})
|
||||
getRelayMap(usersPubkey).then((newRelayMap) => {
|
||||
if (!compareObjects(relayMap, newRelayMap.map)) {
|
||||
dispatch(setRelayMapAction(newRelayMap.map))
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [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 (Object.keys(relayMap).length === 0) {
|
||||
if (relayMap && Object.keys(relayMap).length === 0) {
|
||||
relayRequirementWarning()
|
||||
} else {
|
||||
getRelayInfo(Object.keys(relayMap))
|
||||
}
|
||||
}, [relayMap, getRelayInfo])
|
||||
}, [relayMap])
|
||||
|
||||
const relayRequirementWarning = () =>
|
||||
toast.warning('At least one write relay is needed for SIGit to work.')
|
||||
@ -131,8 +84,7 @@ export const RelaysPage = () => {
|
||||
const relayMapPublishingRes = await publishRelayMap(
|
||||
relayMapCopy,
|
||||
usersPubkey,
|
||||
ndk,
|
||||
publish
|
||||
[relay]
|
||||
).catch((err) => handlePublishRelayMapError(err))
|
||||
|
||||
if (relayMapPublishingRes) {
|
||||
@ -179,9 +131,7 @@ export const RelaysPage = () => {
|
||||
// Publish updated relay map
|
||||
const relayMapPublishingRes = await publishRelayMap(
|
||||
relayMapCopy,
|
||||
usersPubkey,
|
||||
ndk,
|
||||
publish
|
||||
usersPubkey
|
||||
).catch((err) => handlePublishRelayMapError(err))
|
||||
|
||||
if (relayMapPublishingRes) {
|
||||
@ -200,7 +150,7 @@ export const RelaysPage = () => {
|
||||
// Check if new relay URI is a valid string
|
||||
if (
|
||||
relayURI &&
|
||||
!/^wss?:\/\/[-a-zA-Z0-9@:%._\\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}/.test(
|
||||
!/^wss:\/\/[-a-zA-Z0-9@:%._\\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}/.test(
|
||||
relayURI
|
||||
)
|
||||
) {
|
||||
@ -210,10 +160,9 @@ export const RelaysPage = () => {
|
||||
)
|
||||
}
|
||||
} else if (relayURI && usersPubkey) {
|
||||
const ndkRelay = ndk.pool.getRelay(relayURI)
|
||||
await ndkRelay.connect(5000)
|
||||
const relay = await relayController.connectRelay(relayURI)
|
||||
|
||||
if (ndkRelay.status >= NDKRelayStatus.CONNECTED) {
|
||||
if (relay && relay.connected) {
|
||||
const relayMapCopy = JSON.parse(JSON.stringify(relayMap))
|
||||
|
||||
relayMapCopy[relayURI] = { write: true, read: true }
|
||||
@ -221,9 +170,7 @@ export const RelaysPage = () => {
|
||||
// Publish updated relay map
|
||||
const relayMapPublishingRes = await publishRelayMap(
|
||||
relayMapCopy,
|
||||
usersPubkey,
|
||||
ndk,
|
||||
publish
|
||||
usersPubkey
|
||||
).catch((err) => handlePublishRelayMapError(err))
|
||||
|
||||
if (relayMapPublishingRes) {
|
||||
@ -263,13 +210,7 @@ export const RelaysPage = () => {
|
||||
}}
|
||||
className={styles.relayURItextfield}
|
||||
/>
|
||||
<Button
|
||||
sx={{
|
||||
height: '56px'
|
||||
}}
|
||||
variant="contained"
|
||||
onClick={() => handleAddNewRelay()}
|
||||
>
|
||||
<Button variant="contained" onClick={() => handleAddNewRelay()}>
|
||||
Add
|
||||
</Button>
|
||||
</Box>
|
||||
@ -291,7 +232,6 @@ export const RelaysPage = () => {
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
<Footer />
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@ -314,196 +254,177 @@ const RelayItem = ({
|
||||
handleLeaveRelay,
|
||||
handleRelayWriteChange
|
||||
}: RelayItemProp) => {
|
||||
const { ndk } = useNDKContext()
|
||||
|
||||
const [relayConnectionStatus, setRelayConnectionStatus] =
|
||||
useState<RelayConnectionState>()
|
||||
|
||||
const [displayRelayInfo, setDisplayRelayInfo] = useState(false)
|
||||
|
||||
useDidMount(() => {
|
||||
const ndkPool = ndk.pool
|
||||
|
||||
ndkPool.on('relay:connect', (relay) => {
|
||||
if (relay.url === relayURI) {
|
||||
relayController.connectRelay(relayURI).then((relay) => {
|
||||
if (relay && relay.connected) {
|
||||
setRelayConnectionStatus(RelayConnectionState.Connected)
|
||||
}
|
||||
})
|
||||
|
||||
ndkPool.on('relay:disconnect', (relay) => {
|
||||
if (relay.url === relayURI) {
|
||||
} else {
|
||||
setRelayConnectionStatus(RelayConnectionState.NotConnected)
|
||||
}
|
||||
})
|
||||
|
||||
const relay = ndkPool.getRelay(relayURI)
|
||||
if (relay) {
|
||||
setRelayConnectionStatus(
|
||||
relay.status >= NDKRelayStatus.CONNECTED
|
||||
? RelayConnectionState.Connected
|
||||
: RelayConnectionState.NotConnected
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box className={styles.relay}>
|
||||
<List>
|
||||
<ListItem>
|
||||
<span
|
||||
className={[
|
||||
styles.connectionStatus,
|
||||
relayConnectionStatus
|
||||
? relayConnectionStatus === RelayConnectionState.Connected
|
||||
? styles.connectionStatusConnected
|
||||
: styles.connectionStatusNotConnected
|
||||
: styles.connectionStatusUnknown
|
||||
].join(' ')}
|
||||
/>
|
||||
{relayInfo &&
|
||||
relayInfo.limitation &&
|
||||
relayInfo.limitation?.payment_required && (
|
||||
<Tooltip title="Paid Relay" arrow placement="top">
|
||||
<ElectricBoltIcon
|
||||
className={styles.lightningIcon}
|
||||
color="warning"
|
||||
onClick={() => setDisplayRelayInfo((prev) => !prev)}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Box className={styles.relay}>
|
||||
<List>
|
||||
<ListItem>
|
||||
<span
|
||||
className={[
|
||||
styles.connectionStatus,
|
||||
relayConnectionStatus
|
||||
? relayConnectionStatus === RelayConnectionState.Connected
|
||||
? styles.connectionStatusConnected
|
||||
: styles.connectionStatusNotConnected
|
||||
: styles.connectionStatusUnknown
|
||||
].join(' ')}
|
||||
/>
|
||||
{relayInfo &&
|
||||
relayInfo.limitation &&
|
||||
relayInfo.limitation?.payment_required && (
|
||||
<Tooltip title="Paid Relay" arrow placement="top">
|
||||
<ElectricBoltIcon
|
||||
className={styles.lightningIcon}
|
||||
color="warning"
|
||||
onClick={() => setDisplayRelayInfo((prev) => !prev)}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<ListItemText primary={relayURI} />
|
||||
<ListItemText primary={relayURI} />
|
||||
|
||||
<Box
|
||||
className={styles.leaveRelayContainer}
|
||||
onClick={() => handleLeaveRelay(relayURI)}
|
||||
>
|
||||
<LogoutIcon />
|
||||
<span>Leave</span>
|
||||
</Box>
|
||||
</ListItem>
|
||||
<Divider className={styles.relayDivider} />
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary="Publish to this relay?"
|
||||
secondary={
|
||||
relayInfo ? (
|
||||
<span
|
||||
onClick={() => setDisplayRelayInfo((prev) => !prev)}
|
||||
className={styles.showInfo}
|
||||
>
|
||||
Show info{' '}
|
||||
{displayRelayInfo ? (
|
||||
<KeyboardArrowUpIcon className={styles.showInfoIcon} />
|
||||
) : (
|
||||
<KeyboardArrowDownIcon className={styles.showInfoIcon} />
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
''
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Switch
|
||||
checked={isWriteRelay}
|
||||
onChange={(event) => handleRelayWriteChange(relayURI, event)}
|
||||
/>
|
||||
</ListItem>
|
||||
{displayRelayInfo && (
|
||||
<>
|
||||
<Divider className={styles.relayDivider} />
|
||||
<ListItem>
|
||||
<Box className={styles.relayInfoContainer}>
|
||||
{relayInfo &&
|
||||
Object.keys(relayInfo).map((key: string) => {
|
||||
const infoTitle = capitalizeFirstLetter(
|
||||
key.replace('_', ' ')
|
||||
)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let infoValue = (relayInfo as any)[key]
|
||||
<Box
|
||||
className={styles.leaveRelayContainer}
|
||||
onClick={() => handleLeaveRelay(relayURI)}
|
||||
>
|
||||
<LogoutIcon />
|
||||
<span>Leave</span>
|
||||
</Box>
|
||||
</ListItem>
|
||||
<Divider className={styles.relayDivider} />
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary="Publish to this relay?"
|
||||
secondary={
|
||||
relayInfo ? (
|
||||
<span
|
||||
onClick={() => setDisplayRelayInfo((prev) => !prev)}
|
||||
className={styles.showInfo}
|
||||
>
|
||||
Show info{' '}
|
||||
{displayRelayInfo ? (
|
||||
<KeyboardArrowUpIcon className={styles.showInfoIcon} />
|
||||
) : (
|
||||
<KeyboardArrowDownIcon className={styles.showInfoIcon} />
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
''
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Switch
|
||||
checked={isWriteRelay}
|
||||
onChange={(event) => handleRelayWriteChange(relayURI, event)}
|
||||
/>
|
||||
</ListItem>
|
||||
{displayRelayInfo && (
|
||||
<>
|
||||
<Divider className={styles.relayDivider} />
|
||||
<ListItem>
|
||||
<Box className={styles.relayInfoContainer}>
|
||||
{relayInfo &&
|
||||
Object.keys(relayInfo).map((key: string) => {
|
||||
const infoTitle = capitalizeFirstLetter(
|
||||
key.replace('_', ' ')
|
||||
)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let infoValue = (relayInfo as any)[key]
|
||||
|
||||
switch (key) {
|
||||
case 'pubkey':
|
||||
infoValue = shorten(hexToNpub(infoValue), 15)
|
||||
switch (key) {
|
||||
case 'pubkey':
|
||||
infoValue = shorten(hexToNpub(infoValue), 15)
|
||||
|
||||
break
|
||||
break
|
||||
|
||||
case 'limitation':
|
||||
infoValue = (
|
||||
<ul>
|
||||
{Object.keys(infoValue).map((valueKey) => (
|
||||
<li key={`${relayURI}_${key}_${valueKey}`}>
|
||||
<span className={styles.relayInfoSubTitle}>
|
||||
{capitalizeFirstLetter(
|
||||
valueKey.split('_').join(' ')
|
||||
)}
|
||||
:
|
||||
</span>{' '}
|
||||
{`${infoValue[valueKey]}`}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
case 'limitation':
|
||||
infoValue = (
|
||||
<ul>
|
||||
{Object.keys(infoValue).map((valueKey) => (
|
||||
<li key={`${relayURI}_${key}_${valueKey}`}>
|
||||
<span className={styles.relayInfoSubTitle}>
|
||||
{capitalizeFirstLetter(
|
||||
valueKey.split('_').join(' ')
|
||||
)}
|
||||
:
|
||||
</span>{' '}
|
||||
{`${infoValue[valueKey]}`}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
|
||||
break
|
||||
break
|
||||
|
||||
case 'fees':
|
||||
infoValue = (
|
||||
<ul>
|
||||
{Object.keys(infoValue).map((valueKey) => (
|
||||
<li key={`${relayURI}_${key}_${valueKey}`}>
|
||||
<span className={styles.relayInfoSubTitle}>
|
||||
{capitalizeFirstLetter(
|
||||
valueKey.split('_').join(' ')
|
||||
)}
|
||||
:
|
||||
</span>{' '}
|
||||
{`${infoValue[valueKey].map((fee: RelayFee) => `${fee.amount} ${fee.unit}`)}`}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
case 'fees':
|
||||
infoValue = (
|
||||
<ul>
|
||||
{Object.keys(infoValue).map((valueKey) => (
|
||||
<li key={`${relayURI}_${key}_${valueKey}`}>
|
||||
<span className={styles.relayInfoSubTitle}>
|
||||
{capitalizeFirstLetter(
|
||||
valueKey.split('_').join(' ')
|
||||
)}
|
||||
:
|
||||
</span>{' '}
|
||||
{`${infoValue[valueKey].map((fee: RelayFee) => `${fee.amount} ${fee.unit}`)}`}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
if (Array.isArray(infoValue)) {
|
||||
infoValue = infoValue.join(', ')
|
||||
}
|
||||
if (Array.isArray(infoValue)) {
|
||||
infoValue = infoValue.join(', ')
|
||||
}
|
||||
|
||||
return (
|
||||
<span key={`${relayURI}_${key}_container`}>
|
||||
<span className={styles.relayInfoTitle}>
|
||||
{infoTitle}:
|
||||
</span>{' '}
|
||||
{infoValue}
|
||||
{key === 'pubkey' ? (
|
||||
<ContentCopyIcon
|
||||
className={styles.copyItem}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
hexToNpub((relayInfo as any)[key])
|
||||
)
|
||||
return (
|
||||
<span key={`${relayURI}_${key}_container`}>
|
||||
<span className={styles.relayInfoTitle}>
|
||||
{infoTitle}:
|
||||
</span>{' '}
|
||||
{infoValue}
|
||||
{key === 'pubkey' ? (
|
||||
<ContentCopyIcon
|
||||
className={styles.copyItem}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
hexToNpub((relayInfo as any)[key])
|
||||
)
|
||||
|
||||
toast.success('Copied to clipboard', {
|
||||
autoClose: 1000,
|
||||
hideProgressBar: true
|
||||
})
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
</ListItem>
|
||||
</>
|
||||
)}
|
||||
</List>
|
||||
</Box>
|
||||
</>
|
||||
toast.success('Copied to clipboard', {
|
||||
autoClose: 1000,
|
||||
hideProgressBar: true
|
||||
})
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
</ListItem>
|
||||
</>
|
||||
)}
|
||||
</List>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
@ -12,7 +12,6 @@
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.sectionIcon {
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -1,12 +1,10 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'react-toastify'
|
||||
|
||||
import {
|
||||
Cancel,
|
||||
CheckCircle,
|
||||
Download,
|
||||
HourglassTop
|
||||
} from '@mui/icons-material'
|
||||
Meta,
|
||||
ProfileMetadata,
|
||||
SignedEventContent,
|
||||
User,
|
||||
UserRole
|
||||
} from '../../../types'
|
||||
import {
|
||||
Box,
|
||||
IconButton,
|
||||
@ -22,18 +20,21 @@ import {
|
||||
Typography,
|
||||
useTheme
|
||||
} from '@mui/material'
|
||||
|
||||
import {
|
||||
Download,
|
||||
CheckCircle,
|
||||
Cancel,
|
||||
HourglassTop
|
||||
} from '@mui/icons-material'
|
||||
import saveAs from 'file-saver'
|
||||
|
||||
import { Event } from 'nostr-tools'
|
||||
|
||||
import { kinds, Event } from 'nostr-tools'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { toast } from 'react-toastify'
|
||||
import { UserAvatar } from '../../../components/UserAvatar'
|
||||
|
||||
import { Meta, SignedEventContent, User, UserRole } from '../../../types'
|
||||
import { hexToNpub, npubToHex, parseJson } from '../../../utils'
|
||||
import { SigitFile } from '../../../utils/file'
|
||||
|
||||
import { MetadataController } from '../../../controllers'
|
||||
import { npubToHex, shorten, hexToNpub, parseJson } from '../../../utils'
|
||||
import styles from '../style.module.scss'
|
||||
import { SigitFile } from '../../../utils/file'
|
||||
|
||||
type DisplayMetaProps = {
|
||||
meta: Meta
|
||||
@ -66,6 +67,9 @@ export const DisplayMeta = ({
|
||||
theme.palette.background.paper
|
||||
)
|
||||
|
||||
const [metadata, setMetadata] = useState<{ [key: string]: ProfileMetadata }>(
|
||||
{}
|
||||
)
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
@ -100,6 +104,45 @@ export const DisplayMeta = ({
|
||||
})
|
||||
}, [signers, viewers])
|
||||
|
||||
useEffect(() => {
|
||||
const metadataController = new MetadataController()
|
||||
|
||||
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)
|
||||
@ -124,7 +167,20 @@ export const DisplayMeta = ({
|
||||
<Typography variant="h6" sx={{ color: textColor }}>
|
||||
Submitted By
|
||||
</Typography>
|
||||
<UserAvatar pubkey={submittedBy} isNameVisible={true} />
|
||||
{(function () {
|
||||
const profile = metadata[submittedBy]
|
||||
return (
|
||||
<UserAvatar
|
||||
pubkey={submittedBy}
|
||||
name={
|
||||
profile?.display_name ||
|
||||
profile?.name ||
|
||||
shorten(hexToNpub(submittedBy))
|
||||
}
|
||||
image={profile?.picture}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
</ListItem>
|
||||
<ListItem
|
||||
sx={{
|
||||
@ -186,6 +242,7 @@ export const DisplayMeta = ({
|
||||
key={user.pubkey}
|
||||
meta={meta}
|
||||
user={user}
|
||||
metadata={metadata}
|
||||
signedBy={signedBy}
|
||||
nextSigner={nextSigner}
|
||||
getPrevSignersSig={getPrevSignersSig}
|
||||
@ -214,6 +271,7 @@ enum UserStatus {
|
||||
type DisplayUserProps = {
|
||||
meta: Meta
|
||||
user: User
|
||||
metadata: { [key: string]: ProfileMetadata }
|
||||
signedBy: `npub1${string}`[]
|
||||
nextSigner?: string
|
||||
getPrevSignersSig: (usersNpub: string) => string | null
|
||||
@ -222,12 +280,14 @@ type DisplayUserProps = {
|
||||
const DisplayUser = ({
|
||||
meta,
|
||||
user,
|
||||
metadata,
|
||||
signedBy,
|
||||
nextSigner,
|
||||
getPrevSignersSig
|
||||
}: DisplayUserProps) => {
|
||||
const theme = useTheme()
|
||||
|
||||
const userMeta = metadata[user.pubkey]
|
||||
const [userStatus, setUserStatus] = useState<UserStatus>(UserStatus.Pending)
|
||||
const [prevSignatureStatus, setPreviousSignatureStatus] =
|
||||
useState<PrevSignatureValidationEnum>(PrevSignatureValidationEnum.Pending)
|
||||
@ -310,7 +370,15 @@ const DisplayUser = ({
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell className={styles.tableCell}>
|
||||
<UserAvatar pubkey={user.pubkey} isNameVisible={true} />
|
||||
<UserAvatar
|
||||
pubkey={user.pubkey}
|
||||
name={
|
||||
userMeta?.display_name ||
|
||||
userMeta?.name ||
|
||||
shorten(hexToNpub(user.pubkey))
|
||||
}
|
||||
image={userMeta?.picture}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className={styles.tableCell}>{user.role}</TableCell>
|
||||
<TableCell>
|
||||
|
@ -14,7 +14,6 @@
|
||||
border-bottom: 0.5px solid;
|
||||
padding: 8px 16px;
|
||||
font-size: 1.5rem;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.filesWrapper {
|
||||
@ -63,6 +62,7 @@
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
//z-index: 200;
|
||||
}
|
||||
|
||||
.fixedBottomForm input[type='text'] {
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -53,10 +53,14 @@
|
||||
|
||||
.mark {
|
||||
position: absolute;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
[data-dev='true'] {
|
||||
.mark {
|
||||
outline: 1px dotted black;
|
||||
border: 1px dotted black;
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,19 @@
|
||||
import { Modal } from '../layouts/modal'
|
||||
import { CreatePage } from '../pages/create'
|
||||
import { HomePage } from '../pages/home'
|
||||
import { LandingPage } from '../pages/landing'
|
||||
import { Login } from '../pages/login'
|
||||
import { Nostr } from '../pages/nostr'
|
||||
import { ProfilePage } from '../pages/profile'
|
||||
import { Register } from '../pages/register'
|
||||
import { SettingsPage } from '../pages/settings/Settings'
|
||||
import { CacheSettingsPage } from '../pages/settings/cache'
|
||||
import { ProfileSettingsPage } from '../pages/settings/profile'
|
||||
import { RelaysPage } from '../pages/settings/relays'
|
||||
import { SignPage } from '../pages/sign'
|
||||
import { VerifyPage } from '../pages/verify'
|
||||
import { hexToNpub } from '../utils'
|
||||
import { Route, RouteProps } from 'react-router-dom'
|
||||
|
||||
export const appPrivateRoutes = {
|
||||
homePage: '/',
|
||||
@ -7,8 +22,7 @@ export const appPrivateRoutes = {
|
||||
settings: '/settings',
|
||||
profileSettings: '/settings/profile/:npub',
|
||||
cacheSettings: '/settings/cache',
|
||||
relays: '/settings/relays',
|
||||
nostrLogin: '/settings/nostrLogin'
|
||||
relays: '/settings/relays'
|
||||
}
|
||||
|
||||
export const appPublicRoutes = {
|
||||
@ -27,3 +41,111 @@ export const getProfileRoute = (hexKey: string) =>
|
||||
|
||||
export const getProfileSettingsRoute = (hexKey: string) =>
|
||||
appPrivateRoutes.profileSettings.replace(':npub', hexToNpub(hexKey))
|
||||
|
||||
/**
|
||||
* Helper type allows for extending react-router-dom's **RouteProps** with generic type
|
||||
*/
|
||||
type CustomRouteProps<T> = T &
|
||||
Omit<RouteProps, 'children'> & {
|
||||
children?: Array<CustomRouteProps<T>>
|
||||
}
|
||||
|
||||
/**
|
||||
* This function maps over nested routes with optional condition for rendering
|
||||
* @param {CustomRouteProps<T>[]} routes - routes list
|
||||
* @param {RenderConditionCallbackFn} renderConditionCallbackFn - render condition callback (default true)
|
||||
*/
|
||||
export function recursiveRouteRenderer<T>(
|
||||
routes?: CustomRouteProps<T>[],
|
||||
renderConditionCallbackFn: (route: CustomRouteProps<T>) => boolean = () =>
|
||||
true
|
||||
) {
|
||||
if (!routes) return null
|
||||
|
||||
// Callback allows us to pass arbitrary conditions for each route's rendering
|
||||
// Skipping the callback will by default evaluate to true (show route)
|
||||
return routes.map((route, index) =>
|
||||
renderConditionCallbackFn(route) ? (
|
||||
<Route
|
||||
key={`${route.path}${index}`}
|
||||
path={route.path}
|
||||
element={route.element}
|
||||
>
|
||||
{recursiveRouteRenderer(route.children, renderConditionCallbackFn)}
|
||||
</Route>
|
||||
) : null
|
||||
)
|
||||
}
|
||||
|
||||
type PublicRouteProps = CustomRouteProps<{
|
||||
hiddenWhenLoggedIn?: boolean
|
||||
}>
|
||||
|
||||
export const publicRoutes: PublicRouteProps[] = [
|
||||
{
|
||||
path: appPublicRoutes.landingPage,
|
||||
hiddenWhenLoggedIn: true,
|
||||
element: <LandingPage />,
|
||||
children: [
|
||||
{
|
||||
element: <Modal />,
|
||||
children: [
|
||||
{
|
||||
path: appPublicRoutes.login,
|
||||
hiddenWhenLoggedIn: true,
|
||||
element: <Login />
|
||||
},
|
||||
{
|
||||
path: appPublicRoutes.register,
|
||||
hiddenWhenLoggedIn: true,
|
||||
element: <Register />
|
||||
},
|
||||
{
|
||||
path: appPublicRoutes.nostr,
|
||||
hiddenWhenLoggedIn: true,
|
||||
element: <Nostr />
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: appPublicRoutes.profile,
|
||||
element: <ProfilePage />
|
||||
},
|
||||
{
|
||||
path: appPublicRoutes.verify,
|
||||
element: <VerifyPage />
|
||||
}
|
||||
]
|
||||
|
||||
export const privateRoutes = [
|
||||
{
|
||||
path: appPrivateRoutes.homePage,
|
||||
element: <HomePage />
|
||||
},
|
||||
{
|
||||
path: appPrivateRoutes.create,
|
||||
element: <CreatePage />
|
||||
},
|
||||
{
|
||||
path: appPrivateRoutes.sign,
|
||||
element: <SignPage />
|
||||
},
|
||||
{
|
||||
path: appPrivateRoutes.settings,
|
||||
element: <SettingsPage />
|
||||
},
|
||||
{
|
||||
path: appPrivateRoutes.profileSettings,
|
||||
element: <ProfileSettingsPage />
|
||||
},
|
||||
{
|
||||
path: appPrivateRoutes.cacheSettings,
|
||||
element: <CacheSettingsPage />
|
||||
},
|
||||
{
|
||||
path: appPrivateRoutes.relays,
|
||||
element: <RelaysPage />
|
||||
}
|
||||
]
|
||||
|
@ -1,103 +0,0 @@
|
||||
import { Route, RouteProps } from 'react-router-dom'
|
||||
import { appPrivateRoutes, appPublicRoutes } from '.'
|
||||
import { CreatePage } from '../pages/create'
|
||||
import { HomePage } from '../pages/home'
|
||||
import { LandingPage } from '../pages/landing'
|
||||
import { ProfilePage } from '../pages/profile'
|
||||
import { CacheSettingsPage } from '../pages/settings/cache'
|
||||
import { NostrLoginPage } from '../pages/settings/nostrLogin'
|
||||
import { ProfileSettingsPage } from '../pages/settings/profile'
|
||||
import { RelaysPage } from '../pages/settings/relays'
|
||||
import { SettingsPage } from '../pages/settings/Settings'
|
||||
import { SignPage } from '../pages/sign'
|
||||
import { VerifyPage } from '../pages/verify'
|
||||
|
||||
/**
|
||||
* Helper type allows for extending react-router-dom's **RouteProps** with generic type
|
||||
*/
|
||||
type CustomRouteProps<T> = T &
|
||||
Omit<RouteProps, 'children'> & {
|
||||
children?: Array<CustomRouteProps<T>>
|
||||
}
|
||||
|
||||
/**
|
||||
* This function maps over nested routes with optional condition for rendering
|
||||
* @param {CustomRouteProps<T>[]} routes - routes list
|
||||
* @param {RenderConditionCallbackFn} renderConditionCallbackFn - render condition callback (default true)
|
||||
*/
|
||||
export function recursiveRouteRenderer<T>(
|
||||
routes?: CustomRouteProps<T>[],
|
||||
renderConditionCallbackFn: (route: CustomRouteProps<T>) => boolean = () =>
|
||||
true
|
||||
) {
|
||||
if (!routes) return null
|
||||
|
||||
// Callback allows us to pass arbitrary conditions for each route's rendering
|
||||
// Skipping the callback will by default evaluate to true (show route)
|
||||
return routes.map((route, index) =>
|
||||
renderConditionCallbackFn(route) ? (
|
||||
<Route
|
||||
key={`${route.path}${index}`}
|
||||
path={route.path}
|
||||
element={route.element}
|
||||
>
|
||||
{recursiveRouteRenderer(route.children, renderConditionCallbackFn)}
|
||||
</Route>
|
||||
) : null
|
||||
)
|
||||
}
|
||||
|
||||
type PublicRouteProps = CustomRouteProps<{
|
||||
hiddenWhenLoggedIn?: boolean
|
||||
}>
|
||||
|
||||
export const publicRoutes: PublicRouteProps[] = [
|
||||
{
|
||||
path: appPublicRoutes.landingPage,
|
||||
hiddenWhenLoggedIn: true,
|
||||
element: <LandingPage />
|
||||
},
|
||||
{
|
||||
path: appPublicRoutes.profile,
|
||||
element: <ProfilePage />
|
||||
},
|
||||
{
|
||||
path: `${appPublicRoutes.verify}/:id?`,
|
||||
element: <VerifyPage />
|
||||
}
|
||||
]
|
||||
|
||||
export const privateRoutes = [
|
||||
{
|
||||
path: appPrivateRoutes.homePage,
|
||||
element: <HomePage />
|
||||
},
|
||||
{
|
||||
path: appPrivateRoutes.create,
|
||||
element: <CreatePage />
|
||||
},
|
||||
{
|
||||
path: `${appPrivateRoutes.sign}/:id?`,
|
||||
element: <SignPage />
|
||||
},
|
||||
{
|
||||
path: appPrivateRoutes.settings,
|
||||
element: <SettingsPage />
|
||||
},
|
||||
{
|
||||
path: appPrivateRoutes.profileSettings,
|
||||
element: <ProfileSettingsPage />
|
||||
},
|
||||
{
|
||||
path: appPrivateRoutes.cacheSettings,
|
||||
element: <CacheSettingsPage />
|
||||
},
|
||||
{
|
||||
path: appPrivateRoutes.relays,
|
||||
element: <RelaysPage />
|
||||
},
|
||||
{
|
||||
path: appPrivateRoutes.nostrLogin,
|
||||
element: <NostrLoginPage />
|
||||
}
|
||||
]
|
@ -1,81 +0,0 @@
|
||||
import { Event, UnsignedEvent, EventTemplate, NostrEvent } from 'nostr-tools'
|
||||
import { SignedEvent } from '../../types'
|
||||
import { LoginMethodStrategy } from './loginMethodStrategy'
|
||||
import { WindowNostr } from 'nostr-tools/nip07'
|
||||
|
||||
/**
|
||||
* Login Method Strategy when using nostr-login package.
|
||||
*
|
||||
* This class extends {@link LoginMethodStrategy base strategy} and implements all login method operations
|
||||
* @see {@link LoginMethodStrategy}
|
||||
*/
|
||||
export class NostrLoginStrategy extends LoginMethodStrategy {
|
||||
private nostr: WindowNostr
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
if (!window.nostr) {
|
||||
throw new Error(
|
||||
`window.nostr object not present. Make sure you have an nostr extension installed/working properly.`
|
||||
)
|
||||
}
|
||||
|
||||
this.nostr = window.nostr as WindowNostr
|
||||
}
|
||||
|
||||
async nip04Encrypt(receiver: string, content: string): Promise<string> {
|
||||
if (!this.nostr.nip04) {
|
||||
throw new Error(
|
||||
`Your nostr extension does not support nip04 encryption & decryption`
|
||||
)
|
||||
}
|
||||
|
||||
const encrypted = await this.nostr.nip04.encrypt(receiver, content)
|
||||
return encrypted
|
||||
}
|
||||
|
||||
async nip04Decrypt(sender: string, content: string): Promise<string> {
|
||||
if (!this.nostr.nip04) {
|
||||
throw new Error(
|
||||
`Your nostr extension does not support nip04 encryption & decryption`
|
||||
)
|
||||
}
|
||||
|
||||
const decrypted = await this.nostr.nip04.decrypt(sender, content)
|
||||
return decrypted
|
||||
}
|
||||
|
||||
async nip44Encrypt(receiver: string, content: string): Promise<string> {
|
||||
if (!this.nostr.nip44) {
|
||||
throw new Error(
|
||||
`Your nostr extension does not support nip44 encryption & decryption`
|
||||
)
|
||||
}
|
||||
|
||||
// Encrypt the content using NIP-44 provided by the nostr extension.
|
||||
const encrypted = await this.nostr.nip44.encrypt(receiver, content)
|
||||
return encrypted as string
|
||||
}
|
||||
|
||||
async nip44Decrypt(sender: string, content: string): Promise<string> {
|
||||
if (!this.nostr.nip44) {
|
||||
throw new Error(
|
||||
`Your nostr extension does not support nip44 encryption & decryption`
|
||||
)
|
||||
}
|
||||
|
||||
// Decrypt the content using NIP-44 provided by the nostr extension.
|
||||
const decrypted = await this.nostr.nip44.decrypt(sender, content)
|
||||
return decrypted as string
|
||||
}
|
||||
|
||||
async signEvent(event: UnsignedEvent | EventTemplate): Promise<SignedEvent> {
|
||||
return (await this.nostr
|
||||
.signEvent(event as NostrEvent)
|
||||
.catch((err: unknown) => {
|
||||
console.log('Error while signing event: ', err)
|
||||
|
||||
throw err
|
||||
})) as Event
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user