Compare commits
82 Commits
Author | SHA1 | Date | |
---|---|---|---|
9432e99b3b | |||
ff875cc9d7 | |||
d45ea63c20 | |||
aec0d0bdd8 | |||
5f39b55f68 | |||
ebd59471c7 | |||
c2a149c872 | |||
0091d3ec9e | |||
dd53ded518 | |||
6d78d9ed64 | |||
c52fecdf4e | |||
43beac48e8 | |||
f35e2718ab | |||
84c374bb2c | |||
a53914b59d | |||
68c10d1831 | |||
5a2a0ad9c4 | |||
62c1f1b37b | |||
8267eb624b | |||
e1c750495e | |||
8b00ef538b | |||
13254fbe06 | |||
1dfab7b82b | |||
759a40a4f9 | |||
8b4f1a8973 | |||
79ef9eb8d6 | |||
ba24e7417d | |||
ea7e3a0964 | |||
675a763af3 | |||
bf1b3beb63 | |||
2e58b58a6a | |||
17c1700554 | |||
9191336722 | |||
32a6f9d7a3 | |||
5f0234a358 | |||
235e76be4e | |||
7c80643aba | |||
9c545a477c | |||
4d1e672268 | |||
4bc5882ab6 | |||
e2b3dc13fb | |||
0244090c6a | |||
f0ba9da8af | |||
1d1c675dd7 | |||
70f646444b | |||
09229f42c7 | |||
f516fe82d7 | |||
bf506705e6 | |||
b8811d730a | |||
479cca2180 | |||
82b7b9f7ce | |||
3e075754e5 | |||
c6010a5bef | |||
81f40c345a | |||
734026b2ee | |||
5caa7f2282 | |||
fa7f0e2fc0 | |||
7d0d4fcb48 | |||
9223857e18 | |||
2be7f3d51b | |||
36281376bc | |||
a3effd878b | |||
b20ffe6e87 | |||
757012399a | |||
c2d065a8a5 | |||
d1b9eb55d8 | |||
e5f8b797bb | |||
d627db5ac0 | |||
6abdb0ae2b | |||
be9bfc28c8 | |||
2e9e9b0a06 | |||
15aaef948d | |||
bee566424d | |||
f8533b0ffd | |||
6f7d4c9dcf | |||
d9be05165f | |||
2367eb1887 | |||
156bc59e1a | |||
a48751b9a8 | |||
b3564389f9 | |||
5c8cbc1956 | |||
20496c7e3e |
89
package-lock.json
generated
89
package-lock.json
generated
@ -21,6 +21,7 @@
|
||||
"@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",
|
||||
@ -34,9 +35,10 @@
|
||||
"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-html5-backend": "16.0.1",
|
||||
"react-dnd": "^16.0.1",
|
||||
"react-dnd-multi-backend": "^8.0.3",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-redux": "9.1.0",
|
||||
@ -1748,6 +1750,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@pdf-lib/fontkit": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@pdf-lib/fontkit/-/fontkit-1.1.1.tgz",
|
||||
"integrity": "sha512-KjMd7grNapIWS/Dm0gvfHEilSyAmeLvrEGVcqLGi0VYebuqqzTbgF29efCx7tvx+IEbG3zQciRSWl3GkUSvjZg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pako": "^1.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@pdf-lib/standard-fonts": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
|
||||
@ -3267,6 +3278,19 @@
|
||||
"@babel/runtime": "^7.9.2"
|
||||
}
|
||||
},
|
||||
"node_modules/dnd-multi-backend": {
|
||||
"version": "8.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dnd-multi-backend/-/dnd-multi-backend-8.0.3.tgz",
|
||||
"integrity": "sha512-yFFARotr+OEJk787Fsj+V52pi6j7+Pt/CRp3IR2Ai3fnxA/z6J54T7+gxkXzXu4cvxTNE7NiBzzAaJ2f7JjFTw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/LouisBrunner"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"dnd-core": "^16.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/doctrine": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
|
||||
@ -5687,6 +5711,21 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"node_modules/rdndmb-html5-to-touch": {
|
||||
"version": "8.0.3",
|
||||
"resolved": "https://registry.npmjs.org/rdndmb-html5-to-touch/-/rdndmb-html5-to-touch-8.0.3.tgz",
|
||||
"integrity": "sha512-VfIbLjlL9NAnZzc2M5fGPCNkDyK12+ahgILGO5RjS7jkgUlxwB0c/XvxVQNfY/2ocg7isTY/G7tqxJk5fSTZAA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dnd-multi-backend": "^8.0.3",
|
||||
"react-dnd-html5-backend": "^16.0.1",
|
||||
"react-dnd-touch-backend": "^16.0.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/LouisBrunner"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "18.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz",
|
||||
@ -5702,6 +5741,7 @@
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-16.0.1.tgz",
|
||||
"integrity": "sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@react-dnd/invariant": "^4.0.1",
|
||||
"@react-dnd/shallowequal": "^4.0.1",
|
||||
@ -5731,10 +5771,55 @@
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-16.0.1.tgz",
|
||||
"integrity": "sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dnd-core": "^16.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dnd-multi-backend": {
|
||||
"version": "8.0.3",
|
||||
"resolved": "https://registry.npmjs.org/react-dnd-multi-backend/-/react-dnd-multi-backend-8.0.3.tgz",
|
||||
"integrity": "sha512-IwH7Mf6R05KIFohX0hHMTluoAvuUD8SO15KCD+9fY0nJ4nc1FGCMCSyMZw8R1XNStKp+JnNg3ZMtiaf5DebSUg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dnd-multi-backend": "^8.0.3",
|
||||
"react-dnd-preview": "^8.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/LouisBrunner"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"dnd-core": "^16.0.1",
|
||||
"react": "^16.14.0 || ^17.0.2 || ^18.0.0",
|
||||
"react-dnd": "^16.0.1",
|
||||
"react-dom": "^16.14.0 || ^17.0.2 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dnd-preview": {
|
||||
"version": "8.0.3",
|
||||
"resolved": "https://registry.npmjs.org/react-dnd-preview/-/react-dnd-preview-8.0.3.tgz",
|
||||
"integrity": "sha512-s69Ro47QYDthDhj73iQ0VioMCjtlZ1AytKBDkQaHKm5DTjA8D2bIaFKCBQd330QEW0SIzqLJrZGCSlIY2xraJg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/LouisBrunner"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.14.0 || ^17.0.2 || ^18.0.0",
|
||||
"react-dnd": "^16.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dnd-touch-backend": {
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dnd-touch-backend/-/react-dnd-touch-backend-16.0.1.tgz",
|
||||
"integrity": "sha512-NonoCABzzjyWGZuDxSG77dbgMZ2Wad7eQiCd/ECtsR2/NBLTjGksPUx9UPezZ1nQ/L7iD130Tz3RUshL/ClKLA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@react-dnd/invariant": "^4.0.1",
|
||||
"dnd-core": "^16.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "18.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
|
||||
|
@ -8,7 +8,7 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 2",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"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,6 +31,7 @@
|
||||
"@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",
|
||||
@ -44,9 +45,10 @@
|
||||
"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-html5-backend": "16.0.1",
|
||||
"react-dnd": "^16.0.1",
|
||||
"react-dnd-multi-backend": "^8.0.3",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-redux": "9.1.0",
|
||||
|
33
src/App.scss
33
src/App.scss
@ -41,6 +41,7 @@ p {
|
||||
|
||||
body {
|
||||
color: $text-color;
|
||||
background: $body-background-color;
|
||||
font-family: $font-familiy;
|
||||
letter-spacing: $letter-spacing;
|
||||
font-size: $body-font-size;
|
||||
@ -70,6 +71,18 @@ 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;
|
||||
@ -87,10 +100,10 @@ input {
|
||||
// - 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: $header-height + $body-vertical-padding + 20px;
|
||||
scroll-margin-top: $body-vertical-padding + 20px;
|
||||
}
|
||||
&:not(:first-child) {
|
||||
scroll-margin-top: $header-height + $body-vertical-padding;
|
||||
scroll-margin-top: $body-vertical-padding;
|
||||
}
|
||||
}
|
||||
|
||||
@ -122,12 +135,20 @@ input {
|
||||
// Consistent styling for every file mark
|
||||
// Reverts some of the design defaults for font
|
||||
.file-mark {
|
||||
font-family: Arial;
|
||||
font-size: 16px;
|
||||
font-family: 'Roboto';
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
color: black;
|
||||
letter-spacing: normal;
|
||||
border: 1px solid transparent;
|
||||
line-height: 1;
|
||||
|
||||
font-size: 16px;
|
||||
color: black;
|
||||
outline: 1px solid transparent;
|
||||
|
||||
justify-content: start;
|
||||
align-items: start;
|
||||
|
||||
scroll-margin-top: $body-vertical-padding;
|
||||
}
|
||||
|
||||
[data-dev='true'] {
|
||||
|
BIN
src/assets/fonts/roboto-regular.ttf
Normal file
BIN
src/assets/fonts/roboto-regular.ttf
Normal file
Binary file not shown.
@ -36,8 +36,11 @@ 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'
|
||||
|
||||
const metadataController = new MetadataController()
|
||||
const metadataController = MetadataController.getInstance()
|
||||
|
||||
export const AppBar = () => {
|
||||
const navigate = useNavigate()
|
||||
@ -118,125 +121,150 @@ export const AppBar = () => {
|
||||
}
|
||||
const isAuthenticated = authState?.loggedIn === true
|
||||
|
||||
const matches = useMediaQuery('(max-width:767px)')
|
||||
const [isBannerVisible, setIsBannerVisible] = useState(true)
|
||||
const handleBannerHide = () => {
|
||||
setIsBannerVisible(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<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 && (
|
||||
<>
|
||||
{isAuthenticated && isBannerVisible && (
|
||||
<div className={styles.banner}>
|
||||
<Container>
|
||||
<div className={styles.bannerInner}>
|
||||
SIGit is currently Alpha software (available for internal
|
||||
testing), use at your own risk!
|
||||
<Button
|
||||
startIcon={<ButtonIcon />}
|
||||
onClick={() => {
|
||||
navigate(appPublicRoutes.nostr)
|
||||
}}
|
||||
variant="contained"
|
||||
aria-label={`close banner`}
|
||||
variant="text"
|
||||
onClick={handleBannerHide}
|
||||
>
|
||||
LOGIN
|
||||
<FontAwesomeIcon icon={faClose} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
)}
|
||||
<AppBarMui
|
||||
position={matches ? 'sticky' : 'static'}
|
||||
className={styles.AppBar}
|
||||
sx={{
|
||||
boxShadow: 'none'
|
||||
}}
|
||||
>
|
||||
<Container>
|
||||
<Toolbar className={styles.toolbar} disableGutters={true}>
|
||||
<Box className={styles.logoWrapper}>
|
||||
<img src="/logo.svg" alt="Logo" onClick={() => navigate('/')} />
|
||||
</Box>
|
||||
|
||||
{isAuthenticated && (
|
||||
<>
|
||||
<Username
|
||||
username={username}
|
||||
avatarContent={userAvatar}
|
||||
handleClick={handleOpenUserMenu}
|
||||
/>
|
||||
<Menu
|
||||
id="menu-appbar"
|
||||
anchorEl={anchorElUser}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'center'
|
||||
<Box className={styles.rightSideBox}>
|
||||
{!isAuthenticated && (
|
||||
<Button
|
||||
startIcon={<ButtonIcon />}
|
||||
onClick={() => {
|
||||
navigate(appPublicRoutes.nostr)
|
||||
}}
|
||||
keepMounted
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'center'
|
||||
}}
|
||||
open={!!anchorElUser}
|
||||
onClose={handleCloseUserMenu}
|
||||
variant="contained"
|
||||
>
|
||||
<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)
|
||||
LOGIN
|
||||
</Button>
|
||||
)}
|
||||
|
||||
navigate(appPrivateRoutes.settings)
|
||||
{isAuthenticated && (
|
||||
<>
|
||||
<Username
|
||||
username={username}
|
||||
avatarContent={userAvatar}
|
||||
handleClick={handleOpenUserMenu}
|
||||
/>
|
||||
<Menu
|
||||
id="menu-appbar"
|
||||
anchorEl={anchorElUser}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'center'
|
||||
}}
|
||||
sx={{
|
||||
justifyContent: 'center'
|
||||
keepMounted
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: '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' }}
|
||||
open={!!anchorElUser}
|
||||
onClose={handleCloseUserMenu}
|
||||
>
|
||||
<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'
|
||||
}}
|
||||
>
|
||||
Source
|
||||
Profile
|
||||
</MenuItem>
|
||||
</Link>
|
||||
<MenuItem
|
||||
onClick={handleLogout}
|
||||
sx={{
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
Logout
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Toolbar>
|
||||
</Container>
|
||||
</AppBarMui>
|
||||
<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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -34,3 +34,36 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { Meta } from '../../types'
|
||||
import { SigitCardDisplayInfo, SigitStatus, SignStatus } from '../../utils'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { formatTimestamp, hexToNpub, npubToHex, shorten } from '../../utils'
|
||||
import { formatTimestamp, npubToHex } from '../../utils'
|
||||
import { appPublicRoutes, appPrivateRoutes } from '../../routes'
|
||||
import { Button, Divider, Tooltip } from '@mui/material'
|
||||
import { DisplaySigner } from '../DisplaySigner'
|
||||
@ -17,9 +17,7 @@ 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'
|
||||
|
||||
@ -33,12 +31,6 @@ export const DisplaySigit = ({ meta, parsedMeta }: SigitProps) => {
|
||||
parsedMeta
|
||||
|
||||
const { signersStatus, fileHashes } = useSigitMeta(meta)
|
||||
|
||||
const profiles = useSigitProfiles([
|
||||
...(submittedBy ? [submittedBy] : []),
|
||||
...signers
|
||||
])
|
||||
|
||||
const { extensions, isSame } = extractFileExtensions(Object.keys(fileHashes))
|
||||
|
||||
return (
|
||||
@ -54,62 +46,29 @@ export const DisplaySigit = ({ meta, parsedMeta }: SigitProps) => {
|
||||
></Link>
|
||||
<p className={`line-clamp-2 ${styles.title}`}>{title}</p>
|
||||
<div className={styles.users}>
|
||||
{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 && (
|
||||
<DisplaySigner
|
||||
status={isValid ? SignStatus.Signed : SignStatus.Invalid}
|
||||
pubkey={submittedBy}
|
||||
/>
|
||||
)}
|
||||
{submittedBy && signers.length ? (
|
||||
<Divider orientation="vertical" flexItem />
|
||||
) : null}
|
||||
<UserAvatarGroup max={7}>
|
||||
{signers.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={signersStatus[signer]}
|
||||
profile={profile}
|
||||
pubkey={pubkey}
|
||||
/>
|
||||
</TooltipChild>
|
||||
</Tooltip>
|
||||
<DisplaySigner
|
||||
key={pubkey}
|
||||
status={signersStatus[signer]}
|
||||
pubkey={pubkey}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</UserAvatarGroup>
|
||||
</div>
|
||||
<div className={`${styles.details} ${styles.date} ${styles.iconLabel}`}>
|
||||
<div className={`${styles.details} ${styles.iconLabel}`}>
|
||||
<FontAwesomeIcon icon={faCalendar} />
|
||||
{createdAt ? formatTimestamp(createdAt) : null}
|
||||
</div>
|
||||
|
@ -1,5 +1,4 @@
|
||||
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'
|
||||
@ -15,38 +14,33 @@ import { SignStatus } from '../../utils'
|
||||
import { Spinner } from '../Spinner'
|
||||
|
||||
type DisplaySignerProps = {
|
||||
profile: ProfileMetadata
|
||||
pubkey: string
|
||||
status: SignStatus
|
||||
}
|
||||
|
||||
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} />
|
||||
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}
|
||||
@ -56,7 +50,7 @@ export const DisplaySigner = ({
|
||||
<div className={styles.statusBadge}>{getStatusIcon(status)}</div>
|
||||
}
|
||||
>
|
||||
<UserAvatar pubkey={pubkey} image={profile?.picture} />
|
||||
<UserAvatar pubkey={pubkey} />
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
@ -1,7 +1,5 @@
|
||||
import { Close } from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
CircularProgress,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
ListItemIcon,
|
||||
@ -11,22 +9,23 @@ import {
|
||||
} from '@mui/material'
|
||||
import styles from './style.module.scss'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import * as PDFJS from 'pdfjs-dist'
|
||||
import { ProfileMetadata, User, UserRole } from '../../types'
|
||||
import { MouseState, PdfPage, DrawnField, DrawTool } from '../../types/drawing'
|
||||
import { truncate } from 'lodash'
|
||||
import { settleAllFullfilfedPromises, hexToNpub, npubToHex } from '../../utils'
|
||||
import { getSigitFile, SigitFile } from '../../utils/file'
|
||||
import { getToolboxLabelByMarkType } from '../../utils/mark'
|
||||
import { FileDivider } from '../FileDivider'
|
||||
import { ExtensionFileBox } from '../ExtensionFileBox'
|
||||
import { inPx } from '../../utils/pdf'
|
||||
import { FONT_SIZE, FONT_TYPE, inPx } from '../../utils/pdf'
|
||||
import { useScale } from '../../hooks/useScale'
|
||||
import { AvatarIconButton } from '../UserAvatarIconButton'
|
||||
import { LoadingSpinner } from '../LoadingSpinner'
|
||||
|
||||
PDFJS.GlobalWorkerOptions.workerSrc = new URL(
|
||||
'pdfjs-dist/build/pdf.worker.min.mjs',
|
||||
import.meta.url
|
||||
).toString()
|
||||
const DEFAULT_START_SIZE = {
|
||||
width: 140,
|
||||
height: 40
|
||||
} as const
|
||||
|
||||
interface Props {
|
||||
selectedFiles: File[]
|
||||
@ -47,6 +46,8 @@ export const DrawPDFFields = (props: Props) => {
|
||||
clicked: false
|
||||
})
|
||||
|
||||
const [activeDrawField, setActiveDrawField] = useState<number>()
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedFiles) {
|
||||
/**
|
||||
@ -78,10 +79,12 @@ export const DrawPDFFields = (props: Props) => {
|
||||
* Drawing events
|
||||
*/
|
||||
useEffect(() => {
|
||||
window.addEventListener('mouseup', onMouseUp)
|
||||
window.addEventListener('pointerup', handlePointerUp)
|
||||
window.addEventListener('pointercancel', handlePointerUp)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mouseup', onMouseUp)
|
||||
window.removeEventListener('pointerup', handlePointerUp)
|
||||
window.removeEventListener('pointercancel', handlePointerUp)
|
||||
}
|
||||
}, [])
|
||||
|
||||
@ -90,17 +93,14 @@ export const DrawPDFFields = (props: Props) => {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fired only when left click and mouse over pdf page
|
||||
* Fired only on when left (primary pointer interaction) clicking page image
|
||||
* Creates new drawnElement and pushes in the array
|
||||
* It is re rendered and visible right away
|
||||
*
|
||||
* @param event Mouse event
|
||||
* @param event Pointer event
|
||||
* @param page PdfPage where press happened
|
||||
*/
|
||||
const onMouseDown = (
|
||||
event: React.MouseEvent<HTMLDivElement>,
|
||||
page: PdfPage
|
||||
) => {
|
||||
const handlePointerDown = (event: React.PointerEvent, page: PdfPage) => {
|
||||
// Proceed only if left click
|
||||
if (event.button !== 0) return
|
||||
|
||||
@ -108,13 +108,13 @@ export const DrawPDFFields = (props: Props) => {
|
||||
return
|
||||
}
|
||||
|
||||
const { mouseX, mouseY } = getMouseCoordinates(event)
|
||||
const { x, y } = getPointerCoordinates(event)
|
||||
|
||||
const newField: DrawnField = {
|
||||
left: to(page.width, mouseX),
|
||||
top: to(page.width, mouseY),
|
||||
width: 0,
|
||||
height: 0,
|
||||
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: '',
|
||||
type: selectedTool.identifier
|
||||
}
|
||||
@ -131,9 +131,9 @@ export const DrawPDFFields = (props: Props) => {
|
||||
|
||||
/**
|
||||
* Drawing is finished, resets all the variables used to draw
|
||||
* @param event Mouse event
|
||||
* @param event Pointer event
|
||||
*/
|
||||
const onMouseUp = () => {
|
||||
const handlePointerUp = () => {
|
||||
setMouseState((prev) => {
|
||||
return {
|
||||
...prev,
|
||||
@ -145,16 +145,13 @@ export const DrawPDFFields = (props: Props) => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* After {@link handlePointerDown} create an drawing element, this function gets called on every pixel moved
|
||||
* which alters the newly created drawing element, resizing it while pointer moves
|
||||
* @param event Pointer event
|
||||
* @param page PdfPage where moving is happening
|
||||
*/
|
||||
const onMouseMove = (
|
||||
event: React.MouseEvent<HTMLDivElement>,
|
||||
page: PdfPage
|
||||
) => {
|
||||
if (mouseState.clicked && selectedTool) {
|
||||
const handlePointerMove = (event: React.PointerEvent, page: PdfPage) => {
|
||||
if (mouseState.clicked && selectedTool && event.pointerType === 'mouse') {
|
||||
const lastElementIndex = page.drawnFields.length - 1
|
||||
|
||||
const lastDrawnField = page.drawnFields[lastElementIndex]
|
||||
@ -164,10 +161,10 @@ export const DrawPDFFields = (props: Props) => {
|
||||
// to the page below (without releaseing mouse click)
|
||||
if (!lastDrawnField) return
|
||||
|
||||
const { mouseX, mouseY } = getMouseCoordinates(event)
|
||||
const { x, y } = getPointerCoordinates(event)
|
||||
|
||||
const width = to(page.width, mouseX) - lastDrawnField.left
|
||||
const height = to(page.width, mouseY) - lastDrawnField.top
|
||||
const width = to(page.width, x) - lastDrawnField.left
|
||||
const height = to(page.width, y) - lastDrawnField.top
|
||||
|
||||
lastDrawnField.width = width
|
||||
lastDrawnField.height = height
|
||||
@ -182,55 +179,60 @@ export const DrawPDFFields = (props: Props) => {
|
||||
|
||||
/**
|
||||
* Fired when event happens on the drawn element which will be moved
|
||||
* mouse coordinates relative to drawn element will be stored
|
||||
* pointer coordinates relative to drawn element will be stored
|
||||
* so when we start moving, offset can be calculated
|
||||
* mouseX - offsetX
|
||||
* mouseY - offsetY
|
||||
* x - offsetX
|
||||
* y - offsetY
|
||||
*
|
||||
* @param event Mouse event
|
||||
* @param drawnField Which we are moving
|
||||
* @param event Pointer event
|
||||
* @param drawnFieldIndex Which we are moving
|
||||
*/
|
||||
const onDrawnFieldMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
const handleDrawnFieldPointerDown = (
|
||||
event: React.PointerEvent,
|
||||
drawnFieldIndex: number
|
||||
) => {
|
||||
event.stopPropagation()
|
||||
|
||||
// Proceed only if left click
|
||||
if (event.button !== 0) return
|
||||
|
||||
const drawingRectangleCoords = getMouseCoordinates(event)
|
||||
const drawingRectangleCoords = getPointerCoordinates(event)
|
||||
|
||||
setActiveDrawField(drawnFieldIndex)
|
||||
setMouseState({
|
||||
dragging: true,
|
||||
clicked: false,
|
||||
coordsInWrapper: {
|
||||
mouseX: drawingRectangleCoords.mouseX,
|
||||
mouseY: drawingRectangleCoords.mouseY
|
||||
x: drawingRectangleCoords.x,
|
||||
y: drawingRectangleCoords.y
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the drawnElement by the mouse position (mouse can grab anywhere on the drawn element)
|
||||
* @param event Mouse event
|
||||
* Moves the drawnElement by the pointer position (pointer can grab anywhere on the drawn element)
|
||||
* @param event Pointer event
|
||||
* @param drawnField which we are moving
|
||||
* @param pageWidth pdf value which is used to calculate scaled offset
|
||||
*/
|
||||
const onDrawnFieldMouseMove = (
|
||||
event: React.MouseEvent<HTMLDivElement>,
|
||||
const handleDrawnFieldPointerMove = (
|
||||
event: React.PointerEvent,
|
||||
drawnField: DrawnField,
|
||||
pageWidth: number
|
||||
) => {
|
||||
if (mouseState.dragging) {
|
||||
const { mouseX, mouseY, rect } = getMouseCoordinates(
|
||||
const { x, y, rect } = getPointerCoordinates(
|
||||
event,
|
||||
event.currentTarget.parentElement
|
||||
)
|
||||
const coordsOffset = mouseState.coordsInWrapper
|
||||
|
||||
if (coordsOffset) {
|
||||
let left = to(pageWidth, mouseX - coordsOffset.mouseX)
|
||||
let top = to(pageWidth, mouseY - coordsOffset.mouseY)
|
||||
let left = to(pageWidth, x - coordsOffset.x)
|
||||
let top = to(pageWidth, y - coordsOffset.y)
|
||||
|
||||
const rightLimit = to(pageWidth, rect.width) - drawnField.width - 3
|
||||
const bottomLimit = to(pageWidth, rect.height) - drawnField.height - 3
|
||||
const rightLimit = to(pageWidth, rect.width) - drawnField.width
|
||||
const bottomLimit = to(pageWidth, rect.height) - drawnField.height
|
||||
|
||||
if (left < 0) left = 0
|
||||
if (top < 0) top = 0
|
||||
@ -247,17 +249,18 @@ export const DrawPDFFields = (props: Props) => {
|
||||
|
||||
/**
|
||||
* Fired when clicked on the resize handle, sets the state for a resize action
|
||||
* @param event Mouse event
|
||||
* @param drawnField which we are resizing
|
||||
* @param event Pointer event
|
||||
* @param drawnFieldIndex which we are resizing
|
||||
*/
|
||||
const onResizeHandleMouseDown = (
|
||||
event: React.MouseEvent<HTMLSpanElement>
|
||||
const handleResizePointerDown = (
|
||||
event: React.PointerEvent,
|
||||
drawnFieldIndex: number
|
||||
) => {
|
||||
// Proceed only if left click
|
||||
if (event.button !== 0) return
|
||||
|
||||
event.stopPropagation()
|
||||
|
||||
setActiveDrawField(drawnFieldIndex)
|
||||
setMouseState({
|
||||
resizing: true
|
||||
})
|
||||
@ -265,16 +268,17 @@ export const DrawPDFFields = (props: Props) => {
|
||||
|
||||
/**
|
||||
* Resizes the drawn element by the mouse position
|
||||
* @param event Mouse event
|
||||
* @param event Pointer event
|
||||
* @param drawnField which we are resizing
|
||||
* @param pageWidth pdf value which is used to calculate scaled offset
|
||||
*/
|
||||
const onResizeHandleMouseMove = (
|
||||
event: React.MouseEvent<HTMLSpanElement>,
|
||||
const handleResizePointerMove = (
|
||||
event: React.PointerEvent,
|
||||
drawnField: DrawnField,
|
||||
pageWidth: number
|
||||
) => {
|
||||
if (mouseState.resizing) {
|
||||
const { mouseX, mouseY } = getMouseCoordinates(
|
||||
const { x, y } = getPointerCoordinates(
|
||||
event,
|
||||
// currentTarget = span handle
|
||||
// 1st parent = drawnField
|
||||
@ -282,8 +286,8 @@ export const DrawPDFFields = (props: Props) => {
|
||||
event.currentTarget.parentElement?.parentElement
|
||||
)
|
||||
|
||||
const width = to(pageWidth, mouseX) - drawnField.left
|
||||
const height = to(pageWidth, mouseY) - drawnField.top
|
||||
const width = to(pageWidth, x) - drawnField.left
|
||||
const height = to(pageWidth, y) - drawnField.top
|
||||
|
||||
drawnField.width = width
|
||||
drawnField.height = height
|
||||
@ -294,13 +298,13 @@ export const DrawPDFFields = (props: Props) => {
|
||||
|
||||
/**
|
||||
* Removes the drawn element using the indexes in the params
|
||||
* @param event Mouse event
|
||||
* @param event Pointer event
|
||||
* @param pdfFileIndex pdf file index
|
||||
* @param pdfPageIndex pdf page index
|
||||
* @param drawnFileIndex drawn file index
|
||||
*/
|
||||
const onRemoveHandleMouseDown = (
|
||||
event: React.MouseEvent<HTMLSpanElement>,
|
||||
const handleRemovePointerDown = (
|
||||
event: React.PointerEvent,
|
||||
pdfFileIndex: number,
|
||||
pdfPageIndex: number,
|
||||
drawnFileIndex: number
|
||||
@ -314,40 +318,37 @@ export const DrawPDFFields = (props: Props) => {
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to stop mouse click propagating to the parent elements
|
||||
* Used to stop pointer click propagating to the parent elements
|
||||
* so select can work properly
|
||||
* @param event Mouse event
|
||||
* @param event Pointer event
|
||||
*/
|
||||
const onUserSelectHandleMouseDown = (
|
||||
event: React.MouseEvent<HTMLDivElement>
|
||||
) => {
|
||||
const handleUserSelectPointerDown = (event: React.PointerEvent) => {
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* Gets the pointer coordinates relative to a element in the `event` param
|
||||
* @param event PointerEvent
|
||||
* @param customTarget coordinates relative to this element, if not provided
|
||||
* event.target will be used
|
||||
*/
|
||||
const getMouseCoordinates = (
|
||||
event: React.MouseEvent<HTMLElement>,
|
||||
const getPointerCoordinates = (
|
||||
event: React.PointerEvent,
|
||||
customTarget?: HTMLElement | null
|
||||
) => {
|
||||
const target = customTarget ? customTarget : event.currentTarget
|
||||
const rect = target.getBoundingClientRect()
|
||||
|
||||
// Clamp X Y within the target
|
||||
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.
|
||||
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.
|
||||
|
||||
return {
|
||||
mouseX,
|
||||
mouseY,
|
||||
x,
|
||||
y,
|
||||
rect
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the pdf pages and drawing elements
|
||||
*/
|
||||
@ -364,43 +365,77 @@ export const DrawPDFFields = (props: Props) => {
|
||||
className={`image-wrapper ${styles.pdfImageWrapper} ${selectedTool ? styles.drawing : ''}`}
|
||||
>
|
||||
<img
|
||||
onMouseMove={(event) => {
|
||||
onMouseMove(event, page)
|
||||
onPointerMove={(event) => {
|
||||
handlePointerMove(event, page)
|
||||
}}
|
||||
onMouseDown={(event) => {
|
||||
onMouseDown(event, page)
|
||||
onPointerDown={(event) => {
|
||||
handlePointerDown(event, page)
|
||||
}}
|
||||
draggable="false"
|
||||
src={page.image}
|
||||
alt={`page ${pageIndex + 1} of ${file.name}`}
|
||||
/>
|
||||
|
||||
{page.drawnFields.map((drawnField, drawnFieldIndex: number) => {
|
||||
return (
|
||||
<div
|
||||
key={drawnFieldIndex}
|
||||
onMouseDown={onDrawnFieldMouseDown}
|
||||
onMouseMove={(event) => {
|
||||
onDrawnFieldMouseMove(event, drawnField, page.width)
|
||||
onPointerDown={(event) =>
|
||||
handleDrawnFieldPointerDown(event, drawnFieldIndex)
|
||||
}
|
||||
onPointerMove={(event) => {
|
||||
handleDrawnFieldPointerMove(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'
|
||||
pointerEvents: mouseState.clicked ? 'none' : 'all',
|
||||
touchAction: 'none',
|
||||
opacity:
|
||||
mouseState.dragging &&
|
||||
activeDrawField === drawnFieldIndex
|
||||
? 0.8
|
||||
: undefined
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`file-mark ${styles.placeholder}`}
|
||||
style={{
|
||||
fontFamily: FONT_TYPE,
|
||||
fontSize: inPx(from(page.width, FONT_SIZE))
|
||||
}}
|
||||
>
|
||||
{getToolboxLabelByMarkType(drawnField.type) ||
|
||||
'placeholder'}
|
||||
</div>
|
||||
<span
|
||||
onMouseDown={onResizeHandleMouseDown}
|
||||
onMouseMove={(event) => {
|
||||
onResizeHandleMouseMove(event, drawnField, page.width)
|
||||
onPointerDown={(event) =>
|
||||
handleResizePointerDown(event, drawnFieldIndex)
|
||||
}
|
||||
onPointerMove={(event) => {
|
||||
handleResizePointerMove(event, drawnField, page.width)
|
||||
}}
|
||||
className={styles.resizeHandle}
|
||||
style={{
|
||||
background:
|
||||
mouseState.resizing &&
|
||||
activeDrawField === drawnFieldIndex
|
||||
? 'var(--primary-main)'
|
||||
: undefined
|
||||
}}
|
||||
></span>
|
||||
<span
|
||||
onMouseDown={(event) => {
|
||||
onRemoveHandleMouseDown(
|
||||
onPointerDown={(event) => {
|
||||
handleRemovePointerDown(
|
||||
event,
|
||||
fileIndex,
|
||||
pageIndex,
|
||||
@ -412,7 +447,7 @@ export const DrawPDFFields = (props: Props) => {
|
||||
<Close fontSize="small" />
|
||||
</span>
|
||||
<div
|
||||
onMouseDown={onUserSelectHandleMouseDown}
|
||||
onPointerDown={handleUserSelectPointerDown}
|
||||
className={styles.userSelect}
|
||||
>
|
||||
<FormControl fullWidth size="small">
|
||||
@ -529,11 +564,7 @@ export const DrawPDFFields = (props: Props) => {
|
||||
}
|
||||
|
||||
if (parsingPdf) {
|
||||
return (
|
||||
<Box sx={{ width: '100%', textAlign: 'center' }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
)
|
||||
return <LoadingSpinner variant="small" />
|
||||
}
|
||||
|
||||
if (!sigitFiles.length) {
|
||||
|
@ -13,9 +13,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
position: absolute;
|
||||
opacity: 0.5;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.drawingRectangle {
|
||||
position: absolute;
|
||||
border: 1px solid #01aaad;
|
||||
outline: 1px solid #01aaad;
|
||||
z-index: 50;
|
||||
background-color: #01aaad4b;
|
||||
cursor: pointer;
|
||||
@ -29,7 +35,7 @@
|
||||
}
|
||||
|
||||
&.edited {
|
||||
border: 1px dotted #01aaad;
|
||||
outline: 1px dotted #01aaad;
|
||||
}
|
||||
|
||||
.resizeHandle {
|
||||
|
@ -22,30 +22,26 @@ const FileList = ({
|
||||
const isActive = (file: CurrentUserFile) => file.id === currentFile.id
|
||||
return (
|
||||
<div className={styles.wrap}>
|
||||
<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>
|
||||
<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>
|
||||
<div className={styles.fileVisual}>
|
||||
{currentUserFile.isHashValid && (
|
||||
<FontAwesomeIcon icon={faCheck} />
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Button variant="contained" fullWidth onClick={handleDownload}>
|
||||
{downloadLabel || 'Download Files'}
|
||||
</Button>
|
||||
|
@ -1,12 +1,3 @@
|
||||
.container {
|
||||
border-radius: 4px;
|
||||
background: white;
|
||||
padding: 15px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
grid-gap: 0px;
|
||||
}
|
||||
|
||||
.filesPageContainer {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
@ -15,18 +6,6 @@
|
||||
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;
|
||||
@ -34,14 +13,16 @@ li {
|
||||
}
|
||||
|
||||
.files {
|
||||
border-radius: 4px;
|
||||
background: white;
|
||||
padding: 15px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
grid-gap: 15px;
|
||||
max-height: 350px;
|
||||
overflow: auto;
|
||||
padding: 0 5px 0 0;
|
||||
margin: 0 -5px 0 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: none;
|
||||
}
|
||||
|
||||
.files::-webkit-scrollbar {
|
||||
|
@ -4,125 +4,128 @@ 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 = () => (
|
||||
<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'
|
||||
}
|
||||
export const Footer = () =>
|
||||
createPortal(
|
||||
<footer className={`${styles.borderTop} ${styles.footer}`}>
|
||||
<Container
|
||||
style={{
|
||||
paddingBlock: '50px'
|
||||
}}
|
||||
gap={'50px'}
|
||||
>
|
||||
<LinkMui
|
||||
<Box
|
||||
display={'grid'}
|
||||
sx={{
|
||||
justifySelf: {
|
||||
gridTemplateColumns: {
|
||||
xs: '1fr',
|
||||
md: '0.5fr 2fr 0.5fr'
|
||||
},
|
||||
alignItems: {
|
||||
xs: 'center',
|
||||
md: 'start'
|
||||
}
|
||||
}}
|
||||
component={Link}
|
||||
to={'/'}
|
||||
className={styles.logo}
|
||||
gap={'50px'}
|
||||
>
|
||||
<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
|
||||
<LinkMui
|
||||
sx={{
|
||||
justifyContent: 'center'
|
||||
justifySelf: {
|
||||
xs: 'center',
|
||||
md: 'start'
|
||||
}
|
||||
}}
|
||||
component={Link}
|
||||
to={'/'}
|
||||
variant={'text'}
|
||||
className={styles.logo}
|
||||
>
|
||||
Home
|
||||
</Button>
|
||||
<Button
|
||||
<img src="/logo.svg" alt="Logo" />
|
||||
</LinkMui>
|
||||
<Box
|
||||
display={'grid'}
|
||||
sx={{
|
||||
justifyContent: 'center'
|
||||
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={LinkMui}
|
||||
href={appPublicRoutes.docs}
|
||||
target="_blank"
|
||||
variant={'text'}
|
||||
component={'nav'}
|
||||
className={styles.nav}
|
||||
>
|
||||
Documentation
|
||||
</Button>
|
||||
<Button
|
||||
<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}
|
||||
sx={{
|
||||
justifyContent: 'center'
|
||||
justifySelf: {
|
||||
xs: 'center',
|
||||
md: 'end'
|
||||
}
|
||||
}}
|
||||
component={LinkMui}
|
||||
href={appPublicRoutes.source}
|
||||
target="_blank"
|
||||
variant={'text'}
|
||||
>
|
||||
Source
|
||||
</Button>
|
||||
<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>
|
||||
<Box
|
||||
className={styles.links}
|
||||
sx={{
|
||||
justifySelf: {
|
||||
xs: 'center',
|
||||
md: 'end'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
component={LinkMui}
|
||||
href="https://snort.social/npub1yay8e9sqk94jfgdlkpgeelj2t5ddsj2eu0xwt4kh4xw5ses2rauqnstrdv"
|
||||
target="_blank"
|
||||
sx={{
|
||||
minWidth: '45px',
|
||||
padding: '10px'
|
||||
}}
|
||||
variant={'contained'}
|
||||
>
|
||||
<img src={nostrImage} width="25" alt="nostr logo" height="25" />
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Container>
|
||||
<div className={`${styles.borderTop} ${styles.credits}`}>
|
||||
Built by
|
||||
<a href="https://nostrdev.com/" target="_blank">
|
||||
Nostr Dev
|
||||
</a>{' '}
|
||||
2024.
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
</Container>
|
||||
<div className={`${styles.borderTop} ${styles.credits}`}>
|
||||
Built by
|
||||
<a href="https://nostrdev.com/" target="_blank">
|
||||
Nostr Dev
|
||||
</a>{' '}
|
||||
2024.
|
||||
</div>
|
||||
</footer>,
|
||||
document.getElementById('root')!
|
||||
)
|
||||
|
@ -1,18 +1,43 @@
|
||||
import { createPortal } from 'react-dom'
|
||||
import styles from './style.module.scss'
|
||||
import { PropsWithChildren } from 'react'
|
||||
|
||||
interface Props {
|
||||
desc: string
|
||||
desc?: string
|
||||
variant?: 'small' | 'default'
|
||||
}
|
||||
|
||||
export const LoadingSpinner = (props: Props) => {
|
||||
const { desc } = props
|
||||
export const LoadingSpinner = (props: PropsWithChildren<Props>) => {
|
||||
const { desc, children, variant = 'default' } = props
|
||||
|
||||
return (
|
||||
<div className={styles.loadingSpinnerOverlay}>
|
||||
<div className={styles.loadingSpinnerContainer}>
|
||||
<div className={styles.loadingSpinner}></div>
|
||||
{desc && <span className={styles.loadingSpinnerDesc}>{desc}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
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')!
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -2,37 +2,55 @@
|
||||
|
||||
.loadingSpinnerOverlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: 9999;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.loadingSpinnerContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
.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);
|
||||
}
|
||||
|
||||
.loadingSpinner {
|
||||
background: url('/favicon.png') no-repeat center / cover;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
animation: spin 1s linear infinite;
|
||||
&[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;
|
||||
}
|
||||
|
||||
.loadingSpinnerDesc {
|
||||
color: white;
|
||||
margin-top: 13px;
|
||||
width: 100%;
|
||||
padding: 15px;
|
||||
border-top: solid 1px rgba(0, 0, 0, 0.1);
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
|
@ -1,11 +1,19 @@
|
||||
@import '../../styles/sizes.scss';
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
|
||||
@media only screen and (min-width: 768px) {
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
}
|
||||
bottom: $tabs-height + 5px;
|
||||
right: 5px;
|
||||
left: 5px;
|
||||
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
|
||||
@ -107,7 +115,7 @@
|
||||
.actions {
|
||||
background: white;
|
||||
width: 100%;
|
||||
border-radius: 4px;
|
||||
border-radius: 5px;
|
||||
padding: 10px 20px;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
|
@ -36,6 +36,8 @@ const PdfItem = ({
|
||||
return file.pages?.map((page, i) => {
|
||||
return (
|
||||
<PdfPageItem
|
||||
fileName={file.name}
|
||||
pageIndex={i}
|
||||
page={page}
|
||||
key={i}
|
||||
currentUserMarks={filterByPage(currentUserMarks, i)}
|
||||
|
@ -2,6 +2,8 @@ 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'
|
||||
|
||||
interface PdfMarkItemProps {
|
||||
userMark: CurrentUserMark
|
||||
@ -14,35 +16,41 @@ interface PdfMarkItemProps {
|
||||
/**
|
||||
* Responsible for display an individual Pdf Mark.
|
||||
*/
|
||||
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>
|
||||
)
|
||||
}
|
||||
const PdfMarkItem = forwardRef<HTMLDivElement, PdfMarkItemProps>(
|
||||
(
|
||||
{ selectedMark, handleMarkClick, selectedMarkValue, userMark, pageWidth },
|
||||
ref
|
||||
) => {
|
||||
const { location } = userMark.mark
|
||||
const handleClick = () => handleMarkClick(userMark.mark.id)
|
||||
const isEdited = () => selectedMark?.mark.id === userMark.mark.id
|
||||
const getMarkValue = () =>
|
||||
isEdited() ? selectedMarkValue : userMark.currentValue
|
||||
const { from } = useScale()
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
onClick={handleClick}
|
||||
className={`file-mark ${styles.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))
|
||||
}}
|
||||
>
|
||||
{getMarkValue()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
export default PdfMarkItem
|
||||
|
@ -15,6 +15,11 @@ 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[]
|
||||
@ -132,6 +137,9 @@ const PdfMarking = (props: PdfMarkingProps) => {
|
||||
</div>
|
||||
}
|
||||
right={meta !== null && <UsersDetails meta={meta} />}
|
||||
leftIcon={faFileDownload}
|
||||
centerIcon={faPen}
|
||||
rightIcon={faCircleInfo}
|
||||
>
|
||||
{currentUserMarks?.length > 0 && (
|
||||
<PdfView
|
||||
|
@ -7,6 +7,8 @@ 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[]
|
||||
@ -19,6 +21,8 @@ interface PdfPageProps {
|
||||
* Responsible for rendering a single Pdf Page and its Marks
|
||||
*/
|
||||
const PdfPageItem = ({
|
||||
fileName,
|
||||
pageIndex,
|
||||
page,
|
||||
currentUserMarks,
|
||||
handleMarkClick,
|
||||
@ -29,7 +33,8 @@ const PdfPageItem = ({
|
||||
useEffect(() => {
|
||||
if (selectedMark !== null && !!markRefs.current[selectedMark.id]) {
|
||||
markRefs.current[selectedMark.id]?.scrollIntoView({
|
||||
behavior: 'smooth'
|
||||
behavior: 'smooth',
|
||||
block: 'center'
|
||||
})
|
||||
}
|
||||
}, [selectedMark])
|
||||
@ -38,18 +43,21 @@ const PdfPageItem = ({
|
||||
|
||||
return (
|
||||
<div className={`image-wrapper ${styles.pdfImageWrapper}`}>
|
||||
<img draggable="false" src={page.image} />
|
||||
<img
|
||||
draggable="false"
|
||||
src={page.image}
|
||||
alt={`page ${pageIndex + 1} of ${fileName}`}
|
||||
/>
|
||||
{currentUserMarks.map((m, i) => (
|
||||
<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>
|
||||
<PdfMarkItem
|
||||
key={i}
|
||||
ref={(el) => (markRefs.current[m.id] = el)}
|
||||
handleMarkClick={handleMarkClick}
|
||||
selectedMarkValue={selectedMarkValue}
|
||||
userMark={m}
|
||||
selectedMark={selectedMark}
|
||||
pageWidth={page.width}
|
||||
/>
|
||||
))}
|
||||
{otherUserMarks.map((m, i) => {
|
||||
return (
|
||||
|
@ -4,6 +4,7 @@ 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
|
||||
@ -48,30 +49,34 @@ const PdfView = ({
|
||||
index !== files.length - 1
|
||||
return (
|
||||
<div className="files-wrapper">
|
||||
{files.map((currentUserFile, index, arr) => {
|
||||
const { hash, file, id } = currentUserFile
|
||||
{files.length > 0 ? (
|
||||
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>
|
||||
)
|
||||
})}
|
||||
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" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -3,34 +3,56 @@ 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 { shorten } from '../../utils'
|
||||
import { TooltipChild } from '../TooltipChild'
|
||||
|
||||
interface UserAvatarProps {
|
||||
name?: string
|
||||
pubkey: string
|
||||
image?: string
|
||||
isNameVisible?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* This component will be used for the displaying username and profile picture.
|
||||
* Clicking will navigate to the user's profile.
|
||||
*/
|
||||
export const UserAvatar = ({ pubkey, name, image }: UserAvatarProps) => {
|
||||
export const UserAvatar = ({
|
||||
pubkey,
|
||||
isNameVisible = false
|
||||
}: UserAvatarProps) => {
|
||||
const profile = useProfileMetadata(pubkey)
|
||||
const name = profile?.display_name || profile?.name || shorten(pubkey)
|
||||
const image = profile?.picture
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={getProfileRoute(pubkey)}
|
||||
className={styles.container}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<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}
|
||||
<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}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
@ -1,11 +1,9 @@
|
||||
import { Divider, Tooltip } from '@mui/material'
|
||||
import { useSigitProfiles } from '../../hooks/useSigitProfiles'
|
||||
import {
|
||||
formatTimestamp,
|
||||
fromUnixTimestamp,
|
||||
hexToNpub,
|
||||
npubToHex,
|
||||
shorten,
|
||||
SignStatus
|
||||
} from '../../utils'
|
||||
import { useSigitMeta } from '../../hooks/useSigitMeta'
|
||||
@ -24,10 +22,10 @@ import {
|
||||
import { getExtensionIconLabel } from '../getExtensionIconLabel'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { State } from '../../store/rootReducer'
|
||||
import { TooltipChild } from '../TooltipChild'
|
||||
import { DisplaySigner } from '../DisplaySigner'
|
||||
import { Meta } from '../../types'
|
||||
import { extractFileExtensions } from '../../utils/file'
|
||||
import { UserAvatar } from '../UserAvatar'
|
||||
|
||||
interface UsersDetailsProps {
|
||||
meta: Meta
|
||||
@ -36,6 +34,7 @@ interface UsersDetailsProps {
|
||||
export const UsersDetails = ({ meta }: UsersDetailsProps) => {
|
||||
const {
|
||||
submittedBy,
|
||||
exportedBy,
|
||||
signers,
|
||||
viewers,
|
||||
fileHashes,
|
||||
@ -47,11 +46,6 @@ export const UsersDetails = ({ meta }: UsersDetailsProps) => {
|
||||
isValid
|
||||
} = useSigitMeta(meta)
|
||||
const { usersPubkey } = useSelector((state: State) => state.auth)
|
||||
const profiles = useSigitProfiles([
|
||||
...(submittedBy ? [submittedBy] : []),
|
||||
...signers,
|
||||
...viewers
|
||||
])
|
||||
const userCanSign =
|
||||
typeof usersPubkey !== 'undefined' &&
|
||||
signers.includes(hexToNpub(usersPubkey))
|
||||
@ -63,31 +57,12 @@ export const UsersDetails = ({ meta }: UsersDetailsProps) => {
|
||||
<div className={styles.section}>
|
||||
<p>Signers</p>
|
||||
<div className={styles.users}>
|
||||
{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 && (
|
||||
<DisplaySigner
|
||||
status={isValid ? SignStatus.Signed : SignStatus.Invalid}
|
||||
pubkey={submittedBy}
|
||||
/>
|
||||
)}
|
||||
|
||||
{submittedBy && signers.length ? (
|
||||
<Divider orientation="vertical" flexItem />
|
||||
@ -96,54 +71,46 @@ export const UsersDetails = ({ meta }: UsersDetailsProps) => {
|
||||
<UserAvatarGroup max={20}>
|
||||
{signers.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={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>
|
||||
<DisplaySigner
|
||||
key={pubkey}
|
||||
status={signersStatus[signer]}
|
||||
pubkey={pubkey}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</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>
|
||||
|
@ -25,7 +25,7 @@ export class AuthController {
|
||||
|
||||
constructor() {
|
||||
this.nostrController = NostrController.getInstance()
|
||||
this.metadataController = new MetadataController()
|
||||
this.metadataController = MetadataController.getInstance()
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -22,6 +22,7 @@ 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
|
||||
@ -31,6 +32,13 @@ 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.
|
||||
@ -119,7 +127,6 @@ 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)
|
||||
}
|
||||
|
||||
@ -145,20 +152,11 @@ export class MetadataController extends EventEmitter {
|
||||
* or a fallback RelaySet with Sigit's Relay
|
||||
*/
|
||||
public findRelayListMetadata = async (hexKey: string): Promise<RelaySet> => {
|
||||
try {
|
||||
const relayEvent =
|
||||
(await findRelayListInCache(hexKey)) ||
|
||||
(await findRelayListAndUpdateCache(DEFAULT_LOOK_UP_RELAY_LIST, hexKey))
|
||||
const relayEvent =
|
||||
(await findRelayListInCache(hexKey)) ||
|
||||
(await findRelayListAndUpdateCache(DEFAULT_LOOK_UP_RELAY_LIST, hexKey))
|
||||
|
||||
return relayEvent
|
||||
? getUserRelaySet(relayEvent.tags)
|
||||
: getDefaultRelaySet()
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`An error occurred while finding relay list metadata for ${hexKey}`,
|
||||
{ cause: error }
|
||||
)
|
||||
}
|
||||
return relayEvent ? getUserRelaySet(relayEvent.tags) : getDefaultRelaySet()
|
||||
}
|
||||
|
||||
public extractProfileMetadataContent = (event: Event) => {
|
||||
|
@ -2,8 +2,7 @@ import { Event, Filter, Relay } from 'nostr-tools'
|
||||
import {
|
||||
settleAllFullfilfedPromises,
|
||||
normalizeWebSocketURL,
|
||||
publishToRelay,
|
||||
isPromiseFulfilled
|
||||
timeout
|
||||
} from '../utils'
|
||||
import { SIGIT_RELAY } from '../utils/const'
|
||||
|
||||
@ -262,17 +261,17 @@ export class RelayController {
|
||||
event: Event,
|
||||
relayUrls: string[] = []
|
||||
): Promise<string[]> => {
|
||||
/**
|
||||
* Ensure that the default Sigit Relay is included.
|
||||
* Copy the array instead of mutating it.
|
||||
*/
|
||||
const updatedRelayUrls = !relayUrls.includes(SIGIT_RELAY)
|
||||
? [...relayUrls, SIGIT_RELAY]
|
||||
: [...relayUrls]
|
||||
if (!relayUrls.includes(SIGIT_RELAY)) {
|
||||
/**
|
||||
* NOTE: To avoid side-effects on external relayUrls array passed as argument
|
||||
* re-assigned relayUrls with added sigit relay instead of just appending to same array
|
||||
*/
|
||||
relayUrls = [...relayUrls, SIGIT_RELAY] // Add app relay to relays array if not exists already
|
||||
}
|
||||
|
||||
// connect to all specified relays
|
||||
const relays = await settleAllFullfilfedPromises(
|
||||
updatedRelayUrls,
|
||||
relayUrls,
|
||||
this.connectRelay
|
||||
)
|
||||
|
||||
@ -281,15 +280,26 @@ export class RelayController {
|
||||
throw new Error('No relay is connected to publish event!')
|
||||
}
|
||||
|
||||
const settledPromises: PromiseSettledResult<string>[] =
|
||||
await Promise.allSettled(
|
||||
relays.map(async (relay) => publishToRelay(relay, event))
|
||||
)
|
||||
const publishedOnRelays: string[] = [] // List to track which relays successfully published the event
|
||||
|
||||
// Create a promise for publishing the event to each connected relay
|
||||
const publishPromises = relays.map(async (relay) => {
|
||||
try {
|
||||
await Promise.race([
|
||||
relay.publish(event), // Publish the event to the relay
|
||||
timeout(20 * 1000) // Set a timeout to handle cases where publishing takes too long
|
||||
])
|
||||
publishedOnRelays.push(relay.url) // Add the relay URL to the list of successfully published relays
|
||||
} catch (err) {
|
||||
console.error(`Failed to publish event on relay: ${relay.url}`, err)
|
||||
}
|
||||
})
|
||||
|
||||
// Wait for all publish operations to complete (either fulfilled or rejected)
|
||||
await Promise.allSettled(publishPromises)
|
||||
|
||||
// Return the list of relay URLs where the event was published
|
||||
return settledPromises
|
||||
.filter(isPromiseFulfilled)
|
||||
.map((res) => res.value) as string[]
|
||||
return publishedOnRelays
|
||||
}
|
||||
}
|
||||
|
||||
|
46
src/hooks/useProfileMetadata.tsx
Normal file
46
src/hooks/useProfileMetadata.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
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
|
||||
}
|
@ -33,6 +33,10 @@ 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
|
||||
|
||||
@ -68,6 +72,7 @@ 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>()
|
||||
|
||||
@ -99,6 +104,18 @@ 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 } =
|
||||
@ -265,6 +282,7 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
|
||||
tags,
|
||||
createdAt,
|
||||
submittedBy,
|
||||
exportedBy,
|
||||
id,
|
||||
sig,
|
||||
signers,
|
||||
|
@ -1,71 +0,0 @@
|
||||
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
|
||||
}
|
@ -26,7 +26,6 @@ import {
|
||||
} from '../utils'
|
||||
import { useAppSelector } from '../hooks'
|
||||
import styles from './style.module.scss'
|
||||
import { Footer } from '../components/Footer/Footer'
|
||||
|
||||
export const MainLayout = () => {
|
||||
const dispatch: Dispatch = useDispatch()
|
||||
@ -39,7 +38,7 @@ export const MainLayout = () => {
|
||||
const hasSubscribed = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
const metadataController = new MetadataController()
|
||||
const metadataController = MetadataController.getInstance()
|
||||
|
||||
const logout = () => {
|
||||
dispatch(
|
||||
@ -110,6 +109,7 @@ export const MainLayout = () => {
|
||||
|
||||
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
|
||||
@ -160,7 +160,6 @@ export const MainLayout = () => {
|
||||
>
|
||||
<Outlet />
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -3,9 +3,33 @@
|
||||
|
||||
.container {
|
||||
display: grid;
|
||||
grid-template-columns: 0.75fr 1.5fr 0.75fr;
|
||||
grid-gap: 30px;
|
||||
flex-grow: 1;
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
||||
.sidesWrap {
|
||||
@ -16,17 +40,65 @@
|
||||
}
|
||||
|
||||
.sides {
|
||||
position: sticky;
|
||||
top: $header-height + $body-vertical-padding;
|
||||
@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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.files {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
grid-gap: 15px;
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 10px;
|
||||
border: 10px solid $overlay-background-color;
|
||||
border-radius: 4px;
|
||||
@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;
|
||||
}
|
||||
|
@ -1,30 +1,147 @@
|
||||
import { PropsWithChildren, ReactNode } from 'react'
|
||||
import {
|
||||
PropsWithChildren,
|
||||
ReactNode,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState
|
||||
} 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
|
||||
left: ReactNode
|
||||
right: ReactNode
|
||||
leftIcon: IconDefinition
|
||||
centerIcon: IconDefinition
|
||||
rightIcon: IconDefinition
|
||||
}
|
||||
|
||||
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}>
|
||||
<div className={`${styles.sidesWrap} ${styles.files}`}>
|
||||
<div className={styles.sides}>{left}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div id="content-preview" className={styles.content}>
|
||||
{children}
|
||||
<>
|
||||
<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>
|
||||
</div>
|
||||
<div className={styles.sidesWrap}>
|
||||
<div className={styles.sides}>{right}</div>
|
||||
</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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -3,6 +3,5 @@
|
||||
|
||||
.main {
|
||||
flex-grow: 1;
|
||||
padding: $header-height + $body-vertical-padding 0 $body-vertical-padding 0;
|
||||
background-color: $body-background-color;
|
||||
padding: $body-vertical-padding 0 $body-vertical-padding 0;
|
||||
}
|
||||
|
@ -1,20 +1,13 @@
|
||||
import {
|
||||
Button,
|
||||
FormHelperText,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
MenuItem,
|
||||
Select,
|
||||
TextField,
|
||||
Tooltip
|
||||
} from '@mui/material'
|
||||
import styles from './style.module.scss'
|
||||
import { Button, FormHelperText, 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 { HTML5Backend } from 'react-dnd-html5-backend'
|
||||
import { MultiBackend } from 'react-dnd-multi-backend'
|
||||
import { HTML5toTouch } from 'rdndmb-html5-to-touch'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { toast } from 'react-toastify'
|
||||
@ -43,48 +36,31 @@ import {
|
||||
npubToHex,
|
||||
queryNip05,
|
||||
sendNotification,
|
||||
shorten,
|
||||
signEventForMetaFile,
|
||||
updateUsersAppData,
|
||||
uploadToFileStorage
|
||||
uploadToFileStorage,
|
||||
DEFAULT_TOOLBOX
|
||||
} from '../../utils'
|
||||
import { Container } from '../../components/Container'
|
||||
import styles from './style.module.scss'
|
||||
import fileListStyles from '../../components/FileList/style.module.scss'
|
||||
import { DrawTool, MarkType } from '../../types/drawing'
|
||||
import { DrawTool } 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,
|
||||
faSignature,
|
||||
faSquareCaretDown,
|
||||
faSquareCheck,
|
||||
faStamp,
|
||||
faT,
|
||||
faTableCellsLarge,
|
||||
faToolbox,
|
||||
faTrash,
|
||||
faUpload
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
import { SigitFile } from '../../utils/file.ts'
|
||||
import { checkNotifications } from '../../utils/notifications.ts'
|
||||
|
||||
export const CreatePage = () => {
|
||||
const navigate = useNavigate()
|
||||
@ -119,6 +95,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 = useSelector((state: State) => state.auth.usersPubkey)
|
||||
|
||||
@ -130,116 +108,7 @@ export const CreatePage = () => {
|
||||
const [drawnFiles, setDrawnFiles] = useState<SigitFile[]>([])
|
||||
|
||||
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
|
||||
}
|
||||
])
|
||||
const [toolbox] = useState<DrawTool[]>(DEFAULT_TOOLBOX)
|
||||
|
||||
/**
|
||||
* Changes the drawing tool
|
||||
@ -258,7 +127,7 @@ export const CreatePage = () => {
|
||||
useEffect(() => {
|
||||
users.forEach((user) => {
|
||||
if (!(user.pubkey in metadata)) {
|
||||
const metadataController = new MetadataController()
|
||||
const metadataController = MetadataController.getInstance()
|
||||
|
||||
const handleMetadataEvent = (event: Event) => {
|
||||
const metadataContent =
|
||||
@ -432,7 +301,15 @@ export const CreatePage = () => {
|
||||
|
||||
const handleSelectFiles = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (event.target.files) {
|
||||
setSelectedFiles(Array.from(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)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -458,10 +335,8 @@ export const CreatePage = () => {
|
||||
return false
|
||||
}
|
||||
|
||||
if (users.length === 0) {
|
||||
toast.error(
|
||||
'No signer/viewer is provided. At least add one signer or viewer.'
|
||||
)
|
||||
if (!users.some((u) => u.role === UserRole.signer)) {
|
||||
toast.error('No signer is provided. At least add one signer.')
|
||||
return false
|
||||
}
|
||||
|
||||
@ -655,6 +530,11 @@ 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)
|
||||
}
|
||||
|
||||
@ -680,9 +560,6 @@ 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)),
|
||||
@ -711,9 +588,6 @@ 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
|
||||
@ -721,7 +595,7 @@ export const CreatePage = () => {
|
||||
: viewers.map((viewer) => viewer.pubkey)
|
||||
).filter((receiver) => receiver !== usersPubkey)
|
||||
|
||||
return receivers.map(async (receiver) => sendNotification(receiver, meta))
|
||||
return receivers.map((receiver) => sendNotification(receiver, meta))
|
||||
}
|
||||
|
||||
const handleCreate = async () => {
|
||||
@ -785,9 +659,17 @@ export const CreatePage = () => {
|
||||
if (!event) return
|
||||
|
||||
setLoadingSpinnerDesc('Sending notifications to counterparties')
|
||||
const notifications = await Promise.allSettled(sendNotifications(meta))
|
||||
checkNotifications(notifications)
|
||||
navigate(appPrivateRoutes.sign, { state: { meta: meta } })
|
||||
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 } })
|
||||
} else {
|
||||
const zip = new JSZip()
|
||||
|
||||
@ -866,21 +748,12 @@ 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}`}>
|
||||
@ -899,9 +772,6 @@ export const CreatePage = () => {
|
||||
aria-label={`delete ${file.name}`}
|
||||
variant="text"
|
||||
onClick={(event) => handleRemoveFile(event, file)}
|
||||
sx={{
|
||||
minWidth: '44px'
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</Button>
|
||||
@ -924,115 +794,69 @@ export const CreatePage = () => {
|
||||
}
|
||||
right={
|
||||
<div className={styles.flexWrap}>
|
||||
<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}>
|
||||
<div className={`${styles.paperGroup} ${styles.users}`}>
|
||||
<DisplayUser
|
||||
metadata={metadata}
|
||||
users={users}
|
||||
handleUserRoleChange={handleUserRoleChange}
|
||||
handleRemoveUser={handleRemoveUser}
|
||||
moveSigner={moveSigner}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleCreate} variant="contained">
|
||||
Publish
|
||||
</Button>
|
||||
<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}`}>
|
||||
{toolbox.map((drawTool: DrawTool, index: number) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
onClick={
|
||||
drawTool.active
|
||||
? () => {
|
||||
handleToolSelect(drawTool)
|
||||
}
|
||||
: () => null
|
||||
}
|
||||
{...(drawTool.active && {
|
||||
onClick: () => handleToolSelect(drawTool)
|
||||
})}
|
||||
className={`${styles.toolItem} ${selectedTool?.identifier === drawTool.identifier ? styles.selected : ''} ${!drawTool.active ? styles.comingSoon : ''}
|
||||
`}
|
||||
>
|
||||
{drawTool.icon}
|
||||
<FontAwesomeIcon fontSize={'15px'} icon={drawTool.icon} />
|
||||
{drawTool.label}
|
||||
{drawTool.active ? (
|
||||
<FontAwesomeIcon icon={faEllipsis} />
|
||||
<FontAwesomeIcon fontSize={'15px'} icon={faEllipsis} />
|
||||
) : (
|
||||
<span
|
||||
style={{
|
||||
fontSize: '10px'
|
||||
}}
|
||||
>
|
||||
<span className={styles.comingSoonPlaceholder}>
|
||||
Coming soon
|
||||
</span>
|
||||
)}
|
||||
@ -1041,11 +865,18 @@ export const CreatePage = () => {
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Button onClick={handleCreate} variant="contained">
|
||||
Publish
|
||||
</Button>
|
||||
|
||||
{!!error && (
|
||||
<FormHelperText error={!!error}>{error}</FormHelperText>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
leftIcon={faFileCirclePlus}
|
||||
centerIcon={faFile}
|
||||
rightIcon={faToolbox}
|
||||
>
|
||||
<DrawPDFFields
|
||||
metadata={metadata}
|
||||
@ -1061,7 +892,6 @@ export const CreatePage = () => {
|
||||
}
|
||||
|
||||
type DisplayUsersProps = {
|
||||
metadata: { [key: string]: ProfileMetadata }
|
||||
users: User[]
|
||||
handleUserRoleChange: (role: UserRole, pubkey: string) => void
|
||||
handleRemoveUser: (pubkey: string) => void
|
||||
@ -1069,7 +899,6 @@ type DisplayUsersProps = {
|
||||
}
|
||||
|
||||
const DisplayUser = ({
|
||||
metadata,
|
||||
users,
|
||||
handleUserRoleChange,
|
||||
handleRemoveUser,
|
||||
@ -1077,13 +906,12 @@ const DisplayUser = ({
|
||||
}: DisplayUsersProps) => {
|
||||
return (
|
||||
<>
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<DndProvider backend={MultiBackend} options={HTML5toTouch}>
|
||||
{users
|
||||
.filter((user) => user.role === UserRole.signer)
|
||||
.map((user, index) => (
|
||||
<SignerRow
|
||||
key={`signer-${index}`}
|
||||
userMeta={metadata[user.pubkey]}
|
||||
<SignerCounterpart
|
||||
key={`signer-${user.pubkey}`}
|
||||
user={user}
|
||||
index={index}
|
||||
moveSigner={moveSigner}
|
||||
@ -1094,71 +922,14 @@ const DisplayUser = ({
|
||||
</DndProvider>
|
||||
{users
|
||||
.filter((user) => user.role === UserRole.viewer)
|
||||
.map((user, index) => {
|
||||
const userMeta = metadata[user.pubkey]
|
||||
.map((user) => {
|
||||
return (
|
||||
<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 className={styles.user} key={`viewer-${user.pubkey}`}>
|
||||
<Counterpart
|
||||
user={user}
|
||||
handleUserRoleChange={handleUserRoleChange}
|
||||
handleRemoveUser={handleRemoveUser}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@ -1172,23 +943,24 @@ interface DragItem {
|
||||
type: string
|
||||
}
|
||||
|
||||
type SignerRowProps = {
|
||||
userMeta: ProfileMetadata
|
||||
type CounterpartProps = {
|
||||
user: User
|
||||
index: number
|
||||
moveSigner: (dragIndex: number, hoverIndex: number) => void
|
||||
handleUserRoleChange: (role: UserRole, pubkey: string) => void
|
||||
handleRemoveUser: (pubkey: string) => void
|
||||
}
|
||||
|
||||
const SignerRow = ({
|
||||
userMeta,
|
||||
type SignerCounterpartProps = CounterpartProps & {
|
||||
index: number
|
||||
moveSigner: (dragIndex: number, hoverIndex: number) => void
|
||||
}
|
||||
|
||||
const SignerCounterpart = ({
|
||||
user,
|
||||
index,
|
||||
moveSigner,
|
||||
handleUserRoleChange,
|
||||
handleRemoveUser
|
||||
}: SignerRowProps) => {
|
||||
}: SignerCounterpartProps) => {
|
||||
const ref = useRef<HTMLTableRowElement>(null)
|
||||
|
||||
const [{ handlerId }, drop] = useDrop<
|
||||
@ -1262,7 +1034,7 @@ const SignerRow = ({
|
||||
})
|
||||
})
|
||||
|
||||
const opacity = isDragging ? 0 : 1
|
||||
const opacity = isDragging ? 0.2 : 1
|
||||
drag(drop(ref))
|
||||
|
||||
return (
|
||||
@ -1273,67 +1045,50 @@ const SignerRow = ({
|
||||
ref={ref}
|
||||
>
|
||||
<FontAwesomeIcon width={'14px'} fontSize={'14px'} icon={faGripLines} />
|
||||
<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>
|
||||
<Counterpart
|
||||
user={user}
|
||||
handleRemoveUser={handleRemoveUser}
|
||||
handleUserRoleChange={handleUserRoleChange}
|
||||
/>
|
||||
</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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -4,6 +4,8 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.orderedFilesList {
|
||||
@ -40,6 +42,7 @@
|
||||
}
|
||||
|
||||
button {
|
||||
min-width: 44px;
|
||||
color: $primary-main;
|
||||
}
|
||||
|
||||
@ -67,10 +70,6 @@
|
||||
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;
|
||||
}
|
||||
@ -78,8 +77,9 @@
|
||||
.inputWrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
height: 34px;
|
||||
height: 36px;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
outline: solid 1px #dddddd;
|
||||
@ -90,6 +90,43 @@
|
||||
&: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 {
|
||||
@ -104,6 +141,22 @@
|
||||
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 {
|
||||
@ -130,26 +183,35 @@
|
||||
|
||||
.toolbox {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@container (min-width: 204px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
@container (min-width: 309px) {
|
||||
grid-template-columns: repeat(3, 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: inline-flex;
|
||||
display: 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;
|
||||
@ -162,7 +224,7 @@
|
||||
color: white;
|
||||
}
|
||||
|
||||
&:not(.selected) {
|
||||
&:not(.selected, .comingSoon) {
|
||||
&:hover {
|
||||
background: $primary-light;
|
||||
color: white;
|
||||
@ -174,3 +236,7 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.comingSoonPlaceholder {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
@ -18,6 +18,7 @@ import {
|
||||
SigitCardDisplayInfo,
|
||||
SigitStatus
|
||||
} from '../../utils'
|
||||
import { Footer } from '../../components/Footer/Footer'
|
||||
|
||||
// Unsupported Filter options are commented
|
||||
const FILTERS = [
|
||||
@ -262,6 +263,7 @@ export const HomePage = () => {
|
||||
))}
|
||||
</div>
|
||||
</Container>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -19,6 +19,7 @@ import {
|
||||
faWifi
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
import { FontAwesomeIconStack } from '../../components/FontAwesomeIconStack'
|
||||
import { Footer } from '../../components/Footer/Footer'
|
||||
|
||||
export const LandingPage = () => {
|
||||
const navigate = useNavigate()
|
||||
@ -162,6 +163,7 @@ export const LandingPage = () => {
|
||||
|
||||
<Outlet />
|
||||
</Container>
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -18,12 +18,16 @@ import {
|
||||
} from '../../store/actions'
|
||||
import { LoginMethods } from '../../store/auth/types'
|
||||
import { Dispatch } from '../../store/store'
|
||||
import { npubToHex, queryNip05 } from '../../utils'
|
||||
import { npubToHex, queryNip05, timeout } from '../../utils'
|
||||
import { hexToBytes } from '@noble/hashes/utils'
|
||||
import { NIP05_REGEX } from '../../constants'
|
||||
|
||||
import styles from './styles.module.scss'
|
||||
|
||||
import { TimeoutError } from '../../types/errors/TimeoutError'
|
||||
const EXTENSION_LOGIN_DELAY_SECONDS = 5
|
||||
const EXTENSION_LOGIN_TIMEOUT_SECONDS = EXTENSION_LOGIN_DELAY_SECONDS + 55
|
||||
|
||||
export const Nostr = () => {
|
||||
const [searchParams] = useSearchParams()
|
||||
|
||||
@ -31,11 +35,12 @@ export const Nostr = () => {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const authController = new AuthController()
|
||||
const metadataController = new MetadataController()
|
||||
const metadataController = MetadataController.getInstance()
|
||||
const nostrController = NostrController.getInstance()
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
|
||||
const [isExtensionSlow, setIsExtensionSlow] = useState(false)
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const [authUrl, setAuthUrl] = useState<string>()
|
||||
|
||||
@ -72,27 +77,43 @@ export const Nostr = () => {
|
||||
}
|
||||
|
||||
const loginWithExtension = async () => {
|
||||
setIsLoading(true)
|
||||
setLoadingSpinnerDesc('Capturing pubkey from nostr extension')
|
||||
let waitTimeout: number | undefined
|
||||
try {
|
||||
// Wait EXTENSION_LOGIN_DELAY_SECONDS before showing extension delay message
|
||||
waitTimeout = window.setTimeout(() => {
|
||||
setIsExtensionSlow(true)
|
||||
}, EXTENSION_LOGIN_DELAY_SECONDS * 1000)
|
||||
|
||||
nostrController
|
||||
.capturePublicKey()
|
||||
.then(async (pubkey) => {
|
||||
dispatch(updateLoginMethod(LoginMethods.extension))
|
||||
setIsLoading(true)
|
||||
setLoadingSpinnerDesc('Capturing pubkey from nostr extension')
|
||||
|
||||
setLoadingSpinnerDesc('Authenticating and finding metadata')
|
||||
const redirectPath =
|
||||
await authController.authAndGetMetadataAndRelaysMap(pubkey)
|
||||
const pubkey = await nostrController.capturePublicKey()
|
||||
dispatch(updateLoginMethod(LoginMethods.extension))
|
||||
|
||||
if (redirectPath) navigateAfterLogin(redirectPath)
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error('Error capturing public key from nostr extension: ' + err)
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false)
|
||||
setLoadingSpinnerDesc('')
|
||||
})
|
||||
setLoadingSpinnerDesc('Authenticating and finding metadata')
|
||||
const redirectPath = await Promise.race([
|
||||
authController.authAndGetMetadataAndRelaysMap(pubkey),
|
||||
timeout(EXTENSION_LOGIN_TIMEOUT_SECONDS * 1000)
|
||||
])
|
||||
|
||||
if (redirectPath) {
|
||||
navigateAfterLogin(redirectPath)
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof TimeoutError) {
|
||||
// Just log the error, no toast, user has already been notified with the loading screen
|
||||
console.error("Extension didn't respond in time")
|
||||
} else {
|
||||
toast.error('Error capturing public key from nostr extension: ' + error)
|
||||
}
|
||||
} finally {
|
||||
// Clear the wait timeout so we don't change the state unnecessarily
|
||||
window.clearTimeout(waitTimeout)
|
||||
|
||||
setIsLoading(false)
|
||||
setLoadingSpinnerDesc('')
|
||||
setIsExtensionSlow(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -354,7 +375,33 @@ export const Nostr = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
|
||||
{isLoading && (
|
||||
<LoadingSpinner desc={loadingSpinnerDesc}>
|
||||
{isExtensionSlow && (
|
||||
<>
|
||||
<p>
|
||||
Your nostr extension is not responding. Check these
|
||||
alternatives:{' '}
|
||||
<a href="https://github.com/aljazceru/awesome-nostr?tab=readme-ov-file#nip-07-browser-extensions">
|
||||
https://github.com/aljazceru/awesome-nostr
|
||||
</a>
|
||||
</p>
|
||||
<br />
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
onClick={() => {
|
||||
setLoadingSpinnerDesc('')
|
||||
setIsLoading(false)
|
||||
setIsExtensionSlow(false)
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</LoadingSpinner>
|
||||
)}
|
||||
|
||||
{isNostrExtensionAvailable && (
|
||||
<>
|
||||
|
@ -20,13 +20,14 @@ import {
|
||||
} 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(() => new MetadataController(), [])
|
||||
const metadataController = useMemo(() => MetadataController.getInstance(), [])
|
||||
|
||||
const [pubkey, setPubkey] = useState<string>()
|
||||
const [nostrJoiningBlock, setNostrJoiningBlock] =
|
||||
@ -41,6 +42,16 @@ export const ProfilePage = () => {
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [loadingSpinnerDesc] = useState('Fetching metadata')
|
||||
|
||||
const profileName =
|
||||
pubkey &&
|
||||
profileMetadata &&
|
||||
truncate(
|
||||
profileMetadata.display_name || profileMetadata.name || hexToNpub(pubkey),
|
||||
{
|
||||
length: 16
|
||||
}
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (npub) {
|
||||
try {
|
||||
@ -165,7 +176,10 @@ export const ProfilePage = () => {
|
||||
className={`${styles.banner} ${!profileMetadata || !profileMetadata.banner ? styles.noImage : ''}`}
|
||||
>
|
||||
{profileMetadata && profileMetadata.banner ? (
|
||||
<img src={profileMetadata.banner} />
|
||||
<img
|
||||
src={profileMetadata.banner}
|
||||
alt={`banner image for ${profileName}`}
|
||||
/>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
@ -185,6 +199,7 @@ export const ProfilePage = () => {
|
||||
<img
|
||||
className={styles['image-placeholder']}
|
||||
src={getProfileImage(profileMetadata!)}
|
||||
alt={profileName}
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
@ -224,14 +239,7 @@ export const ProfilePage = () => {
|
||||
variant="h6"
|
||||
className={styles.bold}
|
||||
>
|
||||
{truncate(
|
||||
profileMetadata.display_name ||
|
||||
profileMetadata.name ||
|
||||
hexToNpub(pubkey),
|
||||
{
|
||||
length: 16
|
||||
}
|
||||
)}
|
||||
{profileName}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
@ -285,6 +293,7 @@ export const ProfilePage = () => {
|
||||
</Box>
|
||||
</Container>
|
||||
)}
|
||||
<Footer />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -13,6 +13,7 @@ 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'
|
||||
|
||||
export const SettingsPage = () => {
|
||||
const theme = useTheme()
|
||||
@ -43,56 +44,59 @@ export const SettingsPage = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<List
|
||||
sx={{
|
||||
width: '100%',
|
||||
bgcolor: 'background.paper'
|
||||
}}
|
||||
subheader={
|
||||
<ListSubheader
|
||||
sx={{
|
||||
fontSize: '1.5rem',
|
||||
borderBottom: '0.5px solid',
|
||||
paddingBottom: 2,
|
||||
paddingTop: 2
|
||||
<>
|
||||
<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!))
|
||||
}}
|
||||
>
|
||||
Settings
|
||||
</ListSubheader>
|
||||
}
|
||||
>
|
||||
<ListItemButton
|
||||
onClick={() => {
|
||||
navigate(getProfileSettingsRoute(usersPubkey!))
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
<Footer />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
86
src/pages/settings/cache/index.tsx
vendored
86
src/pages/settings/cache/index.tsx
vendored
@ -14,6 +14,7 @@ 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()
|
||||
@ -50,48 +51,51 @@ 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
|
||||
}}
|
||||
>
|
||||
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>
|
||||
<ListItemButton onClick={handleClearData}>
|
||||
<ListItemIcon>
|
||||
<ClearIcon sx={{ color: theme.palette.error.main }} />
|
||||
</ListItemIcon>
|
||||
{listItem('Clear Cache')}
|
||||
</ListItemButton>
|
||||
</List>
|
||||
</Container>
|
||||
<Footer />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -12,7 +12,7 @@ import {
|
||||
useTheme
|
||||
} from '@mui/material'
|
||||
import { UnsignedEvent, nip19, kinds, VerifiedEvent, Event } from 'nostr-tools'
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { toast } from 'react-toastify'
|
||||
import { MetadataController, NostrController } from '../../../controllers'
|
||||
@ -32,6 +32,7 @@ import {
|
||||
unixNow
|
||||
} from '../../../utils'
|
||||
import { Container } from '../../../components/Container'
|
||||
import { Footer } from '../../../components/Footer/Footer'
|
||||
|
||||
export const ProfileSettingsPage = () => {
|
||||
const theme = useTheme()
|
||||
@ -40,7 +41,7 @@ export const ProfileSettingsPage = () => {
|
||||
|
||||
const dispatch: Dispatch = useDispatch()
|
||||
|
||||
const metadataController = useMemo(() => new MetadataController(), [])
|
||||
const metadataController = MetadataController.getInstance()
|
||||
const nostrController = NostrController.getInstance()
|
||||
|
||||
const [pubkey, setPubkey] = useState<string>()
|
||||
@ -385,6 +386,7 @@ export const ProfileSettingsPage = () => {
|
||||
</LoadingButton>
|
||||
)}
|
||||
</Container>
|
||||
<Footer />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -27,6 +27,7 @@ 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)
|
||||
@ -232,6 +233,7 @@ export const RelaysPage = () => {
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
<Footer />
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@ -270,161 +272,163 @@ 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -33,7 +33,8 @@ import {
|
||||
sendNotification,
|
||||
signEventForMetaFile,
|
||||
updateUsersAppData,
|
||||
findOtherUserMarks
|
||||
findOtherUserMarks,
|
||||
timeout
|
||||
} from '../../utils'
|
||||
import { Container } from '../../components/Container'
|
||||
import { DisplayMeta } from './internal/displayMeta'
|
||||
@ -53,7 +54,6 @@ import {
|
||||
SigitFile
|
||||
} from '../../utils/file.ts'
|
||||
import { ARRAY_BUFFER, DEFLATE } from '../../utils/const.ts'
|
||||
import { checkNotifications } from '../../utils/notifications.ts'
|
||||
enum SignedStatus {
|
||||
Fully_Signed,
|
||||
User_Is_Next_Signer,
|
||||
@ -277,17 +277,10 @@ export const SignPage = () => {
|
||||
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
|
||||
// decrypt the encryptionKey, with timeout (duration = 60 seconds)
|
||||
const encryptionKey = await Promise.race([
|
||||
nostrController.nip04Decrypt(sender, key),
|
||||
timeoutPromise
|
||||
timeout(60000)
|
||||
])
|
||||
.then((res) => {
|
||||
return res
|
||||
@ -469,20 +462,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\//, ''))
|
||||
|
||||
// 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) {
|
||||
for (const zipFilePath of fileNames) {
|
||||
const arrayBuffer = await readContentOfZipEntry(
|
||||
zip,
|
||||
fileName,
|
||||
zipFilePath,
|
||||
'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
|
||||
@ -664,22 +657,57 @@ export const SignPage = () => {
|
||||
|
||||
// Handle the online flow: update users app data and send notifications
|
||||
const handleOnlineFlow = async (meta: Meta) => {
|
||||
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 {
|
||||
setLoadingSpinnerDesc('Updating users app data')
|
||||
const updatedEvent = await updateUsersAppData(meta)
|
||||
if (!updatedEvent) {
|
||||
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
|
||||
@ -837,64 +865,6 @@ 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
|
||||
@ -991,12 +961,6 @@ export const SignPage = () => {
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ mt: 1, display: 'flex', justifyContent: 'center' }}>
|
||||
<Button onClick={handleRenotifyUsers} variant="contained">
|
||||
Re-notify Next Signer
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Container>
|
||||
|
@ -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, shorten, hexToNpub, parseJson } from '../../../utils'
|
||||
import { npubToHex, 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 = new MetadataController()
|
||||
const metadataController = MetadataController.getInstance()
|
||||
|
||||
const hexKeys: string[] = [
|
||||
npubToHex(submittedBy)!,
|
||||
@ -167,20 +167,7 @@ export const DisplayMeta = ({
|
||||
<Typography variant="h6" sx={{ color: textColor }}>
|
||||
Submitted By
|
||||
</Typography>
|
||||
{(function () {
|
||||
const profile = metadata[submittedBy]
|
||||
return (
|
||||
<UserAvatar
|
||||
pubkey={submittedBy}
|
||||
name={
|
||||
profile?.display_name ||
|
||||
profile?.name ||
|
||||
shorten(hexToNpub(submittedBy))
|
||||
}
|
||||
image={profile?.picture}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
<UserAvatar pubkey={submittedBy} isNameVisible={true} />
|
||||
</ListItem>
|
||||
<ListItem
|
||||
sx={{
|
||||
@ -280,14 +267,12 @@ 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)
|
||||
@ -370,15 +355,7 @@ const DisplayUser = ({
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell className={styles.tableCell}>
|
||||
<UserAvatar
|
||||
pubkey={user.pubkey}
|
||||
name={
|
||||
userMeta?.display_name ||
|
||||
userMeta?.name ||
|
||||
shorten(hexToNpub(user.pubkey))
|
||||
}
|
||||
image={userMeta?.picture}
|
||||
/>
|
||||
<UserAvatar pubkey={user.pubkey} isNameVisible={true} />
|
||||
</TableCell>
|
||||
<TableCell className={styles.tableCell}>{user.role}</TableCell>
|
||||
<TableCell>
|
||||
|
@ -1,16 +1,11 @@
|
||||
import { Box, Button, Tooltip, Typography } from '@mui/material'
|
||||
import { Box, Button, 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
|
||||
} from '../../types'
|
||||
import { DocSignatureEvent, Meta } from '../../types'
|
||||
import {
|
||||
decryptArrayBuffer,
|
||||
extractMarksFromSignedMeta,
|
||||
@ -20,7 +15,6 @@ import {
|
||||
parseJson,
|
||||
readContentOfZipEntry,
|
||||
signEventForMetaFile,
|
||||
shorten,
|
||||
getCurrentUserFiles
|
||||
} from '../../utils'
|
||||
import styles from './style.module.scss'
|
||||
@ -28,7 +22,6 @@ import { useLocation } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
import {
|
||||
addMarks,
|
||||
convertToPdfBlob,
|
||||
FONT_SIZE,
|
||||
FONT_TYPE,
|
||||
groupMarksByFileNamePage,
|
||||
@ -42,9 +35,6 @@ import { Container } from '../../components/Container'
|
||||
import { useSigitMeta } from '../../hooks/useSigitMeta.tsx'
|
||||
import { StickySideColumns } from '../../layouts/StickySideColumns.tsx'
|
||||
import { UsersDetails } from '../../components/UsersDetails.tsx/index.tsx'
|
||||
import { 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'
|
||||
@ -53,6 +43,11 @@ 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'
|
||||
|
||||
interface PdfViewProps {
|
||||
files: CurrentUserFile[]
|
||||
@ -78,82 +73,106 @@ const SlimPdfView = ({
|
||||
}, [currentFile])
|
||||
return (
|
||||
<div className="files-wrapper">
|
||||
{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.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[] = []
|
||||
|
||||
signatureEvents.forEach((e) => {
|
||||
const m = parsedSignatureEvents[
|
||||
e as `npub1${string}`
|
||||
].parsedContent?.marks.filter(
|
||||
(m) => m.pdfFileHash == hash && m.location.page == i
|
||||
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) => {
|
||||
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>
|
||||
)
|
||||
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>
|
||||
)
|
||||
})}
|
||||
})}
|
||||
{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" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const VerifyPage = () => {
|
||||
const location = useLocation()
|
||||
const usersPubkey = useSelector((state: 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 } = location.state || {}
|
||||
const { uploadedZip, meta: metaInNavState } = location.state || {}
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
||||
useEffect(() => {
|
||||
if (uploadedZip) {
|
||||
setSelectedFile(uploadedZip)
|
||||
}
|
||||
}, [uploadedZip])
|
||||
|
||||
const [meta, setMeta] = useState<Meta>(metaInNavState)
|
||||
const {
|
||||
submittedBy,
|
||||
zipUrl,
|
||||
@ -164,44 +183,22 @@ export const VerifyPage = () => {
|
||||
parsedSignatureEvents
|
||||
} = useSigitMeta(meta)
|
||||
|
||||
const profiles = useSigitProfiles([
|
||||
...(submittedBy ? [submittedBy] : []),
|
||||
...signers,
|
||||
...viewers
|
||||
])
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
|
||||
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
||||
const [files, setFiles] = useState<{ [filename: string]: SigitFile }>({})
|
||||
|
||||
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)
|
||||
|
||||
useEffect(() => {
|
||||
setSignatureFileHashes(fileHashes)
|
||||
}, [fileHashes])
|
||||
|
||||
useEffect(() => {
|
||||
if (Object.entries(files).length > 0) {
|
||||
const tmp = getCurrentUserFiles(files, fileHashes, signatureFileHashes)
|
||||
const tmp = getCurrentUserFiles(files, currentFileHashes, fileHashes)
|
||||
setCurrentFile(tmp[0])
|
||||
}
|
||||
}, [signatureFileHashes, fileHashes, files])
|
||||
|
||||
const usersPubkey = useSelector((state: State) => state.auth.usersPubkey)
|
||||
const nostrController = NostrController.getInstance()
|
||||
}, [currentFileHashes, fileHashes, files])
|
||||
|
||||
useEffect(() => {
|
||||
if (uploadedZip) {
|
||||
setSelectedFile(uploadedZip)
|
||||
} else if (meta && encryptionKey) {
|
||||
if (metaInNavState && encryptionKey) {
|
||||
const processSigit = async () => {
|
||||
setIsLoading(true)
|
||||
|
||||
@ -286,7 +283,7 @@ export const VerifyPage = () => {
|
||||
|
||||
processSigit()
|
||||
}
|
||||
}, [encryptionKey, meta, uploadedZip, zipUrl])
|
||||
}, [encryptionKey, metaInNavState, zipUrl])
|
||||
|
||||
const handleVerify = async () => {
|
||||
if (!selectedFile) return
|
||||
@ -300,6 +297,7 @@ 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)
|
||||
@ -307,24 +305,27 @@ 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 fileName of fileNames) {
|
||||
for (const zipFilePath of fileNames) {
|
||||
const arrayBuffer = await readContentOfZipEntry(
|
||||
zip,
|
||||
fileName,
|
||||
zipFilePath,
|
||||
'arraybuffer'
|
||||
)
|
||||
|
||||
const fileName = zipFilePath.replace(/^files\//, '')
|
||||
if (arrayBuffer) {
|
||||
files[fileName] = await convertToSigitFile(arrayBuffer, fileName)
|
||||
const hash = await getHash(arrayBuffer)
|
||||
|
||||
if (hash) {
|
||||
fileHashes[fileName.replace(/^files\//, '')] = hash
|
||||
fileHashes[fileName] = hash
|
||||
}
|
||||
} else {
|
||||
fileHashes[fileName.replace(/^files\//, '')] = null
|
||||
fileHashes[fileName] = null
|
||||
}
|
||||
}
|
||||
|
||||
setFiles(files)
|
||||
setCurrentFileHashes(fileHashes)
|
||||
|
||||
setLoadingSpinnerDesc('Parsing meta.json')
|
||||
@ -353,43 +354,7 @@ export const VerifyPage = () => {
|
||||
|
||||
if (!parsedMetaJson) return
|
||||
|
||||
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
|
||||
setMeta(parsedMetaJson)
|
||||
|
||||
setIsLoading(false)
|
||||
}
|
||||
@ -433,8 +398,7 @@ export const VerifyPage = () => {
|
||||
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)
|
||||
const blob = await addMarks(file, marksByPage[fileName])
|
||||
zip.file(`files/${fileName}`, blob)
|
||||
} else {
|
||||
zip.file(`files/${fileName}`, file)
|
||||
@ -464,47 +428,6 @@ 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} />}
|
||||
@ -539,32 +462,28 @@ export const VerifyPage = () => {
|
||||
{meta && (
|
||||
<StickySideColumns
|
||||
left={
|
||||
<>
|
||||
{currentFile !== null && (
|
||||
<FileList
|
||||
files={getCurrentUserFiles(
|
||||
files,
|
||||
currentFileHashes,
|
||||
signatureFileHashes
|
||||
)}
|
||||
currentFile={currentFile}
|
||||
setCurrentFile={setCurrentFile}
|
||||
handleDownload={handleExport}
|
||||
downloadLabel="Download Sigit"
|
||||
/>
|
||||
)}
|
||||
{displayExportedBy()}
|
||||
</>
|
||||
currentFile !== null && (
|
||||
<FileList
|
||||
files={getCurrentUserFiles(
|
||||
files,
|
||||
currentFileHashes,
|
||||
fileHashes
|
||||
)}
|
||||
currentFile={currentFile}
|
||||
setCurrentFile={setCurrentFile}
|
||||
handleDownload={handleExport}
|
||||
downloadLabel="Download Sigit"
|
||||
/>
|
||||
)
|
||||
}
|
||||
right={<UsersDetails meta={meta} />}
|
||||
leftIcon={faFileDownload}
|
||||
centerIcon={faFile}
|
||||
rightIcon={faCircleInfo}
|
||||
>
|
||||
<SlimPdfView
|
||||
currentFile={currentFile}
|
||||
files={getCurrentUserFiles(
|
||||
files,
|
||||
currentFileHashes,
|
||||
signatureFileHashes
|
||||
)}
|
||||
files={getCurrentUserFiles(files, currentFileHashes, fileHashes)}
|
||||
parsedSignatureEvents={parsedSignatureEvents}
|
||||
/>
|
||||
</StickySideColumns>
|
||||
|
@ -61,6 +61,6 @@
|
||||
|
||||
[data-dev='true'] {
|
||||
.mark {
|
||||
border: 1px dotted black;
|
||||
outline: 1px dotted black;
|
||||
}
|
||||
}
|
||||
|
@ -18,6 +18,10 @@ export interface RestoreState {
|
||||
payload: State
|
||||
}
|
||||
|
||||
export interface UserLogout {
|
||||
type: typeof ActionTypes.USER_LOGOUT
|
||||
}
|
||||
|
||||
export const userLogOutAction = () => {
|
||||
return {
|
||||
type: ActionTypes.USER_LOGOUT
|
||||
|
@ -1,5 +1,5 @@
|
||||
import * as ActionTypes from '../actionTypes'
|
||||
import { RestoreState } from '../actions'
|
||||
import { RestoreState, UserLogout } from '../actions'
|
||||
|
||||
export enum LoginMethods {
|
||||
extension = 'extension',
|
||||
@ -54,3 +54,4 @@ export type AuthDispatchTypes =
|
||||
| UpdateKeyPair
|
||||
| UpdateNsecBunkerPubkey
|
||||
| UpdateNsecBunkerRelays
|
||||
| UserLogout
|
||||
|
@ -3,12 +3,15 @@ import { combineReducers } from 'redux'
|
||||
import { UserAppData } from '../types'
|
||||
import * as ActionTypes from './actionTypes'
|
||||
import authReducer from './auth/reducer'
|
||||
import { AuthState } from './auth/types'
|
||||
import { AuthDispatchTypes, AuthState } from './auth/types'
|
||||
import metadataReducer from './metadata/reducer'
|
||||
import relaysReducer from './relays/reducer'
|
||||
import { RelaysState } from './relays/types'
|
||||
import { RelaysDispatchTypes, RelaysState } from './relays/types'
|
||||
import UserAppDataReducer from './userAppData/reducer'
|
||||
import userRobotImageReducer from './userRobotImage/reducer'
|
||||
import { MetadataDispatchTypes } from './metadata/types'
|
||||
import { UserAppDataDispatchTypes } from './userAppData/types'
|
||||
import { UserRobotImageDispatchTypes } from './userRobotImage/types'
|
||||
|
||||
export interface State {
|
||||
auth: AuthState
|
||||
@ -18,6 +21,13 @@ export interface State {
|
||||
userAppData?: UserAppData
|
||||
}
|
||||
|
||||
type AppActions =
|
||||
| AuthDispatchTypes
|
||||
| MetadataDispatchTypes
|
||||
| UserRobotImageDispatchTypes
|
||||
| RelaysDispatchTypes
|
||||
| UserAppDataDispatchTypes
|
||||
|
||||
export const appReducer = combineReducers({
|
||||
auth: authReducer,
|
||||
metadata: metadataReducer,
|
||||
@ -26,8 +36,10 @@ export const appReducer = combineReducers({
|
||||
userAppData: UserAppDataReducer
|
||||
})
|
||||
|
||||
// FIXME: define types
|
||||
export default (state: any, action: any) => {
|
||||
export default (
|
||||
state: ReturnType<typeof appReducer> | undefined,
|
||||
action: AppActions
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case ActionTypes.USER_LOGOUT:
|
||||
return appReducer(undefined, action)
|
||||
|
@ -1,11 +1,11 @@
|
||||
import * as ActionTypes from '../actionTypes'
|
||||
import { MetadataDispatchTypes } from './types'
|
||||
import { UserRobotImageDispatchTypes } from './types'
|
||||
|
||||
const initialState: string | null = null
|
||||
|
||||
const reducer = (
|
||||
state = initialState,
|
||||
action: MetadataDispatchTypes
|
||||
action: UserRobotImageDispatchTypes
|
||||
): string | null | undefined => {
|
||||
switch (action.type) {
|
||||
case ActionTypes.SET_USER_ROBOT_IMAGE:
|
||||
|
@ -6,4 +6,4 @@ export interface SetUserRobotImage {
|
||||
payload: string | null
|
||||
}
|
||||
|
||||
export type MetadataDispatchTypes = SetUserRobotImage | RestoreState
|
||||
export type UserRobotImageDispatchTypes = SetUserRobotImage | RestoreState
|
||||
|
@ -2,3 +2,5 @@ $header-height: 65px;
|
||||
$body-vertical-padding: 25px;
|
||||
|
||||
$default-container-padding-inline: 10px;
|
||||
|
||||
$tabs-height: 40px;
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
||||
import { MarkRect } from './mark'
|
||||
|
||||
export interface MouseState {
|
||||
@ -5,8 +6,8 @@ export interface MouseState {
|
||||
dragging?: boolean
|
||||
resizing?: boolean
|
||||
coordsInWrapper?: {
|
||||
mouseX: number
|
||||
mouseY: number
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
}
|
||||
|
||||
@ -27,7 +28,7 @@ export interface DrawnField extends MarkRect {
|
||||
export interface DrawTool {
|
||||
identifier: MarkType
|
||||
label: string
|
||||
icon: JSX.Element
|
||||
icon: IconDefinition
|
||||
defaultValue?: string
|
||||
selected?: boolean
|
||||
active?: boolean
|
||||
|
6
src/types/errors/TimeoutError.ts
Normal file
6
src/types/errors/TimeoutError.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export class TimeoutError extends Error {
|
||||
constructor() {
|
||||
super('Timeout')
|
||||
this.name = this.constructor.name
|
||||
}
|
||||
}
|
@ -21,6 +21,8 @@ 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',
|
||||
|
@ -13,7 +13,7 @@ import { setRelayInfoAction } from '../store/actions'
|
||||
export const getNostrJoiningBlockNumber = async (
|
||||
hexKey: string
|
||||
): Promise<NostrJoiningBlock | null> => {
|
||||
const metadataController = new MetadataController()
|
||||
const metadataController = MetadataController.getInstance()
|
||||
|
||||
const relaySet = await metadataController.findRelayListMetadata(hexKey)
|
||||
|
||||
|
@ -4,7 +4,6 @@ import { MOST_COMMON_MEDIA_TYPES } from './const.ts'
|
||||
import { extractMarksFromSignedMeta } from './mark.ts'
|
||||
import {
|
||||
addMarks,
|
||||
convertToPdfBlob,
|
||||
groupMarksByFileNamePage,
|
||||
isPdf,
|
||||
pdfToImages
|
||||
@ -22,8 +21,7 @@ export const getZipWithFiles = async (
|
||||
for (const [fileName, file] of Object.entries(files)) {
|
||||
if (file.isPdf) {
|
||||
// Handle PDF Files
|
||||
const pages = await addMarks(file, marksByFileNamePage[fileName])
|
||||
const blob = await convertToPdfBlob(pages)
|
||||
const blob = await addMarks(file, marksByFileNamePage[fileName])
|
||||
zip.file(`files/${fileName}`, blob)
|
||||
} else {
|
||||
// Handle other files
|
||||
|
@ -3,6 +3,27 @@ import { hexToNpub } from './nostr.ts'
|
||||
import { Meta, SignedEventContent } from '../types'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { EMPTY } from './const.ts'
|
||||
import { MarkType } from '../types/drawing.ts'
|
||||
import {
|
||||
faT,
|
||||
faSignature,
|
||||
faBriefcase,
|
||||
faIdCard,
|
||||
faHeading,
|
||||
faClock,
|
||||
faCalendarDays,
|
||||
fa1,
|
||||
faImage,
|
||||
faSquareCheck,
|
||||
faCheckDouble,
|
||||
faPaperclip,
|
||||
faCircleDot,
|
||||
faSquareCaretDown,
|
||||
faTableCellsLarge,
|
||||
faStamp,
|
||||
faCreditCard,
|
||||
faPhone
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
/**
|
||||
* Takes in an array of Marks already filtered by User.
|
||||
@ -131,6 +152,121 @@ const findOtherUserMarks = (marks: Mark[], pubkey: string): Mark[] => {
|
||||
return marks.filter((mark) => mark.npub !== hexToNpub(pubkey))
|
||||
}
|
||||
|
||||
export const DEFAULT_TOOLBOX = [
|
||||
{
|
||||
identifier: MarkType.TEXT,
|
||||
icon: faT,
|
||||
label: 'Text',
|
||||
active: true
|
||||
},
|
||||
{
|
||||
identifier: MarkType.SIGNATURE,
|
||||
icon: faSignature,
|
||||
label: 'Signature',
|
||||
active: false
|
||||
},
|
||||
{
|
||||
identifier: MarkType.JOBTITLE,
|
||||
icon: faBriefcase,
|
||||
label: 'Job Title',
|
||||
active: false
|
||||
},
|
||||
{
|
||||
identifier: MarkType.FULLNAME,
|
||||
icon: faIdCard,
|
||||
label: 'Full Name',
|
||||
active: false
|
||||
},
|
||||
{
|
||||
identifier: MarkType.INITIALS,
|
||||
icon: faHeading,
|
||||
label: 'Initials',
|
||||
active: false
|
||||
},
|
||||
{
|
||||
identifier: MarkType.DATETIME,
|
||||
icon: faClock,
|
||||
label: 'Date Time',
|
||||
active: false
|
||||
},
|
||||
{
|
||||
identifier: MarkType.DATE,
|
||||
icon: faCalendarDays,
|
||||
label: 'Date',
|
||||
active: false
|
||||
},
|
||||
{
|
||||
identifier: MarkType.NUMBER,
|
||||
icon: fa1,
|
||||
label: 'Number',
|
||||
active: false
|
||||
},
|
||||
{
|
||||
identifier: MarkType.IMAGES,
|
||||
icon: faImage,
|
||||
label: 'Images',
|
||||
active: false
|
||||
},
|
||||
{
|
||||
identifier: MarkType.CHECKBOX,
|
||||
icon: faSquareCheck,
|
||||
label: 'Checkbox',
|
||||
active: false
|
||||
},
|
||||
{
|
||||
identifier: MarkType.MULTIPLE,
|
||||
icon: faCheckDouble,
|
||||
label: 'Multiple',
|
||||
active: false
|
||||
},
|
||||
{
|
||||
identifier: MarkType.FILE,
|
||||
icon: faPaperclip,
|
||||
label: 'File',
|
||||
active: false
|
||||
},
|
||||
{
|
||||
identifier: MarkType.RADIO,
|
||||
icon: faCircleDot,
|
||||
label: 'Radio',
|
||||
active: false
|
||||
},
|
||||
{
|
||||
identifier: MarkType.SELECT,
|
||||
icon: faSquareCaretDown,
|
||||
label: 'Select',
|
||||
active: false
|
||||
},
|
||||
{
|
||||
identifier: MarkType.CELLS,
|
||||
icon: faTableCellsLarge,
|
||||
label: 'Cells',
|
||||
active: false
|
||||
},
|
||||
{
|
||||
identifier: MarkType.STAMP,
|
||||
icon: faStamp,
|
||||
label: 'Stamp',
|
||||
active: false
|
||||
},
|
||||
{
|
||||
identifier: MarkType.PAYMENT,
|
||||
icon: faCreditCard,
|
||||
label: 'Payment',
|
||||
active: false
|
||||
},
|
||||
{
|
||||
identifier: MarkType.PHONE,
|
||||
icon: faPhone,
|
||||
label: 'Phone',
|
||||
active: false
|
||||
}
|
||||
]
|
||||
|
||||
export const getToolboxLabelByMarkType = (markType: MarkType) => {
|
||||
return DEFAULT_TOOLBOX.find((t) => t.identifier === markType)?.label
|
||||
}
|
||||
|
||||
export {
|
||||
getCurrentUserMarks,
|
||||
filterMarksByPubkey,
|
||||
|
@ -16,6 +16,8 @@ import { CreateSignatureEventContent, Meta } from '../types'
|
||||
import { hexToNpub, unixNow } from './nostr'
|
||||
import { parseJson } from './string'
|
||||
import { hexToBytes } from '@noble/hashes/utils'
|
||||
import { getHash } from './hash.ts'
|
||||
import { SIGIT_BLOSSOM } from './const.ts'
|
||||
|
||||
/**
|
||||
* Uploads a file to a file storage service.
|
||||
@ -25,12 +27,18 @@ import { hexToBytes } from '@noble/hashes/utils'
|
||||
*/
|
||||
export const uploadToFileStorage = async (file: File) => {
|
||||
// Define event metadata for authorization
|
||||
const hash = await getHash(await file.arrayBuffer())
|
||||
if (!hash) {
|
||||
throw new Error("Can't get file hash.")
|
||||
}
|
||||
|
||||
const event: EventTemplate = {
|
||||
kind: 24242,
|
||||
content: 'Authorize Upload',
|
||||
created_at: unixNow(),
|
||||
tags: [
|
||||
['t', 'upload'],
|
||||
['x', hash],
|
||||
['expiration', String(unixNow() + 60 * 5)], // Set expiration time to 5 minutes from now
|
||||
['name', file.name],
|
||||
['size', String(file.size)]
|
||||
@ -47,11 +55,8 @@ export const uploadToFileStorage = async (file: File) => {
|
||||
// Sign the authorization event using the dedicated key stored in user app data
|
||||
const authEvent = finalizeEvent(event, hexToBytes(key))
|
||||
|
||||
// URL of the file storage service
|
||||
const FILE_STORAGE_URL = 'https://blossom.sigit.io' // REFACTOR: should be an env
|
||||
|
||||
// Upload the file to the file storage service using Axios
|
||||
const response = await axios.put(`${FILE_STORAGE_URL}/upload`, file, {
|
||||
const response = await axios.put(`${SIGIT_BLOSSOM}/upload`, file, {
|
||||
headers: {
|
||||
Authorization: 'Nostr ' + btoa(JSON.stringify(authEvent)), // Set authorization header
|
||||
'Content-Type': 'application/sigit' // Set content type header
|
||||
|
@ -35,6 +35,7 @@ import { getDefaultRelayMap } from './relays'
|
||||
import { parseJson, removeLeadingSlash } from './string'
|
||||
import { timeout } from './utils'
|
||||
import { getHash } from './hash'
|
||||
import { SIGIT_BLOSSOM } from './const.ts'
|
||||
|
||||
/**
|
||||
* Generates a `d` tag for userAppData
|
||||
@ -365,7 +366,7 @@ export const getUsersAppData = async (): Promise<UserAppData | null> => {
|
||||
// Check if relayMap is undefined in the Redux store
|
||||
if (!relayMap) {
|
||||
// If relayMap is not present, fetch relay list metadata
|
||||
const metadataController = new MetadataController()
|
||||
const metadataController = MetadataController.getInstance()
|
||||
const relaySet = await metadataController
|
||||
.findRelayListMetadata(usersPubkey)
|
||||
.catch((err) => {
|
||||
@ -723,6 +724,11 @@ const uploadUserAppDataToBlossom = async (
|
||||
type: 'application/octet-stream'
|
||||
})
|
||||
|
||||
const hash = await getHash(await file.arrayBuffer())
|
||||
if (!hash) {
|
||||
throw new Error("Can't get file hash.")
|
||||
}
|
||||
|
||||
// Define event metadata for authorization
|
||||
const event: EventTemplate = {
|
||||
kind: 24242,
|
||||
@ -730,6 +736,7 @@ const uploadUserAppDataToBlossom = async (
|
||||
created_at: unixNow(),
|
||||
tags: [
|
||||
['t', 'upload'],
|
||||
['x', hash],
|
||||
['expiration', String(unixNow() + 60 * 5)], // Set expiration time to 5 minutes from now
|
||||
['name', file.name],
|
||||
['size', String(file.size)]
|
||||
@ -739,11 +746,8 @@ const uploadUserAppDataToBlossom = async (
|
||||
// Finalize the event with the private key
|
||||
const authEvent = finalizeEvent(event, hexToBytes(privateKey))
|
||||
|
||||
// URL of the file storage service
|
||||
const FILE_STORAGE_URL = 'https://blossom.sigit.io'
|
||||
|
||||
// Upload the file to the file storage service using Axios
|
||||
const response = await axios.put(`${FILE_STORAGE_URL}/upload`, file, {
|
||||
const response = await axios.put(`${SIGIT_BLOSSOM}/upload`, file, {
|
||||
headers: {
|
||||
Authorization: 'Nostr ' + btoa(JSON.stringify(authEvent)) // Set authorization header
|
||||
}
|
||||
@ -835,7 +839,7 @@ const getUserAppDataFromBlossom = async (url: string, privateKey: string) => {
|
||||
*/
|
||||
export const subscribeForSigits = async (pubkey: string) => {
|
||||
// Instantiate the MetadataController to retrieve relay list metadata
|
||||
const metadataController = new MetadataController()
|
||||
const metadataController = MetadataController.getInstance()
|
||||
const relaySet = await metadataController
|
||||
.findRelayListMetadata(pubkey)
|
||||
.catch((err) => {
|
||||
@ -859,9 +863,16 @@ export const subscribeForSigits = async (pubkey: string) => {
|
||||
'#p': [pubkey]
|
||||
}
|
||||
|
||||
relayController.subscribeForEvents(filter, relaySet.read, (event) => {
|
||||
processReceivedEvent(event) // Process the received event
|
||||
})
|
||||
// Process the received event synchronously
|
||||
const events = await relayController.fetchEvents(filter, relaySet.read)
|
||||
for (const e of events) {
|
||||
await processReceivedEvent(e)
|
||||
}
|
||||
|
||||
// Async processing of the events has a race condition
|
||||
// relayController.subscribeForEvents(filter, relaySet.read, (event) => {
|
||||
// processReceivedEvent(event)
|
||||
// })
|
||||
}
|
||||
|
||||
const processReceivedEvent = async (event: Event, difficulty: number = 5) => {
|
||||
@ -907,7 +918,7 @@ const processReceivedEvent = async (event: Event, difficulty: number = 5) => {
|
||||
|
||||
if (!meta) return
|
||||
|
||||
updateUsersAppData(meta)
|
||||
await updateUsersAppData(meta)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -916,33 +927,50 @@ const processReceivedEvent = async (event: Event, difficulty: number = 5) => {
|
||||
* @param meta - Metadata associated with the notification.
|
||||
*/
|
||||
export const sendNotification = async (receiver: string, meta: Meta) => {
|
||||
try {
|
||||
// Retrieve the user's public key from the state
|
||||
const usersPubkey = (store.getState().auth as AuthState).usersPubkey!
|
||||
// Create an unsigned event object with the provided metadata
|
||||
const unsignedEvent: UnsignedEvent = {
|
||||
kind: 938,
|
||||
pubkey: usersPubkey,
|
||||
content: JSON.stringify(meta),
|
||||
tags: [],
|
||||
created_at: unixNow()
|
||||
}
|
||||
// Retrieve the user's public key from the state
|
||||
const usersPubkey = (store.getState().auth as AuthState).usersPubkey!
|
||||
|
||||
// Wrap the unsigned event with the receiver's information
|
||||
const wrappedEvent = createWrap(unsignedEvent, receiver)
|
||||
|
||||
// Instantiate the MetadataController to retrieve relay list metadata
|
||||
const metadataController = new MetadataController()
|
||||
|
||||
const relaySet = await metadataController.findRelayListMetadata(receiver)
|
||||
|
||||
return await Promise.race([
|
||||
relayController.publish(wrappedEvent, relaySet.read),
|
||||
timeout(40 * 1000)
|
||||
])
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to publish a notification for ${receiver}`, {
|
||||
cause: error
|
||||
})
|
||||
// Create an unsigned event object with the provided metadata
|
||||
const unsignedEvent: UnsignedEvent = {
|
||||
kind: 938,
|
||||
pubkey: usersPubkey,
|
||||
content: JSON.stringify(meta),
|
||||
tags: [],
|
||||
created_at: unixNow()
|
||||
}
|
||||
|
||||
// Wrap the unsigned event with the receiver's information
|
||||
const wrappedEvent = createWrap(unsignedEvent, receiver)
|
||||
|
||||
// Instantiate the MetadataController to retrieve relay list metadata
|
||||
const metadataController = MetadataController.getInstance()
|
||||
const relaySet = await metadataController
|
||||
.findRelayListMetadata(receiver)
|
||||
.catch((err) => {
|
||||
// Log an error if retrieving relay list metadata fails
|
||||
console.log(
|
||||
`An error occurred while finding relay list metadata for ${hexToNpub(receiver)}`,
|
||||
err
|
||||
)
|
||||
return null
|
||||
})
|
||||
|
||||
// Return if metadata retrieval failed
|
||||
if (!relaySet) return
|
||||
|
||||
// Ensure relay list is not empty
|
||||
if (relaySet.read.length === 0) return
|
||||
|
||||
// Publish the notification event to the recipient's read relays
|
||||
await Promise.race([
|
||||
relayController.publish(wrappedEvent, relaySet.read),
|
||||
timeout(40 * 1000)
|
||||
]).catch((err) => {
|
||||
// Log an error if publishing the notification event fails
|
||||
console.log(
|
||||
`An error occurred while publishing notification event for ${hexToNpub(receiver)}`,
|
||||
err
|
||||
)
|
||||
throw err
|
||||
})
|
||||
}
|
||||
|
@ -1,26 +0,0 @@
|
||||
import { isPromiseFulfilled, isPromiseRejected } from './utils.ts'
|
||||
import { toast } from 'react-toastify'
|
||||
|
||||
const checkNotifications = (
|
||||
notifications: PromiseSettledResult<string[]>[]
|
||||
) => {
|
||||
if (notifications.every(isPromiseFulfilled)) {
|
||||
toast.success('Notifications sent successfully')
|
||||
} else {
|
||||
logRejectedNotifications(notifications)
|
||||
toast.error(
|
||||
'Some notifications failed to publish. Please rebroadcast them again.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const logRejectedNotifications = (
|
||||
notifications: PromiseSettledResult<string[]>[]
|
||||
) => {
|
||||
notifications
|
||||
.filter(isPromiseRejected)
|
||||
.map((res: PromiseRejectedResult) => (res.reason as Error).message)
|
||||
.forEach((message: string) => console.log(message))
|
||||
}
|
||||
|
||||
export { checkNotifications }
|
130
src/utils/pdf.ts
130
src/utils/pdf.ts
@ -1,25 +1,31 @@
|
||||
import { PdfPage } from '../types/drawing.ts'
|
||||
import * as PDFJS from 'pdfjs-dist'
|
||||
import { PDFDocument } from 'pdf-lib'
|
||||
import { PDFDocument, PDFFont, PDFPage, rgb } from 'pdf-lib'
|
||||
import { Mark } from '../types/mark.ts'
|
||||
import * as PDFJS from 'pdfjs-dist'
|
||||
import PDFJSWorker from 'pdfjs-dist/build/pdf.worker.min.mjs?worker'
|
||||
if (!PDFJS.GlobalWorkerOptions.workerPort) {
|
||||
// Use workerPort and allow worker to be shared between all getDocument calls
|
||||
const worker = new PDFJSWorker()
|
||||
PDFJS.GlobalWorkerOptions.workerPort = worker
|
||||
}
|
||||
|
||||
PDFJS.GlobalWorkerOptions.workerSrc = new URL(
|
||||
'pdfjs-dist/build/pdf.worker.min.mjs',
|
||||
import.meta.url
|
||||
).toString()
|
||||
import fontkit from '@pdf-lib/fontkit'
|
||||
import defaultFont from '../assets/fonts/roboto-regular.ttf'
|
||||
|
||||
/**
|
||||
* Defined font size used when generating a PDF. Currently it is difficult to fully
|
||||
* correlate font size used at the time of filling in / drawing on the PDF
|
||||
* because it is dynamically rendered, and the final size.
|
||||
* This should be fixed going forward.
|
||||
* Switching to PDF-Lib will most likely make this problem redundant.
|
||||
*/
|
||||
export const FONT_SIZE: number = 16
|
||||
/**
|
||||
* Current font type used when generating a PDF.
|
||||
*/
|
||||
export const FONT_TYPE: string = 'Arial'
|
||||
export const FONT_TYPE: string = 'Roboto'
|
||||
/**
|
||||
* Current line height used when generating a PDF.
|
||||
*/
|
||||
export const FONT_LINE_HEIGHT: number = 1
|
||||
|
||||
/**
|
||||
* A utility that transforms a drawing coordinate number into a CSS-compatible pixel string
|
||||
@ -113,56 +119,28 @@ export const pdfToImages = async (
|
||||
|
||||
/**
|
||||
* Takes in individual pdf file and an object with Marks grouped by Page number
|
||||
* Returns an array of encoded images where each image is a representation
|
||||
* of a PDF page with completed and signed marks from all users
|
||||
* Returns a PDF blob with embedded, completed and signed marks from all users as text
|
||||
*/
|
||||
export const addMarks = async (
|
||||
file: File,
|
||||
marksPerPage: { [key: string]: Mark[] }
|
||||
) => {
|
||||
const p = await readPdf(file)
|
||||
const pdf = await PDFJS.getDocument(p).promise
|
||||
const canvas = document.createElement('canvas')
|
||||
const pdf = await PDFDocument.load(p)
|
||||
const robotoFont = await embedFont(pdf)
|
||||
const pages = pdf.getPages()
|
||||
|
||||
const images: string[] = []
|
||||
|
||||
for (let i = 0; i < pdf.numPages; i++) {
|
||||
const page = await pdf.getPage(i + 1)
|
||||
const viewport = page.getViewport({ scale: 1 })
|
||||
const context = canvas.getContext('2d')
|
||||
canvas.height = viewport.height
|
||||
canvas.width = viewport.width
|
||||
if (context) {
|
||||
await page.render({ canvasContext: context, viewport: viewport }).promise
|
||||
|
||||
if (marksPerPage && Object.hasOwn(marksPerPage, i)) {
|
||||
marksPerPage[i]?.forEach((mark) => draw(mark, context))
|
||||
}
|
||||
|
||||
images.push(canvas.toDataURL())
|
||||
for (let i = 0; i < pages.length; i++) {
|
||||
if (marksPerPage && Object.hasOwn(marksPerPage, i)) {
|
||||
marksPerPage[i]?.forEach((mark) =>
|
||||
drawMarkText(mark, pages[i], robotoFont)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
canvas.remove()
|
||||
const blob = await pdf.save()
|
||||
|
||||
return images
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility to scale mark in line with the PDF-to-PNG scale
|
||||
*/
|
||||
export const scaleMark = (mark: Mark, scale: number): Mark => {
|
||||
const { location } = mark
|
||||
return {
|
||||
...mark,
|
||||
location: {
|
||||
...location,
|
||||
width: location.width * scale,
|
||||
height: location.height * scale,
|
||||
left: location.left * scale,
|
||||
top: location.top * scale
|
||||
}
|
||||
}
|
||||
return blob
|
||||
}
|
||||
|
||||
/**
|
||||
@ -175,6 +153,7 @@ export const hasValue = (mark: Mark): boolean => !!mark.value
|
||||
* Draws a Mark on a Canvas representation of a PDF Page
|
||||
* @param mark to be drawn
|
||||
* @param ctx a Canvas representation of a specific PDF Page
|
||||
* @deprecated use drawMarkText
|
||||
*/
|
||||
export const draw = (mark: Mark, ctx: CanvasRenderingContext2D) => {
|
||||
const { location } = mark
|
||||
@ -189,27 +168,37 @@ export const draw = (mark: Mark, ctx: CanvasRenderingContext2D) => {
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes an array of encoded PDF pages and returns a blob that is a complete PDF file
|
||||
* @param markedPdfPages
|
||||
* Draws a Mark on a PDF Page
|
||||
* @param mark to be drawn
|
||||
* @param page PDF Page
|
||||
* @param font embedded font
|
||||
*/
|
||||
export const convertToPdfBlob = async (
|
||||
markedPdfPages: string[]
|
||||
): Promise<Blob> => {
|
||||
const pdfDoc = await PDFDocument.create()
|
||||
export const drawMarkText = (mark: Mark, page: PDFPage, font: PDFFont) => {
|
||||
const { location } = mark
|
||||
const { height } = page.getSize()
|
||||
|
||||
for (const page of markedPdfPages) {
|
||||
const pngImage = await pdfDoc.embedPng(page)
|
||||
const p = pdfDoc.addPage([pngImage.width, pngImage.height])
|
||||
p.drawImage(pngImage, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: pngImage.width,
|
||||
height: pngImage.height
|
||||
})
|
||||
}
|
||||
// Convert the mark location origin (top, left) to PDF origin (bottom, left)
|
||||
const x = location.left
|
||||
const y = height - location.top
|
||||
|
||||
const pdfBytes = await pdfDoc.save()
|
||||
return new Blob([pdfBytes], { type: 'application/pdf' })
|
||||
// Adjust y-coordinate for the text, drawText's y is the baseline for the font
|
||||
// We start from the y (top location border) and we need to bump it down
|
||||
// We move font baseline by the difference between rendered height and actual height (gap)
|
||||
// And finally move down by the height without descender to get the new baseline
|
||||
const adjustedY =
|
||||
y -
|
||||
(font.heightAtSize(FONT_SIZE) - FONT_SIZE) -
|
||||
font.heightAtSize(FONT_SIZE, { descender: false })
|
||||
|
||||
page.drawText(`${mark.value}`, {
|
||||
x,
|
||||
y: adjustedY,
|
||||
size: FONT_SIZE,
|
||||
font: font,
|
||||
color: rgb(0, 0, 0),
|
||||
maxWidth: location.width,
|
||||
lineHeight: FONT_SIZE * FONT_LINE_HEIGHT
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@ -247,3 +236,12 @@ export const byPage = (
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function embedFont(pdf: PDFDocument) {
|
||||
const fontBytes = await fetch(defaultFont).then((res) => res.arrayBuffer())
|
||||
|
||||
pdf.registerFontkit(fontkit)
|
||||
|
||||
const embeddedFont = await pdf.embedFont(fontBytes)
|
||||
return embeddedFont
|
||||
}
|
||||
|
@ -1,11 +1,12 @@
|
||||
import { Event, Filter, kinds, Relay, UnsignedEvent } from 'nostr-tools'
|
||||
import { Event, Filter, kinds, UnsignedEvent } from 'nostr-tools'
|
||||
import { RelayList } from 'nostr-tools/kinds'
|
||||
import { getRelayInfo, timeout, unixNow } from '.'
|
||||
import { getRelayInfo, unixNow } from '.'
|
||||
import { NostrController, relayController } from '../controllers'
|
||||
import { localCache } from '../services'
|
||||
import { RelayMap, RelaySet } from '../types'
|
||||
import {
|
||||
DEFAULT_LOOK_UP_RELAY_LIST,
|
||||
ONE_DAY_IN_MS,
|
||||
ONE_WEEK_IN_MS,
|
||||
SIGIT_RELAY
|
||||
} from './const'
|
||||
@ -56,7 +57,7 @@ const findRelayListInCache = async (hexKey: string): Promise<Event | null> => {
|
||||
// Check if the cached event is not older than one week
|
||||
if (
|
||||
cachedRelayListMetadataEvent &&
|
||||
isOlderThanOneWeek(cachedRelayListMetadataEvent.cachedAt)
|
||||
!isOlderThanOneWeek(cachedRelayListMetadataEvent.cachedAt)
|
||||
) {
|
||||
return cachedRelayListMetadataEvent.event
|
||||
}
|
||||
@ -88,11 +89,11 @@ const getDefaultRelayMap = (): RelayMap => ({
|
||||
})
|
||||
|
||||
const isOlderThanOneWeek = (cachedAt: number) => {
|
||||
return Date.now() - cachedAt < ONE_WEEK_IN_MS
|
||||
return Date.now() - cachedAt > ONE_WEEK_IN_MS
|
||||
}
|
||||
|
||||
const isOlderThanOneDay = (cachedAt: number) => {
|
||||
return Date.now() - cachedAt < ONE_WEEK_IN_MS
|
||||
return Date.now() - cachedAt > ONE_DAY_IN_MS
|
||||
}
|
||||
|
||||
const isRelayTag = (tag: string[]): boolean => tag[0] === 'r'
|
||||
@ -229,16 +230,6 @@ const publishRelayMap = async (
|
||||
return Promise.reject('Publishing updated relay map was unsuccessful.')
|
||||
}
|
||||
|
||||
const publishToRelay = async (relay: Relay, event: Event): Promise<string> => {
|
||||
try {
|
||||
await Promise.race([relay.publish(event), timeout(20 * 1000)])
|
||||
return relay.url
|
||||
} catch (err) {
|
||||
console.error(`Failed to publish event on relay: ${relay.url}`, err)
|
||||
throw new Error(`Failed to publish event on relay: ${relay.url}`)
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
findRelayListAndUpdateCache,
|
||||
findRelayListInCache,
|
||||
@ -248,6 +239,5 @@ export {
|
||||
getUserRelaySet,
|
||||
isOlderThanOneDay,
|
||||
isOlderThanOneWeek,
|
||||
publishRelayMap,
|
||||
publishToRelay
|
||||
publishRelayMap
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Function will replace the middle of the string with 3 dots if length greater then
|
||||
* Function will replace the middle of the string with ellipsis if length greater then
|
||||
* offset value
|
||||
* @param str string to shorten
|
||||
* @param offset of how many chars to keep in the beginning and the end
|
||||
@ -9,10 +9,7 @@ export const shorten = (str: string, offset = 9) => {
|
||||
// return original string if it is not long enough
|
||||
if (str.length < offset * 2 + 4) return str
|
||||
|
||||
return `${str.slice(0, offset)}...${str.slice(
|
||||
str.length - offset,
|
||||
str.length
|
||||
)}`
|
||||
return `${str.slice(0, offset)}…${str.slice(str.length - offset, str.length)}`
|
||||
}
|
||||
|
||||
export const stringToHex = (str: string) => {
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { TimeoutError } from '../types/errors/TimeoutError.ts'
|
||||
import { CurrentUserFile } from '../types/file.ts'
|
||||
import { SigitFile } from './file.ts'
|
||||
|
||||
@ -34,7 +35,7 @@ export const isOnline = async () => {
|
||||
|
||||
try {
|
||||
// Define a URL to check the online status
|
||||
const url = 'https://www.google.com'
|
||||
const url = document.location.pathname + '?v=' + new Date().getTime()
|
||||
|
||||
// Make a HEAD request to the URL with 'no-cors' mode
|
||||
// This mode is used to handle opaque responses which do not expose their content
|
||||
@ -63,7 +64,7 @@ export const timeout = (ms: number = 60000) => {
|
||||
// Set a timeout using setTimeout
|
||||
setTimeout(() => {
|
||||
// Reject the promise with an Error indicating a timeout
|
||||
reject(new Error('Timeout'))
|
||||
reject(new TimeoutError())
|
||||
}, ms) // Timeout duration in milliseconds
|
||||
})
|
||||
}
|
||||
@ -118,15 +119,3 @@ export const settleAllFullfilfedPromises = async <Item, FulfilledItem = Item>(
|
||||
return acc
|
||||
}, [])
|
||||
}
|
||||
|
||||
export const isPromiseFulfilled = <T>(
|
||||
result: PromiseSettledResult<T>
|
||||
): result is PromiseFulfilledResult<T> => {
|
||||
return result.status === 'fulfilled'
|
||||
}
|
||||
|
||||
export const isPromiseRejected = <T>(
|
||||
result: PromiseSettledResult<T>
|
||||
): result is PromiseRejectedResult => {
|
||||
return result.status === 'rejected'
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user