Compare commits

...

82 Commits

Author SHA1 Message Date
9432e99b3b chore(git): merge pull request #212 from 186-header-design into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m17s
Reviewed-on: #212
Reviewed-by: eugene <eugene@nostrdev.com>
2024-09-19 11:30:46 +00:00
ff875cc9d7 chore(git): merge pull request #208 from 184-upload-add into staging
Some checks failed
Release to Staging / build_and_release (push) Has been cancelled
Reviewed-on: #208
Reviewed-by: eugene <eugene@nostrdev.com>
2024-09-19 11:29:44 +00:00
d45ea63c20 refactor: remove header height from calc
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 34s
2024-09-19 10:53:53 +02:00
aec0d0bdd8 fix: center block scrolling on mark items 2024-09-19 09:54:51 +02:00
5f39b55f68 feat: add banner and styling 2024-09-19 09:54:19 +02:00
ebd59471c7 fix: footer portal on relays 2024-09-19 09:53:16 +02:00
c2a149c872 Merge branch 'main' into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m18s
2024-09-18 15:12:47 +00:00
0091d3ec9e Merge pull request 'Update Blossom Event' (#209) from blossom-upgrade into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m20s
Reviewed-on: #209
Reviewed-by: enes <enes@noreply.git.nostrdev.com>
2024-09-18 15:09:31 +00:00
dd53ded518 fix: updates blossom authorisation event
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 34s
2024-09-18 17:57:36 +03:00
6d78d9ed64 fix(create): uploading file adds to the existing file list, dedupe file list
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 33s
Closes #184
2024-09-17 17:56:53 +02:00
c52fecdf4e chore(git): merge pull request #204 from 176-178-pdf-quality-multiline into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m18s
Reviewed-on: #204
Reviewed-by: eugene <eugene@nostrdev.com>
2024-09-17 13:59:17 +00:00
43beac48e8 fix(pdf): keep upscaling to match viewport
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 34s
2024-09-17 14:41:54 +02:00
f35e2718ab fix(pdf): mark embedding, position, multiline, & placeholder
Closes  #176, #178
2024-09-17 14:33:50 +02:00
84c374bb2c chore(git): merge pull request #198 from 196-ext-login-infinite-loading into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m18s
Reviewed-on: #198
Reviewed-by: eugene <eugene@nostrdev.com>
2024-09-16 12:27:43 +00:00
a53914b59d Merge branch 'staging' into 196-ext-login-infinite-loading
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 35s
2024-09-16 12:19:09 +00:00
68c10d1831 chore(git): merge pull request #202 from offline-flow-9-13 into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m19s
Reviewed-on: #202
Reviewed-by: s <s@noreply.git.nostrdev.com>
2024-09-16 12:18:18 +00:00
b
5a2a0ad9c4 Merge branch 'staging' into 196-ext-login-infinite-loading
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 33s
2024-09-16 11:45:22 +00:00
62c1f1b37b fix: add show username
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 34s
2024-09-16 12:06:11 +02:00
8267eb624b fix: add keys and show name for counterparts
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 34s
2024-09-16 11:06:55 +02:00
e1c750495e refactor: useSigitProfiles removed, per user profile hook in avatar, refactor name tooltip 2024-09-16 11:06:55 +02:00
8b00ef538b fix: unzip and use timeout util 2024-09-16 11:06:55 +02:00
13254fbe06 feat: add exportedBy to useSigitMeta 2024-09-16 11:06:55 +02:00
1dfab7b82b refactor: metadatacontroller as singleton 2024-09-16 11:06:55 +02:00
759a40a4f9 fix(verify): offline flow 2024-09-16 11:06:55 +02:00
8b4f1a8973 fix(online-detection): use relative url
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 35s
2024-09-16 11:00:16 +02:00
79ef9eb8d6 fix: url
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 34s
2024-09-13 17:47:55 +02:00
ba24e7417d refactor: log timeout error only, no toast
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 34s
2024-09-13 11:14:10 +02:00
ea7e3a0964 refactor: update url for online status 2024-09-13 11:06:06 +02:00
b
675a763af3 Merge pull request 'New Release' (#200) from staging into main
All checks were successful
Release to Production / build_and_release (push) Successful in 1m13s
Reviewed-on: #200
Reviewed-by: enes <enes@noreply.git.nostrdev.com>
2024-09-12 12:17:47 +00:00
bf1b3beb63 Merge branch 'main' into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m17s
2024-09-12 12:09:40 +00:00
2e58b58a6a refactor: move publish button to the bottom
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 33s
2024-09-12 13:30:59 +02:00
17c1700554 fix(login): use const and make sure to clear timeout always
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 34s
2024-09-12 13:24:05 +02:00
9191336722 refactor(login): update the delay message and increase timers 2024-09-12 12:17:58 +02:00
32a6f9d7a3 Merge pull request #199 from hotfix-remove-loop-9-12 into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m16s
Reviewed-on: #199
Reviewed-by: eugene <eugene@nostrdev.com>
2024-09-12 09:32:54 +00:00
5f0234a358 fix: remove unstable fetch events loop
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 34s
2024-09-12 11:27:55 +02:00
235e76be4e fix: processing gift wraps and notifications (#193)
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m16s
This change will potentially close multiple issues related to the gift-wrapped events processing (#168, #158). Further testing will be required to confirm before closing each.

The commented-out code causes the race condition during the processing of the gift wraps with sigits.

During the processing we perform checks to see if sigit is outdated. In cases where sigit includes multiple signers it's possible for a signer to receive multiple sigit updates at once (especially noticeable for 3rd, 4th signer). Due to async nature of processing we can have same sigit enter processing flow with different states. Since this code also updates user's app state, which includes uploads to the blossom server it takes time to upload local user state which causes both to check against the stale data and un-updated app state. This results in both sigits being "new" and both proceed to update user state and upload app data. We have no guarantees as in which event will update last, meaning that the final state we end up with could be already stale.

The issue is also complicated due to the fact that we mark the gift wraps as processed and it's impossible to update the state without creating a new gift wrap with correct state and processing it last to overwrite stale state.

This is temporary solution to stop broken sigit states until proper async implementation is ready.

Co-authored-by: b
Reviewed-on: #193
Reviewed-by: eugene <eugene@nostrdev.com>
Co-authored-by: enes <mulahasanovic@outlook.com>
Co-committed-by: enes <mulahasanovic@outlook.com>
2024-09-12 08:26:59 +00:00
7c80643aba fix(login): extension login infinite loading
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 34s
Fixes #196
2024-09-11 16:44:45 +02:00
9c545a477c fix(errors): add custom timeout error 2024-09-11 16:33:53 +02:00
4d1e672268 feat(loading-spinner): add children support for default variant 2024-09-11 16:33:13 +02:00
4bc5882ab6 fix(loading): make sure the default spinner is absolute relative to root always 2024-09-11 13:27:50 +02:00
e2b3dc13fb chore(git): merge pull request #192 from cache-checks-9-9 into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m16s
Reviewed-on: #192
Reviewed-by: s <s@noreply.git.nostrdev.com>
2024-09-10 08:48:53 +00:00
b
0244090c6a Merge branch 'staging' into cache-checks-9-9
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 35s
2024-09-09 18:44:36 +00:00
f0ba9da8af fix: outdated cache checks
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 33s
2024-09-09 14:16:58 +02:00
1d1c675dd7 Merge pull request #191 from lint-0-warnings into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m17s
Reviewed-on: #191
Reviewed-by: s
2024-09-09 08:55:28 +00:00
70f646444b fix: add types to rootReducer, rename userRobotImage types
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 34s
2024-09-09 10:19:37 +02:00
b
09229f42c7 Merge pull request 'new release' (#190) from staging into main
All checks were successful
Release to Production / build_and_release (push) Successful in 1m10s
Reviewed-on: #190
Reviewed-by: enes <enes@noreply.git.nostrdev.com>
Reviewed-by: eugene <eugene@nostrdev.com>
2024-09-06 18:59:34 +00:00
b
f516fe82d7 Merge branch 'main' into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m18s
2024-09-06 15:37:16 +00:00
bf506705e6 chore(git): merge pull request #182 from 174-add-users-updates into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m20s
Reviewed-on: #182
Reviewed-by: s <sabir@4gl.io>
2024-09-06 10:22:56 +00:00
b8811d730a fix(review): remove inline styles
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 35s
2024-09-05 13:24:34 +02:00
479cca2180 chore(git): merge pull request #183 from 172-sign-marks-scroll into 174-add-users-updates
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 34s
2024-09-05 09:09:11 +00:00
82b7b9f7ce fix(mark): scroll into marks, add scroll margin and forwardRef
Closes #172
2024-09-05 09:32:01 +02:00
3e075754e5 feat(create): touch support for dnd
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 35s
2024-09-05 09:31:52 +02:00
c6010a5bef refactor(create): add counterpart design update 2024-09-05 09:31:52 +02:00
81f40c345a refactor: use ellipsis instead of 3 dots 2024-09-05 09:31:52 +02:00
734026b2ee fix: bad margin value 2024-09-05 09:31:52 +02:00
5caa7f2282 refactor: update add counterparty design 2024-09-05 09:31:52 +02:00
fa7f0e2fc0 refactor(create): move add counterpart below, select role to toggle, remove duplicate code 2024-09-05 09:31:51 +02:00
7d0d4fcb48 chore(git): merge pull request #179 from 177-sticky-side-columns into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m16s
Reviewed-on: #179
Reviewed-by: b <b@4j.cx>
Reviewed-by: eugene <eugene@nostrdev.com>
2024-09-05 07:30:55 +00:00
9223857e18 refactor(drawing): fix mouse comments, use pointer
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 34s
2024-09-04 14:44:27 +02:00
2be7f3d51b fix(tabs): add tab icons
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 34s
2024-09-04 14:05:36 +02:00
36281376bc fix(mobile): use dynamic vh and one-by-one horizontal scroll 2024-09-04 13:44:10 +02:00
a3effd878b fix(pdf): use minified version of pdf 2024-09-03 16:46:54 +02:00
b20ffe6e87 refactor(mobile): drawing mouse to pointer events, field colors and actions feedback, touch action 2024-09-03 10:14:43 +02:00
757012399a fix: main css background, avoid overscroll showing white edge 2024-09-02 14:41:25 +02:00
c2d065a8a5 refactor(pdf-dist): optimize workers 2024-09-02 12:59:35 +02:00
d1b9eb55d8 fix(spinner): remove dummy desc and use variants 2024-09-02 12:49:51 +02:00
e5f8b797bb refactor(spinner): use data variant for styles 2024-09-02 11:36:31 +02:00
d627db5ac0 refactor(spinner): new default design 2024-09-02 11:35:39 +02:00
6abdb0ae2b refactor: files styling, move styling reset to app from module 2024-08-30 17:58:40 +02:00
be9bfc28c8 refactor(mobile): tabs navigation, observer active for tabs, tabs layout scrolling 2024-08-30 17:39:32 +02:00
2e9e9b0a06 refactor(mobile): styling for sign actions box 2024-08-30 17:37:57 +02:00
15aaef948d fix(create): block if no signers 2024-08-30 13:27:10 +02:00
bee566424d refactor: split signers and viewers in the users section 2024-08-30 12:55:04 +02:00
f8533b0ffd refactor(content): add small loading spinner to content sections
Closes #141
2024-08-30 12:28:33 +02:00
6f7d4c9dcf fix(mobile): active tab default state and styling 2024-08-30 10:57:51 +02:00
d9be05165f feat(mobile): tabs and scrolling 2024-08-29 17:18:32 +02:00
2367eb1887 chore(browser-warn): add alt attribute to image, part 2 2024-08-29 17:03:16 +02:00
156bc59e1a chore(browser-warn): add alt attribute to image 2024-08-29 17:02:32 +02:00
a48751b9a8 refactor(footer): make footer a portal and use as needed 2024-08-29 16:43:09 +02:00
b3564389f9 refactor(sticky-columns-layout): initial responsiveness and breakpoints 2024-08-29 13:09:56 +02:00
5c8cbc1956 refactor(toolbox): responsiveness and cleanup 2024-08-29 13:09:56 +02:00
b
20496c7e3e Merge pull request 'Releasing new design' (#161) from staging into main
All checks were successful
Release to Production / build_and_release (push) Successful in 1m9s
Reviewed-on: #161
Reviewed-by: eugene <eugene@nostrdev.com>
2024-08-21 11:38:24 +00:00
64 changed files with 2124 additions and 1774 deletions

89
package-lock.json generated
View File

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

View File

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

View File

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

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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&nbsp;
<a href="https://nostrdev.com/" target="_blank">
Nostr Dev
</a>{' '}
2024.
</div>
</footer>
)
</Container>
<div className={`${styles.borderTop} ${styles.credits}`}>
Built by&nbsp;
<a href="https://nostrdev.com/" target="_blank">
Nostr Dev
</a>{' '}
2024.
</div>
</footer>,
document.getElementById('root')!
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -25,7 +25,7 @@ export class AuthController {
constructor() {
this.nostrController = NostrController.getInstance()
this.metadataController = new MetadataController()
this.metadataController = MetadataController.getInstance()
}
/**

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,43 +36,27 @@ 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'
@ -118,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)
@ -129,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
@ -257,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 =
@ -431,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)
)
)
}
}
@ -457,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
}
@ -654,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)
}
@ -679,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)),
@ -710,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
@ -794,7 +669,7 @@ export const CreatePage = () => {
toast.error('Failed to publish notifications')
})
navigate(appPrivateRoutes.sign, { state: { meta: meta } })
navigate(appPrivateRoutes.sign, { state: { meta } })
} else {
const zip = new JSZip()
@ -873,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}`}>
@ -906,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>
@ -931,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>
)}
@ -1048,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}
@ -1068,7 +892,6 @@ export const CreatePage = () => {
}
type DisplayUsersProps = {
metadata: { [key: string]: ProfileMetadata }
users: User[]
handleUserRoleChange: (role: UserRole, pubkey: string) => void
handleRemoveUser: (pubkey: string) => void
@ -1076,7 +899,6 @@ type DisplayUsersProps = {
}
const DisplayUser = ({
metadata,
users,
handleUserRoleChange,
handleRemoveUser,
@ -1084,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}
@ -1101,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>
)
})}
@ -1179,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<
@ -1269,7 +1034,7 @@ const SignerRow = ({
})
})
const opacity = isDragging ? 0 : 1
const opacity = isDragging ? 0.2 : 1
drag(drop(ref))
return (
@ -1280,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>
</>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -33,7 +33,8 @@ import {
sendNotification,
signEventForMetaFile,
updateUsersAppData,
findOtherUserMarks
findOtherUserMarks,
timeout
} from '../../utils'
import { Container } from '../../components/Container'
import { DisplayMeta } from './internal/displayMeta'
@ -276,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
@ -468,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

View File

@ -32,7 +32,7 @@ import { useState, useEffect } from 'react'
import { toast } from 'react-toastify'
import { UserAvatar } from '../../../components/UserAvatar'
import { MetadataController } from '../../../controllers'
import { npubToHex, 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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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)
}
/**
@ -932,7 +943,7 @@ export const sendNotification = async (receiver: string, meta: Meta) => {
const wrappedEvent = createWrap(unsignedEvent, receiver)
// Instantiate the MetadataController to retrieve relay list metadata
const metadataController = new MetadataController()
const metadataController = MetadataController.getInstance()
const relaySet = await metadataController
.findRelayListMetadata(receiver)
.catch((err) => {

View File

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

View File

@ -6,6 +6,7 @@ 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'

View File

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

View File

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