Compare commits

..

4 Commits

110 changed files with 3443 additions and 6182 deletions

View File

@ -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: {

View File

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

2217
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -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}\"",
@ -31,7 +31,6 @@
"@mui/material": "5.15.11",
"@noble/hashes": "^1.4.0",
"@nostr-dev-kit/ndk": "2.5.0",
"@pdf-lib/fontkit": "^1.1.1",
"@reduxjs/toolkit": "2.2.1",
"axios": "^1.7.4",
"crypto-hash": "3.0.0",
@ -42,14 +41,12 @@
"jszip": "3.10.1",
"lodash": "4.17.21",
"mui-file-input": "4.0.4",
"nostr-login": "^1.6.6",
"nostr-tools": "2.7.0",
"pdf-lib": "^1.17.1",
"pdfjs-dist": "^4.4.168",
"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",
@ -57,7 +54,6 @@
"react-singleton-hook": "^4.0.1",
"react-toastify": "10.0.4",
"redux": "5.0.1",
"svgo": "^3.3.2",
"tseep": "1.2.1"
},
"devDependencies": {
@ -67,7 +63,6 @@
"@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",
@ -80,7 +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-tsconfig-paths": "4.3.2"
},
"lint-staged": {

View File

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

File diff suppressed because one or more lines are too long

View File

@ -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'] {

View File

@ -1,7 +1,7 @@
import { useEffect } from 'react'
import { useAppSelector } from './hooks/store'
import { useSelector } from 'react-redux'
import { Navigate, Route, Routes } from 'react-router-dom'
import { AuthController } from './controllers'
import { AuthController, NostrController } from './controllers'
import { MainLayout } from './layouts/Main'
import {
appPrivateRoutes,
@ -10,10 +10,12 @@ import {
publicRoutes,
recursiveRouteRenderer
} from './routes'
import { State } from './store/rootReducer'
import { getNsecBunkerDelegatedKey, saveNsecBunkerDelegatedKey } from './utils'
import './App.scss'
const App = () => {
const authState = useAppSelector((state) => state.auth)
const authState = useSelector((state: State) => state.auth)
useEffect(() => {
if (window.location.hostname === '0.0.0.0') {
@ -23,10 +25,23 @@ const App = () => {
window.location.hostname = 'localhost'
}
generateBunkerDelegatedKey()
const authController = new AuthController()
authController.checkSession()
}, [])
const generateBunkerDelegatedKey = () => {
const existingKey = getNsecBunkerDelegatedKey()
if (!existingKey) {
const nostrController = NostrController.getInstance()
const newDelegatedKey = nostrController.generateDelegatedKey()
saveNsecBunkerDelegatedKey(newDelegatedKey)
}
}
const handleRootRedirect = () => {
if (authState.loggedIn) return appPrivateRoutes.homePage
const callbackPathEncoded = btoa(

Binary file not shown.

View File

@ -9,42 +9,55 @@ 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 metadataState = useAppSelector((state) => state.metadata)
const userRobotImage = useAppSelector((state) => state.userRobotImage)
const authState = useSelector((state: State) => state.auth)
const metadataState = useSelector((state: State) => state.metadata)
const userRobotImage = useSelector((state: State) => state.userRobotImage)
useEffect(() => {
if (metadataState) {
if (metadataState.content) {
const profileMetadata = JSON.parse(metadataState.content)
const { picture } = profileMetadata
const { picture, display_name, name } = JSON.parse(
metadataState.content
)
if (picture || userRobotImage) {
setUserAvatar(picture || userRobotImage)
@ -54,7 +67,7 @@ export const AppBar = () => {
? hexToNpub(authState.usersPubkey)
: ''
setUsername(getProfileUsername(npub, profileMetadata))
setUsername(shorten(display_name || name || npub, 7))
} else {
setUserAvatar(userRobotImage || '')
setUsername('')
@ -79,157 +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}
startIcon={<ButtonIcon />}
onClick={() => {
navigate(appPublicRoutes.nostr)
}}
variant="contained"
>
<FontAwesomeIcon icon={faClose} />
LOGIN
</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" onClick={() => navigate('/')} />
</Box>
)}
<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>
)
}

View File

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

View File

@ -1,7 +1,7 @@
import { Meta } from '../../types'
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'
@ -17,67 +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'
type SigitProps = {
sigitCreateId: string
meta: Meta
parsedMeta: SigitCardDisplayInfo
}
export const DisplaySigit = ({
meta,
parsedMeta,
sigitCreateId: sigitCreateId
}: SigitProps) => {
export const DisplaySigit = ({ meta, parsedMeta }: SigitProps) => {
const { title, createdAt, submittedBy, signers, signedStatus, isValid } =
parsedMeta
const { signersStatus, fileHashes } = useSigitMeta(meta)
const profiles = useSigitProfiles([
...(submittedBy ? [submittedBy] : []),
...signers
])
const { extensions, isSame } = extractFileExtensions(Object.keys(fileHashes))
return (
<div className={styles.itemWrapper}>
{signedStatus === SigitStatus.Complete && (
<Link
to={appPublicRoutes.verify}
state={{ meta }}
className={styles.insetLink}
></Link>
)}
{signedStatus !== SigitStatus.Complete && (
<Link
to={`${appPrivateRoutes.sign}/${sigitCreateId}`}
className={styles.insetLink}
></Link>
)}
<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>

View File

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

View File

@ -1,5 +1,7 @@
import { Close } from '@mui/icons-material'
import {
Box,
CircularProgress,
FormControl,
InputLabel,
ListItemIcon,
@ -9,87 +11,77 @@ import {
} from '@mui/material'
import styles from './style.module.scss'
import React, { useEffect, useState } from 'react'
import { ProfileMetadata, User, UserRole, KeyboardCode } from '../../types'
import * as PDFJS from 'pdfjs-dist'
import { ProfileMetadata, User, UserRole } from '../../types'
import { MouseState, PdfPage, DrawnField, DrawTool } from '../../types/drawing'
import { hexToNpub, npubToHex, getProfileUsername } from '../../utils'
import { SigitFile } from '../../utils/file'
import { getToolboxLabelByMarkType } from '../../utils/mark'
import { truncate } from 'lodash'
import { settleAllFullfilfedPromises, hexToNpub, npubToHex } from '../../utils'
import { getSigitFile, SigitFile } from '../../utils/file'
import { FileDivider } from '../FileDivider'
import { ExtensionFileBox } from '../ExtensionFileBox'
import { FONT_SIZE, FONT_TYPE, inPx } from '../../utils/pdf'
import { inPx } from '../../utils/pdf'
import { useScale } from '../../hooks/useScale'
import { AvatarIconButton } from '../UserAvatarIconButton'
import { UserAvatar } from '../UserAvatar'
import _ from 'lodash'
const DEFAULT_START_SIZE = {
width: 140,
height: 40
} as const
interface HideSignersForDrawnField {
[key: number]: boolean
}
PDFJS.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url
).toString()
interface Props {
selectedFiles: File[]
users: User[]
metadata: { [key: string]: ProfileMetadata }
sigitFiles: SigitFile[]
setSigitFiles: React.Dispatch<React.SetStateAction<SigitFile[]>>
onDrawFieldsChange: (sigitFiles: SigitFile[]) => void
selectedTool?: DrawTool
}
export const DrawPDFFields = (props: Props) => {
const { selectedTool, sigitFiles, setSigitFiles, users } = props
const signers = users.filter((u) => u.role === UserRole.signer)
const defaultSignerNpub = signers.length ? hexToNpub(signers[0].pubkey) : ''
const [lastSigner, setLastSigner] = useState(defaultSignerNpub)
const [hideSignersForDrawnField, setHideSignersForDrawnField] =
useState<HideSignersForDrawnField>({})
/**
* Return first pubkey that is present in the signers list
* @param pubkeys
* @returns available pubkey or empty string
*/
const getAvailableSigner = (...pubkeys: string[]) => {
const availableSigner: string | undefined = pubkeys.find((pubkey) =>
signers.some((s) => s.pubkey === npubToHex(pubkey))
)
return availableSigner || ''
}
const { selectedFiles, selectedTool, onDrawFieldsChange, users } = props
const { to, from } = useScale()
const [sigitFiles, setSigitFiles] = useState<SigitFile[]>([])
const [parsingPdf, setIsParsing] = useState<boolean>(false)
const [mouseState, setMouseState] = useState<MouseState>({
clicked: false
})
const [activeDrawnField, setActiveDrawnField] = useState<{
fileIndex: number
pageIndex: number
drawnFieldIndex: number
}>()
const isActiveDrawnField = (
fileIndex: number,
pageIndex: number,
drawnFieldIndex: number
) =>
activeDrawnField?.fileIndex === fileIndex &&
activeDrawnField?.pageIndex === pageIndex &&
activeDrawnField?.drawnFieldIndex === drawnFieldIndex
useEffect(() => {
if (selectedFiles) {
/**
* Reads the binary files and converts to internal file type
* and sets to a state (adds images if it's a PDF)
*/
const parsePages = async () => {
const files = await settleAllFullfilfedPromises(
selectedFiles,
getSigitFile
)
setSigitFiles(files)
}
setIsParsing(true)
parsePages().finally(() => {
setIsParsing(false)
})
}
}, [selectedFiles])
useEffect(() => {
if (sigitFiles) onDrawFieldsChange(sigitFiles)
}, [onDrawFieldsChange, sigitFiles])
/**
* Drawing events
*/
useEffect(() => {
window.addEventListener('pointerup', handlePointerUp)
window.addEventListener('pointercancel', handlePointerUp)
window.addEventListener('mouseup', onMouseUp)
return () => {
window.removeEventListener('pointerup', handlePointerUp)
window.removeEventListener('pointercancel', handlePointerUp)
window.removeEventListener('mouseup', onMouseUp)
}
}, [])
@ -98,18 +90,16 @@ export const DrawPDFFields = (props: Props) => {
}
/**
* Fired only on when left (primary pointer interaction) clicking page image
* Fired only when left click and mouse over pdf page
* Creates new drawnElement and pushes in the array
* It is re rendered and visible right away
*
* @param event Pointer event
* @param event Mouse event
* @param page PdfPage where press happened
*/
const handlePointerDown = (
event: React.PointerEvent,
page: PdfPage,
fileIndex: number,
pageIndex: number
const onMouseDown = (
event: React.MouseEvent<HTMLDivElement>,
page: PdfPage
) => {
// Proceed only if left click
if (event.button !== 0) return
@ -118,24 +108,19 @@ export const DrawPDFFields = (props: Props) => {
return
}
const { x, y } = getPointerCoordinates(event)
const { mouseX, mouseY } = getMouseCoordinates(event)
const newField: DrawnField = {
left: to(page.width, x),
top: to(page.width, y),
width: event.pointerType === 'mouse' ? 0 : DEFAULT_START_SIZE.width,
height: event.pointerType === 'mouse' ? 0 : DEFAULT_START_SIZE.height,
counterpart: getAvailableSigner(lastSigner, defaultSignerNpub),
left: to(page.width, mouseX),
top: to(page.width, mouseY),
width: 0,
height: 0,
counterpart: '',
type: selectedTool.identifier
}
page.drawnFields.push(newField)
setActiveDrawnField({
fileIndex,
pageIndex,
drawnFieldIndex: page.drawnFields.length - 1
})
setMouseState((prev) => {
return {
...prev,
@ -146,9 +131,9 @@ export const DrawPDFFields = (props: Props) => {
/**
* Drawing is finished, resets all the variables used to draw
* @param event Pointer event
* @param event Mouse event
*/
const handlePointerUp = () => {
const onMouseUp = () => {
setMouseState((prev) => {
return {
...prev,
@ -160,13 +145,16 @@ export const DrawPDFFields = (props: Props) => {
}
/**
* After {@link handlePointerDown} create an drawing element, this function gets called on every pixel moved
* which alters the newly created drawing element, resizing it while pointer moves
* @param event Pointer event
* After {@link onMouseDown} create an drawing element, this function gets called on every pixel moved
* which alters the newly created drawing element, resizing it while mouse move
* @param event Mouse event
* @param page PdfPage where moving is happening
*/
const handlePointerMove = (event: React.PointerEvent, page: PdfPage) => {
if (mouseState.clicked && selectedTool && event.pointerType === 'mouse') {
const onMouseMove = (
event: React.MouseEvent<HTMLDivElement>,
page: PdfPage
) => {
if (mouseState.clicked && selectedTool) {
const lastElementIndex = page.drawnFields.length - 1
const lastDrawnField = page.drawnFields[lastElementIndex]
@ -176,10 +164,10 @@ export const DrawPDFFields = (props: Props) => {
// to the page below (without releaseing mouse click)
if (!lastDrawnField) return
const { x, y } = getPointerCoordinates(event)
const { mouseX, mouseY } = getMouseCoordinates(event)
const width = to(page.width, x) - lastDrawnField.left
const height = to(page.width, y) - lastDrawnField.top
const width = to(page.width, mouseX) - lastDrawnField.left
const height = to(page.width, mouseY) - lastDrawnField.top
lastDrawnField.width = width
lastDrawnField.height = height
@ -194,68 +182,55 @@ export const DrawPDFFields = (props: Props) => {
/**
* Fired when event happens on the drawn element which will be moved
* pointer coordinates relative to drawn element will be stored
* mouse coordinates relative to drawn element will be stored
* so when we start moving, offset can be calculated
* x - offsetX
* y - offsetY
* mouseX - offsetX
* mouseY - offsetY
*
* @param event Pointer event
* @param drawnFieldIndex Which we are moving
* @param event Mouse event
* @param drawnField Which we are moving
*/
const handleDrawnFieldPointerDown = (
event: React.PointerEvent,
fileIndex: number,
pageIndex: number,
drawnFieldIndex: number
) => {
const onDrawnFieldMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
event.stopPropagation()
// Proceed only if left click
if (event.button !== 0) return
const drawingRectangleCoords = getPointerCoordinates(event)
const drawingRectangleCoords = getMouseCoordinates(event)
setActiveDrawnField({ fileIndex, pageIndex, drawnFieldIndex })
setMouseState({
dragging: true,
clicked: false,
coordsInWrapper: {
x: drawingRectangleCoords.x,
y: drawingRectangleCoords.y
mouseX: drawingRectangleCoords.mouseX,
mouseY: drawingRectangleCoords.mouseY
}
})
// make signers dropdown visible
setHideSignersForDrawnField((prev) => ({
...prev,
[drawnFieldIndex]: false
}))
}
/**
* Moves the drawnElement by the pointer position (pointer can grab anywhere on the drawn element)
* @param event Pointer event
* Moves the drawnElement by the mouse position (mouse can grab anywhere on the drawn element)
* @param event Mouse event
* @param drawnField which we are moving
* @param pageWidth pdf value which is used to calculate scaled offset
*/
const handleDrawnFieldPointerMove = (
event: React.PointerEvent,
const onDrawnFieldMouseMove = (
event: React.MouseEvent<HTMLDivElement>,
drawnField: DrawnField,
pageWidth: number
) => {
if (mouseState.dragging) {
const { x, y, rect } = getPointerCoordinates(
const { mouseX, mouseY, rect } = getMouseCoordinates(
event,
event.currentTarget.parentElement
)
const coordsOffset = mouseState.coordsInWrapper
if (coordsOffset) {
let left = to(pageWidth, x - coordsOffset.x)
let top = to(pageWidth, y - coordsOffset.y)
let left = to(pageWidth, mouseX - coordsOffset.mouseX)
let top = to(pageWidth, mouseY - coordsOffset.mouseY)
const rightLimit = to(pageWidth, rect.width) - drawnField.width
const bottomLimit = to(pageWidth, rect.height) - drawnField.height
const rightLimit = to(pageWidth, rect.width) - drawnField.width - 3
const bottomLimit = to(pageWidth, rect.height) - drawnField.height - 3
if (left < 0) left = 0
if (top < 0) top = 0
@ -272,20 +247,17 @@ export const DrawPDFFields = (props: Props) => {
/**
* Fired when clicked on the resize handle, sets the state for a resize action
* @param event Pointer event
* @param drawnFieldIndex which we are resizing
* @param event Mouse event
* @param drawnField which we are resizing
*/
const handleResizePointerDown = (
event: React.PointerEvent,
fileIndex: number,
pageIndex: number,
drawnFieldIndex: number
const onResizeHandleMouseDown = (
event: React.MouseEvent<HTMLSpanElement>
) => {
// Proceed only if left click
if (event.button !== 0) return
event.stopPropagation()
setActiveDrawnField({ fileIndex, pageIndex, drawnFieldIndex })
setMouseState({
resizing: true
})
@ -293,17 +265,16 @@ export const DrawPDFFields = (props: Props) => {
/**
* Resizes the drawn element by the mouse position
* @param event Pointer event
* @param event Mouse event
* @param drawnField which we are resizing
* @param pageWidth pdf value which is used to calculate scaled offset
*/
const handleResizePointerMove = (
event: React.PointerEvent,
const onResizeHandleMouseMove = (
event: React.MouseEvent<HTMLSpanElement>,
drawnField: DrawnField,
pageWidth: number
) => {
if (mouseState.resizing) {
const { x, y } = getPointerCoordinates(
const { mouseX, mouseY } = getMouseCoordinates(
event,
// currentTarget = span handle
// 1st parent = drawnField
@ -311,8 +282,8 @@ export const DrawPDFFields = (props: Props) => {
event.currentTarget.parentElement?.parentElement
)
const width = to(pageWidth, x) - drawnField.left
const height = to(pageWidth, y) - drawnField.top
const width = to(pageWidth, mouseX) - drawnField.left
const height = to(pageWidth, mouseY) - drawnField.top
drawnField.width = width
drawnField.height = height
@ -323,13 +294,13 @@ export const DrawPDFFields = (props: Props) => {
/**
* Removes the drawn element using the indexes in the params
* @param event Pointer event
* @param event Mouse event
* @param pdfFileIndex pdf file index
* @param pdfPageIndex pdf page index
* @param drawnFileIndex drawn file index
*/
const handleRemovePointerDown = (
event: React.PointerEvent,
const onRemoveHandleMouseDown = (
event: React.MouseEvent<HTMLSpanElement>,
pdfFileIndex: number,
pdfPageIndex: number,
drawnFileIndex: number
@ -343,60 +314,36 @@ export const DrawPDFFields = (props: Props) => {
}
/**
* Used to stop pointer click propagating to the parent elements
* Used to stop mouse click propagating to the parent elements
* so select can work properly
* @param event Pointer event
* @param event Mouse event
*/
const handleUserSelectPointerDown = (event: React.PointerEvent) => {
const onUserSelectHandleMouseDown = (
event: React.MouseEvent<HTMLDivElement>
) => {
event.stopPropagation()
}
/**
* Handles Escape button-down event and hides all signers dropdowns
* @param event SyntheticEvent event
*/
const handleEscapeButtonDown = (event: React.SyntheticEvent) => {
// get native event
const { nativeEvent } = event
//check if event is a keyboard event
if (nativeEvent instanceof KeyboardEvent) {
// check if event code is Escape
if (nativeEvent.code === KeyboardCode.Escape) {
// hide all signers dropdowns
const newHideSignersForDrawnField: HideSignersForDrawnField = {}
Object.keys(hideSignersForDrawnField).forEach((key) => {
// Object.keys always returns an array of strings,
// that is why unknown type is used below
newHideSignersForDrawnField[key as unknown as number] = true
})
setHideSignersForDrawnField(newHideSignersForDrawnField)
}
}
}
/**
* Gets the pointer coordinates relative to a element in the `event` param
* @param event PointerEvent
* @param customTarget coordinates relative to this element, if not provided
* Gets the mouse coordinates relative to a element in the `event` param
* @param event MouseEvent
* @param customTarget mouse coordinates relative to this element, if not provided
* event.target will be used
*/
const getPointerCoordinates = (
event: React.PointerEvent,
const getMouseCoordinates = (
event: React.MouseEvent<HTMLElement>,
customTarget?: HTMLElement | null
) => {
const target = customTarget ? customTarget : event.currentTarget
const rect = target.getBoundingClientRect()
// Clamp X Y within the target
const x = Math.min(event.clientX, rect.right) - rect.left //x position within the element.
const y = Math.min(event.clientY, rect.bottom) - rect.top //y position within the element.
const mouseX = Math.min(event.clientX, rect.right) - rect.left //x position within the element.
const mouseY = Math.min(event.clientY, rect.bottom) - rect.top //y position within the element.
return {
x,
y,
mouseX,
mouseY,
rect
}
}
@ -415,99 +362,45 @@ export const DrawPDFFields = (props: Props) => {
<div
key={pageIndex}
className={`image-wrapper ${styles.pdfImageWrapper} ${selectedTool ? styles.drawing : ''}`}
tabIndex={-1}
onKeyDown={(event) => handleEscapeButtonDown(event)}
>
<img
onPointerMove={(event) => {
handlePointerMove(event, page)
onMouseMove={(event) => {
onMouseMove(event, page)
}}
onPointerDown={(event) => {
handlePointerDown(event, page, fileIndex, pageIndex)
onMouseDown={(event) => {
onMouseDown(event, page)
}}
draggable="false"
src={page.image}
alt={`page ${pageIndex + 1} of ${file.name}`}
/>
{page.drawnFields.map((drawnField, drawnFieldIndex: number) => {
return (
<div
key={drawnFieldIndex}
onPointerDown={(event) =>
handleDrawnFieldPointerDown(
event,
fileIndex,
pageIndex,
drawnFieldIndex
)
}
onPointerMove={(event) => {
handleDrawnFieldPointerMove(event, drawnField, page.width)
onMouseDown={onDrawnFieldMouseDown}
onMouseMove={(event) => {
onDrawnFieldMouseMove(event, drawnField, page.width)
}}
className={styles.drawingRectangle}
style={{
backgroundColor: drawnField.counterpart
? `#${npubToHex(drawnField.counterpart)?.substring(0, 6)}4b`
: undefined,
outlineColor: drawnField.counterpart
? `#${npubToHex(drawnField.counterpart)?.substring(0, 6)}`
: undefined,
left: inPx(from(page.width, drawnField.left)),
top: inPx(from(page.width, drawnField.top)),
width: inPx(from(page.width, drawnField.width)),
height: inPx(from(page.width, drawnField.height)),
pointerEvents: mouseState.clicked ? 'none' : 'all',
touchAction: 'none',
opacity:
mouseState.dragging &&
isActiveDrawnField(
fileIndex,
pageIndex,
drawnFieldIndex
)
? 0.8
: undefined
pointerEvents: mouseState.clicked ? 'none' : 'all'
}}
>
<div
className={`file-mark ${styles.placeholder}`}
style={{
fontFamily: FONT_TYPE,
fontSize: inPx(from(page.width, FONT_SIZE))
}}
>
{getToolboxLabelByMarkType(drawnField.type) ||
'placeholder'}
</div>
<span
onPointerDown={(event) =>
handleResizePointerDown(
event,
fileIndex,
pageIndex,
drawnFieldIndex
)
}
onPointerMove={(event) => {
handleResizePointerMove(event, drawnField, page.width)
onMouseDown={onResizeHandleMouseDown}
onMouseMove={(event) => {
onResizeHandleMouseMove(event, drawnField, page.width)
}}
className={styles.resizeHandle}
style={{
background:
mouseState.resizing &&
isActiveDrawnField(
fileIndex,
pageIndex,
drawnFieldIndex
)
? 'var(--primary-main)'
: undefined
}}
></span>
<span
onPointerDown={(event) => {
handleRemovePointerDown(
onMouseDown={(event) => {
onRemoveHandleMouseDown(
event,
fileIndex,
pageIndex,
@ -518,94 +411,74 @@ export const DrawPDFFields = (props: Props) => {
>
<Close fontSize="small" />
</span>
{!isActiveDrawnField(
fileIndex,
pageIndex,
drawnFieldIndex
) &&
!!drawnField.counterpart && (
<div className={styles.counterpartAvatar}>
<UserAvatar
pubkey={npubToHex(drawnField.counterpart)!}
/>
</div>
)}
{isActiveDrawnField(
fileIndex,
pageIndex,
drawnFieldIndex
) &&
(!hideSignersForDrawnField ||
!hideSignersForDrawnField[drawnFieldIndex]) && (
<div
onPointerDown={handleUserSelectPointerDown}
className={styles.userSelect}
<div
onMouseDown={onUserSelectHandleMouseDown}
className={styles.userSelect}
>
<FormControl fullWidth size="small">
<InputLabel id="counterparts">Counterpart</InputLabel>
<Select
value={drawnField.counterpart}
onChange={(event) => {
drawnField.counterpart = event.target.value
refreshPdfFiles()
}}
labelId="counterparts"
label="Counterparts"
sx={{
background: 'white'
}}
renderValue={(value) => renderCounterpartValue(value)}
>
<FormControl fullWidth size="small">
<InputLabel id="counterparts">
Counterpart
</InputLabel>
<Select
value={getAvailableSigner(drawnField.counterpart)}
onChange={(event) => {
drawnField.counterpart = event.target.value
setLastSigner(event.target.value)
refreshPdfFiles()
}}
labelId="counterparts"
label="Counterparts"
sx={{
background: 'white'
}}
renderValue={(value) =>
renderCounterpartValue(value)
}
>
{signers.map((signer, index) => {
const npub = hexToNpub(signer.pubkey)
const metadata = props.metadata[signer.pubkey]
const displayValue = getProfileUsername(
npub,
metadata
)
// make current signers dropdown visible
if (
hideSignersForDrawnField[drawnFieldIndex] ===
undefined ||
hideSignersForDrawnField[drawnFieldIndex] ===
true
) {
setHideSignersForDrawnField((prev) => ({
...prev,
[drawnFieldIndex]: false
}))
}
{users
.filter((u) => u.role === UserRole.signer)
.map((user, index) => {
const npub = hexToNpub(user.pubkey)
let displayValue = truncate(npub, {
length: 16
})
return (
<MenuItem key={index} value={npub}>
<ListItemIcon>
<AvatarIconButton
src={metadata?.picture}
hexKey={signer.pubkey}
aria-label={`account of user ${displayValue}`}
color="inherit"
sx={{
padding: 0,
'> img': {
width: '30px',
height: '30px'
}
}}
/>
</ListItemIcon>
<ListItemText>{displayValue}</ListItemText>
</MenuItem>
const metadata = props.metadata[user.pubkey]
if (metadata) {
displayValue = truncate(
metadata.name ||
metadata.display_name ||
metadata.username ||
npub,
{
length: 16
}
)
})}
</Select>
</FormControl>
</div>
)}
}
return (
<MenuItem
key={index}
value={hexToNpub(user.pubkey)}
>
<ListItemIcon>
<AvatarIconButton
src={metadata?.picture}
hexKey={user.pubkey}
aria-label={`account of user ${displayValue}`}
color="inherit"
sx={{
padding: 0,
'> img': {
width: '30px',
height: '30px'
}
}}
/>
</ListItemIcon>
<ListItemText>{displayValue}</ListItemText>
</MenuItem>
)
})}
</Select>
</FormControl>
</div>
</div>
)
})}
@ -616,19 +489,28 @@ export const DrawPDFFields = (props: Props) => {
)
}
const renderCounterpartValue = (npub: string) => {
let displayValue = _.truncate(npub, { length: 16 })
const renderCounterpartValue = (value: string) => {
const user = users.find((u) => u.pubkey === npubToHex(value))
if (user) {
let displayValue = truncate(value, {
length: 16
})
const signer = signers.find((u) => u.pubkey === npubToHex(npub))
if (signer) {
const metadata = props.metadata[signer.pubkey]
displayValue = getProfileUsername(npub, metadata)
const metadata = props.metadata[user.pubkey]
if (metadata) {
displayValue = truncate(
metadata.name || metadata.display_name || metadata.username || value,
{
length: 16
}
)
}
return (
<div className={styles.counterpartSelectValue}>
<>
<AvatarIconButton
src={props.metadata[signer.pubkey]?.picture}
hexKey={signer.pubkey || undefined}
src={props.metadata[user.pubkey]?.picture}
hexKey={npubToHex(user.pubkey) || undefined}
sx={{
padding: 0,
marginRight: '6px',
@ -639,11 +521,23 @@ export const DrawPDFFields = (props: Props) => {
}}
/>
{displayValue}
</div>
</>
)
}
return displayValue
return value
}
if (parsingPdf) {
return (
<Box sx={{ width: '100%', textAlign: 'center' }}>
<CircularProgress />
</Box>
)
}
if (!sigitFiles.length) {
return ''
}
return (
@ -664,7 +558,7 @@ export const DrawPDFFields = (props: Props) => {
<ExtensionFileBox extension={file.extension} />
)}
</div>
{i < sigitFiles.length - 1 && <FileDivider />}
{i < selectedFiles.length - 1 && <FileDivider />}
</React.Fragment>
)
})}

View File

@ -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;
@ -39,7 +29,7 @@
}
&.edited {
outline: 1px dotted #01aaad;
border: 1px dotted #01aaad;
}
.resizeHandle {
@ -88,14 +78,3 @@
padding: 5px 0;
}
}
.counterpartSelectValue {
display: flex;
}
.counterpartAvatar {
img {
width: 21px;
height: 21px;
}
}

View File

@ -22,26 +22,30 @@ const FileList = ({
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>
<div className={styles.fileVisual}>
{currentUserFile.isHashValid && (
<FontAwesomeIcon icon={faCheck} />
)}
</div>
</li>
))}
</ul>
</div>
<Button variant="contained" fullWidth onClick={handleDownload}>
{downloadLabel || 'Download Files'}
</Button>

View File

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

View File

@ -4,128 +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={'/'}
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&nbsp;
<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&nbsp;
<a href="https://nostrdev.com/" target="_blank">
Nostr Dev
</a>{' '}
2024.
</div>
</footer>
)

View File

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

View File

@ -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: 50;
-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 {

View File

@ -1,18 +1,20 @@
import { CurrentUserMark } from '../../types/mark.ts'
import styles from './style.module.scss'
import { MARK_TYPE_TRANSLATION, NEXT, SIGN } from '../../utils/const.ts'
import {
findNextIncompleteCurrentUserMark,
getToolboxLabelByMarkType,
isCurrentUserMarksComplete,
isCurrentValueLast
} from '../../utils'
import React, { useState } from 'react'
import { MARK_TYPE_CONFIG } from '../getMarkComponents.tsx'
interface MarkFormFieldProps {
currentUserMarks: CurrentUserMark[]
handleCurrentUserMarkChange: (mark: CurrentUserMark) => void
handleSelectedMarkValueChange: (value: string) => void
handleSelectedMarkValueChange: (
event: React.ChangeEvent<HTMLInputElement>
) => void
handleSubmit: (event: React.FormEvent<HTMLFormElement>) => void
selectedMark: CurrentUserMark
selectedMarkValue: string
@ -30,6 +32,7 @@ const MarkFormField = ({
handleCurrentUserMarkChange
}: MarkFormFieldProps) => {
const [displayActions, setDisplayActions] = useState(true)
const getSubmitButtonText = () => (isReadyToSign() ? SIGN : NEXT)
const isReadyToSign = () =>
isCurrentUserMarksComplete(currentUserMarks) ||
isCurrentValueLast(currentUserMarks, selectedMark, selectedMarkValue)
@ -51,9 +54,6 @@ const MarkFormField = ({
: handleCurrentUserMarkChange(findNext()!)
}
const toggleActions = () => setDisplayActions(!displayActions)
const markLabel = getToolboxLabelByMarkType(selectedMark.mark.type)
const { input: MarkInputComponent } =
MARK_TYPE_CONFIG[selectedMark.mark.type] || {}
return (
<div className={styles.container}>
<div className={styles.trigger}>
@ -61,7 +61,6 @@ const MarkFormField = ({
onClick={toggleActions}
className={styles.triggerBtn}
type="button"
title="Toggle"
>
<svg
xmlns="http://www.w3.org/2000/svg"
@ -79,22 +78,22 @@ const MarkFormField = ({
<div className={styles.actionsWrapper}>
<div className={styles.actionsTop}>
<div className={styles.actionsTopInfo}>
<p className={styles.actionsTopInfoText}>Add {markLabel}</p>
<p className={styles.actionsTopInfoText}>Add your signature</p>
</div>
</div>
<div className={styles.inputWrapper}>
<form onSubmit={(e) => handleFormSubmit(e)}>
{typeof MarkInputComponent !== 'undefined' && (
<MarkInputComponent
value={selectedMarkValue}
placeholder={markLabel}
handler={handleSelectedMarkValueChange}
userMark={selectedMark}
/>
)}
<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}>
NEXT
{getSubmitButtonText()}
</button>
</div>
</form>

View File

@ -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;
@ -115,7 +107,7 @@
.actions {
background: white;
width: 100%;
border-radius: 5px;
border-radius: 4px;
padding: 10px 20px;
display: none;
flex-direction: column;

View File

@ -1,44 +0,0 @@
@import '../../styles/colors.scss';
$padding: 5px;
.wrapper {
display: flex;
justify-content: center;
align-items: center;
padding: $padding;
}
.relative {
position: relative;
}
.canvas {
outline: 1px solid black;
background-color: $body-background-color;
cursor: crosshair;
// Disable panning/zooming when touching canvas element
-ms-touch-action: none;
touch-action: none;
-webkit-user-select: none;
user-select: none;
}
.absolute {
position: absolute;
inset: 0;
pointer-events: none;
}
.reset {
cursor: pointer;
position: absolute;
top: 0;
right: $padding;
color: $primary-main;
&:hover {
color: $primary-dark;
}
}

View File

@ -1,101 +0,0 @@
import { useRef, useState } from 'react'
import { MarkInputProps } from '../../types/mark'
import { getOptimizedPaths, optimizeSVG } from '../../utils'
import { faEraser } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import styles from './Signature.module.scss'
import { MarkRenderSignature } from '../MarkRender/Signature'
export const MarkInputSignature = ({
value,
handler,
userMark
}: MarkInputProps) => {
const location = userMark?.mark.location
const canvasRef = useRef<HTMLCanvasElement>(null)
const [drawing, setDrawing] = useState(false)
const [paths, setPaths] = useState<string[]>(value ? JSON.parse(value) : [])
function update() {
if (location && paths) {
if (paths.length) {
const optimizedSvg = optimizeSVG(location, paths)
const extractedPaths = getOptimizedPaths(optimizedSvg)
handler(JSON.stringify(extractedPaths))
} else {
handler('')
}
}
}
const handlePointerDown = (event: React.PointerEvent) => {
const rect = event.currentTarget.getBoundingClientRect()
const x = event.clientX - rect.left
const y = event.clientY - rect.top
const ctx = canvasRef.current?.getContext('2d')
ctx?.beginPath()
ctx?.moveTo(x, y)
setPaths([...paths, `M ${x} ${y}`])
setDrawing(true)
}
const handlePointerUp = () => {
setDrawing(false)
update()
const ctx = canvasRef.current?.getContext('2d')
ctx?.clearRect(0, 0, canvasRef.current!.width, canvasRef.current!.height)
}
const handlePointerMove = (event: React.PointerEvent) => {
if (!drawing) return
const ctx = canvasRef.current?.getContext('2d')
const rect = canvasRef.current?.getBoundingClientRect()
const x = event.clientX - rect!.left
const y = event.clientY - rect!.top
ctx?.lineTo(x, y)
ctx?.stroke()
// Collect the path data
setPaths((prevPaths) => {
const newPaths = [...prevPaths]
newPaths[newPaths.length - 1] += ` L ${x} ${y}`
return newPaths
})
}
const handleReset = () => {
setPaths([])
setDrawing(false)
update()
const ctx = canvasRef.current?.getContext('2d')
ctx?.clearRect(0, 0, canvasRef.current!.width, canvasRef.current!.height)
}
return (
<>
<div className={styles.wrapper}>
<div className={styles.relative}>
<canvas
height={location?.height}
width={location?.width}
ref={canvasRef}
className={styles.canvas}
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
onPointerMove={handlePointerMove}
onPointerOut={handlePointerUp}
></canvas>
{typeof userMark?.mark !== 'undefined' && (
<div className={styles.absolute}>
<MarkRenderSignature value={value} mark={userMark.mark} />
</div>
)}
<div className={styles.reset}>
<FontAwesomeIcon size="sm" icon={faEraser} onClick={handleReset} />
</div>
</div>
</div>
</>
)
}

View File

@ -1,19 +0,0 @@
import { MarkInputProps } from '../../types/mark'
import styles from '../MarkFormField/style.module.scss'
export const MarkInputText = ({
value,
handler,
placeholder
}: MarkInputProps) => {
return (
<input
className={styles.input}
placeholder={placeholder}
onChange={(e) => {
handler(e.currentTarget.value)
}}
value={value}
/>
)
}

View File

@ -1,13 +0,0 @@
import { MarkRenderProps } from '../../types/mark'
export const MarkRenderSignature = ({ value, mark }: MarkRenderProps) => {
const paths = value ? JSON.parse(value) : []
return (
<svg viewBox={`0 0 ${mark.location.width} ${mark.location.height}`}>
{paths.map((path: string) => (
<path d={path} stroke="black" fill="none" />
))}
</svg>
)
}

View File

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

View File

@ -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 { MARK_TYPE_CONFIG } from '../getMarkComponents.tsx'
interface PdfMarkItemProps {
userMark: CurrentUserMark
@ -17,45 +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()
const { render: MarkRenderComponent } =
MARK_TYPE_CONFIG[userMark.mark.type] || {}
return (
<div
ref={ref}
onClick={handleClick}
className={`file-mark ${styles.drawingRectangle} ${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))
}}
>
{typeof MarkRenderComponent !== 'undefined' && (
<MarkRenderComponent 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

View File

@ -15,11 +15,6 @@ 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[]
@ -117,7 +112,8 @@ const PdfMarking = (props: PdfMarkingProps) => {
// setCurrentUserMarks(updatedCurrentUserMarks)
// }
const handleChange = (value: string) => setSelectedMarkValue(value)
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) =>
setSelectedMarkValue(event.target.value)
return (
<>
@ -136,9 +132,6 @@ const PdfMarking = (props: PdfMarkingProps) => {
</div>
}
right={meta !== null && <UsersDetails meta={meta} />}
leftIcon={faFileDownload}
centerIcon={faPen}
rightIcon={faCircleInfo}
>
{currentUserMarks?.length > 0 && (
<PdfView

View File

@ -7,8 +7,6 @@ import pdfViewStyles from './style.module.scss'
import { FONT_SIZE, FONT_TYPE, inPx } from '../../utils/pdf.ts'
import { useScale } from '../../hooks/useScale.tsx'
interface PdfPageProps {
fileName: string
pageIndex: number
currentUserMarks: CurrentUserMark[]
handleMarkClick: (id: number) => void
otherUserMarks: Mark[]
@ -21,8 +19,6 @@ interface PdfPageProps {
* Responsible for rendering a single Pdf Page and its Marks
*/
const PdfPageItem = ({
fileName,
pageIndex,
page,
currentUserMarks,
handleMarkClick,
@ -33,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])
@ -43,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 (

View File

@ -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
@ -49,34 +48,30 @@ const PdfView = ({
index !== files.length - 1
return (
<div className="files-wrapper">
{files.length > 0 ? (
files.map((currentUserFile, index, arr) => {
const { hash, file, id } = currentUserFile
{files.map((currentUserFile, index, arr) => {
const { hash, file, id } = currentUserFile
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>
)
})
) : (
<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>
)
}

View File

@ -6,7 +6,7 @@
.otherUserMarksDisplay {
position: absolute;
z-index: 40;
z-index: 50;
display: flex;
justify-content: center;
align-items: center;

View File

@ -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?.picture
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>
)
}

View File

@ -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) : <>&mdash;</>}{' '}
{timestamps &&
timestamps.length > 0 &&
id &&
getOpenTimestampsInfo(timestamps, id)}
{createdAt ? formatTimestamp(createdAt) : <>&mdash;</>}
</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) : <>&mdash;</>}
{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) => {
) : (
<>&mdash;</>
)}
{hexToNpub(usersPubkey) in parsedSignatureEvents &&
timestamps &&
timestamps.length > 0 && (
<span className={styles.ticket}>
{getOpenTimestampsInfo(
timestamps,
parsedSignatureEvents[hexToNpub(usersPubkey)].id
)}
</span>
)}
</span>
</Tooltip>
) : null}

View File

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

View File

@ -1,16 +0,0 @@
import { MarkType } from '../types/drawing'
import { MarkConfigs } from '../types/mark'
import { MarkInputSignature } from './MarkInputs/Signature'
import { MarkInputText } from './MarkInputs/Text'
import { MarkRenderSignature } from './MarkRender/Signature'
export const MARK_TYPE_CONFIG: MarkConfigs = {
[MarkType.TEXT]: {
input: MarkInputText,
render: ({ value }) => <>{value}</>
},
[MarkType.SIGNATURE]: {
input: MarkInputSignature,
render: MarkRenderSignature
}
}

View File

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

View File

@ -25,13 +25,13 @@ export class AuthController {
constructor() {
this.nostrController = NostrController.getInstance()
this.metadataController = MetadataController.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 or keys)
* method will be chosen (extension, nsecbunker or keys)
*
* @param pubkey of the user trying to login
* @returns url to redirect if authentication successfull
@ -57,15 +57,12 @@ export class AuthController {
// Nostr uses unix timestamps
const timestamp = unixNow()
const { href } = window.location
const { hostname } = window.location
const authEvent: EventTemplate = {
kind: 27235,
tags: [
['u', href],
['method', 'GET']
],
content: '',
tags: [],
content: `${hostname}-${timestamp}`,
created_at: timestamp
}
@ -86,7 +83,7 @@ export class AuthController {
return Promise.resolve(appPrivateRoutes.relays)
}
if (store.getState().auth.loggedIn) {
if (store.getState().auth?.loggedIn) {
if (!compareObjects(store.getState().relays?.map, relayMap.map))
store.dispatch(setRelayMapAction(relayMap.map))
}

View File

@ -22,7 +22,6 @@ import {
import { DEFAULT_LOOK_UP_RELAY_LIST } from '../utils/const'
export class MetadataController extends EventEmitter {
private static instance: MetadataController
private nostrController: NostrController
private specialMetadataRelay = 'wss://purplepag.es'
private pendingFetches = new Map<string, Promise<Event | null>>() // Track pending fetches
@ -32,13 +31,6 @@ export class MetadataController extends EventEmitter {
this.nostrController = NostrController.getInstance()
}
public static getInstance(): MetadataController {
if (!MetadataController.instance) {
MetadataController.instance = new MetadataController()
}
return MetadataController.instance
}
/**
* Asynchronously checks for more recent metadata events authored by a specific key.
* If a more recent metadata event is found, it is handled and returned.
@ -127,6 +119,7 @@ export class MetadataController extends EventEmitter {
// 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)
}
@ -152,11 +145,20 @@ export class MetadataController extends EventEmitter {
* or a fallback RelaySet with Sigit's Relay
*/
public findRelayListMetadata = async (hexKey: string): Promise<RelaySet> => {
const relayEvent =
(await findRelayListInCache(hexKey)) ||
(await findRelayListAndUpdateCache(DEFAULT_LOOK_UP_RELAY_LIST, hexKey))
try {
const relayEvent =
(await findRelayListInCache(hexKey)) ||
(await findRelayListAndUpdateCache(DEFAULT_LOOK_UP_RELAY_LIST, hexKey))
return relayEvent ? getUserRelaySet(relayEvent.tags) : getDefaultRelaySet()
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) => {

View File

@ -1,24 +1,194 @@
import { EventTemplate, UnsignedEvent } from 'nostr-tools'
import { WindowNostr } from 'nostr-tools/nip07'
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 { 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 = () => {
if (window.nostr) return window.nostr as WindowNostr
// 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()
@ -36,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')
}
/**
@ -53,33 +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
return await context.signEvent(event)
if (!loginMethod) {
return Promise.reject('No login method found in the browser storage')
}
if (loginMethod === LoginMethods.nsecBunker) {
// Check if nsecBunker is available
if (!this.bunkerNDK) {
return Promise.reject(
`Login method is ${loginMethod} but bunkerNDK is not created`
)
}
if (!this.remoteSigner) {
return Promise.reject(
`Login method is ${loginMethod} but bunkerNDK is not created`
)
}
const signedEvent = await this.signWithNsecBunker(
'',
event as NostrEvent
).catch((err) => {
throw err
})
return Promise.resolve(signedEvent as SignedEvent)
} else if (loginMethod === LoginMethods.privateKey) {
const keys = (store.getState().auth as AuthState).keyPair
if (!keys) {
return Promise.reject(
`Login method is ${loginMethod}, but keys are not found`
)
}
const { private: nsec } = keys
const privateKey = nip19.decode(nsec).data as Uint8Array
const signedEvent = finalizeEvent(event, privateKey)
verifySignedEvent(signedEvent)
return Promise.resolve(signedEvent)
} else if (loginMethod === LoginMethods.extension) {
const nostr = this.getNostrObject()
return (await nostr
.signEvent(event as NostrEvent)
.catch((err: unknown) => {
console.log('Error while signing event: ', err)
throw err
})) as Event
} else {
return Promise.reject(
`We could not sign the event, none of the signing methods are available`
)
}
}
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')
}
/**
@ -90,10 +456,51 @@ 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')
}
/**
@ -116,4 +523,12 @@ export class NostrController extends EventEmitter {
return Promise.resolve(pubKey)
}
/**
* Generates NDK Private Signer
* @returns nSecBunker delegated key
*/
generateDelegatedKey = (): string => {
return NDKPrivateKeySigner.generate().privateKey!
}
}

View File

@ -2,7 +2,8 @@ import { Event, Filter, Relay } from 'nostr-tools'
import {
settleAllFullfilfedPromises,
normalizeWebSocketURL,
timeout
publishToRelay,
isPromiseFulfilled
} from '../utils'
import { SIGIT_RELAY } from '../utils/const'
@ -261,17 +262,17 @@ export class RelayController {
event: Event,
relayUrls: string[] = []
): Promise<string[]> => {
if (!relayUrls.includes(SIGIT_RELAY)) {
/**
* NOTE: To avoid side-effects on external relayUrls array passed as argument
* re-assigned relayUrls with added sigit relay instead of just appending to same array
*/
relayUrls = [...relayUrls, SIGIT_RELAY] // Add app relay to relays array if not exists already
}
/**
* 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(
relayUrls,
updatedRelayUrls,
this.connectRelay
)
@ -280,26 +281,15 @@ export class RelayController {
throw new Error('No relay is connected to publish event!')
}
const publishedOnRelays: string[] = [] // List to track which relays successfully published the event
// Create a promise for publishing the event to each connected relay
const publishPromises = relays.map(async (relay) => {
try {
await Promise.race([
relay.publish(event), // Publish the event to the relay
timeout(20 * 1000) // Set a timeout to handle cases where publishing takes too long
])
publishedOnRelays.push(relay.url) // Add the relay URL to the list of successfully published relays
} catch (err) {
console.error(`Failed to publish event on relay: ${relay.url}`, err)
}
})
// Wait for all publish operations to complete (either fulfilled or rejected)
await Promise.allSettled(publishPromises)
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 publishedOnRelays
return settledPromises
.filter(isPromiseFulfilled)
.map((res) => res.value) as string[]
}
}

View File

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

View File

@ -1,46 +0,0 @@
import { useEffect, useState } from 'react'
import { ProfileMetadata } from '../types/profile'
import { MetadataController } from '../controllers/MetadataController'
import { Event, kinds } from 'nostr-tools'
export const useProfileMetadata = (pubkey: string) => {
const [profileMetadata, setProfileMetadata] = useState<ProfileMetadata>()
useEffect(() => {
const metadataController = MetadataController.getInstance()
const handleMetadataEvent = (event: Event) => {
const metadataContent =
metadataController.extractProfileMetadataContent(event)
if (metadataContent) {
setProfileMetadata(metadataContent)
}
}
if (pubkey) {
metadataController.on(pubkey, (kind: number, event: Event) => {
if (kind === kinds.Metadata) {
handleMetadataEvent(event)
}
})
metadataController
.findMetadata(pubkey)
.then((metadataEvent) => {
if (metadataEvent) handleMetadataEvent(metadataEvent)
})
.catch((err) => {
console.error(
`error occurred in finding metadata for: ${pubkey}`,
err
)
})
}
return () => {
metadataController.off(pubkey, handleMetadataEvent)
}
}, [pubkey])
return profileMetadata
}

View File

@ -3,8 +3,7 @@ import {
CreateSignatureEventContent,
DocSignatureEvent,
Meta,
SignedEventContent,
OpenTimestamp
SignedEventContent
} from '../types'
import { Mark } from '../types/mark'
import {
@ -19,6 +18,7 @@ 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'
@ -33,10 +33,6 @@ export interface FlatMeta
// Remove pubkey and use submittedBy as `npub1${string}`
submittedBy?: `npub1${string}`
// Optional field only present on exported sigits
// Exporting adds user's pubkey
exportedBy?: `npub1${string}`
// Remove created_at and replace with createdAt
createdAt?: number
@ -59,8 +55,6 @@ export interface FlatMeta
signersStatus: {
[signer: `npub1${string}`]: SignStatus
}
timestamps?: OpenTimestamp[]
}
/**
@ -74,7 +68,6 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
const [tags, setTags] = useState<string[][]>()
const [createdAt, setCreatedAt] = useState<number>()
const [submittedBy, setSubmittedBy] = useState<`npub1${string}`>() // submittedBy, pubkey from nostr event
const [exportedBy, setExportedBy] = useState<`npub1${string}`>() // pubkey from export signature nostr event
const [id, setId] = useState<string>()
const [sig, setSig] = useState<string>()
@ -106,18 +99,6 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
if (!meta) return
;(async function () {
try {
if (meta.exportSignature) {
const exportSignatureEvent = await parseNostrEvent(
meta.exportSignature
)
if (
verifyEvent(exportSignatureEvent) &&
exportSignatureEvent.pubkey
) {
setExportedBy(exportSignatureEvent.pubkey as `npub1${string}`)
}
}
const createSignatureEvent = await parseNostrEvent(meta.createSignature)
const { kind, tags, created_at, pubkey, id, sig, content } =
@ -145,7 +126,7 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
if (meta.keys) {
const { sender, keys } = meta.keys
// Retrieve the user's public key from the state
const usersPubkey = store.getState().auth.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
@ -165,6 +146,7 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
setEncryptionKey(decrypted)
}
}
// Temp. map to hold events and signers
const parsedSignatureEventsMap = new Map<
`npub1${string}`,
@ -278,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,

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

View File

@ -27,7 +27,7 @@ body {
position: fixed;
top: 80px;
right: 20px;
z-index: 40;
z-index: 100;
}
#root {

View File

@ -1,160 +1,88 @@
import { Event, getPublicKey, kinds, nip19 } from 'nostr-tools'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Outlet, useNavigate, useSearchParams } from 'react-router-dom'
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 {
AuthController,
MetadataController,
NostrController
} from '../controllers'
import { MetadataController, NostrController } from '../controllers'
import {
restoreState,
setAuthState,
setMetadataEvent,
updateKeyPair,
updateLoginMethod,
updateNostrLoginAuthMethod,
updateUserAppData
} from '../store/actions'
import { LoginMethods } from '../store/auth/types'
import { State } from '../store/rootReducer'
import { Dispatch } from '../store/store'
import { setUserRobotImage } from '../store/userRobotImage/action'
import {
clearAuthToken,
clearState,
getRoboHashPicture,
getUsersAppData,
loadState,
saveNsecBunkerDelegatedKey,
subscribeForSigits
} from '../utils'
import { useAppDispatch, useAppSelector } from '../hooks'
import { useAppSelector } from '../hooks'
import styles from './style.module.scss'
import { useLogout } from '../hooks/useLogout'
import { LoginMethod } from '../store/auth/types'
import { NostrLoginAuthOptions } from 'nostr-login/dist/types'
import { init as initNostrLogin } from 'nostr-login'
import { Footer } from '../components/Footer/Footer'
export const MainLayout = () => {
const [searchParams, setSearchParams] = useSearchParams()
const navigate = useNavigate()
const dispatch = useAppDispatch()
const logout = useLogout()
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 () => {
const nostrController = NostrController.getInstance()
const authController = new AuthController()
const pubkey = await nostrController.capturePublicKey()
dispatch(updateLoginMethod(LoginMethod.nostrLogin))
const redirectPath =
await authController.authAndGetMetadataAndRelaysMap(pubkey)
if (redirectPath) {
navigateAfterLogin(redirectPath)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dispatch])
useEffect(() => {
// Developer login with ?nsec= (not recommended)
const nsec = searchParams.get('nsec')
if (!nsec) return
const metadataController = new MetadataController()
// Clear nsec from the url immediately
searchParams.delete('nsec')
setSearchParams(searchParams)
const logout = () => {
dispatch(
setAuthState({
keyPair: undefined,
loggedIn: false,
usersPubkey: undefined,
loginMethod: undefined,
nsecBunkerPubkey: undefined
})
)
if (!authState?.loggedIn) {
if (!nsec.startsWith('nsec')) {
console.error('Invalid format, use private key (nsec)')
return
}
dispatch(setMetadataEvent(metadataController.getEmptyMetadataEvent()))
try {
const privateKey = nip19.decode(nsec).data as Uint8Array
if (!privateKey) {
console.error('Failed to convert the private key.')
return
}
// clear authToken saved in local storage
clearAuthToken()
clearState()
const publickey = getPublicKey(privateKey)
dispatch(
updateKeyPair({
private: nsec,
public: publickey
})
)
dispatch(updateLoginMethod(LoginMethod.privateKey))
const authController = new AuthController()
authController
.authAndGetMetadataAndRelaysMap(publickey)
.catch((err) => {
console.error('Error occurred in authentication: ' + err)
return null
})
} catch (err) {
console.error(`Error decoding the nsec. ${err}`)
}
// update nsecBunker delegated key
const newDelegatedKey =
NostrController.getInstance().generateDelegatedKey()
saveNsecBunkerDelegatedKey(newDelegatedKey)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dispatch, searchParams])
useEffect(() => {
const handleNostrAuth = (_: string, opts: NostrLoginAuthOptions) => {
if (opts.type === 'login' || opts.type === 'signup') {
dispatch(updateNostrLoginAuthMethod(opts.method))
login()
} else if (opts.type === 'logout') {
// Clear `subscribeForSigits` as called after the logout
hasSubscribed.current = false
}
}
// Initialize the nostr-login
initNostrLogin({
methods: ['connect', 'extension', 'local'],
noBanner: true,
onAuth: handleNostrAuth
}).catch((error) => {
console.error('Failed to initialize Nostr-Login', error)
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dispatch])
useEffect(() => {
const metadataController = MetadataController.getInstance()
const 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()
// Update user profile metadata, old state might be outdated
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))
}
@ -174,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])
}, [authState, usersAppData])
/**
* When authState change user logged in / or app reloaded
@ -201,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) {
@ -218,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} />
@ -238,6 +160,7 @@ export const MainLayout = () => {
>
<Outlet />
</main>
<Footer />
</>
)
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,28 +1,34 @@
import styles from './style.module.scss'
import { Button, FormHelperText, TextField, Tooltip } from '@mui/material'
import {
Button,
FormHelperText,
ListItemIcon,
ListItemText,
MenuItem,
Select,
TextField,
Tooltip
} from '@mui/material'
import type { Identifier, XYCoord } from 'dnd-core'
import saveAs from 'file-saver'
import JSZip from 'jszip'
import { Event, kinds } from 'nostr-tools'
import { useEffect, useRef, useState } from 'react'
import { DndProvider, useDrag, useDrop } from 'react-dnd'
import { MultiBackend } from 'react-dnd-multi-backend'
import { HTML5toTouch } from 'rdndmb-html5-to-touch'
import { useAppSelector } from '../../hooks/store'
import { HTML5Backend } from 'react-dnd-html5-backend'
import { useSelector } from 'react-redux'
import { useLocation, useNavigate } from 'react-router-dom'
import { toast } from 'react-toastify'
import { LoadingSpinner } from '../../components/LoadingSpinner'
import { UserAvatar } from '../../components/UserAvatar'
import { MetadataController, NostrController } from '../../controllers'
import { appPrivateRoutes } from '../../routes'
import { State } from '../../store/rootReducer'
import {
CreateSignatureEventContent,
Meta,
ProfileMetadata,
SignedEvent,
User,
UserRole,
KeyboardCode
UserRole
} from '../../types'
import {
encryptArrayBuffer,
@ -37,34 +43,48 @@ import {
npubToHex,
queryNip05,
sendNotification,
shorten,
signEventForMetaFile,
updateUsersAppData,
uploadToFileStorage,
DEFAULT_TOOLBOX,
settleAllFullfilfedPromises
uploadToFileStorage
} from '../../utils'
import { Container } from '../../components/Container'
import styles from './style.module.scss'
import fileListStyles from '../../components/FileList/style.module.scss'
import { DrawTool } from '../../types/drawing'
import { DrawTool, MarkType } from '../../types/drawing'
import { DrawPDFFields } from '../../components/DrawPDFFields'
import { Mark } from '../../types/mark.ts'
import { StickySideColumns } from '../../layouts/StickySideColumns.tsx'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import {
fa1,
faBriefcase,
faCalendarDays,
faCheckDouble,
faCircleDot,
faClock,
faCreditCard,
faEllipsis,
faEye,
faFile,
faFileCirclePlus,
faGripLines,
faHeading,
faIdCard,
faImage,
faPaperclip,
faPen,
faPhone,
faPlus,
faToolbox,
faSignature,
faSquareCaretDown,
faSquareCheck,
faStamp,
faT,
faTableCellsLarge,
faTrash,
faUpload
} from '@fortawesome/free-solid-svg-icons'
import { getSigitFile, SigitFile } from '../../utils/file.ts'
import _ from 'lodash'
import { generateTimestamp } from '../../utils/opentimestamps.ts'
import { SigitFile } from '../../utils/file.ts'
import { checkNotifications } from '../../utils/notifications.ts'
export const CreatePage = () => {
const navigate = useNavigate()
@ -76,6 +96,8 @@ export const CreatePage = () => {
const [isLoading, setIsLoading] = useState(false)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
const [authUrl, setAuthUrl] = useState<string>()
const [title, setTitle] = useState(`sigit_${formatTimestamp(Date.now())}`)
const [selectedFiles, setSelectedFiles] = useState<File[]>([])
@ -88,10 +110,7 @@ export const CreatePage = () => {
const [userInput, setUserInput] = useState('')
const handleInputKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (
event.code === KeyboardCode.Enter ||
event.code === KeyboardCode.NumpadEnter
) {
if (event.code === 'Enter' || event.code === 'NumpadEnter') {
event.preventDefault()
handleAddUser()
}
@ -100,10 +119,8 @@ export const CreatePage = () => {
const [error, setError] = useState<string>()
const [users, setUsers] = useState<User[]>([])
const signers = users.filter((u) => u.role === UserRole.signer)
const viewers = users.filter((u) => u.role === UserRole.viewer)
const usersPubkey = useAppSelector((state) => state.auth.usersPubkey)
const usersPubkey = useSelector((state: State) => state.auth.usersPubkey)
const nostrController = NostrController.getInstance()
@ -111,31 +128,118 @@ export const CreatePage = () => {
{}
)
const [drawnFiles, setDrawnFiles] = useState<SigitFile[]>([])
const [parsingPdf, setIsParsing] = useState<boolean>(false)
useEffect(() => {
if (selectedFiles) {
/**
* Reads the binary files and converts to internal file type
* and sets to a state (adds images if it's a PDF)
*/
const parsePages = async () => {
const files = await settleAllFullfilfedPromises(
selectedFiles,
getSigitFile
)
setDrawnFiles(files)
}
setIsParsing(true)
parsePages().finally(() => {
setIsParsing(false)
})
}
}, [selectedFiles])
const [selectedTool, setSelectedTool] = useState<DrawTool>()
const [toolbox] = useState<DrawTool[]>([
{
identifier: MarkType.TEXT,
icon: <FontAwesomeIcon icon={faT} />,
label: 'Text',
active: true
},
{
identifier: MarkType.SIGNATURE,
icon: <FontAwesomeIcon icon={faSignature} />,
label: 'Signature',
active: false
},
{
identifier: MarkType.JOBTITLE,
icon: <FontAwesomeIcon icon={faBriefcase} />,
label: 'Job Title',
active: false
},
{
identifier: MarkType.FULLNAME,
icon: <FontAwesomeIcon icon={faIdCard} />,
label: 'Full Name',
active: false
},
{
identifier: MarkType.INITIALS,
icon: <FontAwesomeIcon icon={faHeading} />,
label: 'Initials',
active: false
},
{
identifier: MarkType.DATETIME,
icon: <FontAwesomeIcon icon={faClock} />,
label: 'Date Time',
active: false
},
{
identifier: MarkType.DATE,
icon: <FontAwesomeIcon icon={faCalendarDays} />,
label: 'Date',
active: false
},
{
identifier: MarkType.NUMBER,
icon: <FontAwesomeIcon icon={fa1} />,
label: 'Number',
active: false
},
{
identifier: MarkType.IMAGES,
icon: <FontAwesomeIcon icon={faImage} />,
label: 'Images',
active: false
},
{
identifier: MarkType.CHECKBOX,
icon: <FontAwesomeIcon icon={faSquareCheck} />,
label: 'Checkbox',
active: false
},
{
identifier: MarkType.MULTIPLE,
icon: <FontAwesomeIcon icon={faCheckDouble} />,
label: 'Multiple',
active: false
},
{
identifier: MarkType.FILE,
icon: <FontAwesomeIcon icon={faPaperclip} />,
label: 'File',
active: false
},
{
identifier: MarkType.RADIO,
icon: <FontAwesomeIcon icon={faCircleDot} />,
label: 'Radio',
active: false
},
{
identifier: MarkType.SELECT,
icon: <FontAwesomeIcon icon={faSquareCaretDown} />,
label: 'Select',
active: false
},
{
identifier: MarkType.CELLS,
icon: <FontAwesomeIcon icon={faTableCellsLarge} />,
label: 'Cells',
active: false
},
{
identifier: MarkType.STAMP,
icon: <FontAwesomeIcon icon={faStamp} />,
label: 'Stamp',
active: false
},
{
identifier: MarkType.PAYMENT,
icon: <FontAwesomeIcon icon={faCreditCard} />,
label: 'Payment',
active: false
},
{
identifier: MarkType.PHONE,
icon: <FontAwesomeIcon icon={faPhone} />,
label: 'Phone',
active: false
}
])
/**
* Changes the drawing tool
@ -154,7 +258,7 @@ export const CreatePage = () => {
useEffect(() => {
users.forEach((user) => {
if (!(user.pubkey in metadata)) {
const metadataController = MetadataController.getInstance()
const metadataController = new MetadataController()
const handleMetadataEvent = (event: Event) => {
const metadataContent =
@ -186,6 +290,10 @@ export const CreatePage = () => {
}
})
}, [metadata, users])
// Set up event listener for authentication event
nostrController.on('nsecbunker-auth', (url) => {
setAuthUrl(url)
})
useEffect(() => {
if (uploadedFiles) {
@ -252,22 +360,17 @@ export const CreatePage = () => {
const input = userInput.toLowerCase()
if (input.startsWith('npub')) {
return handleAddNpubUser(input)
const pubkey = npubToHex(input)
if (pubkey) {
addUser(pubkey)
setUserInput('')
} else {
setError('Provided npub is not valid. Please enter correct npub.')
}
return
}
if (input.includes('@')) {
return await handleAddNip05User(input)
}
// If the user enters the domain (w/o @) assume it's the "root" and append _@
// https://github.com/nostr-protocol/nips/blob/master/05.md#showing-just-the-domain-as-an-identifier
if (input.includes('.')) {
return await handleAddNip05User(`_@${input}`)
}
setError('Invalid input! Make sure to provide correct npub or nip05.')
async function handleAddNip05User(input: string) {
setIsLoading(true)
setLoadingSpinnerDesc('Querying for nip05')
const nip05Profile = await queryNip05(input)
@ -290,16 +393,7 @@ export const CreatePage = () => {
return
}
function handleAddNpubUser(input: string) {
const pubkey = npubToHex(input)
if (pubkey) {
addUser(pubkey)
setUserInput('')
} else {
setError('Provided npub is not valid. Please enter correct npub.')
}
return
}
setError('Invalid input! Make sure to provide correct npub or nip05.')
}
const handleUserRoleChange = (role: UserRole, pubkey: string) => {
@ -319,19 +413,6 @@ export const CreatePage = () => {
const handleRemoveUser = (pubkey: string) => {
setUsers((prev) => prev.filter((user) => user.pubkey !== pubkey))
// Set counterpart to ''
const drawnFilesCopy = _.cloneDeep(drawnFiles)
drawnFilesCopy.forEach((s) => {
s.pages?.forEach((p) => {
p.drawnFields.forEach((d) => {
if (d.counterpart === hexToNpub(pubkey)) {
d.counterpart = ''
}
})
})
})
setDrawnFiles(drawnFilesCopy)
}
/**
@ -351,15 +432,7 @@ export const CreatePage = () => {
const handleSelectFiles = (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.files) {
// Get the uploaded files
const files = Array.from(event.target.files)
// Remove duplicates based on the file.name
setSelectedFiles((p) =>
[...p, ...files].filter(
(file, i, array) => i === array.findIndex((t) => t.name === file.name)
)
)
setSelectedFiles(Array.from(event.target.files))
}
}
@ -385,8 +458,10 @@ export const CreatePage = () => {
return false
}
if (!users.some((u) => u.role === UserRole.signer)) {
toast.error('No signer is provided. At least add one signer.')
if (users.length === 0) {
toast.error(
'No signer/viewer is provided. At least add one signer or viewer.'
)
return false
}
@ -580,11 +655,6 @@ export const CreatePage = () => {
}
saveAs(finalZipFile, `request-${unixNow()}.sigit.zip`)
// If user is the next signer, we can navigate directly to sign page
if (signers[0].pubkey === usersPubkey) {
navigate(appPrivateRoutes.sign, { state: { uploadedZip: finalZipFile } })
}
setIsLoading(false)
}
@ -610,6 +680,9 @@ export const CreatePage = () => {
},
zipUrl: string
) => {
const signers = users.filter((user) => user.role === UserRole.signer)
const viewers = users.filter((user) => user.role === UserRole.viewer)
const content: CreateSignatureEventContent = {
signers: signers.map((signer) => hexToNpub(signer.pubkey)),
viewers: viewers.map((viewer) => hexToNpub(viewer.pubkey)),
@ -638,6 +711,9 @@ export const CreatePage = () => {
// Send notifications to signers and viewers
const sendNotifications = (meta: Meta) => {
const signers = users.filter((user) => user.role === UserRole.signer)
const viewers = users.filter((user) => user.role === UserRole.viewer)
// no need to send notification to self so remove it from the list
const receivers = (
signers.length > 0
@ -645,12 +721,7 @@ export const CreatePage = () => {
: viewers.map((viewer) => viewer.pubkey)
).filter((receiver) => receiver !== usersPubkey)
return receivers.map((receiver) => sendNotification(receiver, meta))
}
const extractNostrId = (stringifiedEvent: string): string => {
const e = JSON.parse(stringifiedEvent) as SignedEvent
return e.id
return receivers.map(async (receiver) => sendNotification(receiver, meta))
}
const handleCreate = async () => {
@ -702,12 +773,6 @@ export const CreatePage = () => {
const keys = await generateKeys(pubkeys, encryptionKey)
if (!keys) return
setLoadingSpinnerDesc('Generating an open timestamp.')
const timestamp = await generateTimestamp(
extractNostrId(createSignature)
)
const meta: Meta = {
createSignature,
keys,
@ -715,26 +780,14 @@ export const CreatePage = () => {
docSignatures: {}
}
if (timestamp) {
meta.timestamps = [timestamp]
}
setLoadingSpinnerDesc('Updating user app data')
const event = await updateUsersAppData(meta)
if (!event) return
setLoadingSpinnerDesc('Sending notifications to counterparties')
const promises = sendNotifications(meta)
await Promise.all(promises)
.then(() => {
toast.success('Notifications sent successfully')
})
.catch(() => {
toast.error('Failed to publish notifications')
})
navigate(appPrivateRoutes.sign, { state: { meta } })
const notifications = await Promise.allSettled(sendNotifications(meta))
checkNotifications(notifications)
navigate(appPrivateRoutes.sign, { state: { meta: meta } })
} else {
const zip = new JSZip()
@ -789,6 +842,21 @@ export const CreatePage = () => {
}
}
const onDrawFieldsChange = (sigitFiles: SigitFile[]) => {
setDrawnFiles(sigitFiles)
}
if (authUrl) {
return (
<iframe
title="Nsecbunker auth"
src={authUrl}
width="100%"
height="500px"
/>
)
}
return (
<>
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
@ -798,12 +866,21 @@ export const CreatePage = () => {
<div className={styles.flexWrap}>
<div className={styles.inputWrapper}>
<TextField
fullWidth
placeholder="Title"
size="small"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
sx={{
width: '100%',
fontSize: '16px',
'& .MuiInputBase-input': {
padding: '7px 14px'
},
'& .MuiOutlinedInput-notchedOutline': {
display: 'none'
}
}}
/>
</div>
<ol className={`${styles.paperGroup} ${styles.orderedFilesList}`}>
@ -822,6 +899,9 @@ export const CreatePage = () => {
aria-label={`delete ${file.name}`}
variant="text"
onClick={(event) => handleRemoveFile(event, file)}
sx={{
minWidth: '44px'
}}
>
<FontAwesomeIcon icon={faTrash} />
</Button>
@ -844,109 +924,136 @@ export const CreatePage = () => {
}
right={
<div className={styles.flexWrap}>
<div className={`${styles.paperGroup} ${styles.users}`}>
<div className={styles.inputWrapper}>
<TextField
placeholder="Add user"
value={userInput}
onChange={(e) => setUserInput(e.target.value)}
onKeyDown={handleInputKeyDown}
error={!!error}
fullWidth
sx={{
fontSize: '16px',
'& .MuiInputBase-input': {
padding: '7px 14px'
},
'& .MuiOutlinedInput-notchedOutline': {
display: 'none'
}
}}
/>
<Select
name="add-user-role"
aria-label="role"
value={userRole}
variant="filled"
// Hide arrow for dropdown
IconComponent={() => null}
renderValue={(value) => (
<FontAwesomeIcon
color="var(--primary-main)"
icon={value === UserRole.signer ? faPen : faEye}
/>
)}
onChange={(e) => setUserRole(e.target.value as UserRole)}
sx={{
fontSize: '16px',
minWidth: '44px',
'& .MuiInputBase-input': {
padding: '7px 14px!important',
textOverflow: 'unset!important'
}
}}
>
<MenuItem value={UserRole.signer}>
<ListItemIcon>
<FontAwesomeIcon icon={faPen} />
</ListItemIcon>
<ListItemText>{UserRole.signer}</ListItemText>
</MenuItem>
<MenuItem value={UserRole.viewer} sx={{}}>
<ListItemIcon>
<FontAwesomeIcon icon={faEye} />
</ListItemIcon>
<ListItemText>{UserRole.viewer}</ListItemText>
</MenuItem>
</Select>
<Button
disabled={!userInput}
onClick={handleAddUser}
variant="contained"
aria-label="Add"
sx={{
minWidth: '44px',
padding: '11.5px 12px',
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0
}}
>
<FontAwesomeIcon icon={faPlus} />
</Button>
</div>
<div className={styles.paperGroup}>
<DisplayUser
metadata={metadata}
users={users}
handleUserRoleChange={handleUserRoleChange}
handleRemoveUser={handleRemoveUser}
moveSigner={moveSigner}
/>
</div>
<div className={styles.addCounterpart}>
<div className={styles.inputWrapper}>
<TextField
fullWidth
placeholder="Add counterpart"
value={userInput}
onChange={(e) => setUserInput(e.target.value)}
onKeyDown={handleInputKeyDown}
error={!!error}
/>
</div>
<Button
onClick={() =>
setUserRole(
userRole === UserRole.signer
? UserRole.viewer
: UserRole.signer
)
}
variant="contained"
aria-label="Toggle User Role"
className={styles.counterpartToggleButton}
>
<FontAwesomeIcon
icon={userRole === UserRole.signer ? faPen : faEye}
/>
</Button>
<Button
disabled={!userInput}
onClick={handleAddUser}
variant="contained"
aria-label="Add"
className={styles.counterpartToggleButton}
>
<FontAwesomeIcon icon={faPlus} />
</Button>
</div>
<div className={`${styles.paperGroup} ${styles.toolbox}`}>
{DEFAULT_TOOLBOX.filter((drawTool) => !drawTool.isHidden).map(
(drawTool: DrawTool, index: number) => {
return (
<div
key={index}
{...(!drawTool.isComingSoon && {
onClick: () => handleToolSelect(drawTool)
})}
className={`${styles.toolItem} ${selectedTool?.identifier === drawTool.identifier ? styles.selected : ''} ${drawTool.isComingSoon ? styles.comingSoon : ''}
`}
>
<FontAwesomeIcon
fontSize={'15px'}
icon={drawTool.icon}
/>
{drawTool.label}
{!drawTool.isComingSoon ? (
<FontAwesomeIcon
fontSize={'15px'}
icon={faEllipsis}
/>
) : (
<span className={styles.comingSoonPlaceholder}>
Coming soon
</span>
)}
</div>
)
}
)}
</div>
<Button onClick={handleCreate} variant="contained">
Publish
</Button>
<div className={`${styles.paperGroup} ${styles.toolbox}`}>
{toolbox.map((drawTool: DrawTool, index: number) => {
return (
<div
key={index}
onClick={
drawTool.active
? () => {
handleToolSelect(drawTool)
}
: () => null
}
className={`${styles.toolItem} ${selectedTool?.identifier === drawTool.identifier ? styles.selected : ''} ${!drawTool.active ? styles.comingSoon : ''}
`}
>
{drawTool.icon}
{drawTool.label}
{drawTool.active ? (
<FontAwesomeIcon icon={faEllipsis} />
) : (
<span
style={{
fontSize: '10px'
}}
>
Coming soon
</span>
)}
</div>
)
})}
</div>
{!!error && (
<FormHelperText error={!!error}>{error}</FormHelperText>
)}
</div>
}
leftIcon={faFileCirclePlus}
centerIcon={faFile}
rightIcon={faToolbox}
>
{parsingPdf ? (
<LoadingSpinner variant="small" />
) : (
<DrawPDFFields
users={users}
metadata={metadata}
selectedTool={selectedTool}
sigitFiles={drawnFiles}
setSigitFiles={setDrawnFiles}
/>
)}
<DrawPDFFields
metadata={metadata}
users={users}
selectedFiles={selectedFiles}
onDrawFieldsChange={onDrawFieldsChange}
selectedTool={selectedTool}
/>
</StickySideColumns>
</Container>
</>
@ -954,6 +1061,7 @@ export const CreatePage = () => {
}
type DisplayUsersProps = {
metadata: { [key: string]: ProfileMetadata }
users: User[]
handleUserRoleChange: (role: UserRole, pubkey: string) => void
handleRemoveUser: (pubkey: string) => void
@ -961,6 +1069,7 @@ type DisplayUsersProps = {
}
const DisplayUser = ({
metadata,
users,
handleUserRoleChange,
handleRemoveUser,
@ -968,12 +1077,13 @@ const DisplayUser = ({
}: DisplayUsersProps) => {
return (
<>
<DndProvider backend={MultiBackend} options={HTML5toTouch}>
<DndProvider backend={HTML5Backend}>
{users
.filter((user) => user.role === UserRole.signer)
.map((user, index) => (
<SignerCounterpart
key={`signer-${user.pubkey}`}
<SignerRow
key={`signer-${index}`}
userMeta={metadata[user.pubkey]}
user={user}
index={index}
moveSigner={moveSigner}
@ -984,14 +1094,71 @@ const DisplayUser = ({
</DndProvider>
{users
.filter((user) => user.role === UserRole.viewer)
.map((user) => {
.map((user, index) => {
const userMeta = metadata[user.pubkey]
return (
<div className={styles.user} key={`viewer-${user.pubkey}`}>
<Counterpart
user={user}
handleUserRoleChange={handleUserRoleChange}
handleRemoveUser={handleRemoveUser}
/>
<div className={styles.user} key={index}>
<div className={styles.avatar}>
<UserAvatar
pubkey={user.pubkey}
name={
userMeta?.display_name ||
userMeta?.name ||
shorten(hexToNpub(user.pubkey))
}
image={userMeta?.picture}
/>
</div>
<Select
name={`change-user-role-${user.pubkey}`}
aria-label="role"
value={user.role}
variant="outlined"
IconComponent={() => null}
renderValue={(value) => (
<FontAwesomeIcon
fontSize={'14px'}
color="var(--primary-main)"
icon={value === UserRole.signer ? faPen : faEye}
/>
)}
onChange={(e) =>
handleUserRoleChange(e.target.value as UserRole, user.pubkey)
}
sx={{
fontSize: '16px',
minWidth: '34px',
maxWidth: '34px',
minHeight: '34px',
maxHeight: '34px',
'& .MuiInputBase-input': {
padding: '10px !important',
textOverflow: 'unset!important'
},
'& .MuiOutlinedInput-notchedOutline': {
display: 'none'
}
}}
>
<MenuItem value={UserRole.signer}>{UserRole.signer}</MenuItem>
<MenuItem value={UserRole.viewer}>{UserRole.viewer}</MenuItem>
</Select>
<Tooltip title="Remove User" arrow>
<Button
onClick={() => handleRemoveUser(user.pubkey)}
sx={{
minWidth: '34px',
height: '34px',
padding: 0,
color: 'rgba(0, 0, 0, 0.35)',
'&:hover': {
color: 'white'
}
}}
>
<FontAwesomeIcon fontSize={'14px'} icon={faTrash} />
</Button>
</Tooltip>
</div>
)
})}
@ -1005,24 +1172,23 @@ interface DragItem {
type: string
}
type CounterpartProps = {
type SignerRowProps = {
userMeta: ProfileMetadata
user: User
index: number
moveSigner: (dragIndex: number, hoverIndex: number) => void
handleUserRoleChange: (role: UserRole, pubkey: string) => void
handleRemoveUser: (pubkey: string) => void
}
type SignerCounterpartProps = CounterpartProps & {
index: number
moveSigner: (dragIndex: number, hoverIndex: number) => void
}
const SignerCounterpart = ({
const SignerRow = ({
userMeta,
user,
index,
moveSigner,
handleUserRoleChange,
handleRemoveUser
}: SignerCounterpartProps) => {
}: SignerRowProps) => {
const ref = useRef<HTMLTableRowElement>(null)
const [{ handlerId }, drop] = useDrop<
@ -1096,7 +1262,7 @@ const SignerCounterpart = ({
})
})
const opacity = isDragging ? 0.2 : 1
const opacity = isDragging ? 0 : 1
drag(drop(ref))
return (
@ -1107,50 +1273,67 @@ const SignerCounterpart = ({
ref={ref}
>
<FontAwesomeIcon width={'14px'} fontSize={'14px'} icon={faGripLines} />
<Counterpart
user={user}
handleRemoveUser={handleRemoveUser}
handleUserRoleChange={handleUserRoleChange}
/>
<div className={styles.avatar}>
<UserAvatar
pubkey={user.pubkey}
name={
userMeta?.display_name ||
userMeta?.name ||
shorten(hexToNpub(user.pubkey))
}
image={userMeta?.picture}
/>
</div>
<Select
name={`change-user-role-${user.pubkey}`}
aria-label="role"
value={user.role}
variant="outlined"
IconComponent={() => null}
renderValue={(value) => (
<FontAwesomeIcon
fontSize={'14px'}
color="var(--primary-main)"
icon={value === UserRole.signer ? faPen : faEye}
/>
)}
onChange={(e) =>
handleUserRoleChange(e.target.value as UserRole, user.pubkey)
}
sx={{
fontSize: '16px',
minWidth: '34px',
maxWidth: '34px',
minHeight: '34px',
maxHeight: '34px',
'& .MuiInputBase-input': {
padding: '10px !important',
textOverflow: 'unset!important'
},
'& .MuiOutlinedInput-notchedOutline': {
display: 'none'
}
}}
>
<MenuItem value={UserRole.signer}>{UserRole.signer}</MenuItem>
<MenuItem value={UserRole.viewer}>{UserRole.viewer}</MenuItem>
</Select>
<Tooltip title="Remove User" arrow>
<Button
onClick={() => handleRemoveUser(user.pubkey)}
sx={{
minWidth: '34px',
height: '34px',
padding: 0,
color: 'rgba(0, 0, 0, 0.35)',
'&:hover': {
color: 'white'
}
}}
>
<FontAwesomeIcon fontSize={'14px'} icon={faTrash} />
</Button>
</Tooltip>
</div>
)
}
const Counterpart = ({
user,
handleUserRoleChange,
handleRemoveUser
}: CounterpartProps) => {
return (
<>
<div className={styles.avatar}>
<UserAvatar pubkey={user.pubkey} isNameVisible={true} />
</div>
<Tooltip title="Toggle User Role" arrow disableInteractive>
<Button
onClick={() =>
handleUserRoleChange(
user.role === UserRole.signer ? UserRole.viewer : UserRole.signer,
user.pubkey
)
}
className={styles.counterpartRowToggleButton}
data-variant="primary"
>
<FontAwesomeIcon
icon={user.role === UserRole.signer ? faPen : faEye}
/>
</Button>
</Tooltip>
<Tooltip title="Remove User" arrow disableInteractive>
<Button
onClick={() => handleRemoveUser(user.pubkey)}
className={styles.counterpartRowToggleButton}
data-variant="secondary"
>
<FontAwesomeIcon icon={faTrash} />
</Button>
</Tooltip>
</>
)
}

View File

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

View File

@ -18,7 +18,6 @@ import {
SigitCardDisplayInfo,
SigitStatus
} from '../../utils'
import { Footer } from '../../components/Footer/Footer'
// Unsupported Filter options are commented
const FILTERS = [
@ -257,14 +256,12 @@ export const HomePage = () => {
.map((key) => (
<DisplaySigit
key={`sigit-${key}`}
sigitCreateId={key}
parsedMeta={parsedSigits[key]}
meta={sigits[key]}
/>
))}
</div>
</Container>
<Footer />
</div>
)
}

View File

@ -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 &amp; Private Agreements</h1>
<h1 className={styles.title}>
Secure &amp; 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>
)
}

View File

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

View File

@ -1,44 +1,43 @@
import { launch as launchNostrLoginDialog } from 'nostr-login'
import { Button, Divider, TextField } from '@mui/material'
import { getPublicKey, nip19 } from 'nostr-tools'
import { useEffect, useState } from 'react'
import { useAppDispatch } from '../../hooks/store'
import { useDispatch } from 'react-redux'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { toast } from 'react-toastify'
import { LoadingSpinner } from '../../components/LoadingSpinner'
import { AuthController } from '../../controllers'
import { updateKeyPair, updateLoginMethod } from '../../store/actions'
import { KeyboardCode } from '../../types'
import { LoginMethod } from '../../store/auth/types'
import {
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 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,7 +130,7 @@ export const Nostr = () => {
public: publickey
})
)
dispatch(updateLoginMethod(LoginMethod.privateKey))
dispatch(updateLoginMethod(LoginMethods.privateKey))
setIsLoading(true)
setLoadingSpinnerDesc('Authenticating and finding metadata')
@ -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}

View File

@ -1,48 +1,46 @@
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
import EditIcon from '@mui/icons-material/Edit'
import { Box, IconButton, SxProps, Theme, Typography } from '@mui/material'
import { truncate } from 'lodash'
import { Event, VerifiedEvent, kinds, nip19 } from 'nostr-tools'
import { useEffect, useMemo, useState } from 'react'
import { useAppSelector } from '../../hooks/store'
import { useSelector } from 'react-redux'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { toast } from 'react-toastify'
import { LoadingSpinner } from '../../components/LoadingSpinner'
import { MetadataController } from '../../controllers'
import { getProfileSettingsRoute } from '../../routes'
import { State } from '../../store/rootReducer'
import { NostrJoiningBlock, ProfileMetadata } from '../../types'
import {
getNostrJoiningBlockNumber,
getProfileUsername,
getRoboHashPicture,
hexToNpub,
shorten
} from '../../utils'
import styles from './style.module.scss'
import { Container } from '../../components/Container'
import { Footer } from '../../components/Footer/Footer'
export const ProfilePage = () => {
const navigate = useNavigate()
const { npub } = useParams()
const metadataController = useMemo(() => MetadataController.getInstance(), [])
const metadataController = useMemo(() => new MetadataController(), [])
const [pubkey, setPubkey] = useState<string>()
const [nostrJoiningBlock, setNostrJoiningBlock] =
useState<NostrJoiningBlock | null>(null)
const [profileMetadata, setProfileMetadata] = useState<ProfileMetadata>()
const metadataState = useAppSelector((state) => state.metadata)
const { usersPubkey } = useAppSelector((state) => state.auth)
const userRobotImage = useAppSelector((state) => state.userRobotImage)
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)
const [isLoading, setIsLoading] = useState(true)
const [loadingSpinnerDesc] = useState('Fetching metadata')
const profileName = pubkey && getProfileUsername(pubkey, profileMetadata)
useEffect(() => {
if (npub) {
try {
@ -167,10 +165,7 @@ export const ProfilePage = () => {
className={`${styles.banner} ${!profileMetadata || !profileMetadata.banner ? styles.noImage : ''}`}
>
{profileMetadata && profileMetadata.banner ? (
<img
src={profileMetadata.banner}
alt={`banner image for ${profileName}`}
/>
<img src={profileMetadata.banner} />
) : (
''
)}
@ -190,7 +185,6 @@ export const ProfilePage = () => {
<img
className={styles['image-placeholder']}
src={getProfileImage(profileMetadata!)}
alt={profileName}
/>
</div>
</Box>
@ -230,7 +224,14 @@ export const ProfilePage = () => {
variant="h6"
className={styles.bold}
>
{profileName}
{truncate(
profileMetadata.display_name ||
profileMetadata.name ||
hexToNpub(pubkey),
{
length: 16
}
)}
</Typography>
)}
</Box>
@ -284,7 +285,6 @@ export const ProfilePage = () => {
</Box>
</Container>
)}
<Footer />
</>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -12,19 +12,19 @@ import {
useTheme
} from '@mui/material'
import { UnsignedEvent, nip19, kinds, VerifiedEvent, Event } from 'nostr-tools'
import React, { useEffect, useRef, useState } from 'react'
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 { useAppDispatch, useAppSelector } from '../../../hooks/store'
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 { LoginMethod, NostrLoginAuthMethod } from '../../../store/auth/types'
import { LoginMethods } from '../../../store/auth/types'
import { SmartToy } from '@mui/icons-material'
import {
getNostrJoiningBlockNumber,
@ -32,18 +32,15 @@ import {
unixNow
} from '../../../utils'
import { Container } from '../../../components/Container'
import { Footer } from '../../../components/Footer/Footer'
import LaunchIcon from '@mui/icons-material/Launch'
import { launch as launchNostrLoginDialog } from 'nostr-login'
export const ProfileSettingsPage = () => {
const theme = useTheme()
const { npub } = useParams()
const dispatch: Dispatch = useAppDispatch()
const dispatch: Dispatch = useDispatch()
const metadataController = MetadataController.getInstance()
const metadataController = useMemo(() => new MetadataController(), [])
const nostrController = NostrController.getInstance()
const [pubkey, setPubkey] = useState<string>()
@ -51,12 +48,10 @@ export const ProfileSettingsPage = () => {
useState<NostrJoiningBlock | null>(null)
const [profileMetadata, setProfileMetadata] = useState<ProfileMetadata>()
const [savingProfileMetadata, setSavingProfileMetadata] = useState(false)
const metadataState = useAppSelector((state) => state.metadata)
const keys = useAppSelector((state) => state.auth?.keyPair)
const { usersPubkey, loginMethod, nostrLoginAuthMethod } = useAppSelector(
(state) => state.auth
)
const userRobotImage = useAppSelector((state) => state.userRobotImage)
const 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)
@ -291,8 +286,7 @@ export const ProfileSettingsPage = () => {
sx={{
paddingBottom: 1,
paddingTop: 1,
fontSize: '1.5rem',
zIndex: 2
fontSize: '1.5rem'
}}
className={styles.subHeader}
>
@ -368,7 +362,7 @@ export const ProfileSettingsPage = () => {
<>
{usersPubkey &&
copyItem(nip19.npubEncode(usersPubkey), 'Public Key')}
{loginMethod === LoginMethod.privateKey &&
{loginMethod === LoginMethods.privateKey &&
keys &&
keys.private &&
copyItem(
@ -378,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>
@ -418,7 +385,6 @@ export const ProfileSettingsPage = () => {
</LoadingButton>
)}
</Container>
<Footer />
</>
)
}

View File

@ -27,7 +27,6 @@ import {
shorten
} from '../../../utils'
import styles from './style.module.scss'
import { Footer } from '../../../components/Footer/Footer'
export const RelaysPage = () => {
const usersPubkey = useAppSelector((state) => state.auth?.usersPubkey)
@ -233,7 +232,6 @@ export const RelaysPage = () => {
))}
</Box>
)}
<Footer />
</Container>
)
}
@ -272,163 +270,161 @@ const RelayItem = ({
})
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>
)
}

View File

@ -6,12 +6,13 @@ import _ from 'lodash'
import { MuiFileInput } from 'mui-file-input'
import { Event, verifyEvent } from 'nostr-tools'
import { useCallback, useEffect, useState } from 'react'
import { useAppSelector } from '../../hooks/store'
import { useLocation, useNavigate, useParams } from 'react-router-dom'
import { useSelector } from 'react-redux'
import { useLocation, useNavigate } from 'react-router-dom'
import { toast } from 'react-toastify'
import { LoadingSpinner } from '../../components/LoadingSpinner'
import { NostrController } from '../../controllers'
import { appPublicRoutes } from '../../routes'
import { State } from '../../store/rootReducer'
import { CreateSignatureEventContent, Meta, SignedEvent } from '../../types'
import {
decryptArrayBuffer,
@ -32,8 +33,7 @@ import {
sendNotification,
signEventForMetaFile,
updateUsersAppData,
findOtherUserMarks,
timeout
findOtherUserMarks
} from '../../utils'
import { Container } from '../../components/Container'
import { DisplayMeta } from './internal/displayMeta'
@ -53,8 +53,7 @@ import {
SigitFile
} from '../../utils/file.ts'
import { ARRAY_BUFFER, DEFLATE } from '../../utils/const.ts'
import { generateTimestamp } from '../../utils/opentimestamps.ts'
import { checkNotifications } from '../../utils/notifications.ts'
enum SignedStatus {
Fully_Signed,
User_Is_Next_Signer,
@ -64,39 +63,17 @@ enum SignedStatus {
export const SignPage = () => {
const navigate = useNavigate()
const location = useLocation()
const params = useParams()
const usersAppData = useAppSelector((state) => state.userAppData)
/**
* Received from `location.state`
*
* uploadedZip will be received from home page when a user uploads a sigit zip wrapper that contains keys.json
* arrayBuffer (decryptedArrayBuffer) will be received in navigation from create page in offline mode
* meta (metaInNavState) will be received in navigation from create & home page in online mode
* arrayBuffer will be received in navigation from create page in offline mode
* meta will be received in navigation from create & home page in online mode
*/
let metaInNavState = location?.state?.meta || undefined
const { arrayBuffer: decryptedArrayBuffer, uploadedZip } = location.state || {
decryptedArrayBuffer: undefined,
uploadedZip: undefined
}
/**
* If userAppData (redux) is available, and we have the route param (sigit id)
* which is actually a `createEventId`, we will fetch a `sigit`
* based on the provided route ID and set fetched `sigit` to the `metaInNavState`
*/
if (usersAppData) {
const sigitCreateId = params.id
if (sigitCreateId) {
const sigit = usersAppData.sigits[sigitCreateId]
if (sigit) {
metaInNavState = sigit
}
}
}
const {
meta: metaInNavState,
arrayBuffer: decryptedArrayBuffer,
uploadedZip
} = location.state || {}
const [displayInput, setDisplayInput] = useState(false)
@ -129,8 +106,9 @@ export const SignPage = () => {
// This state variable indicates whether the logged-in user is a signer, a creator, or neither.
const [isSignerOrCreator, setIsSignerOrCreator] = useState(false)
const usersPubkey = useAppSelector((state) => state.auth.usersPubkey)
const usersPubkey = useSelector((state: State) => state.auth.usersPubkey)
const [authUrl, setAuthUrl] = useState<string>()
const nostrController = NostrController.getInstance()
const [currentUserMarks, setCurrentUserMarks] = useState<CurrentUserMark[]>(
[]
@ -294,10 +272,22 @@ export const SignPage = () => {
const { keys, sender } = parsedKeysJson
for (const key of keys) {
// decrypt the encryptionKey, with timeout (duration = 60 seconds)
// Set up event listener for authentication event
nostrController.on('nsecbunker-auth', (url) => {
setAuthUrl(url)
})
// Set up timeout promise to handle encryption timeout
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => {
reject(new Error('Timeout occurred'))
}, 60000) // Timeout duration = 60 seconds
})
// decrypt the encryptionKey, with timeout
const encryptionKey = await Promise.race([
nostrController.nip04Decrypt(sender, key),
timeout(60000)
timeoutPromise
])
.then((res) => {
return res
@ -306,6 +296,9 @@ export const SignPage = () => {
console.log('err :>> ', err)
return null
})
.finally(() => {
setAuthUrl(undefined) // Clear authentication URL
})
// Return if encryption failed
if (!encryptionKey) continue
@ -476,20 +469,20 @@ export const SignPage = () => {
const fileNames = Object.values(zip.files)
.filter((entry) => entry.name.startsWith('files/') && !entry.dir)
.map((entry) => entry.name)
.map((entry) => entry.replace(/^files\//, ''))
for (const zipFilePath of fileNames) {
// generate hashes for all entries in files folder of zipArchive
// these hashes can be used to verify the originality of files
for (const fileName of fileNames) {
const arrayBuffer = await readContentOfZipEntry(
zip,
zipFilePath,
fileName,
'arraybuffer'
)
const fileName = zipFilePath.replace(/^files\//, '')
if (arrayBuffer) {
files[fileName] = await convertToSigitFile(arrayBuffer, fileName)
// generate hashes for all entries in files folder of zipArchive
// these hashes can be used to verify the originality of files
const hash = await getHash(arrayBuffer)
if (hash) {
fileHashes[fileName] = hash
@ -537,11 +530,7 @@ export const SignPage = () => {
setIsLoading(true)
const arrayBuffer = await decrypt(selectedFile)
if (!arrayBuffer) {
setIsLoading(false)
toast.error('Error decrypting file')
return
}
if (!arrayBuffer) return
handleDecryptedArrayBuffer(arrayBuffer)
}
@ -567,14 +556,6 @@ export const SignPage = () => {
const updatedMeta = updateMetaSignatures(meta, signedEvent)
setLoadingSpinnerDesc('Generating an open timestamp.')
const timestamp = await generateTimestamp(signedEvent.id)
if (timestamp) {
updatedMeta.timestamps = [...(updatedMeta.timestamps || []), timestamp]
updatedMeta.modifiedAt = unixNow()
}
if (await isOnline()) {
await handleOnlineFlow(updatedMeta)
} else {
@ -683,57 +664,22 @@ export const SignPage = () => {
// Handle the online flow: update users app data and send notifications
const handleOnlineFlow = async (meta: Meta) => {
setLoadingSpinnerDesc('Updating users app data')
const updatedEvent = await updateUsersAppData(meta)
if (!updatedEvent) {
try {
setLoadingSpinnerDesc('Updating users app data')
const updatedEvent = await updateUsersAppData(meta)
if (!updatedEvent) {
throw new Error('There was an error updating user app data.')
}
setLoadingSpinnerDesc('Sending notifications')
const notifications = await notifyUsers(meta)
checkNotifications(notifications)
} catch (error) {
console.error(error)
toast.error('There was an error finalising signatures.')
} finally {
setIsLoading(false)
return
}
const userSet = new Set<`npub1${string}`>()
if (submittedBy && submittedBy !== usersPubkey) {
userSet.add(hexToNpub(submittedBy))
}
const usersNpub = hexToNpub(usersPubkey!)
const isLastSigner = checkIsLastSigner(signers)
if (isLastSigner) {
signers.forEach((signer) => {
if (signer !== usersNpub) {
userSet.add(signer)
}
})
viewers.forEach((viewer) => {
userSet.add(viewer)
})
} else {
const currentSignerIndex = signers.indexOf(usersNpub)
const prevSigners = signers.slice(0, currentSignerIndex)
prevSigners.forEach((signer) => {
userSet.add(signer)
})
const nextSigner = signers[currentSignerIndex + 1]
userSet.add(nextSigner)
}
setLoadingSpinnerDesc('Sending notifications')
const users = Array.from(userSet)
const promises = users.map((user) =>
sendNotification(npubToHex(user)!, meta)
)
await Promise.all(promises)
.then(() => {
toast.success('Notifications sent successfully')
setMeta(meta)
})
.catch(() => {
toast.error('Failed to publish notifications')
})
setIsLoading(false)
}
// Check if the current user is the last signer
@ -784,9 +730,14 @@ export const SignPage = () => {
2
)
const zip = await getZipWithFiles(meta, files)
const zip = new JSZip()
zip.file('meta.json', stringifiedMeta)
for (const [fileName, file] of Object.entries(files)) {
zip.file(`files/${fileName}`, await file.arrayBuffer())
}
const arrayBuffer = await zip
.generateAsync({
type: 'arraybuffer',
@ -812,14 +763,19 @@ export const SignPage = () => {
navigate(appPublicRoutes.verify)
}
const handleEncryptedExport = async () => {
const handleExportSigit = async () => {
if (Object.entries(files).length === 0 || !meta) return
const zip = new JSZip()
const stringifiedMeta = JSON.stringify(meta, null, 2)
const zip = await getZipWithFiles(meta, files)
zip.file('meta.json', stringifiedMeta)
for (const [fileName, file] of Object.entries(files)) {
zip.file(`files/${fileName}`, await file.arrayBuffer())
}
const arrayBuffer = await zip
.generateAsync({
type: 'arraybuffer',
@ -881,6 +837,75 @@ export const SignPage = () => {
}
}
const getUsersToNotify = (): `npub1${string}`[] => {
const userSet = new Set<`npub1${string}`>()
if (submittedBy && submittedBy !== usersPubkey) {
userSet.add(hexToNpub(submittedBy))
}
const usersNpub = hexToNpub(usersPubkey!)
const isLastSigner = checkIsLastSigner(signers)
if (isLastSigner) {
signers.forEach((signer) => {
if (signer !== usersNpub) {
userSet.add(signer)
}
})
viewers.forEach((viewer) => userSet.add(viewer))
} else {
const currentSignerIndex = signers.indexOf(usersNpub)
const prevSigners = signers.slice(0, currentSignerIndex)
prevSigners.forEach((signer) => {
userSet.add(signer)
})
const nextSigner = signers[currentSignerIndex + 1]
userSet.add(nextSigner)
}
return Array.from(userSet)
}
const notifyUsers = async (meta: Meta) => {
try {
const usersToNotify = getUsersToNotify()
return await Promise.allSettled(
usersToNotify.map(async (user) =>
sendNotification(npubToHex(user)!, meta)
)
)
} catch (error) {
throw new Error('There was a problem sending notifications to users', {
cause: error
})
}
}
const handleRenotifyUsers = async () => {
try {
setIsLoading(true)
const notifications = await notifyUsers(meta!)
checkNotifications(notifications)
} catch (error) {
console.error(error)
toast.error('There was an error re-notifying users')
} finally {
setIsLoading(false)
}
}
if (authUrl) {
return (
<iframe
title="Nsecbunker auth"
src={authUrl}
width="100%"
height="500px"
/>
)
}
if (isLoading) {
return <LoadingSpinner desc={loadingSpinnerDesc} />
}
@ -946,7 +971,7 @@ export const SignPage = () => {
{signedStatus === SignedStatus.Fully_Signed && (
<Box sx={{ mt: 1, display: 'flex', justifyContent: 'center' }}>
<Button onClick={handleExport} variant="contained">
Export Sigit
Export
</Button>
</Box>
)}
@ -961,11 +986,17 @@ export const SignPage = () => {
{isSignerOrCreator && (
<Box sx={{ mt: 1, display: 'flex', justifyContent: 'center' }}>
<Button onClick={handleEncryptedExport} variant="contained">
Export Encrypted Sigit
<Button onClick={handleExportSigit} variant="contained">
Export Sigit
</Button>
</Box>
)}
<Box sx={{ mt: 1, display: 'flex', justifyContent: 'center' }}>
<Button onClick={handleRenotifyUsers} variant="contained">
Re-notify Next Signer
</Button>
</Box>
</>
)}
</Container>

View File

@ -32,7 +32,7 @@ import { useState, useEffect } from 'react'
import { toast } from 'react-toastify'
import { UserAvatar } from '../../../components/UserAvatar'
import { MetadataController } from '../../../controllers'
import { npubToHex, hexToNpub, parseJson } from '../../../utils'
import { npubToHex, shorten, hexToNpub, parseJson } from '../../../utils'
import styles from '../style.module.scss'
import { SigitFile } from '../../../utils/file'
@ -105,7 +105,7 @@ export const DisplayMeta = ({
}, [signers, viewers])
useEffect(() => {
const metadataController = MetadataController.getInstance()
const metadataController = new MetadataController()
const hexKeys: string[] = [
npubToHex(submittedBy)!,
@ -167,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={{
@ -267,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)
@ -355,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>

View File

@ -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'] {

View File

@ -1,61 +1,58 @@
import { Box, Button, Typography } from '@mui/material'
import { Box, Button, Tooltip, Typography } from '@mui/material'
import JSZip from 'jszip'
import { MuiFileInput } from 'mui-file-input'
import { Event, verifyEvent } from 'nostr-tools'
import { useEffect, useRef, useState } from 'react'
import { toast } from 'react-toastify'
import { LoadingSpinner } from '../../components/LoadingSpinner'
import { NostrController } from '../../controllers'
import {
CreateSignatureEventContent,
DocSignatureEvent,
Meta,
SignedEvent,
OpenTimestamp,
OpenTimestampUpgradeVerifyResponse
Meta
} from '../../types'
import {
decryptArrayBuffer,
extractMarksFromSignedMeta,
getHash,
hexToNpub,
unixNow,
parseJson,
readContentOfZipEntry,
signEventForMetaFile,
getCurrentUserFiles,
updateUsersAppData,
npubToHex,
sendNotification
shorten,
getCurrentUserFiles
} from '../../utils'
import styles from './style.module.scss'
import { useLocation } from 'react-router-dom'
import axios from 'axios'
import { FONT_SIZE, FONT_TYPE, inPx } from '../../utils/pdf.ts'
import { useAppSelector } from '../../hooks/store'
import {
addMarks,
convertToPdfBlob,
FONT_SIZE,
FONT_TYPE,
groupMarksByFileNamePage,
inPx
} from '../../utils/pdf.ts'
import { State } from '../../store/rootReducer.ts'
import { useSelector } from 'react-redux'
import { getLastSignersSig } from '../../utils/sign.ts'
import { saveAs } from 'file-saver'
import { Container } from '../../components/Container'
import { useSigitMeta } from '../../hooks/useSigitMeta.tsx'
import { StickySideColumns } from '../../layouts/StickySideColumns.tsx'
import { UsersDetails } from '../../components/UsersDetails.tsx'
import { UsersDetails } from '../../components/UsersDetails.tsx/index.tsx'
import { UserAvatar } from '../../components/UserAvatar/index.tsx'
import { useSigitProfiles } from '../../hooks/useSigitProfiles.tsx'
import { TooltipChild } from '../../components/TooltipChild.tsx'
import FileList from '../../components/FileList'
import { CurrentUserFile } from '../../types/file.ts'
import { Mark } from '../../types/mark.ts'
import React from 'react'
import {
convertToSigitFile,
getZipWithFiles,
SigitFile
} from '../../utils/file.ts'
import { convertToSigitFile, SigitFile } from '../../utils/file.ts'
import { FileDivider } from '../../components/FileDivider.tsx'
import { ExtensionFileBox } from '../../components/ExtensionFileBox.tsx'
import { useScale } from '../../hooks/useScale.tsx'
import {
faCircleInfo,
faFile,
faFileDownload
} from '@fortawesome/free-solid-svg-icons'
import { upgradeAndVerifyTimestamp } from '../../utils/opentimestamps.ts'
import _ from 'lodash'
import { MARK_TYPE_CONFIG } from '../../components/getMarkComponents.tsx'
interface PdfViewProps {
files: CurrentUserFile[]
@ -81,110 +78,82 @@ const SlimPdfView = ({
}, [currentFile])
return (
<div className="files-wrapper">
{files.length > 0 ? (
files.map((currentUserFile, i) => {
const { hash, file, id } = currentUserFile
const signatureEvents = Object.keys(parsedSignatureEvents)
if (!hash) return
return (
<React.Fragment key={file.name}>
<div
id={file.name}
ref={(el) => (pdfRefs.current[id] = el)}
className="file-wrapper"
>
{file.isPdf &&
file.pages?.map((page, i) => {
const marks: Mark[] = []
{files.map((currentUserFile, i) => {
const { hash, file, id } = currentUserFile
const signatureEvents = Object.keys(parsedSignatureEvents)
if (!hash) return
return (
<React.Fragment key={file.name}>
<div
id={file.name}
ref={(el) => (pdfRefs.current[id] = el)}
className="file-wrapper"
>
{file.isPdf &&
file.pages?.map((page, i) => {
const marks: Mark[] = []
signatureEvents.forEach((e) => {
const m = parsedSignatureEvents[
e as `npub1${string}`
].parsedContent?.marks.filter(
(m) => m.pdfFileHash == hash && m.location.page == i
)
if (m) {
marks.push(...m)
}
})
return (
<div className="image-wrapper" key={i}>
<img
draggable="false"
src={page.image}
alt={`page ${i} of ${file.name}`}
/>
{marks.map((m) => {
const { render: MarkRenderComponent } =
MARK_TYPE_CONFIG[m.type] || {}
return (
<div
className={`file-mark ${styles.mark}`}
key={m.id}
style={{
left: inPx(from(page.width, m.location.left)),
top: inPx(from(page.width, m.location.top)),
width: inPx(from(page.width, m.location.width)),
height: inPx(
from(page.width, m.location.height)
),
fontFamily: FONT_TYPE,
fontSize: inPx(from(page.width, FONT_SIZE))
}}
>
{typeof MarkRenderComponent !== 'undefined' && (
<MarkRenderComponent value={m.value} mark={m} />
)}
</div>
)
})}
</div>
signatureEvents.forEach((e) => {
const m = parsedSignatureEvents[
e as `npub1${string}`
].parsedContent?.marks.filter(
(m) => m.pdfFileHash == hash && m.location.page == i
)
})}
{file.isImage && (
<img
className="file-image"
src={file.objectUrl}
alt={file.name}
/>
)}
{!(file.isPdf || file.isImage) && (
<ExtensionFileBox extension={file.extension} />
)}
</div>
{i < files.length - 1 && <FileDivider />}
</React.Fragment>
)
})
) : (
<LoadingSpinner variant="small" />
)}
if (m) {
marks.push(...m)
}
})
return (
<div className="image-wrapper" key={i}>
<img draggable="false" src={page.image} />
{marks.map((m) => {
return (
<div
className={`file-mark ${styles.mark}`}
key={m.id}
style={{
left: inPx(from(page.width, m.location.left)),
top: inPx(from(page.width, m.location.top)),
width: inPx(from(page.width, m.location.width)),
height: inPx(from(page.width, m.location.height)),
fontFamily: FONT_TYPE,
fontSize: inPx(from(page.width, FONT_SIZE))
}}
>
{m.value}
</div>
)
})}
</div>
)
})}
{file.isImage && (
<img
className="file-image"
src={file.objectUrl}
alt={file.name}
/>
)}
{!(file.isPdf || file.isImage) && (
<ExtensionFileBox extension={file.extension} />
)}
</div>
{i < files.length - 1 && <FileDivider />}
</React.Fragment>
)
})}
</div>
)
}
export const VerifyPage = () => {
const location = useLocation()
const usersPubkey = useAppSelector((state) => state.auth.usersPubkey)
const nostrController = NostrController.getInstance()
const [isLoading, setIsLoading] = useState(false)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
/**
* uploadedZip will be received from home page when a user uploads a sigit zip wrapper that contains meta.json
* meta will be received in navigation from create & home page in online mode
*/
const { uploadedZip, meta: metaInNavState } = location.state || {}
const [selectedFile, setSelectedFile] = useState<File | null>(null)
useEffect(() => {
if (uploadedZip) {
setSelectedFile(uploadedZip)
}
}, [uploadedZip])
const { uploadedZip, meta } = location.state || {}
const [meta, setMeta] = useState<Meta>(metaInNavState)
const {
submittedBy,
zipUrl,
@ -192,177 +161,47 @@ export const VerifyPage = () => {
signers,
viewers,
fileHashes,
parsedSignatureEvents,
timestamps
parsedSignatureEvents
} = useSigitMeta(meta)
const [files, setFiles] = useState<{ [filename: string]: SigitFile }>({})
const profiles = useSigitProfiles([
...(submittedBy ? [submittedBy] : []),
...signers,
...viewers
])
const [isLoading, setIsLoading] = useState(false)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [currentFile, setCurrentFile] = useState<CurrentUserFile | null>(null)
const [currentFileHashes, setCurrentFileHashes] = useState<{
[key: string]: string | null
}>({})
const [files, setFiles] = useState<{ [filename: string]: SigitFile }>({})
const [currentFile, setCurrentFile] = useState<CurrentUserFile | null>(null)
const [signatureFileHashes, setSignatureFileHashes] = useState<{
[key: string]: string
}>(fileHashes)
const signTimestampEvent = async (signerContent: {
timestamps: OpenTimestamp[]
}): Promise<SignedEvent | null> => {
return await signEventForMetaFile(
JSON.stringify(signerContent),
nostrController,
setIsLoading
)
}
useEffect(() => {
setSignatureFileHashes(fileHashes)
}, [fileHashes])
useEffect(() => {
if (Object.entries(files).length > 0) {
const tmp = getCurrentUserFiles(files, currentFileHashes, fileHashes)
const tmp = getCurrentUserFiles(files, fileHashes, signatureFileHashes)
setCurrentFile(tmp[0])
}
}, [currentFileHashes, fileHashes, files])
}, [signatureFileHashes, fileHashes, files])
const usersPubkey = useSelector((state: State) => state.auth.usersPubkey)
const nostrController = NostrController.getInstance()
useEffect(() => {
if (
timestamps &&
timestamps.length > 0 &&
usersPubkey &&
submittedBy &&
parsedSignatureEvents
) {
if (timestamps.every((t) => !!t.verification)) {
return
}
const upgradeT = async (timestamps: OpenTimestamp[]) => {
try {
setLoadingSpinnerDesc('Upgrading your timestamps.')
const findCreatorTimestamp = (timestamps: OpenTimestamp[]) => {
if (usersPubkey === submittedBy) {
return timestamps[0]
}
}
const findSignerTimestamp = (timestamps: OpenTimestamp[]) => {
const parsedEvent = parsedSignatureEvents[hexToNpub(usersPubkey)]
if (parsedEvent?.id) {
return timestamps.find((t) => t.nostrId === parsedEvent.id)
}
}
/**
* Checks if timestamp verification has been achieved for the first time.
* Note that the upgrade flag is separate from verification. It is possible for a timestamp
* to not be upgraded, but to be verified for the first time.
* @param upgradedTimestamp
* @param timestamps
*/
const isNewlyVerified = (
upgradedTimestamp: OpenTimestampUpgradeVerifyResponse,
timestamps: OpenTimestamp[]
) => {
if (!upgradedTimestamp.verified) return false
const oldT = timestamps.find(
(t) => t.nostrId === upgradedTimestamp.timestamp.nostrId
)
if (!oldT) return false
if (!oldT.verification && upgradedTimestamp.verified) return true
}
const userTimestamps: OpenTimestamp[] = []
const creatorTimestamp = findCreatorTimestamp(timestamps)
if (creatorTimestamp) {
userTimestamps.push(creatorTimestamp)
}
const signerTimestamp = findSignerTimestamp(timestamps)
if (signerTimestamp) {
userTimestamps.push(signerTimestamp)
}
if (userTimestamps.every((t) => !!t.verification)) {
return
}
const upgradedUserTimestamps = await Promise.all(
userTimestamps.map(upgradeAndVerifyTimestamp)
)
const upgradedTimestamps = upgradedUserTimestamps
.filter((t) => t.upgraded || isNewlyVerified(t, userTimestamps))
.map((t) => {
const timestamp: OpenTimestamp = { ...t.timestamp }
if (t.verified) {
timestamp.verification = t.verification
}
return timestamp
})
if (upgradedTimestamps.length === 0) {
return
}
setLoadingSpinnerDesc('Signing a timestamp upgrade event.')
const signedEvent = await signTimestampEvent({
timestamps: upgradedTimestamps
})
if (!signedEvent) return
const finalTimestamps = timestamps.map((t) => {
const upgraded = upgradedTimestamps.find(
(tu) => tu.nostrId === t.nostrId
)
if (upgraded) {
return {
...upgraded,
signature: JSON.stringify(signedEvent, null, 2)
}
}
return t
})
const updatedMeta = _.cloneDeep(meta)
updatedMeta.timestamps = [...finalTimestamps]
updatedMeta.modifiedAt = unixNow()
const updatedEvent = await updateUsersAppData(updatedMeta)
if (!updatedEvent) return
const userSet = new Set<`npub1${string}`>()
signers.forEach((signer) => {
if (signer !== usersPubkey) {
userSet.add(signer)
}
})
viewers.forEach((viewer) => {
userSet.add(viewer)
})
const users = Array.from(userSet)
const promises = users.map((user) =>
sendNotification(npubToHex(user)!, updatedMeta)
)
await Promise.all(promises)
toast.success('Timestamp updates have been sent successfully.')
setMeta(meta)
} catch (err) {
console.error(err)
toast.error(
'There was an error upgrading or verifying your timestamps!'
)
}
}
upgradeT(timestamps)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [timestamps, submittedBy, parsedSignatureEvents])
useEffect(() => {
if (metaInNavState && encryptionKey) {
if (uploadedZip) {
setSelectedFile(uploadedZip)
} else if (meta && encryptionKey) {
const processSigit = async () => {
setIsLoading(true)
@ -447,7 +286,7 @@ export const VerifyPage = () => {
processSigit()
}
}, [encryptionKey, metaInNavState, zipUrl])
}, [encryptionKey, meta, uploadedZip, zipUrl])
const handleVerify = async () => {
if (!selectedFile) return
@ -461,7 +300,6 @@ export const VerifyPage = () => {
if (!zip) return
const files: { [filename: string]: SigitFile } = {}
const fileHashes: { [key: string]: string | null } = {}
const fileNames = Object.values(zip.files)
.filter((entry) => entry.name.startsWith('files/') && !entry.dir)
@ -469,27 +307,24 @@ export const VerifyPage = () => {
// generate hashes for all entries in files folder of zipArchive
// these hashes can be used to verify the originality of files
for (const zipFilePath of fileNames) {
for (const fileName of fileNames) {
const arrayBuffer = await readContentOfZipEntry(
zip,
zipFilePath,
fileName,
'arraybuffer'
)
const fileName = zipFilePath.replace(/^files\//, '')
if (arrayBuffer) {
files[fileName] = await convertToSigitFile(arrayBuffer, fileName)
const hash = await getHash(arrayBuffer)
if (hash) {
fileHashes[fileName] = hash
fileHashes[fileName.replace(/^files\//, '')] = hash
}
} else {
fileHashes[fileName] = null
fileHashes[fileName.replace(/^files\//, '')] = null
}
}
setFiles(files)
setCurrentFileHashes(fileHashes)
setLoadingSpinnerDesc('Parsing meta.json')
@ -518,12 +353,48 @@ export const VerifyPage = () => {
if (!parsedMetaJson) return
setMeta(parsedMetaJson)
const createSignatureEvent = await parseJson<Event>(
parsedMetaJson.createSignature
).catch((err) => {
console.log('err in parsing the createSignature event:>> ', err)
toast.error(
err.message || 'error occurred in parsing the create signature event'
)
setIsLoading(false)
return null
})
if (!createSignatureEvent) return
const isValidCreateSignature = verifyEvent(createSignatureEvent)
if (!isValidCreateSignature) {
toast.error('Create signature is invalid')
setIsLoading(false)
return
}
const createSignatureContent = await parseJson<CreateSignatureEventContent>(
createSignatureEvent.content
).catch((err) => {
console.log(
`err in parsing the createSignature event's content :>> `,
err
)
toast.error(
err.message ||
`error occurred in parsing the create signature event's content`
)
setIsLoading(false)
return null
})
if (!createSignatureContent) return
setIsLoading(false)
}
const handleMarkedExport = async () => {
const handleExport = async () => {
if (Object.entries(files).length === 0 || !meta || !usersPubkey) return
const usersNpub = hexToNpub(usersPubkey)
@ -553,9 +424,23 @@ export const VerifyPage = () => {
const updatedMeta = { ...meta, exportSignature }
const stringifiedMeta = JSON.stringify(updatedMeta, null, 2)
const zip = await getZipWithFiles(updatedMeta, files)
const zip = new JSZip()
zip.file('meta.json', stringifiedMeta)
const marks = extractMarksFromSignedMeta(updatedMeta)
const marksByPage = groupMarksByFileNamePage(marks)
for (const [fileName, file] of Object.entries(files)) {
if (file.isPdf) {
// Draw marks into PDF file and generate a brand new blob
const pages = await addMarks(file, marksByPage[fileName])
const blob = await convertToPdfBlob(pages)
zip.file(`files/${fileName}`, blob)
} else {
zip.file(`files/${fileName}`, file)
}
}
const arrayBuffer = await zip
.generateAsync({
type: 'arraybuffer',
@ -579,6 +464,47 @@ export const VerifyPage = () => {
setIsLoading(false)
}
const displayExportedBy = () => {
if (!meta || !meta.exportSignature) return null
const exportSignatureString = meta.exportSignature
try {
const exportSignatureEvent = JSON.parse(exportSignatureString) as Event
if (verifyEvent(exportSignatureEvent)) {
const exportedBy = exportSignatureEvent.pubkey
const profile = profiles[exportedBy]
return (
<Tooltip
title={
profile?.display_name ||
profile?.name ||
shorten(hexToNpub(exportedBy))
}
placement="top"
arrow
disableInteractive
>
<TooltipChild>
<UserAvatar pubkey={exportedBy} image={profile?.picture} />
</TooltipChild>
</Tooltip>
)
} else {
toast.error(`Invalid export signature!`)
return (
<Typography component="label" sx={{ color: 'red' }}>
Invalid export signature
</Typography>
)
}
} catch (error) {
console.error(`An error occurred wile parsing exportSignature`, error)
return null
}
}
return (
<>
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
@ -613,28 +539,32 @@ export const VerifyPage = () => {
{meta && (
<StickySideColumns
left={
currentFile !== null && (
<FileList
files={getCurrentUserFiles(
files,
currentFileHashes,
fileHashes
)}
currentFile={currentFile}
setCurrentFile={setCurrentFile}
handleDownload={handleMarkedExport}
downloadLabel="Download Sigit"
/>
)
<>
{currentFile !== null && (
<FileList
files={getCurrentUserFiles(
files,
currentFileHashes,
signatureFileHashes
)}
currentFile={currentFile}
setCurrentFile={setCurrentFile}
handleDownload={handleExport}
downloadLabel="Download Sigit"
/>
)}
{displayExportedBy()}
</>
}
right={<UsersDetails meta={meta} />}
leftIcon={faFileDownload}
centerIcon={faFile}
rightIcon={faCircleInfo}
>
<SlimPdfView
currentFile={currentFile}
files={getCurrentUserFiles(files, currentFileHashes, fileHashes)}
files={getCurrentUserFiles(
files,
currentFileHashes,
signatureFileHashes
)}
parsedSignatureEvents={parsedSignatureEvents}
/>
</StickySideColumns>

View File

@ -61,6 +61,6 @@
[data-dev='true'] {
.mark {
outline: 1px dotted black;
border: 1px dotted black;
}
}

View File

@ -1,10 +1,13 @@
import { Modal } from '../layouts/modal'
import { CreatePage } from '../pages/create'
import { HomePage } from '../pages/home'
import { LandingPage } from '../pages/landing'
import { Login } from '../pages/login'
import { Nostr } from '../pages/nostr'
import { ProfilePage } from '../pages/profile'
import { Register } from '../pages/register'
import { SettingsPage } from '../pages/settings/Settings'
import { CacheSettingsPage } from '../pages/settings/cache'
import { NostrLoginPage } from '../pages/settings/nostrLogin'
import { ProfileSettingsPage } from '../pages/settings/profile'
import { RelaysPage } from '../pages/settings/relays'
import { SignPage } from '../pages/sign'
@ -19,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 = {
@ -83,7 +85,29 @@ export const publicRoutes: PublicRouteProps[] = [
{
path: appPublicRoutes.landingPage,
hiddenWhenLoggedIn: true,
element: <LandingPage />
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,
@ -105,7 +129,7 @@ export const privateRoutes = [
element: <CreatePage />
},
{
path: `${appPrivateRoutes.sign}/:id?`,
path: appPrivateRoutes.sign,
element: <SignPage />
},
{
@ -123,9 +147,5 @@ export const privateRoutes = [
{
path: appPrivateRoutes.relays,
element: <RelaysPage />
},
{
path: appPrivateRoutes.nostrLogin,
element: <NostrLoginPage />
}
]

View File

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

View File

@ -1,124 +0,0 @@
import {
UnsignedEvent,
EventTemplate,
nip19,
nip44,
finalizeEvent,
nip04
} from 'nostr-tools'
import { SignedEvent } from '../../types'
import store from '../../store/store'
import { LoginMethod } from '../../store/auth/types'
import { LoginMethodStrategy } from './loginMethodStrategy'
import { verifySignedEvent } from '../../utils/nostr'
/**
* Login Method Strategy when using dev private key login.
*
* This class extends {@link LoginMethodStrategy base strategy} and implements all login method operations
* @see {@link LoginMethodStrategy}
*/
export class PrivateKeyStrategy extends LoginMethodStrategy {
async nip04Encrypt(receiver: string, content: string): Promise<string> {
const keys = store.getState().auth.keyPair
if (!keys) {
throw new Error(
`Login method is ${LoginMethod.privateKey} but private & public key pair is not found.`
)
}
const { private: nsec } = keys
const privateKey = nip19.decode(nsec).data as Uint8Array
const encrypted = await nip04.encrypt(privateKey, receiver, content)
return encrypted
}
async nip04Decrypt(sender: string, content: string): Promise<string> {
const keys = store.getState().auth.keyPair
if (!keys) {
throw new Error(
`Login method is ${LoginMethod.privateKey} but private & public key pair is not found.`
)
}
const { private: nsec } = keys
const privateKey = nip19.decode(nsec).data as Uint8Array
const decrypted = await nip04.decrypt(privateKey, sender, content)
return decrypted
}
async nip44Encrypt(receiver: string, content: string): Promise<string> {
const keys = store.getState().auth.keyPair
// Check if the private and public key pair is available.
if (!keys) {
throw new Error(
`Login method is ${LoginMethod.privateKey} but private & public key pair is not found.`
)
}
// Decode the private key.
const { private: nsec } = keys
const privateKey = nip19.decode(nsec).data as Uint8Array
// Generate the conversation key using NIP-44 utilities.
const nip44ConversationKey = nip44.v2.utils.getConversationKey(
privateKey,
receiver
)
// Encrypt the content using the generated conversation key.
const encrypted = nip44.v2.encrypt(content, nip44ConversationKey)
return encrypted
}
async nip44Decrypt(sender: string, content: string): Promise<string> {
const keys = store.getState().auth.keyPair
// Check if the private and public key pair is available.
if (!keys) {
throw new Error(
`Login method is ${LoginMethod.privateKey} but private & public key pair is not found.`
)
}
// Decode the private key.
const { private: nsec } = keys
const privateKey = nip19.decode(nsec).data as Uint8Array
// Generate the conversation key using NIP-44 utilities.
const nip44ConversationKey = nip44.v2.utils.getConversationKey(
privateKey,
sender
)
// Decrypt the content using the generated conversation key.
const decrypted = nip44.v2.decrypt(content, nip44ConversationKey)
return decrypted
}
async signEvent(event: UnsignedEvent | EventTemplate): Promise<SignedEvent> {
const keys = store.getState().auth.keyPair
if (!keys) {
return Promise.reject(
`Login method is ${LoginMethod.privateKey}, but keys are not found`
)
}
const { private: nsec } = keys
const privateKey = nip19.decode(nsec).data as Uint8Array
const signedEvent = finalizeEvent(event, privateKey)
verifySignedEvent(signedEvent)
return Promise.resolve(signedEvent)
}
}

View File

@ -1,50 +0,0 @@
import { UnsignedEvent, EventTemplate } from 'nostr-tools'
import { SignedEvent } from '../../types'
import {
LoginMethodStrategy,
LoginMethodOperations
} from './loginMethodStrategy'
import { LoginMethod } from '../../store/auth/types'
import { NostrLoginStrategy } from './NostrLoginStrategy'
import { PrivateKeyStrategy } from './PrivateKeyStrategy'
/**
* This class is a context provider and helper class. This MUST be instantiated and used as an entry point for any of the {@link LoginMethodOperations LoginMethodOperations}
* @constructor Takes {@link LoginMethod LoginMethod} as an argument and sets the correct strategy
*
* @see {@link LoginMethod}
* @see {@link LoginMethodOperations}
*/
export class LoginMethodContext implements LoginMethodOperations {
private strategy: LoginMethodStrategy
constructor(loginMethod?: LoginMethod) {
switch (loginMethod) {
case LoginMethod.nostrLogin:
this.strategy = new NostrLoginStrategy()
break
case LoginMethod.privateKey:
this.strategy = new PrivateKeyStrategy()
break
default:
this.strategy = new LoginMethodStrategy()
break
}
}
nip04Encrypt(receiver: string, content: string): Promise<string> {
return this.strategy.nip04Encrypt(receiver, content)
}
nip04Decrypt(sender: string, content: string): Promise<string> {
return this.strategy.nip04Decrypt(sender, content)
}
nip44Encrypt(receiver: string, content: string): Promise<string> {
return this.strategy.nip44Encrypt(receiver, content)
}
nip44Decrypt(sender: string, content: string): Promise<string> {
return this.strategy.nip44Decrypt(sender, content)
}
signEvent(event: UnsignedEvent | EventTemplate): Promise<SignedEvent> {
return this.strategy.signEvent(event)
}
}

View File

@ -1,38 +0,0 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { EventTemplate, UnsignedEvent } from 'nostr-tools'
import { SignedEvent } from '../../types/nostr'
/**
* This interface holds all operations that are dependant on the login method and is used as the basis for the login strategies.
*/
export interface LoginMethodOperations {
nip04Encrypt(receiver: string, content: string): Promise<string>
nip04Decrypt(sender: string, content: string): Promise<string>
nip44Encrypt(receiver: string, content: string): Promise<string>
nip44Decrypt(sender: string, content: string): Promise<string>
signEvent(event: UnsignedEvent | EventTemplate): Promise<SignedEvent>
}
/**
* This is the fallback class that provides base implementation for the {@link LoginMethodOperations login method operations} . Only used to throw errors in case when the LoginMethod is missing (login context not set).
* @see {@link LoginMethodOperations}
*/
export class LoginMethodStrategy implements LoginMethodOperations {
async nip04Encrypt(_receiver: string, _content: string): Promise<string> {
throw new Error('Login method strategy is undefined')
}
async nip04Decrypt(_sender: string, _content: string): Promise<string> {
throw new Error('Login method strategy is undefined')
}
async nip44Encrypt(_receiver: string, _content: string): Promise<string> {
throw new Error('Login method strategy is undefined')
}
async nip44Decrypt(_sender: string, _content: string): Promise<string> {
throw new Error('Login method strategy is undefined')
}
async signEvent(_event: UnsignedEvent | EventTemplate): Promise<SignedEvent> {
return Promise.reject(
`We could not sign the event, none of the signing methods are available`
)
}
}

View File

@ -4,8 +4,9 @@ export const USER_LOGOUT = 'USER_LOGOUT'
export const SET_AUTH_STATE = 'SET_AUTH_STATE'
export const UPDATE_LOGIN_METHOD = 'UPDATE_LOGIN_METHOD'
export const UPDATE_NOSTR_LOGIN_AUTH_METHOD = 'UPDATE_NOSTR_LOGIN_AUTH_METHOD'
export const UPDATE_KEYPAIR = 'UPDATE_KEYPAIR'
export const UPDATE_NSECBUNKER_PUBKEY = 'UPDATE_NSECBUNKER_PUBKEY'
export const UPDATE_NSECBUNKER_RELAYS = 'UPDATE_NSECBUNKER_RELAYS'
export const SET_METADATA_EVENT = 'SET_METADATA_EVENT'

View File

@ -18,10 +18,6 @@ export interface RestoreState {
payload: State
}
export interface UserLogout {
type: typeof ActionTypes.USER_LOGOUT
}
export const userLogOutAction = () => {
return {
type: ActionTypes.USER_LOGOUT

View File

@ -2,12 +2,12 @@ import * as ActionTypes from '../actionTypes'
import {
AuthState,
Keys,
LoginMethod,
LoginMethods,
SetAuthState,
UpdateKeyPair,
UpdateLoginMethod,
NostrLoginAuthMethod,
UpdateNostrLoginAuthMethod
UpdateNsecBunkerPubkey,
UpdateNsecBunkerRelays
} from './types'
export const setAuthState = (payload: AuthState): SetAuthState => ({
@ -16,20 +16,27 @@ export const setAuthState = (payload: AuthState): SetAuthState => ({
})
export const updateLoginMethod = (
payload: LoginMethod | undefined
payload: LoginMethods | undefined
): UpdateLoginMethod => ({
type: ActionTypes.UPDATE_LOGIN_METHOD,
payload
})
export const updateNostrLoginAuthMethod = (
payload: NostrLoginAuthMethod | undefined
): UpdateNostrLoginAuthMethod => ({
type: ActionTypes.UPDATE_NOSTR_LOGIN_AUTH_METHOD,
payload
})
export const updateKeyPair = (payload: Keys | undefined): UpdateKeyPair => ({
type: ActionTypes.UPDATE_KEYPAIR,
payload
})
export const updateNsecbunkerPubkey = (
payload: string | undefined
): UpdateNsecBunkerPubkey => ({
type: ActionTypes.UPDATE_NSECBUNKER_PUBKEY,
payload
})
export const updateNsecbunkerRelays = (
payload: string[] | undefined
): UpdateNsecBunkerRelays => ({
type: ActionTypes.UPDATE_NSECBUNKER_RELAYS,
payload
})

View File

@ -8,15 +8,16 @@ const initialState: AuthState = {
const reducer = (
state = initialState,
action: AuthDispatchTypes
): AuthState => {
): AuthState | null => {
switch (action.type) {
case ActionTypes.SET_AUTH_STATE: {
const { loginMethod, nostrLoginAuthMethod, keyPair } = state
const { loginMethod, keyPair, nsecBunkerPubkey, nsecBunkerRelays } = state
return {
loginMethod,
nostrLoginAuthMethod,
keyPair,
nsecBunkerPubkey,
nsecBunkerRelays,
...action.payload
}
}
@ -29,15 +30,6 @@ const reducer = (
}
}
case ActionTypes.UPDATE_NOSTR_LOGIN_AUTH_METHOD: {
const { payload } = action
return {
...state,
nostrLoginAuthMethod: payload
}
}
case ActionTypes.UPDATE_KEYPAIR: {
const { payload } = action
@ -47,8 +39,26 @@ const reducer = (
}
}
case ActionTypes.UPDATE_NSECBUNKER_PUBKEY: {
const { payload } = action
return {
...state,
nsecBunkerPubkey: payload
}
}
case ActionTypes.UPDATE_NSECBUNKER_RELAYS: {
const { payload } = action
return {
...state,
nsecBunkerRelays: payload
}
}
case ActionTypes.RESTORE_STATE:
return action.payload.auth || initialState
return action.payload.auth
default:
return state

View File

@ -1,17 +1,10 @@
import * as ActionTypes from '../actionTypes'
import { RestoreState, UserLogout } from '../actions'
import { RestoreState } from '../actions'
export enum NostrLoginAuthMethod {
Connect = 'connect',
ReadOnly = 'readOnly',
Extension = 'extension',
Local = 'local',
OTP = 'otp'
}
export enum LoginMethod {
nostrLogin = 'nostrLogin',
export enum LoginMethods {
extension = 'extension',
privateKey = 'privateKey',
nsecBunker = 'nsecBunker',
register = 'register'
}
@ -23,21 +16,10 @@ export interface Keys {
export interface AuthState {
loggedIn: boolean
usersPubkey?: string
/**
* sigit login {@link LoginMethod methods }
* @see {@link LoginMethod}
*/
loginMethod?: LoginMethod
/**
* nostr-login package specific {@link NostrLoginAuthMethod method }
* @see {@link NostrLoginAuthMethod}
*/
nostrLoginAuthMethod?: NostrLoginAuthMethod
/**
* {@link Keys keyPair} for user auth (usually only public is available)
* @see {@link Keys}
*/
loginMethod?: LoginMethods
keyPair?: Keys
nsecBunkerPubkey?: string
nsecBunkerRelays?: string[]
}
export interface SetAuthState {
@ -47,12 +29,7 @@ export interface SetAuthState {
export interface UpdateLoginMethod {
type: typeof ActionTypes.UPDATE_LOGIN_METHOD
payload: LoginMethod | undefined
}
export interface UpdateNostrLoginAuthMethod {
type: typeof ActionTypes.UPDATE_NOSTR_LOGIN_AUTH_METHOD
payload: NostrLoginAuthMethod | undefined
payload: LoginMethods | undefined
}
export interface UpdateKeyPair {
@ -60,10 +37,20 @@ export interface UpdateKeyPair {
payload: Keys | undefined
}
export interface UpdateNsecBunkerPubkey {
type: typeof ActionTypes.UPDATE_NSECBUNKER_PUBKEY
payload: string | undefined
}
export interface UpdateNsecBunkerRelays {
type: typeof ActionTypes.UPDATE_NSECBUNKER_RELAYS
payload: string[] | undefined
}
export type AuthDispatchTypes =
| RestoreState
| SetAuthState
| UpdateLoginMethod
| UpdateNostrLoginAuthMethod
| UpdateKeyPair
| UserLogout
| UpdateNsecBunkerPubkey
| UpdateNsecBunkerRelays

View File

@ -15,7 +15,7 @@ const reducer = (
}
case ActionTypes.RESTORE_STATE:
return action.payload.metadata || initialState
return action.payload.metadata || null
default:
return state

View File

@ -10,7 +10,7 @@ const initialState: RelaysState = {
const reducer = (
state = initialState,
action: RelaysDispatchTypes
): RelaysState => {
): RelaysState | null => {
switch (action.type) {
case ActionTypes.SET_RELAY_MAP:
return { ...state, map: action.payload, mapUpdated: Date.now() }
@ -25,7 +25,7 @@ const reducer = (
}
case ActionTypes.RESTORE_STATE:
return action.payload.relays || initialState
return action.payload.relays
default:
return state

View File

@ -3,15 +3,12 @@ import { combineReducers } from 'redux'
import { UserAppData } from '../types'
import * as ActionTypes from './actionTypes'
import authReducer from './auth/reducer'
import { AuthDispatchTypes, AuthState } from './auth/types'
import { AuthState } from './auth/types'
import metadataReducer from './metadata/reducer'
import relaysReducer from './relays/reducer'
import { RelaysDispatchTypes, RelaysState } from './relays/types'
import { RelaysState } from './relays/types'
import UserAppDataReducer from './userAppData/reducer'
import userRobotImageReducer from './userRobotImage/reducer'
import { MetadataDispatchTypes } from './metadata/types'
import { UserAppDataDispatchTypes } from './userAppData/types'
import { UserRobotImageDispatchTypes } from './userRobotImage/types'
export interface State {
auth: AuthState
@ -21,13 +18,6 @@ export interface State {
userAppData?: UserAppData
}
type AppActions =
| AuthDispatchTypes
| MetadataDispatchTypes
| UserRobotImageDispatchTypes
| RelaysDispatchTypes
| UserAppDataDispatchTypes
export const appReducer = combineReducers({
auth: authReducer,
metadata: metadataReducer,
@ -36,10 +26,8 @@ export const appReducer = combineReducers({
userAppData: UserAppDataReducer
})
export default (
state: ReturnType<typeof appReducer> | undefined,
action: AppActions
) => {
// FIXME: define types
export default (state: any, action: any) => {
switch (action.type) {
case ActionTypes.USER_LOGOUT:
return appReducer(undefined, action)

View File

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

View File

@ -6,4 +6,4 @@ export interface SetUserRobotImage {
payload: string | null
}
export type UserRobotImageDispatchTypes = SetUserRobotImage | RestoreState
export type MetadataDispatchTypes = SetUserRobotImage | RestoreState

View File

@ -2,5 +2,3 @@ $header-height: 65px;
$body-vertical-padding: 25px;
$default-container-padding-inline: 10px;
$tabs-height: 40px;

View File

@ -18,7 +18,6 @@ export interface Meta {
docSignatures: { [key: `npub1${string}`]: string }
exportSignature?: string
keys?: { sender: string; keys: { [user: `npub1${string}`]: string } }
timestamps?: OpenTimestamp[]
}
export interface CreateSignatureEventContent {
@ -40,44 +39,11 @@ export interface Sigit {
meta: Meta
}
export interface OpenTimestamp {
nostrId: string
value: string
verification?: OpenTimestampVerification
signature?: string
}
export interface OpenTimestampVerification {
height: number
timestamp: number
}
export interface OpenTimestampUpgradeVerifyResponse {
timestamp: OpenTimestamp
upgraded: boolean
verified?: boolean
verification?: OpenTimestampVerification
}
export interface UserAppData {
/**
* Key will be id of create signature
*/
sigits: { [key: string]: Meta }
/**
* An array of ids of processed gift wrapped events
*/
processedGiftWraps: string[]
/**
* Generated ephemeral key pair (https://docs.sigit.io/#/technical?id=storing-app-data).
* This {@link Keys key pair} is used for blossom requests authentication.
* @see {@link Keys}
*/
keyPair?: Keys
/**
* Array for storing Urls for the files that stores all the sigits and processedGiftWraps on blossom.
*/
blossomUrls: string[]
sigits: { [key: string]: Meta } // key will be id of create signature
processedGiftWraps: string[] // an array of ids of processed gift wrapped events
keyPair?: Keys // this key pair is used for blossom requests authentication
blossomUrls: string[] // array for storing Urls for the files that stores all the sigits and processedGiftWraps on blossom
}
export interface DocSignatureEvent extends Event {

View File

@ -1,4 +1,3 @@
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
import { MarkRect } from './mark'
export interface MouseState {
@ -6,8 +5,8 @@ export interface MouseState {
dragging?: boolean
resizing?: boolean
coordsInWrapper?: {
x: number
y: number
mouseX: number
mouseY: number
}
}
@ -28,13 +27,10 @@ export interface DrawnField extends MarkRect {
export interface DrawTool {
identifier: MarkType
label: string
icon: IconDefinition
icon: JSX.Element
defaultValue?: string
selected?: boolean
/** show or hide the toolbox item */
isHidden?: boolean
/** show or hide "coming soon" message on the toolbox item */
isComingSoon?: boolean
active?: boolean
}
export enum MarkType {

View File

@ -1,6 +0,0 @@
export class TimeoutError extends Error {
constructor() {
super('Timeout')
this.name = this.constructor.name
}
}

View File

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

View File

@ -4,4 +4,3 @@ export * from './nostr'
export * from './profile'
export * from './relay'
export * from './zip'
export * from './event'

View File

@ -28,24 +28,3 @@ export interface MarkRect {
width: number
height: number
}
export interface MarkInputProps {
value: string
handler: (value: string) => void
placeholder?: string
userMark?: CurrentUserMark
}
export interface MarkRenderProps {
value?: string
mark: Mark
}
export interface MarkConfig {
input: React.FC<MarkInputProps>
render?: React.FC<MarkRenderProps>
}
export type MarkConfigs = {
[key in MarkType]?: MarkConfig
}

View File

@ -1,38 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
interface OpenTimestamps {
// Create a detached timestamp file from a buffer or file hash
DetachedTimestampFile: {
fromHash(op: any, hash: Uint8Array): any
fromBytes(op: any, buffer: Uint8Array): any
deserialize(buffer: any): any
}
// Stamp the provided timestamp file and return a Promise
stamp(file: any): Promise<void>
// Verify the provided timestamp proof file
verify(
ots: string,
file: string
): Promise<TimestampVerficiationResponse | Record<string, never>>
// Other utilities or operations (like OpSHA256, serialization)
Ops: {
OpSHA256: any
OpSHA1?: any
}
Context: {
StreamSerialization: any
}
// Load a timestamp file from a buffer
deserialize(bytes: Uint8Array): any
// Other potential methods based on repo functions
upgrade(file: any): Promise<boolean>
}
interface TimestampVerficiationResponse {
bitcoin: { timestamp: number; height: number }
}

View File

@ -1,7 +1,6 @@
export interface ProfileMetadata {
name?: string
display_name?: string
/** @deprecated use name instead */
username?: string
picture?: string
banner?: string

View File

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

View File

@ -1,4 +1,11 @@
import { MarkType } from '../types/drawing.ts'
export const EMPTY: string = ''
export const MARK_TYPE_TRANSLATION: { [key: string]: string } = {
[MarkType.FULLNAME.valueOf()]: 'Full Name'
}
export const SIGN: string = 'Sign'
export const NEXT: string = 'Next'
export const ARRAY_BUFFER = 'arraybuffer'
export const DEFLATE = 'DEFLATE'
@ -14,8 +21,6 @@ export const ONE_DAY_IN_MS = 24 * 60 * 60 * 1000
export const SIGIT_RELAY = 'wss://relay.sigit.io'
export const SIGIT_BLOSSOM = 'https://blossom.sigit.io'
export const DEFAULT_LOOK_UP_RELAY_LIST = [
SIGIT_RELAY,
'wss://user.kindpag.es',

View File

@ -13,7 +13,7 @@ import { setRelayInfoAction } from '../store/actions'
export const getNostrJoiningBlockNumber = async (
hexKey: string
): Promise<NostrJoiningBlock | null> => {
const metadataController = MetadataController.getInstance()
const metadataController = new MetadataController()
const relaySet = await metadataController.findRelayListMetadata(hexKey)

View File

@ -4,6 +4,7 @@ import { MOST_COMMON_MEDIA_TYPES } from './const.ts'
import { extractMarksFromSignedMeta } from './mark.ts'
import {
addMarks,
convertToPdfBlob,
groupMarksByFileNamePage,
isPdf,
pdfToImages
@ -19,14 +20,15 @@ export const getZipWithFiles = async (
const marksByFileNamePage = groupMarksByFileNamePage(marks)
for (const [fileName, file] of Object.entries(files)) {
// Handle PDF Files, add marks
if (file.isPdf && fileName in marksByFileNamePage) {
const blob = await addMarks(file, marksByFileNamePage[fileName])
zip.file(`marked/${fileName}`, blob)
if (file.isPdf) {
// Handle PDF Files
const pages = await addMarks(file, marksByFileNamePage[fileName])
const blob = await convertToPdfBlob(pages)
zip.file(`files/${fileName}`, blob)
} else {
// Handle other files
zip.file(`files/${fileName}`, file)
}
// Save original files
zip.file(`files/${fileName}`, file)
}
return zip

View File

@ -26,6 +26,14 @@ export const clearState = () => {
localStorage.removeItem('state')
}
export const saveNsecBunkerDelegatedKey = (privateKey: string) => {
localStorage.setItem('nsecbunker-delegated-key', privateKey)
}
export const getNsecBunkerDelegatedKey = () => {
return localStorage.getItem('nsecbunker-delegated-key')
}
export const saveVisitedLink = (pathname: string, search: string) => {
localStorage.setItem(
'visitedLink',
@ -61,8 +69,3 @@ export const getAuthToken = () => {
export const clearAuthToken = () => {
localStorage.removeItem('authToken')
}
export const clear = () => {
clearAuthToken()
clearState()
}

View File

@ -1,30 +1,8 @@
import { CurrentUserMark, Mark, MarkLocation } from '../types/mark.ts'
import { CurrentUserMark, Mark } from '../types/mark.ts'
import { hexToNpub } from './nostr.ts'
import { Meta, SignedEventContent } from '../types'
import { Event } from 'nostr-tools'
import { EMPTY } from './const.ts'
import { DrawTool, MarkType } from '../types/drawing.ts'
import {
faT,
faSignature,
faBriefcase,
faIdCard,
faClock,
fa1,
faCalendarDays,
faCheckDouble,
faCircleDot,
faCreditCard,
faHeading,
faImage,
faPaperclip,
faPhone,
faSquareCaretDown,
faSquareCheck,
faStamp,
faTableCellsLarge
} from '@fortawesome/free-solid-svg-icons'
import { Config, optimize } from 'svgo'
/**
* Takes in an array of Marks already filtered by User.
@ -153,137 +131,6 @@ const findOtherUserMarks = (marks: Mark[], pubkey: string): Mark[] => {
return marks.filter((mark) => mark.npub !== hexToNpub(pubkey))
}
export const DEFAULT_TOOLBOX: DrawTool[] = [
{
identifier: MarkType.TEXT,
icon: faT,
label: 'Text'
},
{
identifier: MarkType.SIGNATURE,
icon: faSignature,
label: 'Signature'
},
{
identifier: MarkType.FULLNAME,
icon: faIdCard,
label: 'Full Name',
isComingSoon: true
},
{
identifier: MarkType.JOBTITLE,
icon: faBriefcase,
label: 'Job Title',
isComingSoon: true
},
{
identifier: MarkType.DATETIME,
icon: faClock,
label: 'Date Time',
isComingSoon: true
},
{
identifier: MarkType.NUMBER,
icon: fa1,
label: 'Number',
isComingSoon: true
},
{
identifier: MarkType.INITIALS,
icon: faHeading,
label: 'Initials',
isHidden: true
},
{
identifier: MarkType.DATE,
icon: faCalendarDays,
label: 'Date',
isHidden: true
},
{
identifier: MarkType.IMAGES,
icon: faImage,
label: 'Images',
isHidden: true
},
{
identifier: MarkType.CHECKBOX,
icon: faSquareCheck,
label: 'Checkbox',
isHidden: true
},
{
identifier: MarkType.MULTIPLE,
icon: faCheckDouble,
label: 'Multiple',
isHidden: true
},
{
identifier: MarkType.FILE,
icon: faPaperclip,
label: 'File',
isHidden: true
},
{
identifier: MarkType.RADIO,
icon: faCircleDot,
label: 'Radio',
isHidden: true
},
{
identifier: MarkType.SELECT,
icon: faSquareCaretDown,
label: 'Select',
isHidden: true
},
{
identifier: MarkType.CELLS,
icon: faTableCellsLarge,
label: 'Cells',
isHidden: true
},
{
identifier: MarkType.STAMP,
icon: faStamp,
label: 'Stamp',
isHidden: true
},
{
identifier: MarkType.PAYMENT,
icon: faCreditCard,
label: 'Payment',
isHidden: true
},
{
identifier: MarkType.PHONE,
icon: faPhone,
label: 'Phone',
isHidden: true
}
]
export const getToolboxLabelByMarkType = (markType: MarkType) => {
return DEFAULT_TOOLBOX.find((t) => t.identifier === markType)?.label
}
export const optimizeSVG = (location: MarkLocation, paths: string[]) => {
const svgContent = `<svg xmlns="http://www.w3.org/2000/svg" width="${location.width}" height="${location.height}">${paths.map((path) => `<path d="${path}" stroke="black" fill="none" />`).join('')}</svg>`
const optimizedSVG = optimize(svgContent, {
multipass: true, // Optimize multiple times if needed
floatPrecision: 2 // Adjust precision
} as Config)
return optimizedSVG.data
}
export const getOptimizedPaths = (svgString: string) => {
const regex = / d="([^"]*)"/g
const matches = [...svgString.matchAll(regex)]
const pathValues = matches.map((match) => match[1])
return pathValues
}
export {
getCurrentUserMarks,
filterMarksByPubkey,

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