Compare commits

...

355 Commits

Author SHA1 Message Date
b
af036b1bb7 Merge pull request 'feat: configured semantic releases' (#307) from semantic-releases into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 2m4s
Reviewed-on: #307
2025-01-29 13:49:15 +00:00
c0b903929d feat: configured semantic releases
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 1m5s
2025-01-29 16:46:04 +03:00
b
b97afdecfd Merge branch 'main' into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m47s
2025-01-23 18:50:53 +00:00
b
feea3197d0 Merge pull request 'Offline flow separation' (#304) from 231-offline-flow into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m45s
Reviewed-on: #304
Reviewed-by: eugene <eugene@nostrdev.com>
2025-01-22 13:05:30 +00:00
b
c45e3912a2 Merge branch 'staging' into 231-offline-flow
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 46s
2025-01-22 12:10:05 +00:00
f72fa1a886 chore(git): merge pull request #305 from 297-settings-relays-allow-ws into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m13s
Reviewed-on: #305
Reviewed-by: eugene <eugene@nostrdev.com>
2025-01-22 09:36:08 +00:00
enes
a4310675c1 refactor(offline): update strings, create offline navigate to sign/verify w/o auto download
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 39s
2025-01-20 21:24:18 +01:00
enes
5f3d92d62f fix(relays): relay add button size height
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 46s
Closes #244
2025-01-20 20:14:44 +01:00
enes
04f1d692a4 fix(relays): allow adding ws://
Closes #297
2025-01-20 20:06:18 +01:00
enes
99d562a3ed feat(export): add icons and make encrypted be first/top option
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 38s
2025-01-20 19:41:14 +01:00
enes
e60c4cbc31 chore(git): merge branch 'staging' into 231-offline-flow 2025-01-17 21:13:05 +01:00
enes
3f01ab8fca feat(offline): split online and offline flow with dedicated buttons, remove export in sign, all counterparties can decrypt 2025-01-17 21:12:31 +01:00
enes
b7410c7d33 refactor(offline): make both export types as optional 2025-01-17 21:09:38 +01:00
enes
b6a84dedbe refactor(offline): remove unused function 2025-01-17 21:07:47 +01:00
enes
8b5abe02e2 feat(offline): add decrypt as zip util 2025-01-17 21:06:55 +01:00
enes
7b2537e355 refactor(offline): useSigitMeta and remove async ops when parsing json 2025-01-17 21:05:59 +01:00
enes
bcd57138ca feat(offline): add signer service util class 2025-01-17 21:02:18 +01:00
2d472470be Merge pull request '#215 Change Naming from PdfFileHash to FileHash' (#303) from issue-215-file-hash-naming into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m36s
Reviewed-on: #303
Reviewed-by: enes <enes@noreply.git.nostrdev.com>
2025-01-14 08:35:57 +00:00
c69d55c3a8 chore: adds comment
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 39s
2025-01-14 10:32:28 +02:00
3be0fd7bbb chore: adds comment
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 44s
2025-01-14 10:30:03 +02:00
enes
5079b68bdf chore(git): merge branch 'staging' into 231-offline-flow 2025-01-10 12:21:00 +01:00
70e7e5305e refactor: renames to fileHash
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 44s
2025-01-09 12:58:23 +02:00
fdaae33aa1 Merge pull request '#288 Fixes Sign and Complete Spinner Issue' (#301) from issue-288-sign-complete-spinner-fix into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m36s
Reviewed-on: #301
Reviewed-by: enes <enes@noreply.git.nostrdev.com>
2025-01-08 12:25:17 +00:00
746338465d fix: disables redundant metaInNavState updates
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 38s
2025-01-08 12:29:59 +02:00
b
736dafce94 chore: testing guidance in contributing.md
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m40s
2025-01-07 20:20:22 +00:00
b
b361ab3d99 Merge pull request 'staging release' (#299) from staging into main
All checks were successful
Release to Production / build_and_release (push) Successful in 1m35s
Reviewed-on: #299
2025-01-07 10:10:29 +00:00
s
60b3a28435 Merge pull request 'chore: use-ndk' (#283) from use-ndk into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m37s
Reviewed-on: #283
2025-01-06 11:10:49 +00:00
daniyal
48f85f54c8 chore: quick fixes
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 44s
2025-01-06 16:04:54 +05:00
enes
dbcc96aca2 refactor: split online and offline create 2025-01-04 20:36:14 +01:00
enes
9a1d3d98bf feat: add minimal styling secondary button 2025-01-04 19:28:30 +01:00
daniyal
14c103dd40 chore: merge staging into use-ndk
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 44s
2025-01-04 12:21:47 +05:00
daniyal
0d93e16f3a chore: enable debug logs only in dev mode
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 41s
2025-01-04 11:33:44 +05:00
daniyal
3a09d4c595 chore: process received events all together instead of one by one which casuses in-consistencies due to async nature of redux updates
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 44s
2025-01-04 11:24:35 +05:00
daniyal
c4c0ecba4a chore: handle restore state action in user reducer 2025-01-04 11:21:49 +05:00
b
3fefcc5a98 Merge pull request 'Log out user if extension's pubkey and auth's pubkey are different' (#295) from 290-user-ext-log-missmatch into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m42s
Reviewed-on: #295
Reviewed-by: s <s@noreply.git.nostrdev.com>
2025-01-02 09:44:28 +00:00
b
8af90d93cb Merge branch 'staging' into 290-user-ext-log-missmatch
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 41s
2025-01-02 09:43:57 +00:00
b
222ad06644 Merge pull request 'allow signing and show PdfView without any marks (never created)' (#287) from 286-missing-pdf-and-sign into staging
Some checks failed
Release to Staging / build_and_release (push) Has been cancelled
Reviewed-on: #287
Reviewed-by: Stixx <m@noreply.git.nostrdev.com>
2025-01-02 09:43:46 +00:00
enes
c96a7fac4f fix: logout user if decryption fails due to diff pubkeys
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 44s
2024-12-31 13:02:39 +01:00
enes
8153ef03fb fix: logout user if signEvent's and auth's pubkeys are diff 2024-12-31 13:01:42 +01:00
enes
84062f2ed0 fix(login): redirect to landing instead of login popup page 2024-12-31 12:59:41 +01:00
enes
ec43324cae refactor(nostr): capturePublicKey from signedEvent instead of nostr api call 2024-12-31 12:10:56 +01:00
enes
1f980201dd fix(login): update login method before using nostrController instance 2024-12-31 12:09:12 +01:00
daniyal
95f5398736 chore: replace the usage of ProfileMetadata with NDKUserProfile
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 46s
2024-12-28 14:57:19 +05:00
daniyal
01bb68d87b chore: fix issue in publishing sigit
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 45s
2024-12-28 00:44:21 +05:00
daniyal
006ed7b548 chore: fix fetching of user profile 2024-12-28 00:43:00 +05:00
daniyal
4f9fdd19b0 chore: remove import of deleted file
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 40s
2024-12-27 17:21:02 +05:00
enes
66ad9b5edc chore(git): merge branch 'staging' into 286-missing-pdf-and-sign
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 40s
2024-12-27 12:44:21 +01:00
daniyal
4e54d12175 chore: merge stating into use-ndk 2024-12-27 15:33:43 +05:00
b
1909534079 Merge pull request 'fix: Opening a sigit asks you to sign when you are not the next signer' (#285) from issue-281 into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m36s
Reviewed-on: #285
Reviewed-by: enes <enes@noreply.git.nostrdev.com>
2024-12-24 10:02:35 +00:00
a2b7423c8b chore(git): merge
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 39s
2024-12-24 10:58:58 +01:00
ee3e0e1bb1 fix: If creator is not the first signer we should not redirect to /sign page 2024-12-24 10:58:29 +01:00
b
8000f4a349 Merge branch 'staging' into issue-281
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 46s
2024-12-23 17:32:59 +00:00
enes
cb0d2dd7bc fix(sign): allow sumit without selectedMark
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 42s
2024-12-23 17:45:13 +01:00
enes
92f23bab91 fix(sign): allow signing without selectedMark - no currentUserMarks 2024-12-23 17:44:33 +01:00
enes
8df5084703 fix(sign): always show PdfView 2024-12-23 16:39:24 +01:00
ae3d461661 fix: Opening a sigit asks you to sign when you are not the next signer
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 46s
2024-12-23 13:57:25 +01:00
enes
ca7b87a967 refactor(create): reduce minimum field threshold size
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m44s
2024-12-23 13:47:14 +01:00
b
a5e31b0bdf Merge pull request 'Refactor create page interactions and fix the "excel" bug' (#284) from 282-create-page-interactions into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m44s
Reviewed-on: #284
Reviewed-by: Stixx <m@noreply.git.nostrdev.com>
2024-12-23 10:47:27 +00:00
enes
1827a20755 refactor(create): update location state to keep latest selected files
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 43s
2024-12-23 11:10:23 +01:00
daniyal
e8a53bc73e chore: removed relay controller in favor of NDKContext
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 47s
2024-12-22 23:38:27 +05:00
daniyal
0ea6ba0033 chore: remove dvm utils file and use useDvm hook instead 2024-12-22 21:53:00 +05:00
daniyal
3615de70ad chore: include sigit relay explicitly when fetching event from user relays 2024-12-22 21:49:56 +05:00
enes
abea8dcd15 refactor(create): optimize the events and fix responsiveness of the drawn fields with proper re-renders 2024-12-21 17:15:59 +01:00
enes
4cde0796a2 refactor: bump z-index on loading spinner 2024-12-21 17:03:03 +01:00
enes
4131eb5de1 feat(create): add counterpart component for drawing field 2024-12-21 17:02:13 +01:00
enes
671bb0561a refactor(create): use immer for sigit files creation 2024-12-21 17:00:51 +01:00
daniyal
5c24c5bde0 chore(refactor): use getNDKRelayList function from NDKContext instead of findRelayListMetadata function of metadata controller 2024-12-20 21:58:45 +05:00
enes
889d6a0f44 feat(create): add Image and File items 2024-12-20 16:09:48 +01:00
enes
c391b01b90 build(deps): add immer package 2024-12-20 16:09:06 +01:00
enes
b7eadf0081 refactor(sign): cleaner divider code in PDVview 2024-12-20 16:07:12 +01:00
daniyal
458de18f12 chore(refactore): use NDKContext for findMetadata instead of metadata controller 2024-12-20 15:52:45 +05:00
daniyal
0cc1a32059 chore(refactor): replace authContoller with useAuth hook 2024-12-20 15:25:02 +05:00
b
d965e56f76 Merge pull request 'fix: build failing due to type issue' (#280) from build-fix into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m39s
Reviewed-on: #280
2024-12-19 09:18:17 +00:00
652ea06c0d fix: build failing due to type issue
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 44s
2024-12-19 10:16:18 +01:00
b
6aeb98b233 Merge pull request 'fix: when opening a sigit after user signed it, asks user to sign again instead of redirecting to /verify' (#279) from issue-277 into staging
Some checks failed
Release to Staging / build_and_release (push) Failing after 53s
Reviewed-on: #279
2024-12-18 16:58:42 +00:00
ccb4036029 fix: when opening a sigit after user signed it, asks user to sign again instead of redirecting to /verify
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 40s
2024-12-18 17:23:05 +01:00
b
9882393b7a Merge pull request 'Send notifications with blossom url to meta.json' (#276) from 260-meta-blossom into staging
Some checks failed
Release to Staging / build_and_release (push) Failing after 52s
Reviewed-on: #276
Reviewed-by: Stixx <m@noreply.git.nostrdev.com>
2024-12-18 11:46:57 +00:00
enes
829eb5fd83 chore(git): merge branch 'staging' into 260-meta-blossom
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 40s
2024-12-18 12:40:01 +01:00
b
3a74bddb30 Merge pull request 'fix: include purplepage and userkindpages relays when searching for user in create page' (#265) from issue-261 into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m45s
Reviewed-on: #265
Reviewed-by: enes <enes@noreply.git.nostrdev.com>
2024-12-18 09:14:21 +00:00
eac9053208 chore(git): Merge branch 'issue-261' of ssh://stixx.git.nostrdev.com:29418/sigit/sigit.io into issue-261
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 39s
2024-12-17 21:25:09 +01:00
40b081f059 Merge branch 'staging' into issue-261
Some checks failed
Open PR on Staging / audit_and_check (pull_request) Failing after 33s
2024-12-17 20:24:49 +00:00
6f6ed3c39f fix: updated latest version of nostr-login which includes outboxRelays option 2024-12-17 21:17:49 +01:00
enes
c5724858d6 refactor(sign): increase the size of final sign button
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 44s
2024-12-13 13:44:17 +01:00
enes
2a23912c08 refactor(sign): autoFocus sign button, use mui/button for focus ripple effect 2024-12-13 13:36:44 +01:00
enes
e1e5ae7f1a build(vulnerabilities): bump dependencies with audit fix
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 44s
2024-12-13 12:59:50 +01:00
enes
0be63265b5 chore(git): merge branch 'staging' into 260-meta-blossom
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 44s
2024-12-13 12:53:37 +01:00
enes
7007492a0d feat(meta): add error handling for meta.json blossom operations
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 45s
2024-12-13 12:39:00 +01:00
b
5ed3d2f389 Merge pull request 'New release' (#275) from staging into main
All checks were successful
Release to Production / build_and_release (push) Successful in 1m34s
Reviewed-on: #275
2024-12-11 16:49:23 +00:00
b
c3e4f6055c Merge pull request 'Searching counterparts glitchy when includes @ (nip05)' (#273) from issue-270 into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m43s
Reviewed-on: #273
Reviewed-by: enes <enes@noreply.git.nostrdev.com>
2024-12-10 15:37:00 +00:00
b
1c0984cb3b Merge branch 'staging' into issue-270
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 41s
2024-12-10 15:36:44 +00:00
b
80dd597ada Merge pull request 'feat: Sign Directly From the Marking Screen fix: Marking inputs glitches, losing values' (#272) from issue-173 into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m38s
Reviewed-on: #272
Reviewed-by: enes <enes@noreply.git.nostrdev.com>
2024-12-10 15:34:01 +00:00
99fa3add56 fix: bug, when valid npub, clicking + was saying npub was invalid
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 44s
2024-12-10 15:29:24 +01:00
245585662a fix: removed redundant variable
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 45s
2024-12-10 14:46:26 +01:00
6716c3da63 fix: including signatures in both export and encrypted export 2024-12-10 14:42:12 +01:00
8d8c38e90b fix: addressing comments 2024-12-09 22:37:27 +01:00
8e1acc0bd6 chore(git): Merge 2024-12-09 09:44:35 +01:00
555504f42f fix: nostr-login custom outbox relays 2024-12-09 09:44:22 +01:00
8bb556a446 Merge branch 'staging' into issue-261
Some checks failed
Open PR on Staging / audit_and_check (pull_request) Failing after 27s
2024-12-09 08:43:51 +00:00
afbe05b4c8 fix: footer 'Home' button scroll to top when on home page, fixed logic
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 46s
2024-12-06 21:00:52 +01:00
7b29d7055e fix: search counterparts nip05 does not need to include '@'
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 50s
2024-12-06 20:58:20 +01:00
enes
3d1bdece4d feat(meta): send notifications with blossom instead of meta.json 2024-12-06 20:00:38 +01:00
daniyal
2248128001 chore(refactor): use NDKContext in profile page 2024-12-06 21:12:40 +05:00
0fd0f26fc7 fix: counterpart search NIP05 glitching
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 44s
2024-12-06 16:22:23 +01:00
69efd9e09d fix: clicking logo not redirecting to home 2024-12-06 15:49:57 +01:00
fc8f73962b chore(git): merge
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 45s
2024-12-06 14:42:11 +01:00
d0231b0652 chore: typo 2024-12-06 14:41:44 +01:00
241d0cc79c Merge branch 'staging' into issue-173
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 41s
2024-12-06 12:27:09 +00:00
0a0a9bef34 feat: Sign Directly From the Marking Screen fix: Marking inputs glitches, losing values
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 46s
2024-12-06 13:24:10 +01:00
b
092bb98670 Merge pull request 'fix: better UX when clicking on logo when on home screen or home button in footer' (#266) from issue-255 into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m38s
Reviewed-on: #266
Reviewed-by: enes <enes@noreply.git.nostrdev.com>
2024-12-06 10:52:44 +00:00
b
f4cf497c1a Merge branch 'staging' into issue-255
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 41s
2024-12-06 10:51:19 +00:00
b
e05dcf4a07 Merge pull request 'fix: inform user then search term provided no results' (#267) from issue-248 into staging
Some checks failed
Release to Staging / build_and_release (push) Has been cancelled
Reviewed-on: #267
Reviewed-by: enes <enes@noreply.git.nostrdev.com>
2024-12-06 10:51:08 +00:00
b
fd13fd5c7e Merge branch 'staging' into issue-248
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 48s
2024-12-06 10:48:21 +00:00
b
f59e84d905 Merge pull request 'fix: display no results when no submissions are found' (#268) from issue-254 into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m44s
Reviewed-on: #268
Reviewed-by: enes <enes@noreply.git.nostrdev.com>
2024-12-06 10:48:11 +00:00
b
5e80935c0a Merge branch 'staging' into issue-254
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 42s
2024-12-06 10:28:50 +00:00
b
047f961c8d Merge pull request 'fix: create page, improving message "preparing document for signing"' (#269) from issue-253 into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m50s
Reviewed-on: #269
Reviewed-by: enes <enes@noreply.git.nostrdev.com>
2024-12-06 10:28:39 +00:00
daniyal
3c061d5920 feat: added ndkContext and used it in relays page 2024-12-06 14:16:46 +05:00
98fbe80648 fix: create page, improving message "preparing document for signing"
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 44s
2024-12-03 11:25:31 +01:00
bbe34b6011 fix: display no results when no submissions are found
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 43s
2024-12-03 10:48:40 +01:00
24463a53c5 fix: inform user then search term provided no results
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 38s
2024-12-02 15:34:17 +01:00
834d70d774 fix: better UX when clicking on logo when on home screen or home button in footer
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 38s
2024-12-02 15:01:29 +01:00
f44e1bca06 Merge branch 'staging' into issue-261
Some checks failed
Open PR on Staging / audit_and_check (pull_request) Failing after 26s
2024-12-02 13:32:21 +00:00
8a9910db87 fix: include purplepage and userkindpages relays when searching for user in create page
Some checks failed
Open PR on Staging / audit_and_check (pull_request) Failing after 30s
2024-12-02 11:51:02 +01:00
8ece8283e1 Merge pull request 'fix: clicking on marked fileds is losing input text/squiggle, squiggle field is mobile friendly' (#262) from issue-256 into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m39s
Reviewed-on: #262
Reviewed-by: enes <enes@noreply.git.nostrdev.com>
2024-12-02 09:56:35 +00:00
5767abc902 Merge branch 'staging' into issue-256
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 43s
2024-12-02 09:56:02 +00:00
55e2e6e35a chore(git): merge pull request #264 from issue-234-empty-box-creation into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m39s
Reviewed-on: #264
Reviewed-by: s <s@noreply.git.nostrdev.com>
2024-11-29 14:41:16 +00:00
enes
6193c20ac3 refactor(create): update comment
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 38s
2024-11-29 12:45:37 +01:00
enes
902ad73faf fix(create): remove small drawn fields
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 38s
Closes #234
2024-11-29 12:17:23 +01:00
enes
413da78c5c fix(mark): css position 2024-11-29 11:15:10 +01:00
602e23c719 fix: clicking on marked fileds is losing input text/squiggle, squiggle field is mobile friendly
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 39s
2024-11-27 17:00:59 +01:00
52f8b92c5d Merge pull request 'Add Sigit ID as a Path Param to /verify' (#243) from issue-232 into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m35s
Reviewed-on: #243
Reviewed-by: enes <enes@noreply.git.nostrdev.com>
2024-11-26 11:24:04 +00:00
23fe48b615 chore(git): merge, verify route :id
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 38s
2024-11-26 12:02:53 +01:00
57efef9806 chore(git): merge 2024-11-26 12:02:25 +01:00
760ac176e8 chore(git): merge pull request #258 from feat/signature-pad into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m38s
Reviewed-on: #258
Reviewed-by: Stixx <m@noreply.git.nostrdev.com>
2024-11-26 09:35:44 +00:00
enes
a371e98e9e feat(signature): verify hash
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 38s
2024-11-22 16:31:38 +01:00
enes
3255e93121 chore(git): merge branch 'staging' into feat/signature-pad
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 38s
2024-11-22 13:27:05 +01:00
82376838bd Merge pull request 'Create page: search users' (#259) from issue-56 into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m39s
Reviewed-on: #259
2024-11-21 10:18:32 +00:00
2f9017b840 fix: removed viewer/signer button
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 43s
2024-11-21 11:06:31 +01:00
6c7cac2336 feat: search users by nip05, npub and filter: serach, improved UX
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 43s
2024-11-21 09:20:20 +01:00
4af28abcb6 feat: create page search users
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 37s
2024-11-19 12:03:41 +01:00
enes
206169dbaa build(vite): fix hmr circular reference warning
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 38s
2024-11-18 17:29:46 +01:00
enes
be146fa0fa refactor(signature): apply strategy pattern and make it easier to expand with new tools 2024-11-18 17:20:20 +01:00
enes
f72ad37ec0 refactor(signature): save only decrypted signature files on export 2024-11-18 13:49:39 +01:00
enes
a1c308727f fix(signature): force re-render on value change 2024-11-18 13:39:54 +01:00
enes
cdf26b6614 feat(signature): export signature files 2024-11-15 18:04:40 +01:00
enes
9551750cbe feat(signature): signature pad encrypt, upload, fetch, decrypt, render, add to pdf 2024-11-15 17:51:11 +01:00
enes
3f081c1632 refactor(signature): use reduced point group data 2024-11-11 16:21:22 +01:00
enes
7fa9a008fa refactor(signature): stretch svg render 2024-11-08 17:34:22 +01:00
enes
6fd5014302 refactor(signature): fixed pad size, scale to fit mark 2024-11-08 17:33:55 +01:00
enes
7c7a222d4f refactor: use signature pad, smoother signature line
BREAKING CHANGE: mark.value type changed
2024-11-08 16:58:27 +01:00
c89e96b824 chore: added comment
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 31s
2024-11-04 22:04:27 +01:00
22debeb033 chore(git): Merge branch 'staging' into issue-232
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 34s
2024-11-04 14:28:27 +01:00
0008e98146 feat: Add Sigit ID as a Path Param to /verify
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 29s
2024-11-04 13:35:06 +01:00
4cb6f07a68 Merge pull request 'issue-236-fixed' (#239) from issue-236-fixed into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m22s
Reviewed-on: #239
Reviewed-by: enes <enes@noreply.git.nostrdev.com>
2024-11-04 07:43:58 +00:00
NostrDev
5b1654c341 chore: added handleEscapeButtonDown description
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 35s
2024-11-04 10:42:55 +03:00
.
02f651acc7 chore: revert (wrong site)
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m25s
2024-11-02 11:40:20 +00:00
.
cd0e4523e1 chore: goat@nostrdev.com
Some checks failed
Release to Staging / build_and_release (push) Has been cancelled
2024-11-02 11:39:36 +00:00
NostrDev
76b1fa792c feat(signers-dropdown): improved hiding/displaying logic
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 31s
2024-11-01 17:00:46 +03:00
NostrDev
3a94fbc0ae chore(types): used KeyboardCode enum
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 31s
2024-11-01 11:23:05 +03:00
NostrDev
e37f90d6db feat(pdf-fields): add logic to hide signers on ESC 2024-11-01 11:22:31 +03:00
b
cc059f6cb4 Merge pull request 'feat: signature squiggle' (#237) from feat/signature into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m33s
Reviewed-on: #237
Reviewed-by: b <b@4j.cx>
2024-10-28 16:23:28 +00:00
enes
de44370a96 feat: add squiggle support
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 36s
2024-10-25 18:42:16 +02:00
enes
dfa2832e8d feat: add MarkConfig and components 2024-10-25 18:40:50 +02:00
enes
9286e4304f feat: add SVGO, enable signature 2024-10-25 18:38:47 +02:00
aae11589a4 Merge pull request 'issue-166-open-timestamps' (#220) from issue-166-open-timestamps into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m16s
Reviewed-on: #220
Reviewed-by: enes <enes@noreply.git.nostrdev.com>
2024-10-25 11:18:46 +00:00
69f67fc812 chore: disables rules for specific parts of code
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 30s
2024-10-25 14:15:12 +03:00
38cd88fd86 fix: moves styling to SVG
Some checks failed
Open PR on Staging / audit_and_check (pull_request) Failing after 32s
2024-10-25 13:13:22 +03:00
dbcd54cec0 chore: merge branch 'main' into issue-166-open-timestamps
Some checks failed
Open PR on Staging / audit_and_check (pull_request) Failing after 27s
2024-10-24 15:58:39 +03:00
2d0212fd6c fix: redundant updates
Some checks failed
Open PR on Staging / audit_and_check (pull_request) Failing after 32s
2024-10-24 12:54:47 +03:00
19b815e528 feat(opentimestamps): updates tooltip
Some checks failed
Open PR on Staging / audit_and_check (pull_request) Failing after 27s
2024-10-24 12:42:21 +03:00
b
33e7fc7771 Merge pull request 'Release' (#233) from staging into main
All checks were successful
Release to Production / build_and_release (push) Successful in 1m9s
Reviewed-on: #233
2024-10-18 15:09:39 +00:00
97d9857bef Merge branch 'main' into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m18s
2024-10-18 14:59:01 +00:00
enes
4465b8c3ac refactor(landing): cards description
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m19s
2024-10-18 16:54:31 +02:00
54047740f9 chore: updates packages 2024-10-18 11:26:25 +03:00
7f411f09a7 chore: merge branch 'main' into issue-166-open-timestamps 2024-10-18 11:24:31 +03:00
849e47da00 chore: updates packages 2024-10-18 11:03:51 +03:00
b
bb323be87c Merge pull request 'Release' (#228) from staging into main
All checks were successful
Release to Production / build_and_release (push) Successful in 1m8s
Reviewed-on: #228
2024-10-14 09:02:41 +00:00
b
fd2f179273 Merge branch 'main' into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m18s
2024-10-14 09:02:14 +00:00
b
4559f16d86 Merge pull request 'fix: add files and marked to sign page exports' (#226) from fixes-10-11 into staging
Some checks failed
Release to Staging / build_and_release (push) Has been cancelled
Reviewed-on: #226
2024-10-14 09:01:42 +00:00
d6f92accb0 chore(git): merge branch 'origin/fixes-10-11' into fixes-10-11
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 34s
2024-10-14 09:56:22 +02:00
b
ee03cc545e Merge branch 'staging' into fixes-10-11
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 33s
2024-10-13 12:47:38 +00:00
b
70e525357c Merge pull request 'feat: handle root _@ users on add counterpart' (#225) from adding-domain-user-10-10 into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m23s
Reviewed-on: #225
Reviewed-by: eugene <eugene@nostrdev.com>
2024-10-13 12:46:55 +00:00
b
3eed2964a0 Merge branch 'staging' into fixes-10-11
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 33s
2024-10-12 11:19:14 +00:00
b
3a0f155010 Merge pull request 'fix: processing events, stale sigits' (#227) from hotfix-processing-events-10-12 into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m21s
Reviewed-on: #227
2024-10-12 11:18:46 +00:00
1d1986f082 fix: clear hasSubscribed after the logout
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 35s
2024-10-12 12:05:55 +02:00
25764c7ab4 fix: processing events
Partially revert to before 23a04faad89ae3138008f4b1b9a112bf944f279b
2024-10-12 11:52:43 +02:00
cc382f0726 fix: show error if decrypt fails 2024-10-11 16:43:55 +02:00
9dd190d65b fix: add files and marked to sign page exports
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 34s
Skip marked if the file contains  no marks
2024-10-11 16:16:59 +02:00
c3dacbe111 fix: add mark label
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 34s
2024-10-11 15:05:28 +02:00
897daaa1fa feat: handle root _@ users on add counterpart
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 35s
2024-10-10 13:56:08 +02:00
b
ed90168e5d Merge pull request 'staging' (#223) from staging into main
All checks were successful
Release to Production / build_and_release (push) Successful in 1m8s
Reviewed-on: #223
2024-10-09 13:50:23 +00:00
b
7f5fd4534f Merge branch 'main' into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m20s
2024-10-09 13:48:05 +00:00
b
7f172178a1 Merge pull request 'feat: include marked and original files in zip' (#222) from 203-export-original-and-modified into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m23s
Reviewed-on: #222
Reviewed-by: b <b@4j.cx>
2024-10-09 13:43:02 +00:00
1116867224 refactor: rename functions and labels
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 33s
2024-10-09 11:52:32 +02:00
db9cf9d20c feat: include the original files always 2024-10-09 11:51:26 +02:00
58c457b62c fix: profile image scale 2024-10-09 10:58:31 +02:00
b6846c0006 chore(git): merge pull request #217 from nostr-login-9-30 into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m18s
Reviewed-on: #217
Reviewed-by: eugene <eugene@nostrdev.com>
2024-10-09 08:54:33 +00:00
8deb5bd7cd Merge branch 'staging' into nostr-login-9-30
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 33s
2024-10-09 08:35:13 +00:00
7b7f23a779 chore(git): merge pull request #221 from beta-release into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m16s
Reviewed-on: #221
2024-10-09 08:33:52 +00:00
db4a202363 refactor: update banner and package for beta release
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 33s
2024-10-08 20:31:03 +02:00
3a507246ca refactor: add comments
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 32s
2024-10-08 20:25:34 +02:00
f09d9b2378 refactor(ts): remove type assertion
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 33s
2024-10-08 20:14:44 +02:00
fe9f282984 Merge branch 'staging' into nostr-login-9-30
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 33s
2024-10-08 18:06:40 +00:00
aa4637dd0d refactor(login): add comments
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 34s
2024-10-08 19:12:21 +02:00
23a04faad8 refactor(auth): main effect order and deps
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 33s
2024-10-08 18:50:33 +02:00
ad2ec070be refactor(reducers): match state types and reducers 2024-10-08 18:42:34 +02:00
d610c79cad refactor: use useAppDispatch, useAppSelector hooks 2024-10-08 17:08:43 +02:00
b7bd922af3 fix: removes unneeded notification
Some checks failed
Open PR on Staging / audit_and_check (pull_request) Failing after 31s
2024-10-08 17:04:07 +02:00
f12aaf1c2b feat(opentimestamps): amends to flow to only upgrade users timestamps
Some checks failed
Open PR on Staging / audit_and_check (pull_request) Failing after 32s
2024-10-08 17:01:51 +02:00
6c5ed3a69c fix: typo 2024-10-08 13:47:30 +02:00
862012e405 refactor: update kind 27235 auth event with recommendations from nip98 2024-10-08 13:46:54 +02:00
8689c7f753 fix: remove screen on nostr-login launch
Ignores the init options param when screen is passed
2024-10-08 13:45:58 +02:00
b
a3c45b504e Merge pull request 'Release Oct 7th' (#218) from staging into main
All checks were successful
Release to Production / build_and_release (push) Successful in 1m10s
Reviewed-on: #218
2024-10-08 09:02:11 +00:00
b
da30dba368 Merge branch 'main' into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m15s
2024-10-08 08:59:35 +00:00
.
51e2ab6f8a chore: lint fix
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m18s
2024-10-08 09:57:44 +01:00
.
9091bbc251 chore: landing page wording
Some checks failed
Release to Staging / build_and_release (push) Failing after 33s
2024-10-07 22:55:48 +01:00
331759de5c refactor: add useCallback, add methods and split effects
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 34s
2024-10-07 20:37:46 +02:00
995c7ce293 feat(auth): nsec login with url params
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 34s
2024-10-07 19:17:19 +02:00
3d5006a715 fix: removes retrier and updates notification
Some checks failed
Open PR on Staging / audit_and_check (pull_request) Failing after 30s
2024-10-07 17:24:25 +02:00
f38344b9ac fix: adds notifications 2024-10-07 17:20:00 +02:00
2b630c94b6 feat(opentimestamps): updates the flow and adds notifications 2024-10-07 17:19:32 +02:00
edeb22fb37 chore: updates namings 2024-10-07 17:18:27 +02:00
a2138f1de1 feat(opentimestamps): updates utils and adds comments 2024-10-07 17:18:06 +02:00
85bf907f54 feat(opentimestamps): updates data model 2024-10-07 17:17:37 +02:00
3b447dcf6a chore: merge branch 'main' into issue-166-open-timestamps 2024-10-07 16:18:29 +02:00
532cdaed8e refactor(auth): open nostr-login directly 2024-10-07 15:36:29 +02:00
67d545de2f fix: show import/export only for local 2024-10-07 13:44:04 +02:00
637e26bf35 refactor: init nostr-login, login method strategies, remove bunker 2024-10-07 13:36:45 +02:00
110621a125 feat: add nostrLoginAuthMethod to state 2024-10-07 13:36:45 +02:00
59e153595a refactor: z-index levels 2024-10-07 13:36:45 +02:00
0b79ebd909 refactor: z-index levels 2024-10-07 13:36:45 +02:00
e2dbed2b03 refactor: add nostr-login package, show not-allowed on disabled form fields 2024-10-07 13:36:45 +02:00
7c26edf84e chore(git): merge pull request #219 from fixes-10-7 into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m20s
Reviewed-on: #219
Reviewed-by: eugene <eugene@nostrdev.com>
2024-10-07 11:27:41 +00:00
2d7bb234f4 refactor: next on each mark, including the final one
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 34s
2024-10-07 12:59:55 +02:00
c4d50293ff refactor: toolbox order 2024-10-07 12:53:43 +02:00
b
89971fb176 Merge pull request 'Add Sigit ID as a path param' (#195) from issue-171 into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m17s
Reviewed-on: #195
Reviewed-by: enes <enes@noreply.git.nostrdev.com>
2024-10-07 09:16:30 +00:00
b
acad24dc06 Merge branch 'staging' into issue-171
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 34s
2024-10-07 09:15:38 +00:00
.
55abe814c9 fix: AGPL Licence, closes #197
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m21s
2024-10-06 20:28:27 +01:00
21aa25a42a feat(opentimestamps): update the full flow 2024-10-06 15:37:04 +02:00
b
e33996c1f9 Merge branch 'staging' into issue-171
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 35s
2024-10-05 21:02:40 +00:00
edbe708b65 feat(opentimestamps): updates data model and useSigitMeta hook 2024-10-02 14:47:32 +02:00
b
7056ad3cd3 Merge pull request 'Release to main' (#216) from staging into main
All checks were successful
Release to Production / build_and_release (push) Successful in 1m16s
Reviewed-on: #216
2024-10-01 20:19:09 +00:00
b
7dffe75bd7 Merge branch 'main' into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m24s
2024-10-01 20:15:23 +00:00
8da2510a18 Merge pull request #206 from 201-toolbox-update into staging
All checks were successful
Release to Staging / build_and_release (push) Successful in 1m20s
Reviewed-on: #206
Reviewed-by: eugene <eugene@nostrdev.com>
2024-09-30 14:20:07 +00:00
b92790ceed feat(opentimestamps): updates opentimestamps type 2024-09-27 16:03:40 +03:00
7f00f9e8bf feat(opentimestamps): updates signing flow 2024-09-27 16:00:48 +03:00
07f1a15aa1 feat(opentimestamps): refactors to timestamp the nostr event id 2024-09-27 14:18:26 +03:00
85bcfac2e0 feat(opentimestamps): adds timestamps to create flow 2024-09-26 15:54:06 +03:00
edfe9a2954 feat(opentimestamps): adds OTS library and retrier function 2024-09-26 15:50:28 +03:00
633c23e459 refactor: center banner notice text
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 34s
2024-09-20 11:29:35 +02:00
2e1d48168a refactor: rename userId to npub
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 34s
2024-09-20 11:13:48 +02:00
e05d3e53a2 fix: remove duplicate states and fix default signer 2024-09-20 10:26:32 +02:00
d8d51be603 fix: add small avatar when select is not showing 2024-09-19 15:05:03 +02:00
5f92906032 fix: add file and page index, hide select if not active 2024-09-19 15:02:19 +02:00
70cca9dd10 refactor: add getProfileUsername utility func 2024-09-19 14:46:22 +02:00
9bae5b9ba2 Merge branch 'staging' into 201-toolbox-update
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 33s
2024-09-19 11:31:03 +00:00
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
a1bf88d243 chore(git): merge pull request #207 from 145-default-signer into 201-toolbox-update
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 34s
Reviewed-on: #207
2024-09-19 11:24:16 +00:00
67c3c74515 Merge branch '201-toolbox-update' into 145-default-signer 2024-09-19 11:20:05 +00:00
f81f2b0523 refactor: better variable names
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 33s
2024-09-19 13:15:54 +02:00
182ef40d8d Merge branch 'staging' into 201-toolbox-update
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 34s
2024-09-19 10:00:45 +00:00
39934f59c3 fix: last signer as default next
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 34s
2024-09-19 11:46:34 +02: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
b
dd97dfbaf0 Merge pull request 'New release' (#210) from staging into main
All checks were successful
Release to Production / build_and_release (push) Successful in 1m11s
Reviewed-on: #210
Reviewed-by: eugene <eugene@nostrdev.com>
2024-09-19 08:23:14 +00: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
dfdcb8419d fix(marks): add default signer
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 33s
2024-09-17 17:27:37 +02:00
f8a4480994 refactor(toolbox): reduce number of mark types
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 33s
Closes #201
2024-09-17 17:22:15 +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
6ba3b6ec89 Merge branch 'staging' into issue-171
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 34s
2024-09-16 12:37:39 +00: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
b
aa8214d015 Merge branch 'staging' into issue-171
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 34s
2024-09-13 10:07:24 +00: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
e48a396990 fix: verify/sign link
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 34s
2024-09-11 17:29:47 +02: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
79e14d45a1 chore: comment fix
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 34s
2024-09-11 15:41:42 +02:00
64e8ebba85 chore: renamed sigitKey to sigitCreateId
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 34s
2024-09-11 13:30:39 +02:00
4bc5882ab6 fix(loading): make sure the default spinner is absolute relative to root always 2024-09-11 13:27:50 +02:00
5dc8d53503 chore: sigitCreateId naming
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 33s
2024-09-11 12:33:40 +02:00
86a16c13ce chore: comments and lint (typing)
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 33s
2024-09-11 12:29:38 +02:00
7c027825cd style: lint fix
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 34s
2024-09-11 12:03:23 +02:00
8e71592d88 fix: routing, removed useEffect 2024-09-11 11:59:12 +02:00
75a715d002 feat: Add Sigit ID as a path param
All checks were successful
Open PR on Staging / audit_and_check (pull_request) Successful in 33s
2024-09-10 16:00:48 +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
152 changed files with 19066 additions and 6656 deletions

View File

@ -6,7 +6,7 @@ module.exports = {
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended'
],
ignorePatterns: ['dist', '.eslintrc.cjs', 'licenseChecker.cjs'],
ignorePatterns: ['dist', '.eslintrc.cjs', 'licenseChecker.cjs', "*.min.js"],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {

View File

@ -26,9 +26,27 @@ jobs:
- name: Create Build
run: npm run build
- name: Release Build
- name: Deploy Build
run: |
npm -g install cloudron-surfer
surfer config --token ${{ secrets.CLOUDRON_SURFER_TOKEN }} --server sigit.io
surfer config --token ${{ secrets.CLOUDRON_SURFER_TOKEN }} --server sigit.io
surfer put dist/* / --all -d
surfer put dist/.well-known / --all
surfer put dist/.well-known / --all
- name: Create Empty Release (assets are posted later)
run: |
npm i
npm i -g semantic-release
# We do a semantic-release DRY RUN to make the job fail if there are no changes to release
GITEA_TOKEN=${{ secrets.RELEASE_TOKEN }} GITEA_URL=https://git.nostrdev.com/sigit/sigit.io semantic-release --dry-run | grep -q "There are no relevant changes, so no new version is released." && exit 1
GITEA_TOKEN=${{ secrets.RELEASE_TOKEN }} GITEA_URL=https://git.nostrdev.com/sigit/sigit.io semantic-release
- name: Upload assets to release
run: |
RELEASE_ID=`curl -k 'https://git.nostrdev.com/sigit/sigit.io/api/v1/repos/sigit/sigit.io/releases/latest?access_token=${{ secrets.RELEASE_TOKEN }}' | jq -r '.id'`
RELEASE_BODY=`curl -k 'https://git.nostrdev.com/sigit/sigit.io/api/v1/repos/sigit/sigit.io/releases/latest?access_token=${{ secrets.RELEASE_TOKEN }}' | jq -r '.body'`
# Update body
curl --data '{"draft": false,"body":"'"$RELEASE_BODY\n\nFor installation instructions, please visit https://docs.sigit.io/#/"'"}' -X PATCH --header 'Content-Type: application/json' -k https://git.nostrdev.com/sigit/sigit.io/api/v1/repos/sigit/sigit.io/releases/$RELEASE_ID?access_token=${{ secrets.RELEASE_TOKEN }}
# Upload assets
URL="https://git.nostrdev.com/sigit/sigit.io/api/v1/repos/sigit/sigit.io/releases/$RELEASE_ID/assets?access_token=${{ secrets.RELEASE_TOKEN }}"
curl -k $URL -F attachment=@dist.zip

2
.gitignore vendored
View File

@ -8,7 +8,7 @@ pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-zip
dist-ssr
*.local

31
.releaserc Normal file
View File

@ -0,0 +1,31 @@
{
"branches": [
"main"
],
"plugins": [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
"@semantic-release/changelog",
"@semantic-release/npm",
[
"@semantic-release/git",
{
"assets": [
"CHANGELOG.md",
"package.json"
]
}
],
[
"@saithodev/semantic-release-gitea",
{
"giteaUrl": "https://git.nostrdev.com/sigit/sigit.io",
"assets": [
{
"path": "dist-zip/dist.zip"
}
]
}
]
]
}

View File

@ -6,19 +6,19 @@ Welcome to Sigit! We are thrilled that you are interested in contributing to thi
### Reporting Bugs
If you encounter a bug while using Sigit, please [open an issue](https://git.sigit.io/g/web/issues/new) on this repository. Provide as much detail as possible, including steps to reproduce the bug.
If you encounter a bug while using Sigit, please [open an issue](https://git.nostrdev.com/sigit/sigit.io/issues/new) on this repository. Provide as much detail as possible, including steps to reproduce the bug.
### Suggesting Enhancements
If you have an idea for how to improve Sigit, we would love to hear from you! [Open an issue](https://git.sigit.io/g/web/issues/new) to suggest an enhancement.
If you have an idea for how to improve Sigit, we would love to hear from you! [Open an issue](https://git.nostrdev.com/sigit/sigit.io/issues/new) to suggest an enhancement.
### Pull Requests
We welcome pull requests from contributors! To contribute code changes:
1. Fork the repository and create your branch from `main`.
1. Fork the repository and create your branch from `staging`.
2. Make your changes and ensure they pass any existing tests.
3. Write meaningful commit messages.
3. Write meaningful commit messages (conventional commit standard)
4. Submit a pull request, describing your changes in detail and referencing any related issues.
## Development Setup
@ -35,4 +35,14 @@ All contributions, including pull requests, undergo code review. Code review ens
## Contact
If you have questions or need further assistance, you can reach out to [maintainer's email].
If you have questions or need further assistance, you can reach out to `npub1d0csynrrxcynkcedktdzrdj6gnras2psg48mf46kxjazs8skrjgq9uzhlq`
## Testing
The following items should be tested with each release:
- Create a SIGit with at least 3 signers
- Create a SIGit where the creator is not the first signer
- Create a SIGit where one co-signer has no marks
- Create a SIGit using a file other than a PDF
- Use several login mechanisms, browsers, operating systems whilst testing

View File

@ -8,6 +8,7 @@
</head>
<body>
<div id="root"></div>
<script src="/opentimestamps.min.js"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

11124
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,14 +1,14 @@
{
"name": "sigit",
"private": true,
"version": "0.0.0",
"version": "0.0.0-beta",
"type": "module",
"homepage": "https://sigit.io/",
"license": "AGPL-3.0-or-later ",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 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}\"",
@ -17,7 +17,8 @@
"preview": "vite preview",
"preinstall": "git config core.hooksPath .git-hooks",
"license-checker": "node licenseChecker.cjs",
"lint-staged": "lint-staged"
"lint-staged": "lint-staged",
"release": "commit-and-tag-version"
},
"dependencies": {
"@emotion/react": "11.11.4",
@ -30,23 +31,29 @@
"@mui/lab": "5.0.0-alpha.166",
"@mui/material": "5.15.11",
"@noble/hashes": "^1.4.0",
"@nostr-dev-kit/ndk": "2.5.0",
"@nostr-dev-kit/ndk": "2.10.0",
"@nostr-dev-kit/ndk-cache-dexie": "2.5.1",
"@pdf-lib/fontkit": "^1.1.1",
"@reduxjs/toolkit": "2.2.1",
"axios": "^1.7.4",
"crypto-hash": "3.0.0",
"crypto-js": "^4.2.0",
"dexie": "4.0.8",
"dnd-core": "16.0.1",
"file-saver": "2.0.5",
"idb": "8.0.0",
"jszip": "3.10.1",
"lodash": "4.17.21",
"material-ui-popup-state": "^5.3.1",
"mui-file-input": "4.0.4",
"nostr-login": "1.6.14",
"nostr-tools": "2.7.0",
"pdf-lib": "^1.17.1",
"pdfjs-dist": "^4.4.168",
"rdndmb-html5-to-touch": "^8.0.3",
"react": "^18.2.0",
"react-dnd": "16.0.1",
"react-dnd-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",
@ -54,18 +61,28 @@
"react-singleton-hook": "^4.0.1",
"react-toastify": "10.0.4",
"redux": "5.0.1",
"tseep": "1.2.1"
"signature_pad": "^5.0.4",
"tseep": "1.2.1",
"use-immer": "^0.11.0"
},
"devDependencies": {
"@saithodev/semantic-release-gitea": "^2.1.0",
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/commit-analyzer": "^10.0.1",
"@semantic-release/git": "^10.0.1",
"@semantic-release/npm": "11.0.0",
"@semantic-release/release-notes-generator": "^11.0.4",
"@types/crypto-js": "^4.2.2",
"@types/file-saver": "2.0.7",
"@types/lodash": "4.14.202",
"@types/pdfjs-dist": "^2.10.378",
"@types/react": "^18.2.56",
"@types/react-dom": "^18.2.19",
"@types/svgo": "^3.0.0",
"@typescript-eslint/eslint-plugin": "^7.0.2",
"@typescript-eslint/parser": "^7.0.2",
"@vitejs/plugin-react": "^4.2.1",
"commit-and-tag-version": "^11.2.2",
"eslint": "^8.56.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
@ -75,6 +92,8 @@
"ts-css-modules-vite-plugin": "1.0.20",
"typescript": "^5.2.2",
"vite": "^5.1.4",
"vite-plugin-node-polyfills": "^0.22.0",
"vite-plugin-zip-pack": "^1.2.4",
"vite-tsconfig-paths": "4.3.2"
},
"lint-staged": {

View File

@ -1,15 +1,15 @@
{
"names": {
"_": "6bf1024c6336093b632db2da21b65a44c7d82830454fb4d75634ba281e161c90"
},
"relays": {
"6bf1024c6336093b632db2da21b65a44c7d82830454fb4d75634ba281e161c90": [
"wss://brb.io",
"wss://nostr.v0l.io",
"wss://nostr.coinos.io",
"wss://rsslay.nostr.net",
"wss://relay.current.fyi",
"wss://nos.io"
]
}
"names": {
"_": "6bf1024c6336093b632db2da21b65a44c7d82830454fb4d75634ba281e161c90"
},
"relays": {
"6bf1024c6336093b632db2da21b65a44c7d82830454fb4d75634ba281e161c90": [
"wss://brb.io",
"wss://nostr.v0l.io",
"wss://nostr.coinos.io",
"wss://rsslay.nostr.net",
"wss://relay.current.fyi",
"wss://nos.io"
]
}
}

2
public/opentimestamps.min.js vendored Normal file

File diff suppressed because one or more lines are too long

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

View File

@ -1,21 +1,22 @@
import { useEffect } from 'react'
import { useSelector } from 'react-redux'
import { Navigate, Route, Routes } from 'react-router-dom'
import { AuthController, NostrController } from './controllers'
import { useAppSelector, useAuth } from './hooks'
import { MainLayout } from './layouts/Main'
import { appPrivateRoutes, appPublicRoutes } from './routes'
import {
appPrivateRoutes,
appPublicRoutes,
privateRoutes,
publicRoutes,
recursiveRouteRenderer
} from './routes'
import { State } from './store/rootReducer'
import { getNsecBunkerDelegatedKey, saveNsecBunkerDelegatedKey } from './utils'
} from './routes/util'
import './App.scss'
const App = () => {
const authState = useSelector((state: State) => state.auth)
const { checkSession } = useAuth()
const authState = useAppSelector((state) => state.auth)
useEffect(() => {
if (window.location.hostname === '0.0.0.0') {
@ -25,29 +26,17 @@ const App = () => {
window.location.hostname = 'localhost'
}
generateBunkerDelegatedKey()
const authController = new AuthController()
authController.checkSession()
}, [])
const generateBunkerDelegatedKey = () => {
const existingKey = getNsecBunkerDelegatedKey()
if (!existingKey) {
const nostrController = NostrController.getInstance()
const newDelegatedKey = nostrController.generateDelegatedKey()
saveNsecBunkerDelegatedKey(newDelegatedKey)
}
}
checkSession()
}, [checkSession])
const handleRootRedirect = () => {
if (authState.loggedIn) return appPrivateRoutes.homePage
const callbackPathEncoded = btoa(
window.location.href.split(`${window.location.origin}/#`)[1]
)
return `${appPublicRoutes.login}?callbackPath=${callbackPathEncoded}`
return `${appPublicRoutes.landingPage}?callbackPath=${callbackPathEncoded}`
}
// Hide route only if loggedIn and r.hiddenWhenLoggedIn are both true

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

View File

@ -9,71 +9,47 @@ import {
} from '@mui/material'
import { useEffect, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import {
setAuthState,
setMetadataEvent,
userLogOutAction
} from '../../store/actions'
import { State } from '../../store/rootReducer'
import { Dispatch } from '../../store/store'
import { useAppSelector } from '../../hooks/store'
import Username from '../username'
import { Link, useNavigate } from 'react-router-dom'
import { MetadataController, NostrController } from '../../controllers'
import {
appPublicRoutes,
appPrivateRoutes,
getProfileRoute
} from '../../routes'
import {
clearAuthToken,
hexToNpub,
saveNsecBunkerDelegatedKey,
shorten
} from '../../utils'
import { getProfileUsername, hexToNpub } from '../../utils'
import styles from './style.module.scss'
import { setUserRobotImage } from '../../store/userRobotImage/action'
import { Container } from '../Container'
import { ButtonIcon } from '../ButtonIcon'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faClose } from '@fortawesome/free-solid-svg-icons'
import useMediaQuery from '@mui/material/useMediaQuery'
import { useLogout } from '../../hooks/useLogout'
const metadataController = new MetadataController()
import { launch as launchNostrLoginDialog } from 'nostr-login'
export const AppBar = () => {
const navigate = useNavigate()
const dispatch: Dispatch = useDispatch()
const logout = useLogout()
const [username, setUsername] = useState('')
const [userAvatar, setUserAvatar] = useState('')
const [anchorElUser, setAnchorElUser] = useState<null | HTMLElement>(null)
const authState = useSelector((state: State) => state.auth)
const metadataState = useSelector((state: State) => state.metadata)
const userRobotImage = useSelector((state: State) => state.userRobotImage)
const authState = useAppSelector((state) => state.auth)
const userProfile = useAppSelector((state) => state.user.profile)
const userRobotImage = useAppSelector((state) => state.user.robotImage)
useEffect(() => {
if (metadataState) {
if (metadataState.content) {
const { picture, display_name, name } = JSON.parse(
metadataState.content
)
if (picture || userRobotImage) {
setUserAvatar(picture || userRobotImage)
}
const npub = authState.usersPubkey
? hexToNpub(authState.usersPubkey)
: ''
setUsername(shorten(display_name || name || npub, 7))
} else {
setUserAvatar(userRobotImage || '')
setUsername('')
}
const npub = authState.usersPubkey ? hexToNpub(authState.usersPubkey) : ''
if (userProfile) {
setUserAvatar(userProfile.image || userRobotImage || '')
setUsername(getProfileUsername(npub, userProfile))
} else {
setUserAvatar('')
setUsername(getProfileUsername(npub))
}
}, [metadataState, userRobotImage, authState.usersPubkey])
}, [userRobotImage, authState.usersPubkey, userProfile])
const handleOpenUserMenu = (event: React.MouseEvent<HTMLElement>) => {
setAnchorElUser(event.currentTarget)
@ -92,151 +68,167 @@ export const AppBar = () => {
const handleLogout = () => {
handleCloseUserMenu()
dispatch(
setAuthState({
keyPair: undefined,
loggedIn: false,
usersPubkey: undefined,
loginMethod: undefined,
nsecBunkerPubkey: undefined
})
)
dispatch(setMetadataEvent(metadataController.getEmptyMetadataEvent()))
dispatch(setUserRobotImage(null))
// clear authToken saved in local storage
clearAuthToken()
dispatch(userLogOutAction())
// update nsecBunker delegated key after logout
const nostrController = NostrController.getInstance()
const newDelegatedKey = nostrController.generateDelegatedKey()
saveNsecBunkerDelegatedKey(newDelegatedKey)
logout()
navigate('/')
}
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}>
<p className={styles.bannerText}>
SIGit is currently Beta software (available for user experience
testing), use at your own risk!
</p>
<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={() => {
if (['', '#/'].includes(window.location.hash)) {
location.reload()
} else {
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={() => {
launchNostrLoginDialog()
}}
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,42 @@
justify-content: flex-end;
}
}
.banner {
color: #ffffff;
background-color: $primary-main;
}
.bannerInner {
display: flex;
gap: 10px;
padding-block: 10px;
z-index: 1;
width: 100%;
justify-content: space-between;
flex-direction: row;
button {
min-width: 44px;
color: inherit;
}
&:hover,
&.active,
&:focus-within {
background: $primary-main;
color: inherit;
button {
color: inherit;
}
}
}
.bannerText {
margin-left: 54px;
flex-grow: 1;
text-align: center;
}

View File

@ -0,0 +1,24 @@
import { PropsWithChildren } from 'react'
import styles from './style.module.scss'
interface ButtonUnderlineProps {
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void
disabled?: boolean | undefined
}
export const ButtonUnderline = ({
onClick,
disabled = false,
children
}: PropsWithChildren<ButtonUnderlineProps>) => {
return (
<button
type="button"
className={styles.button}
disabled={disabled}
onClick={onClick}
>
{children}
</button>
)
}

View File

@ -0,0 +1,25 @@
@import '../../styles/colors.scss';
.button {
color: $primary-main !important;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
width: max-content;
margin: 0 auto;
// Override default styling
border: none !important;
outline: none !important;
// Override leaky css in sign page
background: transparent !important;
&:focus,
&:hover {
text-decoration: underline;
text-decoration-color: inherit;
}
}

View File

@ -1,7 +1,12 @@
import { Meta } from '../../types'
import { SigitCardDisplayInfo, SigitStatus, SignStatus } from '../../utils'
import {
hexToNpub,
SigitCardDisplayInfo,
SigitStatus,
SignStatus
} from '../../utils'
import { Link } from 'react-router-dom'
import { formatTimestamp, 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,99 +22,73 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { UserAvatarGroup } from '../UserAvatarGroup'
import styles from './style.module.scss'
import { TooltipChild } from '../TooltipChild'
import { getExtensionIconLabel } from '../getExtensionIconLabel'
import { useSigitProfiles } from '../../hooks/useSigitProfiles'
import { useSigitMeta } from '../../hooks/useSigitMeta'
import { extractFileExtensions } from '../../utils/file'
import { useAppSelector } from '../../hooks'
type SigitProps = {
sigitCreateId: string
meta: Meta
parsedMeta: SigitCardDisplayInfo
}
export const DisplaySigit = ({ meta, parsedMeta }: SigitProps) => {
export const DisplaySigit = ({
meta,
parsedMeta,
sigitCreateId: sigitCreateId
}: SigitProps) => {
const { usersPubkey } = useAppSelector((state) => state.auth)
const { title, createdAt, submittedBy, signers, signedStatus, isValid } =
parsedMeta
const { signersStatus, fileHashes } = useSigitMeta(meta)
const profiles = useSigitProfiles([
...(submittedBy ? [submittedBy] : []),
...signers
])
const { extensions, isSame } = extractFileExtensions(Object.keys(fileHashes))
const currentUserNpub: string = usersPubkey ? hexToNpub(usersPubkey) : ''
const currentUserNextSigner =
signersStatus[currentUserNpub as `npub1${string}`] === SignStatus.Awaiting
return (
<div className={styles.itemWrapper}>
<Link
to={
signedStatus === SigitStatus.Complete
? appPublicRoutes.verify
: appPrivateRoutes.sign
}
state={{ meta }}
className={styles.insetLink}
></Link>
{signedStatus === SigitStatus.Complete || !currentUserNextSigner ? (
<Link
to={`${appPublicRoutes.verify}/${sigitCreateId}`}
className={styles.insetLink}
></Link>
) : (
<Link
to={`${appPrivateRoutes.sign}/${sigitCreateId}`}
className={styles.insetLink}
></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>
)
}

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@ -13,10 +13,20 @@
}
}
.pdfImageWrapper:focus {
outline: none;
}
.placeholder {
position: absolute;
opacity: 0.5;
inset: 0;
}
.drawingRectangle {
position: absolute;
border: 1px solid #01aaad;
z-index: 50;
outline: 1px solid #01aaad;
z-index: 40;
background-color: #01aaad4b;
cursor: pointer;
display: flex;
@ -28,10 +38,6 @@
visibility: hidden;
}
&.edited {
border: 1px dotted #01aaad;
}
.resizeHandle {
position: absolute;
right: -5px;
@ -41,7 +47,7 @@
background-color: #fff;
border: 1px solid rgb(160, 160, 160);
border-radius: 50%;
cursor: nwse-resize;
cursor: grab;
// Increase the area a bit so it's easier to click
&::after {
@ -78,3 +84,35 @@
padding: 5px 0;
}
}
.counterpartAvatar {
img {
width: 21px;
height: 21px;
}
}
.signingRectangle {
position: absolute;
outline: 1px solid #01aaad;
z-index: 40;
background-color: #01aaad4b;
cursor: pointer;
&.edited {
outline: 1px dotted #01aaad;
}
}
.drawingRectanglePreview {
position: absolute;
outline: 1px solid;
z-index: 50;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
pointer-events: none;
touch-action: none;
opacity: 0.8;
}

View File

@ -1,54 +1,96 @@
import React from 'react'
import { Button, Menu, MenuItem } from '@mui/material'
import PopupState, { bindTrigger, bindMenu } from 'material-ui-popup-state'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import {
faCheck,
faLock,
faTriangleExclamation
} from '@fortawesome/free-solid-svg-icons'
import { CurrentUserFile } from '../../types/file.ts'
import styles from './style.module.scss'
import { Button } from '@mui/material'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faCheck } from '@fortawesome/free-solid-svg-icons'
interface FileListProps {
files: CurrentUserFile[]
currentFile: CurrentUserFile
setCurrentFile: (file: CurrentUserFile) => void
handleDownload: () => void
downloadLabel?: string
handleExport?: () => void
handleEncryptedExport?: () => void
}
const FileList = ({
files,
currentFile,
setCurrentFile,
handleDownload,
downloadLabel
handleExport,
handleEncryptedExport
}: FileListProps) => {
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 className={styles.fileVisual}>
{currentUserFile.isHashValid && (
<FontAwesomeIcon icon={faCheck} />
)}
</div>
</li>
))}
</ul>
{(typeof handleExport === 'function' ||
typeof handleEncryptedExport === 'function') && (
<PopupState variant="popover" popupId="download-popup-menu">
{(popupState) => (
<React.Fragment>
<Button variant="contained" {...bindTrigger(popupState)}>
Export files
</Button>
<Menu {...bindMenu(popupState)}>
{typeof handleEncryptedExport === 'function' && (
<MenuItem
onClick={() => {
popupState.close
handleEncryptedExport()
}}
>
<FontAwesomeIcon
color={'var(--mui-palette-primary-main)'}
icon={faLock}
/>
&nbsp; ENCRYPTED
</MenuItem>
)}
</div>
</li>
))}
</ul>
</div>
<Button variant="contained" fullWidth onClick={handleDownload}>
{downloadLabel || 'Download Files'}
</Button>
{typeof handleExport === 'function' && (
<MenuItem
onClick={() => {
popupState.close
handleExport()
}}
>
<FontAwesomeIcon
color={'var(--mui-palette-primary-main)'}
icon={faTriangleExclamation}
/>
&nbsp; UNENCRYPTED
</MenuItem>
)}
</Menu>
</React.Fragment>
)}
</PopupState>
)}
</div>
)
}

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,134 @@ 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={'/'}
onClick={(event) => {
if (['', '#/'].includes(window.location.hash)) {
event.preventDefault()
window.scrollTo(0, 0)
}
}}
variant={'text'}
>
Home
</Button>
<Button
sx={{
justifyContent: 'center'
}}
component={LinkMui}
href={appPublicRoutes.docs}
target="_blank"
variant={'text'}
>
Documentation
</Button>
<Button
sx={{
justifyContent: 'center'
}}
component={LinkMui}
href={appPublicRoutes.source}
target="_blank"
variant={'text'}
>
Source
</Button>
</Box>
<Box
className={styles.links}
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 rel="noopener" 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,56 @@
.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;
z-index: 70;
-webkit-backdrop-filter: blur(10px);
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,22 +1,27 @@
import { CurrentUserMark } from '../../types/mark.ts'
import styles from './style.module.scss'
import { MARK_TYPE_TRANSLATION, NEXT, SIGN } from '../../utils/const.ts'
import {
findNextIncompleteCurrentUserMark,
getToolboxLabelByMarkType,
isCurrentUserMarksComplete,
isCurrentValueLast
} from '../../utils'
import React, { useState } from 'react'
import { MarkInput } from '../MarkTypeStrategy/MarkInput.tsx'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faCheck, faDownload } from '@fortawesome/free-solid-svg-icons'
import { Button } from '@mui/material'
import styles from './style.module.scss'
import { ButtonUnderline } from '../ButtonUnderline/index.tsx'
interface MarkFormFieldProps {
currentUserMarks: CurrentUserMark[]
handleCurrentUserMarkChange: (mark: CurrentUserMark) => void
handleSelectedMarkValueChange: (
event: React.ChangeEvent<HTMLInputElement>
handleSelectedMarkValueChange: (value: string) => void
handleSubmit: (
event: React.MouseEvent<HTMLButtonElement>,
type: 'online' | 'offline'
) => void
handleSubmit: (event: React.FormEvent<HTMLFormElement>) => void
selectedMark: CurrentUserMark
selectedMark: CurrentUserMark | null
selectedMarkValue: string
}
@ -32,28 +37,52 @@ const MarkFormField = ({
handleCurrentUserMarkChange
}: MarkFormFieldProps) => {
const [displayActions, setDisplayActions] = useState(true)
const getSubmitButtonText = () => (isReadyToSign() ? SIGN : NEXT)
const [complete, setComplete] = useState(false)
const isReadyToSign = () =>
isCurrentUserMarksComplete(currentUserMarks) ||
isCurrentValueLast(currentUserMarks, selectedMark, selectedMarkValue)
const isCurrent = (currentMark: CurrentUserMark) =>
currentMark.id === selectedMark.id
currentMark.id === selectedMark?.id && !complete
const isDone = (currentMark: CurrentUserMark) =>
isCurrent(currentMark) ? !!selectedMarkValue : currentMark.isCompleted
const findNext = () => {
return (
currentUserMarks[selectedMark.id] ||
currentUserMarks[selectedMark!.id] ||
findNextIncompleteCurrentUserMark(currentUserMarks)
)
}
const handleFormSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
console.log('handle form submit runs...')
return isReadyToSign()
? handleSubmit(event)
: handleCurrentUserMarkChange(findNext()!)
// Without this line, we lose mark values when switching
handleCurrentUserMarkChange(selectedMark!)
if (!complete) {
isReadyToSign()
? setComplete(true)
: handleCurrentUserMarkChange(findNext()!)
}
}
const toggleActions = () => setDisplayActions(!displayActions)
const markLabel = selectedMark
? getToolboxLabelByMarkType(selectedMark.mark.type)
: ''
const handleCurrentUserMarkClick = (mark: CurrentUserMark) => {
setComplete(false)
handleCurrentUserMarkChange(mark)
}
const handleSelectCompleteMark = () => {
if (currentUserMarks.length) handleCurrentUserMarkChange(selectedMark!)
setComplete(true)
}
const handleSignAndComplete =
(type: 'online' | 'offline') =>
(event: React.MouseEvent<HTMLButtonElement>) => {
handleSubmit(event, type)
}
return (
<div className={styles.container}>
<div className={styles.trigger}>
@ -61,6 +90,7 @@ const MarkFormField = ({
onClick={toggleActions}
className={styles.triggerBtn}
type="button"
title="Toggle"
>
<svg
xmlns="http://www.w3.org/2000/svg"
@ -78,33 +108,64 @@ const MarkFormField = ({
<div className={styles.actionsWrapper}>
<div className={styles.actionsTop}>
<div className={styles.actionsTopInfo}>
<p className={styles.actionsTopInfoText}>Add your signature</p>
{!complete && selectedMark ? (
<p className={styles.actionsTopInfoText}>Add {markLabel}</p>
) : (
<p className={styles.actionsTopInfoText}>Finish</p>
)}
</div>
</div>
<div className={styles.inputWrapper}>
<form onSubmit={(e) => handleFormSubmit(e)}>
<input
className={styles.input}
placeholder={
MARK_TYPE_TRANSLATION[selectedMark.mark.type.valueOf()]
}
onChange={handleSelectedMarkValueChange}
value={selectedMarkValue}
/>
<div className={styles.actionsBottom}>
<button type="submit" className={styles.submitButton}>
{getSubmitButtonText()}
</button>
</div>
</form>
{!complete && selectedMark ? (
<form onSubmit={(e) => handleFormSubmit(e)}>
<MarkInput
markType={selectedMark.mark.type}
key={selectedMark.id}
value={selectedMarkValue}
placeholder={markLabel}
handler={handleSelectedMarkValueChange}
userMark={selectedMark}
/>
<div className={styles.actionsBottom}>
<Button type="submit" className={styles.submitButton}>
NEXT
</Button>
</div>
</form>
) : (
<>
<div className={styles.actionsBottom}>
<Button
onClick={handleSignAndComplete('online')}
className={[
styles.submitButton,
styles.completeButton
].join(' ')}
disabled={!isReadyToSign()}
autoFocus
>
SIGN AND BROADCAST
</Button>
</div>
<ButtonUnderline
onClick={handleSignAndComplete('offline')}
disabled={!isReadyToSign()}
>
<FontAwesomeIcon icon={faDownload} />
Sign and export locally instead
</ButtonUnderline>
</>
)}
<div className={styles.footerContainer}>
<div className={styles.footer}>
{currentUserMarks.map((mark, index) => {
return (
<div className={styles.pagination} key={index}>
<button
className={`${styles.paginationButton} ${isDone(mark) && styles.paginationButtonDone}`}
onClick={() => handleCurrentUserMarkChange(mark)}
type="button"
className={`${styles.paginationButton} ${isDone(mark) ? styles.paginationButtonDone : ''}`}
onClick={() => handleCurrentUserMarkClick(mark)}
>
{mark.id}
</button>
@ -114,6 +175,22 @@ const MarkFormField = ({
</div>
)
})}
<div className={styles.pagination}>
<button
type="button"
className={`${styles.paginationButton} ${isReadyToSign() ? styles.paginationButtonDone : ''}`}
onClick={handleSelectCompleteMark}
title="Complete"
>
<FontAwesomeIcon
className={styles.finishPage}
icon={faCheck}
/>
</button>
{complete && (
<div className={styles.paginationButtonCurrent}></div>
)}
</div>
</div>
</div>
</div>

View File

@ -1,13 +1,21 @@
@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;
z-index: 40;
button {
transition: ease 0.2s;
@ -62,6 +70,11 @@
margin-top: 10px;
}
.completeButton {
font-size: 18px;
padding: 10px 20px;
}
.paginationButton {
font-size: 12px;
padding: 5px 10px;
@ -70,7 +83,8 @@
color: rgba(0, 0, 0, 0.5);
}
.paginationButton:hover {
.paginationButton:hover,
.paginationButton:focus {
background: #447592;
color: rgba(255, 255, 255, 0.5);
}
@ -107,14 +121,14 @@
.actions {
background: white;
width: 100%;
border-radius: 4px;
border-radius: 5px;
padding: 10px 20px;
display: none;
flex-direction: column;
align-items: center;
grid-gap: 15px;
box-shadow: 0 -2px 4px 0 rgb(0, 0, 0, 0.1);
max-width: 750px;
max-width: 450px;
&.expanded {
display: flex;
@ -208,3 +222,7 @@
flex-direction: column;
grid-gap: 5px;
}
.finishPage {
padding: 1px 0;
}

View File

@ -0,0 +1,16 @@
import { MarkType } from '../../types/drawing'
import { MARK_TYPE_CONFIG, MarkInputProps } from './MarkStrategy'
interface MarkInputComponentProps extends MarkInputProps {
markType: MarkType
}
export const MarkInput = ({ markType, ...rest }: MarkInputComponentProps) => {
const { input: InputComponent } = MARK_TYPE_CONFIG[markType] || {}
if (typeof InputComponent !== 'undefined') {
return <InputComponent {...rest} />
}
return null
}

View File

@ -0,0 +1,20 @@
import { MarkType } from '../../types/drawing'
import { MARK_TYPE_CONFIG, MarkRenderProps } from './MarkStrategy'
interface MarkRenderComponentProps extends MarkRenderProps {
markType: MarkType
}
export const MarkRender = ({ markType, ...rest }: MarkRenderComponentProps) => {
const { render: RenderComponent } = MARK_TYPE_CONFIG[markType] || {}
if (typeof RenderComponent !== 'undefined') {
return <RenderComponent {...rest} />
}
return <DefaultRenderComponent {...rest} />
}
const DefaultRenderComponent = ({ value }: MarkRenderProps) => (
<span>{value}</span>
)

View File

@ -0,0 +1,32 @@
import { MarkType } from '../../types/drawing'
import { CurrentUserMark, Mark } from '../../types/mark'
import { TextStrategy } from './Text'
import { SignatureStrategy } from './Signature'
export interface MarkInputProps {
value: string
handler: (value: string) => void
placeholder?: string
userMark?: CurrentUserMark
}
export interface MarkRenderProps {
value?: string
mark: Mark
}
export interface MarkStrategy {
input: React.FC<MarkInputProps>
render: React.FC<MarkRenderProps>
encryptAndUpload?: (value: string, key?: string) => Promise<string>
fetchAndDecrypt?: (value: string, key?: string) => Promise<string>
}
export type MarkStrategies = {
[key in MarkType]?: MarkStrategy
}
export const MARK_TYPE_CONFIG: MarkStrategies = {
[MarkType.TEXT]: TextStrategy,
[MarkType.SIGNATURE]: SignatureStrategy
}

View File

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

View File

@ -0,0 +1,101 @@
import { useCallback, useEffect, useRef } from 'react'
import { faEraser } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { MarkRenderSignature } from './Render'
import SignaturePad from 'signature_pad'
import { SIGNATURE_PAD_OPTIONS, SIGNATURE_PAD_SIZE } from '../../../utils/const'
import { BasicPoint } from 'signature_pad/dist/types/point'
import { MarkInputProps } from '../MarkStrategy'
import styles from './Input.module.scss'
export const MarkInputSignature = ({
value,
handler,
userMark
}: MarkInputProps) => {
const canvasRef = useRef<HTMLCanvasElement>(null)
const signaturePad = useRef<SignaturePad | null>(null)
const update = useCallback(() => {
const data = signaturePad.current?.toData()
const reduced = data?.map((pg) => pg.points)
const json = JSON.stringify(reduced)
if (signaturePad.current && !signaturePad.current?.isEmpty()) {
handler(json)
} else {
handler('')
}
}, [handler])
useEffect(() => {
const handleEndStroke = () => {
update()
}
if (canvasRef.current) {
if (signaturePad.current === null) {
signaturePad.current = new SignaturePad(
canvasRef.current,
SIGNATURE_PAD_OPTIONS
)
}
signaturePad.current.addEventListener('endStroke', handleEndStroke)
}
return () => {
window.removeEventListener('endStroke', handleEndStroke)
}
}, [update])
useEffect(() => {
if (signaturePad.current) {
if (value) {
signaturePad.current.fromData(
JSON.parse(value).map((p: BasicPoint[]) => ({
points: p
}))
)
} else {
signaturePad.current?.clear()
}
}
update()
}, [update, value])
const handleReset = () => {
signaturePad.current?.clear()
update()
}
return (
<div className={styles.wrapper}>
<div
className={styles.relative}
style={{
width: SIGNATURE_PAD_SIZE.width,
height: SIGNATURE_PAD_SIZE.height
}}
>
<canvas
width={SIGNATURE_PAD_SIZE.width}
height={SIGNATURE_PAD_SIZE.height}
ref={canvasRef}
className={styles.canvas}
></canvas>
{typeof userMark?.mark !== 'undefined' && (
<div className={styles.absolute}>
<MarkRenderSignature
key={userMark.mark.value}
value={userMark.mark.value}
mark={userMark.mark}
/>
</div>
)}
<div className={styles.reset}>
<FontAwesomeIcon size="sm" icon={faEraser} onClick={handleReset} />
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,9 @@
.img {
width: 100%;
height: 100%;
object-fit: contain;
overflow: hidden;
pointer-events: none;
-webkit-user-select: none;
user-select: none;
}

View File

@ -0,0 +1,27 @@
import { useEffect, useState } from 'react'
import SignaturePad from 'signature_pad'
import { SIGNATURE_PAD_OPTIONS, SIGNATURE_PAD_SIZE } from '../../../utils'
import { BasicPoint } from 'signature_pad/dist/types/point'
import { MarkRenderProps } from '../MarkStrategy'
import styles from './Render.module.scss'
export const MarkRenderSignature = ({ value }: MarkRenderProps) => {
const [dataUrl, setDataUrl] = useState<string | undefined>()
useEffect(() => {
if (value) {
const canvas = document.createElement('canvas')
canvas.width = SIGNATURE_PAD_SIZE.width
canvas.height = SIGNATURE_PAD_SIZE.height
const pad = new SignaturePad(canvas, SIGNATURE_PAD_OPTIONS)
pad.fromData(
JSON.parse(value).map((p: BasicPoint[]) => ({
points: p
}))
)
setDataUrl(canvas.toDataURL('image/webp'))
}
}, [value])
return dataUrl ? <img src={dataUrl} className={styles.img} alt="" /> : null
}

View File

@ -0,0 +1,89 @@
import axios from 'axios'
import {
decryptArrayBuffer,
encryptArrayBuffer,
getHash,
uploadToFileStorage
} from '../../../utils'
import { MarkStrategy } from '../MarkStrategy'
import { MarkInputSignature } from './Input'
import { MarkRenderSignature } from './Render'
export const SignatureStrategy: MarkStrategy = {
input: MarkInputSignature,
render: MarkRenderSignature,
encryptAndUpload: async (value, encryptionKey) => {
// Value is the stringified signature object
// Encode it to the arrayBuffer
const encoder = new TextEncoder()
const uint8Array = encoder.encode(value)
if (!encryptionKey) {
throw new Error('Signature requires an encryption key')
}
// Encrypt the file contents with the same encryption key from the create signature
const encryptedArrayBuffer = await encryptArrayBuffer(
uint8Array,
encryptionKey
)
const hash = await getHash(encryptedArrayBuffer)
if (!hash) {
throw new Error("Can't get encrypted file hash.")
}
// Create the encrypted json file from array buffer and hash
const file = new File([encryptedArrayBuffer], `${hash}.json`)
try {
const url = await uploadToFileStorage(file)
console.info(`${file.name} uploaded to file storage`)
return url
} catch (error) {
if (error instanceof Error) {
console.error(
`Error occurred in uploading file ${file.name}`,
error.message
)
}
}
return value
},
fetchAndDecrypt: async (value, encryptionKey) => {
if (!encryptionKey) {
throw new Error('Signature requires an encryption key')
}
const encryptedArrayBuffer = await axios.get(value, {
responseType: 'arraybuffer'
})
// Verify hash
const parts = value.split('/')
const urlHash = parts[parts.length - 1]
const hash = await getHash(encryptedArrayBuffer.data)
if (hash !== urlHash) {
// TODO: handle hash verification failing
throw new Error('Unable to verify signature')
}
const arrayBuffer = await decryptArrayBuffer(
encryptedArrayBuffer.data,
encryptionKey
).catch((err) => {
console.log('err in decryption:>> ', err)
return null
})
if (arrayBuffer) {
// decode json
const decoder = new TextDecoder()
const json = decoder.decode(arrayBuffer)
return json
}
return value
}
}

View File

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

View File

@ -0,0 +1,7 @@
import { MarkStrategy } from '../MarkStrategy'
import { MarkInputText } from './Input'
export const TextStrategy: MarkStrategy = {
input: MarkInputText,
render: ({ value }) => <>{value}</>
}

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,9 @@ import { CurrentUserMark } from '../../types/mark.ts'
import styles from '../DrawPDFFields/style.module.scss'
import { FONT_SIZE, FONT_TYPE, inPx } from '../../utils/pdf.ts'
import { useScale } from '../../hooks/useScale.tsx'
import { forwardRef } from 'react'
import { npubToHex } from '../../utils/nostr.ts'
import { MarkRender } from '../MarkTypeStrategy/MarkRender.tsx'
interface PdfMarkItemProps {
userMark: CurrentUserMark
@ -14,35 +17,46 @@ 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.signingRectangle} ${isEdited() && styles.edited}`}
style={{
backgroundColor: selectedMark?.mark.npub
? `#${npubToHex(selectedMark?.mark.npub)?.substring(0, 6)}4b`
: undefined,
outlineColor: selectedMark?.mark.npub
? `#${npubToHex(selectedMark?.mark.npub)?.substring(0, 6)}`
: undefined,
left: inPx(from(pageWidth, location.left)),
top: inPx(from(pageWidth, location.top)),
width: inPx(from(pageWidth, location.width)),
height: inPx(from(pageWidth, location.height)),
fontFamily: FONT_TYPE,
fontSize: inPx(from(pageWidth, FONT_SIZE))
}}
>
<MarkRender
key={getMarkValue()}
markType={userMark.mark.type}
value={getMarkValue()}
mark={userMark.mark}
/>
</div>
)
}
)
export default PdfMarkItem

View File

@ -15,15 +15,20 @@ import FileList from '../FileList'
import { StickySideColumns } from '../../layouts/StickySideColumns.tsx'
import { UsersDetails } from '../UsersDetails.tsx'
import { Meta } from '../../types'
import {
faCircleInfo,
faFileDownload,
faPen
} from '@fortawesome/free-solid-svg-icons'
interface PdfMarkingProps {
currentUserMarks: CurrentUserMark[]
files: CurrentUserFile[]
handleDownload: () => void
handleSign: () => void
handleSignOffline: () => void
meta: Meta | null
otherUserMarks: Mark[]
setCurrentUserMarks: (currentUserMarks: CurrentUserMark[]) => void
setIsMarksCompleted: (isMarksCompleted: boolean) => void
setUpdatedMarks: (markToUpdate: Mark) => void
}
@ -33,17 +38,16 @@ interface PdfMarkingProps {
* @param props
* @constructor
*/
const PdfMarking = (props: PdfMarkingProps) => {
const {
files,
currentUserMarks,
setIsMarksCompleted,
setCurrentUserMarks,
setUpdatedMarks,
handleDownload,
meta,
otherUserMarks
} = props
const PdfMarking = ({
files,
currentUserMarks,
setCurrentUserMarks,
setUpdatedMarks,
handleSign,
handleSignOffline,
meta,
otherUserMarks
}: PdfMarkingProps) => {
const [selectedMark, setSelectedMark] = useState<CurrentUserMark | null>(null)
const [selectedMarkValue, setSelectedMarkValue] = useState<string>('')
const [currentFile, setCurrentFile] = useState<CurrentUserFile | null>(null)
@ -65,8 +69,8 @@ const PdfMarking = (props: PdfMarkingProps) => {
const handleMarkClick = (id: number) => {
const nextMark = currentUserMarks.find((mark) => mark.mark.id === id)
setSelectedMark(nextMark!)
setSelectedMarkValue(nextMark?.mark.value ?? EMPTY)
if (nextMark) handleCurrentUserMarkChange(nextMark)
}
const handleCurrentUserMarkChange = (mark: CurrentUserMark) => {
@ -81,39 +85,45 @@ const PdfMarking = (props: PdfMarkingProps) => {
updatedSelectedMark
)
setCurrentUserMarks(updatedCurrentUserMarks)
setSelectedMarkValue(mark.currentValue ?? EMPTY)
setSelectedMark(mark)
// If clicking on the same mark, don't update the value, otherwise do update
if (mark.id !== selectedMark.id) {
setSelectedMarkValue(mark.currentValue ?? EMPTY)
setSelectedMark(mark)
}
}
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
/**
* Sign and Complete
*/
const handleSubmit = (
event: React.MouseEvent<HTMLButtonElement>,
type: 'online' | 'offline'
) => {
event.preventDefault()
if (!selectedMarkValue || !selectedMark) return
if (selectedMarkValue && selectedMark) {
const updatedMark: CurrentUserMark = getUpdatedMark(
selectedMark,
selectedMarkValue
)
const updatedMark: CurrentUserMark = getUpdatedMark(
selectedMark,
selectedMarkValue
)
setSelectedMarkValue(EMPTY)
const updatedCurrentUserMarks = updateCurrentUserMarks(
currentUserMarks,
updatedMark
)
setCurrentUserMarks(updatedCurrentUserMarks)
setSelectedMark(null)
setUpdatedMarks(updatedMark.mark)
}
setSelectedMarkValue(EMPTY)
const updatedCurrentUserMarks = updateCurrentUserMarks(
currentUserMarks,
updatedMark
)
setCurrentUserMarks(updatedCurrentUserMarks)
setSelectedMark(null)
setIsMarksCompleted(true)
setUpdatedMarks(updatedMark.mark)
if (type === 'online') handleSign()
else if (type === 'offline') handleSignOffline()
}
// const updateCurrentUserMarkValues = () => {
// const updatedMark: CurrentUserMark = getUpdatedMark(selectedMark!, selectedMarkValue)
// const updatedCurrentUserMarks = updateCurrentUserMarks(currentUserMarks, updatedMark)
// setSelectedMarkValue(EMPTY)
// setCurrentUserMarks(updatedCurrentUserMarks)
// }
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) =>
setSelectedMarkValue(event.target.value)
const handleChange = (value: string) => {
setSelectedMarkValue(value)
}
return (
<>
@ -126,35 +136,33 @@ const PdfMarking = (props: PdfMarkingProps) => {
files={files}
currentFile={currentFile}
setCurrentFile={setCurrentFile}
handleDownload={handleDownload}
/>
)}
</div>
}
right={meta !== null && <UsersDetails meta={meta} />}
leftIcon={faFileDownload}
centerIcon={faPen}
rightIcon={faCircleInfo}
>
{currentUserMarks?.length > 0 && (
<PdfView
currentFile={currentFile}
files={files}
handleMarkClick={handleMarkClick}
selectedMarkValue={selectedMarkValue}
selectedMark={selectedMark}
currentUserMarks={currentUserMarks}
otherUserMarks={otherUserMarks}
/>
)}
</StickySideColumns>
{selectedMark !== null && (
<MarkFormField
handleSubmit={handleSubmit}
handleSelectedMarkValueChange={handleChange}
selectedMark={selectedMark}
<PdfView
currentFile={currentFile}
files={files}
handleMarkClick={handleMarkClick}
selectedMarkValue={selectedMarkValue}
selectedMark={selectedMark}
currentUserMarks={currentUserMarks}
handleCurrentUserMarkChange={handleCurrentUserMarkChange}
otherUserMarks={otherUserMarks}
/>
)}
</StickySideColumns>
<MarkFormField
handleSubmit={handleSubmit}
handleSelectedMarkValueChange={handleChange}
selectedMark={selectedMark}
selectedMarkValue={selectedMarkValue}
currentUserMarks={currentUserMarks}
handleCurrentUserMarkChange={handleCurrentUserMarkChange}
/>
</Container>
</>
)

View File

@ -6,7 +6,10 @@ import { useEffect, useRef } from 'react'
import pdfViewStyles from './style.module.scss'
import { FONT_SIZE, FONT_TYPE, inPx } from '../../utils/pdf.ts'
import { useScale } from '../../hooks/useScale.tsx'
import { MarkRender } from '../MarkTypeStrategy/MarkRender.tsx'
interface PdfPageProps {
fileName: string
pageIndex: number
currentUserMarks: CurrentUserMark[]
handleMarkClick: (id: number) => void
otherUserMarks: Mark[]
@ -19,6 +22,8 @@ interface PdfPageProps {
* Responsible for rendering a single Pdf Page and its Marks
*/
const PdfPageItem = ({
fileName,
pageIndex,
page,
currentUserMarks,
handleMarkClick,
@ -29,7 +34,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 +44,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 (
@ -65,7 +74,7 @@ const PdfPageItem = ({
fontSize: inPx(from(page.width, FONT_SIZE))
}}
>
{m.value}
<MarkRender value={m.value} mark={m} markType={m.type} />
</div>
)
})}

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
@ -37,41 +38,51 @@ const PdfView = ({
currentUserMarks: CurrentUserMark[],
hash: string
): CurrentUserMark[] => {
return currentUserMarks.filter(
(currentUserMark) => currentUserMark.mark.pdfFileHash === hash
return currentUserMarks.filter((currentUserMark) =>
currentUserMark.mark.pdfFileHash
? currentUserMark.mark.pdfFileHash === hash
: currentUserMark.mark.fileHash === hash
)
}
const filterMarksByFile = (marks: Mark[], hash: string): Mark[] => {
return marks.filter((mark) => mark.pdfFileHash === hash)
return marks.filter((mark) =>
mark.pdfFileHash ? mark.pdfFileHash === hash : mark.fileHash === hash
)
}
const isNotLastPdfFile = (index: number, files: CurrentUserFile[]): boolean =>
index !== files.length - 1
return (
<div className="files-wrapper">
{files.map((currentUserFile, index, arr) => {
const { hash, file, id } = currentUserFile
{files.length > 0 ? (
files
.map<React.ReactNode>((currentUserFile) => {
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 (
<div
key={`file-${file.name}`}
id={file.name}
className="file-wrapper"
ref={(el) => (pdfRefs.current[id] = el)}
>
<PdfItem
file={file}
currentUserMarks={filterByFile(currentUserMarks, hash)}
selectedMark={selectedMark}
handleMarkClick={handleMarkClick}
selectedMarkValue={selectedMarkValue}
otherUserMarks={filterMarksByFile(otherUserMarks, hash)}
/>
</div>
)
})
.reduce((prev, curr, i) => [
prev,
<FileDivider key={`separator-${i}`} />,
curr
])
) : (
<LoadingSpinner variant="small" />
)}
</div>
)
}

View File

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

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 { getProfileUsername } 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 = getProfileUsername(pubkey, profile)
const image = profile?.image
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,10 @@
import { Divider, Tooltip } from '@mui/material'
import { useSigitProfiles } from '../../hooks/useSigitProfiles'
import {
formatTimestamp,
fromUnixTimestamp,
hexToNpub,
npubToHex,
shorten,
SigitStatus,
SignStatus
} from '../../utils'
import { useSigitMeta } from '../../hooks/useSigitMeta'
@ -17,17 +16,18 @@ import {
faCalendar,
faCalendarCheck,
faCalendarPlus,
faCheck,
faClock,
faEye,
faFile,
faFileCircleExclamation
} from '@fortawesome/free-solid-svg-icons'
import { getExtensionIconLabel } from '../getExtensionIconLabel'
import { useSelector } from 'react-redux'
import { State } from '../../store/rootReducer'
import { TooltipChild } from '../TooltipChild'
import { useAppSelector } from '../../hooks/store'
import { DisplaySigner } from '../DisplaySigner'
import { Meta } from '../../types'
import { Meta, OpenTimestamp } from '../../types'
import { extractFileExtensions } from '../../utils/file'
import { UserAvatar } from '../UserAvatar'
interface UsersDetailsProps {
meta: Meta
@ -36,6 +36,7 @@ interface UsersDetailsProps {
export const UsersDetails = ({ meta }: UsersDetailsProps) => {
const {
submittedBy,
exportedBy,
signers,
viewers,
fileHashes,
@ -44,50 +45,72 @@ export const UsersDetails = ({ meta }: UsersDetailsProps) => {
completedAt,
parsedSignatureEvents,
signedStatus,
isValid
isValid,
id,
timestamps
} = useSigitMeta(meta)
const { usersPubkey } = useSelector((state: State) => state.auth)
const profiles = useSigitProfiles([
...(submittedBy ? [submittedBy] : []),
...signers,
...viewers
])
const { usersPubkey } = useAppSelector((state) => state.auth)
const userCanSign =
typeof usersPubkey !== 'undefined' &&
signers.includes(hexToNpub(usersPubkey))
const { extensions, isSame } = extractFileExtensions(Object.keys(fileHashes))
const isTimestampVerified = (
timestamps: OpenTimestamp[],
nostrId: string
): boolean => {
const matched = timestamps.find((t) => t.nostrId === nostrId)
return !!(matched && matched.verification)
}
const getOpenTimestampsInfo = (
timestamps: OpenTimestamp[],
nostrId: string
) => {
if (isTimestampVerified(timestamps, nostrId)) {
return <FontAwesomeIcon className={styles.ticket} icon={faCheck} />
} else {
return <FontAwesomeIcon className={styles.ticket} icon={faClock} />
}
}
const getCompletedOpenTimestampsInfo = (timestamp: OpenTimestamp) => {
if (timestamp.verification) {
return <FontAwesomeIcon className={styles.ticket} icon={faCheck} />
} else {
return <FontAwesomeIcon className={styles.ticket} icon={faClock} />
}
}
const getTimestampTooltipTitle = (label: string, isVerified: boolean) => {
return `${label} / Open Timestamp ${isVerified ? 'Verified' : 'Pending'}`
}
const isUserSignatureTimestampVerified = () => {
if (
userCanSign &&
hexToNpub(usersPubkey) in parsedSignatureEvents &&
timestamps &&
timestamps.length > 0
) {
const nostrId = parsedSignatureEvents[hexToNpub(usersPubkey)].id
return isTimestampVerified(timestamps, nostrId)
}
return false
}
return submittedBy ? (
<div className={styles.container}>
<div className={styles.section}>
<p>Signers</p>
<div className={styles.users}>
{submittedBy &&
(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,72 +119,80 @@ 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>
<Tooltip
title={'Publication date'}
title={getTimestampTooltipTitle(
'Publication date',
!!(timestamps && id && isTimestampVerified(timestamps, id))
)}
placement="top"
arrow
disableInteractive
>
<span className={styles.detailsItem}>
<FontAwesomeIcon icon={faCalendarPlus} />{' '}
{createdAt ? formatTimestamp(createdAt) : <>&mdash;</>}
{createdAt ? formatTimestamp(createdAt) : <>&mdash;</>}{' '}
{timestamps &&
timestamps.length > 0 &&
id &&
getOpenTimestampsInfo(timestamps, id)}
</span>
</Tooltip>
<Tooltip
title={'Completion date'}
title={getTimestampTooltipTitle(
'Completion date',
!!(
signedStatus === SigitStatus.Complete &&
completedAt &&
timestamps &&
timestamps.length > 0 &&
timestamps[timestamps.length - 1].verification
)
)}
placement="top"
arrow
disableInteractive
@ -169,13 +200,26 @@ export const UsersDetails = ({ meta }: UsersDetailsProps) => {
<span className={styles.detailsItem}>
<FontAwesomeIcon icon={faCalendarCheck} />{' '}
{completedAt ? formatTimestamp(completedAt) : <>&mdash;</>}
{signedStatus === SigitStatus.Complete &&
completedAt &&
timestamps &&
timestamps.length > 0 && (
<span className={styles.ticket}>
{getCompletedOpenTimestampsInfo(
timestamps[timestamps.length - 1]
)}
</span>
)}
</span>
</Tooltip>
{/* User signed date */}
{userCanSign ? (
<Tooltip
title={'Your signature date'}
title={getTimestampTooltipTitle(
'Your signature date',
isUserSignatureTimestampVerified()
)}
placement="top"
arrow
disableInteractive
@ -195,6 +239,16 @@ export const UsersDetails = ({ meta }: UsersDetailsProps) => {
) : (
<>&mdash;</>
)}
{hexToNpub(usersPubkey) in parsedSignatureEvents &&
timestamps &&
timestamps.length > 0 && (
<span className={styles.ticket}>
{getOpenTimestampsInfo(
timestamps,
parsedSignatureEvents[hexToNpub(usersPubkey)].id
)}
</span>
)}
</span>
</Tooltip>
) : null}

View File

@ -31,8 +31,6 @@
padding: 5px;
display: flex;
align-items: center;
justify-content: start;
> :first-child {
padding: 5px;
@ -44,3 +42,7 @@
color: white;
}
}
.ticket {
margin-left: auto;
}

View File

@ -1,6 +1,5 @@
import { Typography } from '@mui/material'
import { useSelector } from 'react-redux'
import { State } from '../store/rootReducer'
import { useAppSelector } from '../hooks/store'
import styles from './username.module.scss'
import { AvatarIconButton } from './UserAvatarIconButton'
@ -16,7 +15,7 @@ type Props = {
* Clicking will open the menu.
*/
const Username = ({ username, avatarContent, handleClick }: Props) => {
const hexKey = useSelector((state: State) => state.auth.usersPubkey)
const hexKey = useAppSelector((state) => state.auth.usersPubkey)
return (
<div className={styles.container}>

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

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

View File

@ -1,139 +0,0 @@
import { EventTemplate } from 'nostr-tools'
import { MetadataController, NostrController } from '.'
import { appPrivateRoutes } from '../routes'
import {
setAuthState,
setMetadataEvent,
setRelayMapAction
} from '../store/actions'
import store from '../store/store'
import { SignedEvent } from '../types'
import {
base64DecodeAuthToken,
base64EncodeSignedEvent,
compareObjects,
getAuthToken,
getRelayMap,
getVisitedLink,
saveAuthToken,
unixNow
} from '../utils'
export class AuthController {
private nostrController: NostrController
private metadataController: MetadataController
constructor() {
this.nostrController = NostrController.getInstance()
this.metadataController = new MetadataController()
}
/**
* Function will authenticate user by signing an auth event
* which is done by calling the sign() function, where appropriate
* method will be chosen (extension, nsecbunker or keys)
*
* @param pubkey of the user trying to login
* @returns url to redirect if authentication successfull
* or error if otherwise
*/
async authAndGetMetadataAndRelaysMap(pubkey: string) {
const emptyMetadata = this.metadataController.getEmptyMetadataEvent()
this.metadataController
.findMetadata(pubkey)
.then((event) => {
if (event) {
store.dispatch(setMetadataEvent(event))
} else {
store.dispatch(setMetadataEvent(emptyMetadata))
}
})
.catch((err) => {
console.warn('Error occurred while finding metadata', err)
store.dispatch(setMetadataEvent(emptyMetadata))
})
// Nostr uses unix timestamps
const timestamp = unixNow()
const { hostname } = window.location
const authEvent: EventTemplate = {
kind: 27235,
tags: [],
content: `${hostname}-${timestamp}`,
created_at: timestamp
}
const signedAuthEvent = await this.nostrController.signEvent(authEvent)
this.createAndSaveAuthToken(signedAuthEvent)
store.dispatch(
setAuthState({
loggedIn: true,
usersPubkey: pubkey
})
)
const relayMap = await getRelayMap(pubkey)
if (Object.keys(relayMap).length < 1) {
// Navigate user to relays page if relay map is empty
return Promise.resolve(appPrivateRoutes.relays)
}
if (store.getState().auth?.loggedIn) {
if (!compareObjects(store.getState().relays?.map, relayMap.map))
store.dispatch(setRelayMapAction(relayMap.map))
}
const currentLocation = window.location.hash.replace('#', '')
if (!Object.values(appPrivateRoutes).includes(currentLocation)) {
// User did change the location to one of the private routes
const visitedLink = getVisitedLink()
if (visitedLink) {
const { pathname, search } = visitedLink
return Promise.resolve(`${pathname}${search}`)
} else {
// Navigate user in
return Promise.resolve(appPrivateRoutes.homePage)
}
}
}
checkSession() {
const savedAuthToken = getAuthToken()
if (savedAuthToken) {
const signedEvent = base64DecodeAuthToken(savedAuthToken)
store.dispatch(
setAuthState({
loggedIn: true,
usersPubkey: signedEvent.pubkey
})
)
return
}
store.dispatch(
setAuthState({
loggedIn: false,
usersPubkey: undefined
})
)
}
private createAndSaveAuthToken(signedAuthEvent: SignedEvent) {
const base64Encoded = base64EncodeSignedEvent(signedAuthEvent)
// save newly created auth token (base64 nostr singed event) in local storage along with expiry time
saveAuthToken(base64Encoded)
return base64Encoded
}
}

View File

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

View File

@ -1,194 +1,20 @@
import NDK, {
NDKEvent,
NDKNip46Signer,
NDKPrivateKeySigner,
NDKUser,
NostrEvent
} from '@nostr-dev-kit/ndk'
import {
Event,
EventTemplate,
UnsignedEvent,
finalizeEvent,
nip04,
nip19,
nip44
} from 'nostr-tools'
import { EventTemplate, UnsignedEvent } from 'nostr-tools'
import { EventEmitter } from 'tseep'
import { updateNsecbunkerPubkey } from '../store/actions'
import { AuthState, LoginMethods } from '../store/auth/types'
import store from '../store/store'
import { SignedEvent } from '../types'
import { getNsecBunkerDelegatedKey, verifySignedEvent } from '../utils'
import { LoginMethodContext } from '../services/LoginMethodStrategy/loginMethodContext'
import { clear, unixNow } from '../utils'
import { LoginMethod } from '../store/auth/types'
import { logout as nostrLogout } from 'nostr-login'
import { userLogOutAction } from '../store/actions'
export class NostrController extends EventEmitter {
private static instance: NostrController
private bunkerNDK: NDK | undefined
private remoteSigner: NDKNip46Signer | undefined
private constructor() {
super()
}
private getNostrObject = () => {
// fix: this is not picking up type declaration from src/system/index.d.ts
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (window.nostr) return window.nostr as any
throw new Error(
`window.nostr object not present. Make sure you have an nostr extension installed/working properly.`
)
}
public nsecBunkerInit = async (relays: string[]) => {
// Don't reinstantiate bunker NDK if exists with same relays
if (
this.bunkerNDK &&
this.bunkerNDK.explicitRelayUrls?.length === relays.length &&
this.bunkerNDK.explicitRelayUrls?.every((relay) => relays.includes(relay))
)
return
this.bunkerNDK = new NDK({
explicitRelayUrls: relays
})
try {
await this.bunkerNDK
.connect(2000)
.then(() => {
console.log(
`Successfully connected to the nsecBunker relays: ${relays.join(
','
)}`
)
})
.catch((err) => {
console.error(
`Error connecting to the nsecBunker relays: ${relays.join(
','
)} ${err}`
)
})
} catch (err) {
console.error(err)
}
}
/**
* Creates nSecBunker signer instance for the given npub
* Or if npub omitted it will return existing signer
* If neither, error will be thrown
* @param npub nPub / public key in hex format
* @returns nsecBunker Signer instance
*/
public createNsecBunkerSigner = async (
npub: string | undefined
): Promise<NDKNip46Signer> => {
const nsecBunkerDelegatedKey = getNsecBunkerDelegatedKey()
return new Promise((resolve, reject) => {
if (!nsecBunkerDelegatedKey) {
reject('nsecBunker delegated key is not found in the browser.')
return
}
const localSigner = new NDKPrivateKeySigner(nsecBunkerDelegatedKey)
if (!npub) {
if (this.remoteSigner) resolve(this.remoteSigner)
const npubFromStorage = (store.getState().auth as AuthState)
.nsecBunkerPubkey
if (npubFromStorage) {
npub = npubFromStorage
} else {
reject(
'No signer instance present, no npub provided by user or found in the browser.'
)
return
}
} else {
store.dispatch(updateNsecbunkerPubkey(npub))
}
// Pubkey of a key pair stored in nsecbunker that will be used to sign event with
const appPubkeyOrToken = npub.includes('npub')
? npub
: nip19.npubEncode(npub)
/**
* When creating and NDK instance we create new connection to the relay
* To prevent too much connections and hitting rate limits, if npub against which we sign
* we will reuse existing instance. Otherwise we will create new NDK and signer instance.
*/
if (!this.remoteSigner || this.remoteSigner?.remotePubkey !== npub) {
this.remoteSigner = new NDKNip46Signer(
this.bunkerNDK!,
appPubkeyOrToken,
localSigner
)
}
/**
* when nsecbunker-delegated-key is regenerated we have to reinitialize the remote signer
*/
if (this.remoteSigner.localSigner !== localSigner) {
this.remoteSigner = new NDKNip46Signer(
this.bunkerNDK!,
appPubkeyOrToken,
localSigner
)
}
resolve(this.remoteSigner)
})
}
/**
* Signs the nostr event and returns the sig and id or full raw nostr event
* @param npub stored in nsecBunker to sign with
* @param event to be signed
* @param returnFullEvent whether to return full raw nostr event or just SIG and ID values
*/
public signWithNsecBunker = async (
npub: string | undefined,
event: NostrEvent,
returnFullEvent = true
): Promise<{ id: string; sig: string } | NostrEvent> => {
return new Promise((resolve, reject) => {
this.createNsecBunkerSigner(npub)
.then(async (signer) => {
const ndkEvent = new NDKEvent(undefined, event)
const timeout = setTimeout(() => {
reject('Timeout occurred while waiting for event signing')
}, 60000) // 60000 ms (1 min) = 1000 * 60
await ndkEvent.sign(signer).catch((err) => {
clearTimeout(timeout)
reject(err)
return
})
clearTimeout(timeout)
if (returnFullEvent) {
resolve(ndkEvent.rawEvent())
} else {
resolve({
id: ndkEvent.id,
sig: ndkEvent.sig!
})
}
})
.catch((err) => {
reject(err)
})
})
}
public static getInstance(): NostrController {
if (!NostrController.instance) {
NostrController.instance = new NostrController()
@ -206,60 +32,11 @@ export class NostrController extends EventEmitter {
*/
nip44Encrypt = async (receiver: string, content: string) => {
// Retrieve the current login method from the application's redux state.
const loginMethod = (store.getState().auth as AuthState).loginMethod
const loginMethod = store.getState().auth.loginMethod
const context = new LoginMethodContext(loginMethod)
// Handle encryption when the login method is via an extension.
if (loginMethod === LoginMethods.extension) {
const nostr = this.getNostrObject()
// Check if the nostr object supports NIP-44 encryption.
if (!nostr.nip44) {
throw new Error(
`Your nostr extension does not support nip44 encryption & decryption`
)
}
// Encrypt the content using NIP-44 provided by the nostr extension.
const encrypted = await nostr.nip44.encrypt(receiver, content)
return encrypted as string
}
// Handle encryption when the login method is via a private key.
if (loginMethod === LoginMethods.privateKey) {
const keys = (store.getState().auth as AuthState).keyPair
// Check if the private and public key pair is available.
if (!keys) {
throw new Error(
`Login method is ${LoginMethods.privateKey} but private & public key pair is not found.`
)
}
// Decode the private key.
const { private: nsec } = keys
const privateKey = nip19.decode(nsec).data as Uint8Array
// Generate the conversation key using NIP-44 utilities.
const nip44ConversationKey = nip44.v2.utils.getConversationKey(
privateKey,
receiver
)
// Encrypt the content using the generated conversation key.
const encrypted = nip44.v2.encrypt(content, nip44ConversationKey)
return encrypted
}
// Throw an error if the login method is nsecBunker (not supported).
if (loginMethod === LoginMethods.nsecBunker) {
throw new Error(
`nip44 encryption is not yet supported for login method '${LoginMethods.nsecBunker}'`
)
}
// Throw an error if the login method is undefined or unsupported.
throw new Error('Login method is undefined')
return await context.nip44Encrypt(receiver, content)
}
/**
@ -272,180 +49,48 @@ export class NostrController extends EventEmitter {
*/
nip44Decrypt = async (sender: string, content: string) => {
// Retrieve the current login method from the application's redux state.
const loginMethod = (store.getState().auth as AuthState).loginMethod
const loginMethod = store.getState().auth.loginMethod
const context = new LoginMethodContext(loginMethod)
// Handle decryption when the login method is via an extension.
if (loginMethod === LoginMethods.extension) {
const nostr = this.getNostrObject()
// Check if the nostr object supports NIP-44 decryption.
if (!nostr.nip44) {
throw new Error(
`Your nostr extension does not support nip44 encryption & decryption`
)
}
// Decrypt the content using NIP-44 provided by the nostr extension.
const decrypted = await nostr.nip44.decrypt(sender, content)
return decrypted as string
}
// Handle decryption when the login method is via a private key.
if (loginMethod === LoginMethods.privateKey) {
const keys = (store.getState().auth as AuthState).keyPair
// Check if the private and public key pair is available.
if (!keys) {
throw new Error(
`Login method is ${LoginMethods.privateKey} but private & public key pair is not found.`
)
}
// Decode the private key.
const { private: nsec } = keys
const privateKey = nip19.decode(nsec).data as Uint8Array
// Generate the conversation key using NIP-44 utilities.
const nip44ConversationKey = nip44.v2.utils.getConversationKey(
privateKey,
sender
)
// Decrypt the content using the generated conversation key.
const decrypted = nip44.v2.decrypt(content, nip44ConversationKey)
return decrypted
}
// Throw an error if the login method is nsecBunker (not supported).
if (loginMethod === LoginMethods.nsecBunker) {
throw new Error(
`nip44 decryption is not yet supported for login method '${LoginMethods.nsecBunker}'`
)
}
// Throw an error if the login method is undefined or unsupported.
throw new Error('Login method is undefined')
// Handle decryption
return await context.nip44Decrypt(sender, content)
}
/**
* Signs an event with private key (if it is present in local storage) or
* with browser extension (if it is present) or
* with nSecBunker instance.
* with browser extension (if it is present)
* @param event - unsigned nostr event.
* @returns - a promised that is resolved with signed nostr event.
*/
signEvent = async (
event: UnsignedEvent | EventTemplate
): Promise<SignedEvent> => {
const loginMethod = (store.getState().auth as AuthState).loginMethod
const loginMethod = store.getState().auth.loginMethod
const context = new LoginMethodContext(loginMethod)
if (!loginMethod) {
return Promise.reject('No login method found in the browser storage')
const authkey = store.getState().auth.usersPubkey
const signedEvent = await context.signEvent(event)
const pubkey = signedEvent.pubkey
// Forcefully log out the user if we detect missmatch between pubkeys
// Allow undefined authkey, intial log in
if (authkey && authkey !== pubkey) {
if (loginMethod === LoginMethod.nostrLogin) {
nostrLogout()
}
store.dispatch(userLogOutAction())
clear()
throw new Error('User missmatch.\n\nPlease log in again.')
}
if (loginMethod === LoginMethods.nsecBunker) {
// Check if nsecBunker is available
if (!this.bunkerNDK) {
return Promise.reject(
`Login method is ${loginMethod} but bunkerNDK is not created`
)
}
if (!this.remoteSigner) {
return Promise.reject(
`Login method is ${loginMethod} but bunkerNDK is not created`
)
}
const signedEvent = await this.signWithNsecBunker(
'',
event as NostrEvent
).catch((err) => {
throw err
})
return Promise.resolve(signedEvent as SignedEvent)
} else if (loginMethod === LoginMethods.privateKey) {
const keys = (store.getState().auth as AuthState).keyPair
if (!keys) {
return Promise.reject(
`Login method is ${loginMethod}, but keys are not found`
)
}
const { private: nsec } = keys
const privateKey = nip19.decode(nsec).data as Uint8Array
const signedEvent = finalizeEvent(event, privateKey)
verifySignedEvent(signedEvent)
return Promise.resolve(signedEvent)
} else if (loginMethod === LoginMethods.extension) {
const nostr = this.getNostrObject()
return (await nostr
.signEvent(event as NostrEvent)
.catch((err: unknown) => {
console.log('Error while signing event: ', err)
throw err
})) as Event
} else {
return Promise.reject(
`We could not sign the event, none of the signing methods are available`
)
}
return signedEvent
}
nip04Encrypt = async (receiver: string, content: string): Promise<string> => {
const loginMethod = (store.getState().auth as AuthState).loginMethod
const loginMethod = store.getState().auth.loginMethod
const context = new LoginMethodContext(loginMethod)
if (loginMethod === LoginMethods.extension) {
const nostr = this.getNostrObject()
if (!nostr.nip04) {
throw new Error(
`Your nostr extension does not support nip04 encryption & decryption`
)
}
const encrypted = await nostr.nip04.encrypt(receiver, content)
return encrypted
}
if (loginMethod === LoginMethods.privateKey) {
const keys = (store.getState().auth as AuthState).keyPair
if (!keys) {
throw new Error(
`Login method is ${LoginMethods.privateKey} but private & public key pair is not found.`
)
}
const { private: nsec } = keys
const privateKey = nip19.decode(nsec).data as Uint8Array
const encrypted = await nip04.encrypt(privateKey, receiver, content)
return encrypted
}
if (loginMethod === LoginMethods.nsecBunker) {
const user = new NDKUser({ pubkey: receiver })
this.remoteSigner?.on('authUrl', (authUrl) => {
this.emit('nsecbunker-auth', authUrl)
})
if (!this.remoteSigner) throw new Error('Remote signer is undefined.')
const encrypted = await this.remoteSigner.encrypt(user, content)
return encrypted
}
throw new Error('Login method is undefined')
return await context.nip04Encrypt(receiver, content)
}
/**
@ -456,79 +101,44 @@ export class NostrController extends EventEmitter {
* @returns A promise that resolves to the decrypted content.
*/
nip04Decrypt = async (sender: string, content: string): Promise<string> => {
const loginMethod = (store.getState().auth as AuthState).loginMethod
const loginMethod = store.getState().auth.loginMethod
const context = new LoginMethodContext(loginMethod)
if (loginMethod === LoginMethods.extension) {
const nostr = this.getNostrObject()
if (!nostr.nip04) {
throw new Error(
`Your nostr extension does not support nip04 encryption & decryption`
)
}
const decrypted = await nostr.nip04.decrypt(sender, content)
return decrypted
}
if (loginMethod === LoginMethods.privateKey) {
const keys = (store.getState().auth as AuthState).keyPair
if (!keys) {
throw new Error(
`Login method is ${LoginMethods.privateKey} but private & public key pair is not found.`
)
}
const { private: nsec } = keys
const privateKey = nip19.decode(nsec).data as Uint8Array
const decrypted = await nip04.decrypt(privateKey, sender, content)
return decrypted
}
if (loginMethod === LoginMethods.nsecBunker) {
const user = new NDKUser({ pubkey: sender })
this.remoteSigner?.on('authUrl', (authUrl) => {
this.emit('nsecbunker-auth', authUrl)
})
if (!this.remoteSigner) throw new Error('Remote signer is undefined.')
const decrypted = await this.remoteSigner.decrypt(user, content)
return decrypted
}
throw new Error('Login method is undefined')
return await context.nip04Decrypt(sender, content)
}
/**
* Function will capture the public key from the nostr extension or if no extension present
* function wil capture the public key from the local storage
* Function will capture the public key from signedEvent
*/
capturePublicKey = async (): Promise<string> => {
const nostr = this.getNostrObject()
const pubKey = await nostr.getPublicKey().catch((err: unknown) => {
if (err instanceof Error) {
return Promise.reject(err.message)
} else {
return Promise.reject(JSON.stringify(err))
try {
const timestamp = unixNow()
const { href } = window.location
const authEvent: EventTemplate = {
kind: 27235,
tags: [
['u', href],
['method', 'GET']
],
content: '',
created_at: timestamp
}
})
if (!pubKey) {
return Promise.reject('Error getting public key, user canceled')
const signedAuthEvent = await this.signEvent(authEvent)
const pubkey = signedAuthEvent.pubkey
if (!pubkey) {
return Promise.reject('Error getting public key, user canceled')
}
return Promise.resolve(pubkey)
} catch (error) {
if (error instanceof Error) {
return Promise.reject(error.message)
} else {
return Promise.reject(JSON.stringify(error))
}
}
return Promise.resolve(pubKey)
}
/**
* Generates NDK Private Signer
* @returns nSecBunker delegated key
*/
generateDelegatedKey = (): string => {
return NDKPrivateKeySigner.generate().privateKey!
}
}

View File

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

View File

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

View File

@ -19,7 +19,7 @@
"page": 1
},
"npub": "npub1x77qywdllzetv9ncnhlfpv62kshlgtt0uqlsq3v22uzzkk2xvvrsn6uyfy",
"pdfFileHash": "da5f857e77d3aa59c461efad804116931c059b36e6b4da0b5d9452753ec70c05"
"fileHash": "da5f857e77d3aa59c461efad804116931c059b36e6b4da0b5d9452753ec70c05"
}
],
"da5f857e77d3aa59c461efad804116931c059b36e6b4da0b5d9452753ec70c05/2.png": [
@ -34,7 +34,7 @@
"page": 2
},
"npub": "npub1x77qywdllzetv9ncnhlfpv62kshlgtt0uqlsq3v22uzzkk2xvvrsn6uyfy",
"pdfFileHash": "da5f857e77d3aa59c461efad804116931c059b36e6b4da0b5d9452753ec70c05"
"fileHash": "da5f857e77d3aa59c461efad804116931c059b36e6b4da0b5d9452753ec70c05"
}
]
}
@ -54,7 +54,7 @@
"page": 1
},
"npub": "npub1x77qywdllzetv9ncnhlfpv62kshlgtt0uqlsq3v22uzzkk2xvvrsn6uyfy",
"pdfFileHash": "da5f857e77d3aa59c461efad804116931c059b36e6b4da0b5d9452753ec70c05",
"fileHash": "da5f857e77d3aa59c461efad804116931c059b36e6b4da0b5d9452753ec70c05",
"value": "Pera Peric"
},
{
@ -68,7 +68,7 @@
"page": 2
},
"npub": "npub1x77qywdllzetv9ncnhlfpv62kshlgtt0uqlsq3v22uzzkk2xvvrsn6uyfy",
"pdfFileHash": "da5f857e77d3aa59c461efad804116931c059b36e6b4da0b5d9452753ec70c05",
"fileHash": "da5f857e77d3aa59c461efad804116931c059b36e6b4da0b5d9452753ec70c05",
"value": "Pera Peric"
}
]

View File

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

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

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

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

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

26
src/hooks/useLogout.tsx Normal file
View File

@ -0,0 +1,26 @@
import { logout as nostrLogout } from 'nostr-login'
import { clear } from '../utils/localStorage'
import { userLogOutAction } from '../store/actions'
import { LoginMethod } from '../store/auth/types'
import { useAppDispatch, useAppSelector } from './store'
import { useCallback } from 'react'
export const useLogout = () => {
const loginMethod = useAppSelector((state) => state.auth?.loginMethod)
const dispatch = useAppDispatch()
const logout = useCallback(() => {
// Log out of the nostr-login
if (loginMethod === LoginMethod.nostrLogin) {
nostrLogout()
}
// Reset redux state with the logout
dispatch(userLogOutAction())
// Clear the local storage states
clear()
}, [dispatch, loginMethod])
return logout
}

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

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

View File

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

View File

@ -0,0 +1,27 @@
import { useEffect, useState } from 'react'
import { NDKUserProfile } from '@nostr-dev-kit/ndk'
import { useNDKContext } from './useNDKContext'
export const useProfileMetadata = (pubkey: string) => {
const { findMetadata } = useNDKContext()
const [userProfile, setUserProfile] = useState<NDKUserProfile>()
useEffect(() => {
if (pubkey) {
findMetadata(pubkey)
.then((profile) => {
if (profile) setUserProfile(profile)
})
.catch((err) => {
console.error(
`error occurred in finding metadata for: ${pubkey}`,
err
)
})
}
}, [pubkey, findMetadata])
return userProfile
}

View File

@ -1,10 +1,5 @@
import { useEffect, useState } from 'react'
import {
CreateSignatureEventContent,
DocSignatureEvent,
Meta,
SignedEventContent
} from '../types'
import { DocSignatureEvent, Meta, SignedEventContent, FlatMeta } from '../types'
import { Mark } from '../types/mark'
import {
fromUnixTimestamp,
@ -16,46 +11,10 @@ import {
} from '../utils'
import { toast } from 'react-toastify'
import { verifyEvent } from 'nostr-tools'
import { Event } from 'nostr-tools'
import store from '../store/store'
import { AuthState } from '../store/auth/types'
import { NostrController } from '../controllers'
import { MetaParseError } from '../types/errors/MetaParseError'
/**
* Flattened interface that combines properties `Meta`, `CreateSignatureEventContent`,
* and `Event` (event's fields are made optional and pubkey and created_at replaced with our versions)
*/
export interface FlatMeta
extends Meta,
CreateSignatureEventContent,
Partial<Omit<Event, 'pubkey' | 'created_at'>> {
// Remove pubkey and use submittedBy as `npub1${string}`
submittedBy?: `npub1${string}`
// Remove created_at and replace with createdAt
createdAt?: number
// Validated create signature event
isValid: boolean
// Decryption
encryptionKey: string | null
// Parsed Document Signatures
parsedSignatureEvents: {
[signer: `npub1${string}`]: DocSignatureEvent
}
// Calculated completion time
completedAt?: number
// Calculated status fields
signedStatus: SigitStatus
signersStatus: {
[signer: `npub1${string}`]: SignStatus
}
}
import { MARK_TYPE_CONFIG } from '../components/MarkTypeStrategy/MarkStrategy'
/**
* Custom use hook for parsing the Sigit Meta
@ -67,7 +26,8 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
const [kind, setKind] = useState<number>()
const [tags, setTags] = useState<string[][]>()
const [createdAt, setCreatedAt] = useState<number>()
const [submittedBy, setSubmittedBy] = useState<`npub1${string}`>() // submittedBy, pubkey from nostr event
const [submittedBy, setSubmittedBy] = useState<string>() // submittedBy, pubkey from nostr event (hex)
const [exportedBy, setExportedBy] = useState<string>() // pubkey from export signature nostr event (hex)
const [id, setId] = useState<string>()
const [sig, setSig] = useState<string>()
@ -93,13 +53,23 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
[signer: `npub1${string}`]: SignStatus
}>({})
const [encryptionKey, setEncryptionKey] = useState<string | null>(null)
const [encryptionKey, setEncryptionKey] = useState<string | undefined>()
useEffect(() => {
if (!meta) return
;(async function () {
try {
const createSignatureEvent = await parseNostrEvent(meta.createSignature)
if (meta.exportSignature) {
const exportSignatureEvent = parseNostrEvent(meta.exportSignature)
if (
verifyEvent(exportSignatureEvent) &&
exportSignatureEvent.pubkey
) {
setExportedBy(exportSignatureEvent.pubkey)
}
}
const createSignatureEvent = parseNostrEvent(meta.createSignature)
const { kind, tags, created_at, pubkey, id, sig, content } =
createSignatureEvent
@ -109,12 +79,12 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
setTags(tags)
// created_at in nostr events are stored in seconds
setCreatedAt(fromUnixTimestamp(created_at))
setSubmittedBy(pubkey as `npub1${string}`)
setSubmittedBy(pubkey)
setId(id)
setSig(sig)
const { title, signers, viewers, fileHashes, markConfig, zipUrl } =
await parseCreateSignatureEventContent(content)
parseCreateSignatureEventContent(content)
setTitle(title)
setSigners(signers)
@ -123,10 +93,11 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
setMarkConfig(markConfig)
setZipUrl(zipUrl)
let encryptionKey: string | undefined
if (meta.keys) {
const { sender, keys } = meta.keys
// Retrieve the user's public key from the state
const usersPubkey = (store.getState().auth as AuthState).usersPubkey!
const usersPubkey = store.getState().auth.usersPubkey!
const usersNpub = hexToNpub(usersPubkey)
// Check if the user's public key is in the keys object
@ -140,13 +111,13 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
'An error occurred in decrypting encryption key',
err
)
return null
return undefined
})
encryptionKey = decrypted
setEncryptionKey(decrypted)
}
}
// Temp. map to hold events and signers
const parsedSignatureEventsMap = new Map<
`npub1${string}`,
@ -188,13 +159,40 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
}
}
parsedSignatureEventsMap.forEach((event, npub) => {
for (const [npub, event] of parsedSignatureEventsMap) {
const isValidSignature = verifyEvent(event)
if (isValidSignature) {
// get the signature of prev signer from the content of current signers signedEvent
const prevSignersSig = getPrevSignerSig(npub)
try {
const obj: SignedEventContent = JSON.parse(event.content)
// Signature object can include values that need to be fetched and decrypted
for (let i = 0; i < obj.marks.length; i++) {
const m = obj.marks[i]
try {
const { fetchAndDecrypt } = MARK_TYPE_CONFIG[m.type] || {}
if (
typeof fetchAndDecrypt === 'function' &&
m.value &&
encryptionKey
) {
const decrypted = await fetchAndDecrypt(
m.value,
encryptionKey
)
obj.marks[i].value = decrypted
}
} catch (error) {
console.error(
`Error during mark fetchAndDecrypt phase`,
error
)
}
}
parsedSignatureEventsMap.set(npub, {
...event,
parsedContent: obj
@ -210,7 +208,7 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
signerStatusMap.set(npub as `npub1${string}`, SignStatus.Invalid)
}
}
})
}
signers
.filter((s) => !parsedSignatureEventsMap.has(s))
@ -260,11 +258,13 @@ export const useSigitMeta = (meta: Meta): FlatMeta => {
createSignature: meta?.createSignature,
docSignatures: meta?.docSignatures,
keys: meta?.keys,
timestamps: meta?.timestamps,
isValid,
kind,
tags,
createdAt,
submittedBy,
exportedBy,
id,
sig,
signers,

View File

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

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

View File

@ -1,100 +1,171 @@
import { Event, kinds } from 'nostr-tools'
import { useEffect, useRef, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { Outlet } from 'react-router-dom'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Outlet, useNavigate, useSearchParams } from 'react-router-dom'
import { getPublicKey, nip19 } from 'nostr-tools'
import { init as initNostrLogin } from 'nostr-login'
import { NostrLoginAuthOptions } from 'nostr-login/dist/types'
import { AppBar } from '../components/AppBar/AppBar'
import { LoadingSpinner } from '../components/LoadingSpinner'
import { MetadataController, NostrController } from '../controllers'
import { NostrController } from '../controllers'
import {
useAppDispatch,
useAppSelector,
useAuth,
useLogout,
useNDK,
useNDKContext
} from '../hooks'
import {
restoreState,
setAuthState,
setMetadataEvent,
updateUserAppData
setUserProfile,
updateKeyPair,
updateLoginMethod,
updateNostrLoginAuthMethod,
updateUserAppData,
setUserRobotImage
} from '../store/actions'
import { LoginMethods } from '../store/auth/types'
import { State } from '../store/rootReducer'
import { Dispatch } from '../store/store'
import { setUserRobotImage } from '../store/userRobotImage/action'
import {
clearAuthToken,
clearState,
getRoboHashPicture,
getUsersAppData,
loadState,
saveNsecBunkerDelegatedKey,
subscribeForSigits
} from '../utils'
import { useAppSelector } from '../hooks'
import { LoginMethod } from '../store/auth/types'
import { getRoboHashPicture, loadState } from '../utils'
import styles from './style.module.scss'
import { Footer } from '../components/Footer/Footer'
export const MainLayout = () => {
const dispatch: Dispatch = useDispatch()
const [searchParams, setSearchParams] = useSearchParams()
const navigate = useNavigate()
const dispatch = useAppDispatch()
const logout = useLogout()
const { findMetadata } = useNDKContext()
const { authAndGetMetadataAndRelaysMap } = useAuth()
const { getUsersAppData, subscribeForSigits } = useNDK()
const [isLoading, setIsLoading] = useState(true)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState(`Loading App`)
const authState = useSelector((state: State) => state.auth)
const isLoggedIn = useAppSelector((state) => state.auth?.loggedIn)
const authState = useAppSelector((state) => state.auth)
const usersAppData = useAppSelector((state) => state.userAppData)
// Ref to track if `subscribeForSigits` has been called
const hasSubscribed = useRef(false)
useEffect(() => {
const metadataController = new MetadataController()
const navigateAfterLogin = (path: string) => {
const callbackPath = searchParams.get('callbackPath')
const logout = () => {
dispatch(
setAuthState({
keyPair: undefined,
loggedIn: false,
usersPubkey: undefined,
loginMethod: undefined,
nsecBunkerPubkey: undefined
})
)
dispatch(setMetadataEvent(metadataController.getEmptyMetadataEvent()))
// clear authToken saved in local storage
clearAuthToken()
clearState()
// update nsecBunker delegated key
const newDelegatedKey =
NostrController.getInstance().generateDelegatedKey()
saveNsecBunkerDelegatedKey(newDelegatedKey)
if (callbackPath) {
// base64 decoded path
const path = atob(callbackPath)
navigate(path)
return
}
navigate(path)
}
const login = useCallback(async () => {
dispatch(updateLoginMethod(LoginMethod.nostrLogin))
const nostrController = NostrController.getInstance()
const pubkey = await nostrController.capturePublicKey()
const redirectPath = await authAndGetMetadataAndRelaysMap(pubkey)
if (redirectPath) {
navigateAfterLogin(redirectPath)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dispatch])
useEffect(() => {
// Developer login with ?nsec= (not recommended)
const nsec = searchParams.get('nsec')
if (!nsec) return
// Clear nsec from the url immediately
searchParams.delete('nsec')
setSearchParams(searchParams)
if (!authState?.loggedIn) {
if (!nsec.startsWith('nsec')) {
console.error('Invalid format, use private key (nsec)')
return
}
try {
const privateKey = nip19.decode(nsec).data as Uint8Array
if (!privateKey) {
console.error('Failed to convert the private key.')
return
}
const publickey = getPublicKey(privateKey)
dispatch(
updateKeyPair({
private: nsec,
public: publickey
})
)
dispatch(updateLoginMethod(LoginMethod.privateKey))
authAndGetMetadataAndRelaysMap(publickey).catch((err) => {
console.error('Error occurred in authentication: ' + err)
return null
})
} catch (err) {
console.error(`Error decoding the nsec. ${err}`)
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dispatch, searchParams])
useEffect(() => {
const handleNostrAuth = (_: string, opts: NostrLoginAuthOptions) => {
if (opts.type === 'login' || opts.type === 'signup') {
dispatch(updateNostrLoginAuthMethod(opts.method))
login()
} else if (opts.type === 'logout') {
// Clear `subscribeForSigits` as called after the logout
hasSubscribed.current = false
}
}
// Initialize the nostr-login
initNostrLogin({
methods: ['connect', 'extension', 'local'],
noBanner: true,
onAuth: handleNostrAuth,
outboxRelays: [
'wss://purplepag.es',
'wss://relay.nos.social',
'wss://user.kindpag.es',
'wss://relay.damus.io',
'wss://nos.lol',
'wss://relay.sigit.io'
]
}).catch((error) => {
console.error('Failed to initialize Nostr-Login', error)
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dispatch])
useEffect(() => {
const restoredState = loadState()
if (restoredState) {
dispatch(restoreState(restoredState))
const { loggedIn, loginMethod, usersPubkey, nsecBunkerRelays } =
restoredState.auth
const { loggedIn, loginMethod, usersPubkey } = restoredState.auth
if (loggedIn) {
if (!loginMethod || !usersPubkey) return logout()
if (loginMethod === LoginMethods.nsecBunker) {
if (!nsecBunkerRelays) return logout()
const nostrController = NostrController.getInstance()
nostrController.nsecBunkerInit(nsecBunkerRelays).then(() => {
nostrController.createNsecBunkerSigner(usersPubkey)
})
}
const handleMetadataEvent = (event: Event) => {
dispatch(setMetadataEvent(event))
}
metadataController.on(usersPubkey, (kind: number, event: Event) => {
if (kind === kinds.Metadata) {
handleMetadataEvent(event)
}
})
metadataController.findMetadata(usersPubkey).then((metadataEvent) => {
if (metadataEvent) handleMetadataEvent(metadataEvent)
findMetadata(usersPubkey).then((profile) => {
dispatch(setUserProfile(profile))
})
} else {
setIsLoading(false)
@ -102,21 +173,26 @@ export const MainLayout = () => {
} else {
setIsLoading(false)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dispatch])
/**
* Subscribe for the sigits
*/
useEffect(() => {
if (authState.loggedIn && usersAppData) {
if (authState && isLoggedIn && usersAppData) {
const pubkey = authState.usersPubkey || authState.keyPair?.public
if (pubkey && !hasSubscribed.current) {
// Call `subscribeForSigits` only if it hasn't been called before
// #193 disabled websocket subscribtion, until #194 is done
subscribeForSigits(pubkey)
// Mark `subscribeForSigits` as called
hasSubscribed.current = true
}
}
}, [authState, usersAppData])
}, [authState, isLoggedIn, usersAppData, subscribeForSigits])
/**
* When authState change user logged in / or app reloaded
@ -124,7 +200,7 @@ export const MainLayout = () => {
* so that avatar will be consistent across the app when kind 0 is empty
*/
useEffect(() => {
if (authState && authState.loggedIn) {
if (authState && isLoggedIn) {
const pubkey = authState.usersPubkey || authState.keyPair?.public
if (pubkey) {
@ -141,7 +217,8 @@ export const MainLayout = () => {
})
.finally(() => setIsLoading(false))
}
}, [authState, dispatch])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dispatch, isLoggedIn])
if (isLoading) return <LoadingSpinner desc={loadingSpinnerDesc} />
@ -160,7 +237,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

@ -33,7 +33,7 @@ export const Modal = () => {
{ to: appPublicRoutes.register, title: 'Register', label: 'Register' },
{
to: appPublicRoutes.nostr,
title: 'Login',
title: 'Nostr Login',
sx: { padding: '10px' },
label: <img src={nostrImage} width="25" alt="nostr logo" height="25" />
}

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

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

File diff suppressed because it is too large Load Diff

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

@ -1,10 +1,9 @@
import { Button, TextField } from '@mui/material'
import JSZip from 'jszip'
import { useCallback, useEffect, useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { toast } from 'react-toastify'
import { useAppSelector } from '../../hooks'
import { appPrivateRoutes, appPublicRoutes } from '../../routes'
import { appPrivateRoutes } from '../../routes'
import { Meta } from '../../types'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faSearch } from '@fortawesome/free-solid-svg-icons'
@ -15,9 +14,11 @@ import { Container } from '../../components/Container'
import styles from './style.module.scss'
import {
extractSigitCardDisplayInfo,
navigateFromZip,
SigitCardDisplayInfo,
SigitStatus
} from '../../utils'
import { Footer } from '../../components/Footer/Footer'
// Unsupported Filter options are commented
const FILTERS = [
@ -55,14 +56,15 @@ export const HomePage = () => {
[key: string]: SigitCardDisplayInfo
}>({})
const usersAppData = useAppSelector((state) => state.userAppData)
const usersPubkey = useAppSelector((state) => state.auth.usersPubkey)
useEffect(() => {
if (usersAppData) {
if (usersAppData?.sigits) {
const getSigitInfo = async () => {
const parsedSigits: { [key: string]: SigitCardDisplayInfo } = {}
for (const key in usersAppData.sigits) {
if (Object.prototype.hasOwnProperty.call(usersAppData.sigits, key)) {
const sigitInfo = await extractSigitCardDisplayInfo(
const sigitInfo = extractSigitCardDisplayInfo(
usersAppData.sigits[key]
)
if (sigitInfo) {
@ -79,7 +81,7 @@ export const HomePage = () => {
setSigits(usersAppData.sigits)
getSigitInfo()
}
}, [usersAppData])
}, [usersAppData?.sigits])
const onDrop = useCallback(
async (acceptedFiles: File[]) => {
@ -91,27 +93,12 @@ export const HomePage = () => {
const fileName = file.name
const fileExtension = fileName.slice(-10) // ".sigit.zip" has 10 characters
if (fileExtension === '.sigit.zip') {
const zip = await JSZip.loadAsync(file).catch((err) => {
console.log('err in loading zip file :>> ', err)
toast.error(err.message || 'An error occurred in loading zip file.')
return null
})
const nav = await navigateFromZip(
file,
usersPubkey as `npub1${string}`
)
if (!zip) return
// navigate to sign page if zip contains keys.json
if ('keys.json' in zip.files) {
return navigate(appPrivateRoutes.sign, {
state: { uploadedZip: file }
})
}
// navigate to verify page if zip contains meta.json
if ('meta.json' in zip.files) {
return navigate(appPublicRoutes.verify, {
state: { uploadedZip: file }
})
}
if (nav) return navigate(nav.to, nav.options)
toast.error('Invalid SiGit zip file')
return
@ -123,7 +110,7 @@ export const HomePage = () => {
state: { uploadedFiles: acceptedFiles }
})
},
[navigate]
[navigate, usersPubkey]
)
const { getRootProps, getInputProps, isDragActive, open } = useDropzone({
@ -134,6 +121,46 @@ export const HomePage = () => {
const [filter, setFilter] = useState<Filter>('Show all')
const [sort, setSort] = useState<Sort>('desc')
const renderSubmissions = () => {
const submissions = Object.keys(parsedSigits)
.filter((s) => {
const { title, signedStatus } = parsedSigits[s]
const isMatch = title?.toLowerCase().includes(q.toLowerCase())
switch (filter) {
case 'Completed':
return signedStatus === SigitStatus.Complete && isMatch
case 'In-progress':
return signedStatus === SigitStatus.Partial && isMatch
case 'Show all':
return isMatch
default:
console.error('Filter case not handled.')
}
})
.sort((a, b) => {
const x = parsedSigits[a].createdAt ?? 0
const y = parsedSigits[b].createdAt ?? 0
return sort === 'desc' ? y - x : x - y
})
if (submissions.length) {
return submissions.map((key) => (
<DisplaySigit
key={`sigit-${key}`}
sigitCreateId={key}
parsedMeta={parsedSigits[key]}
meta={sigits[key]}
/>
))
} else {
return (
<div className={styles.noResults}>
<p>No results</p>
</div>
)
}
}
return (
<div {...getRootProps()} tabIndex={-1}>
<Container className={styles.container}>
@ -232,36 +259,10 @@ export const HomePage = () => {
<label htmlFor="file-upload">Click or drag files to upload!</label>
)}
</button>
<div className={styles.submissions}>
{Object.keys(parsedSigits)
.filter((s) => {
const { title, signedStatus } = parsedSigits[s]
const isMatch = title?.toLowerCase().includes(q.toLowerCase())
switch (filter) {
case 'Completed':
return signedStatus === SigitStatus.Complete && isMatch
case 'In-progress':
return signedStatus === SigitStatus.Partial && isMatch
case 'Show all':
return isMatch
default:
console.error('Filter case not handled.')
}
})
.sort((a, b) => {
const x = parsedSigits[a].createdAt ?? 0
const y = parsedSigits[b].createdAt ?? 0
return sort === 'desc' ? y - x : x - y
})
.map((key) => (
<DisplaySigit
key={`sigit-${key}`}
parsedMeta={parsedSigits[key]}
meta={sigits[key]}
/>
))}
</div>
<div className={styles.submissions}>{renderSubmissions()}</div>
</Container>
<Footer />
</div>
)
}

View File

@ -99,3 +99,10 @@
gap: 25px;
grid-template-columns: repeat(auto-fit, minmax(365px, 1fr));
}
.noResults {
display: flex;
justify-content: center;
font-weight: normal;
color: #a1a1a1;
}

View File

@ -1,7 +1,6 @@
import { Box, Button } from '@mui/material'
import { useEffect } from 'react'
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
import { appPublicRoutes } from '../../routes'
import { Outlet, useLocation } from 'react-router-dom'
import { saveVisitedLink } from '../../utils'
import { CardComponent } from '../../components/Landing/CardComponent/CardComponent'
import { Container } from '../../components/Container'
@ -19,13 +18,14 @@ import {
faWifi
} from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIconStack } from '../../components/FontAwesomeIconStack'
import { Footer } from '../../components/Footer/Footer'
import { launch as launchNostrLoginDialog } from 'nostr-login'
export const LandingPage = () => {
const navigate = useNavigate()
const location = useLocation()
const onSignInClick = async () => {
navigate(appPublicRoutes.nostr)
launchNostrLoginDialog()
}
const cards = [
@ -34,7 +34,7 @@ export const LandingPage = () => {
title: <>Open Source</>,
description: (
<>
Code is MIT licenced and available at{' '}
Code is AGPL licenced and available at{' '}
<a href="https://git.nostrdev.com/sigit/sigit.io">
https://git.nostrdev.com/sigit/sigit.io
</a>
@ -69,8 +69,8 @@ export const LandingPage = () => {
title: <>Verifiable</>,
description: (
<>
Thanks to Schnorr Signatures and Web of Trust, SIGit is far more
auditable than traditional server-based offerings.
SIGit Agreements can be directly verified - unlike traditional,
server-based offerings.
</>
)
},
@ -84,8 +84,8 @@ export const LandingPage = () => {
title: <>Works Offline</>,
description: (
<>
Presuming you have a hardware signing device, it is possible to
complete a SIGit round without an internet connection.
It is possible to complete a SIGit round without an internet
connection.
</>
)
},
@ -94,8 +94,8 @@ export const LandingPage = () => {
title: <>Multi-Party Signing</>,
description: (
<>
Choose any number of Signers and Viewers, track the signature status,
send reminders, get notifications on completion.
Choose any number of Signers and Viewers, track status, get
notifications on completion.
</>
)
}
@ -119,9 +119,7 @@ export const LandingPage = () => {
<Container className={styles.container}>
<img className={styles.logo} src="/logo.svg" alt="Logo" width={300} />
<div className={styles.titleSection}>
<h1 className={styles.title}>
Secure &amp; Private Document Signing
</h1>
<h1 className={styles.title}>Secure &amp; Private Agreements</h1>
<p className={styles.subTitle}>
An open-source and self-hostable solution for secure document
signing and verification.
@ -162,6 +160,7 @@ export const LandingPage = () => {
<Outlet />
</Container>
<Footer />
</div>
)
}

View File

@ -12,6 +12,11 @@ export const Login = () => {
margin="dense"
autoComplete="username"
disabled
sx={{
input: {
cursor: 'not-allowed'
}
}}
/>
<TextField
label="Password"
@ -20,6 +25,11 @@ export const Login = () => {
margin="dense"
autoComplete="current-password"
disabled
sx={{
input: {
cursor: 'not-allowed'
}
}}
/>
<Button variant="contained" fullWidth disabled>

View File

@ -1,62 +1,31 @@
import { Button, Divider, TextField } from '@mui/material'
import { getPublicKey, nip19 } from 'nostr-tools'
import { useEffect, useState } from 'react'
import { useDispatch } from 'react-redux'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { Button, Divider, TextField } from '@mui/material'
import { toast } from 'react-toastify'
import { LoadingSpinner } from '../../components/LoadingSpinner'
import {
AuthController,
MetadataController,
NostrController
} from '../../controllers'
import {
updateKeyPair,
updateLoginMethod,
updateNsecbunkerPubkey,
updateNsecbunkerRelays
} from '../../store/actions'
import { LoginMethods } from '../../store/auth/types'
import { Dispatch } from '../../store/store'
import { npubToHex, queryNip05 } from '../../utils'
import { hexToBytes } from '@noble/hashes/utils'
import { NIP05_REGEX } from '../../constants'
import { launch as launchNostrLoginDialog } from 'nostr-login'
import { getPublicKey, nip19 } from 'nostr-tools'
import { LoadingSpinner } from '../../components/LoadingSpinner'
import { useAppDispatch, useAuth } from '../../hooks'
import { updateKeyPair, updateLoginMethod } from '../../store/actions'
import { LoginMethod } from '../../store/auth/types'
import { KeyboardCode } from '../../types'
import styles from './styles.module.scss'
export const Nostr = () => {
const [searchParams] = useSearchParams()
const { authAndGetMetadataAndRelaysMap } = useAuth()
const dispatch: Dispatch = useDispatch()
const dispatch = useAppDispatch()
const navigate = useNavigate()
const authController = new AuthController()
const metadataController = new MetadataController()
const nostrController = NostrController.getInstance()
const [isLoading, setIsLoading] = useState(false)
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
const [inputValue, setInputValue] = useState('')
const [authUrl, setAuthUrl] = useState<string>()
const [isNostrExtensionAvailable, setIsNostrExtensionAvailable] =
useState(false)
useEffect(() => {
setTimeout(() => {
setIsNostrExtensionAvailable(!!window.nostr)
}, 500)
}, [])
/**
* Call login function when enter is pressed
*/
const handleInputKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.code === 'Enter' || event.code === 'NumpadEnter') {
event.preventDefault()
login()
}
}
const navigateAfterLogin = (path: string) => {
const callbackPath = searchParams.get('callbackPath')
@ -71,28 +40,26 @@ export const Nostr = () => {
navigate(path)
}
const loginWithExtension = async () => {
setIsLoading(true)
setLoadingSpinnerDesc('Capturing pubkey from nostr extension')
const [isNostrExtensionAvailable, setIsNostrExtensionAvailable] =
useState(false)
nostrController
.capturePublicKey()
.then(async (pubkey) => {
dispatch(updateLoginMethod(LoginMethods.extension))
useEffect(() => {
setTimeout(() => {
setIsNostrExtensionAvailable(!!window.nostr)
}, 500)
}, [])
setLoadingSpinnerDesc('Authenticating and finding metadata')
const redirectPath =
await authController.authAndGetMetadataAndRelaysMap(pubkey)
if (redirectPath) navigateAfterLogin(redirectPath)
})
.catch((err) => {
toast.error('Error capturing public key from nostr extension: ' + err)
})
.finally(() => {
setIsLoading(false)
setLoadingSpinnerDesc('')
})
/**
* Call login function when enter is pressed
*/
const handleInputKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (
event.code === KeyboardCode.Enter ||
event.code === KeyboardCode.NumpadEnter
) {
event.preventDefault()
login()
}
}
/**
@ -130,17 +97,17 @@ export const Nostr = () => {
public: publickey
})
)
dispatch(updateLoginMethod(LoginMethods.privateKey))
dispatch(updateLoginMethod(LoginMethod.privateKey))
setIsLoading(true)
setLoadingSpinnerDesc('Authenticating and finding metadata')
const redirectPath = await authController
.authAndGetMetadataAndRelaysMap(publickey)
.catch((err) => {
const redirectPath = await authAndGetMetadataAndRelaysMap(publickey).catch(
(err) => {
toast.error('Error occurred in authentication: ' + err)
return null
})
}
)
if (redirectPath) navigateAfterLogin(redirectPath)
@ -148,182 +115,10 @@ export const Nostr = () => {
setLoadingSpinnerDesc('')
}
const loginWithNsecBunker = async () => {
let relays: string[] | undefined
let pubkey: string | undefined
setIsLoading(true)
const displayError = (message: string) => {
toast.error(message)
setIsLoading(false)
setLoadingSpinnerDesc('')
}
if (inputValue.match(NIP05_REGEX)) {
const nip05Profile = await queryNip05(inputValue).catch((err) => {
toast.error('An error occurred while querying nip05 profile: ' + err)
return null
})
if (nip05Profile) {
pubkey = nip05Profile.pubkey
relays = nip05Profile.relays
}
} else if (inputValue.startsWith('npub')) {
pubkey = nip19.decode(inputValue).data as string
const metadataEvent = await metadataController
.findMetadata(pubkey)
.catch(() => {
return null
})
if (!metadataEvent) {
return displayError('metadata not found!')
}
const metadataContent =
metadataController.extractProfileMetadataContent(metadataEvent)
if (!metadataContent?.nip05) {
return displayError('nip05 not present in metadata')
}
const nip05Profile = await queryNip05(inputValue).catch((err) => {
toast.error('An error occurred while querying nip05 profile: ' + err)
return null
})
if (nip05Profile) {
if (nip05Profile.pubkey !== pubkey) {
return displayError(
'pubkey in nip05 does not match with provided npub'
)
}
relays = nip05Profile.relays
}
}
if (!relays || relays.length === 0) {
return displayError('No relay found for nsecbunker')
}
if (!pubkey) {
return displayError('pubkey not found')
}
setLoadingSpinnerDesc('Initializing nsecBunker')
await nostrController.nsecBunkerInit(relays)
setLoadingSpinnerDesc('Creating nsecbunker singer')
await nostrController
.createNsecBunkerSigner(pubkey)
.then(async (signer) => {
signer.on('authUrl', (url: string) => {
setAuthUrl(url)
})
dispatch(updateLoginMethod(LoginMethods.nsecBunker))
dispatch(updateNsecbunkerPubkey(pubkey))
dispatch(updateNsecbunkerRelays(relays))
setLoadingSpinnerDesc('Authenticating and finding metadata')
const redirectPath = await authController
.authAndGetMetadataAndRelaysMap(pubkey!)
.catch((err) => {
toast.error('Error occurred in authentication: ' + err)
return null
})
if (redirectPath) navigateAfterLogin(redirectPath)
})
.catch((err) => {
toast.error(
'An error occurred while creating nsecbunker signer: ' + err
)
})
.finally(() => {
setIsLoading(false)
setLoadingSpinnerDesc('')
})
}
const loginWithBunkerConnectionString = async () => {
// Extract the key
const keyStartIndex = inputValue.indexOf('bunker://') + 'bunker://'.length
const keyEndIndex = inputValue.indexOf('?relay=')
const key = inputValue.substring(keyStartIndex, keyEndIndex)
const pubkey = npubToHex(key)
if (!pubkey) {
toast.error('Invalid pubkey in bunker connection string.')
setIsLoading(false)
return
}
// Extract the relay value
const relayIndex = inputValue.indexOf('relay=')
const relay = inputValue.substring(
relayIndex + 'relay='.length,
inputValue.length
)
setIsLoading(true)
setLoadingSpinnerDesc('Initializing bunker NDK')
await nostrController.nsecBunkerInit([relay])
setLoadingSpinnerDesc('Creating remote signer')
await nostrController
.createNsecBunkerSigner(pubkey)
.then(async (signer) => {
signer.on('authUrl', (url: string) => {
setAuthUrl(url)
})
dispatch(updateLoginMethod(LoginMethods.nsecBunker))
dispatch(updateNsecbunkerPubkey(pubkey))
dispatch(updateNsecbunkerRelays([relay]))
setLoadingSpinnerDesc('Authenticating and finding metadata')
const redirectPath = await authController
.authAndGetMetadataAndRelaysMap(pubkey!)
.catch((err) => {
toast.error('Error occurred in authentication: ' + err)
return null
})
if (redirectPath) navigateAfterLogin(redirectPath)
})
.catch((err) => {
toast.error(
'An error occurred while creating nsecbunker signer: ' + err
)
})
.finally(() => {
setIsLoading(false)
setLoadingSpinnerDesc('')
})
}
const login = () => {
if (inputValue.startsWith('bunker://')) {
return loginWithBunkerConnectionString()
}
if (inputValue.startsWith('nsec')) {
return loginWithNsec()
}
if (inputValue.startsWith('npub')) {
return loginWithNsecBunker()
}
if (inputValue.match(NIP05_REGEX)) {
return loginWithNsecBunker()
}
// Check if maybe hex nsec
try {
@ -335,38 +130,33 @@ export const Nostr = () => {
console.warn('err', err)
}
toast.error(
'Invalid format, please use: private key (hex), nsec..., bunker:// or nip05 format.'
)
toast.error('Invalid format, please use: private key (hex or nsec)')
return
}
if (authUrl) {
return (
<iframe
title="Nsecbunker auth"
src={authUrl}
width="100%"
height="500px"
/>
)
}
return (
<>
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
{isNostrExtensionAvailable && (
<>
<label className={styles.label} htmlFor="extension-login">
Login by using a browser extension
Login by using a{' '}
<a
rel="noopener"
href="https://github.com/nostrband/nostr-login"
target="_blank"
>
nostr-login
</a>
</label>
<Button
id="extension-login"
onClick={loginWithExtension}
id="nostr-login"
variant="contained"
onClick={() => {
launchNostrLoginDialog()
}}
>
Extension Login
Nostr Login
</Button>
<Divider
sx={{
@ -377,16 +167,18 @@ export const Nostr = () => {
</Divider>
</>
)}
<TextField
onKeyDown={handleInputKeyDown}
label="nip05 login / nip46 bunker string"
helperText="Private key (Not recommended)"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
fullWidth
margin="dense"
/>
<form autoComplete="off">
<TextField
onKeyDown={handleInputKeyDown}
label="Private key (Not recommended)"
type="password"
autoComplete="off"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
fullWidth
margin="dense"
/>
</form>
<Button
disabled={!inputValue}
onClick={login}

View File

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

View File

@ -24,7 +24,7 @@
}
.container {
color: black
color: black;
}
.left {
@ -51,7 +51,8 @@
}
.image-placeholder {
width: 150px;
width: 100%;
height: auto;
}
.link {
@ -99,4 +100,4 @@
margin-left: 5px;
margin-top: 2px;
}
}
}

View File

@ -12,6 +12,11 @@ export const Register = () => {
margin="dense"
autoComplete="username"
disabled
sx={{
input: {
cursor: 'not-allowed'
}
}}
/>
<TextField
label="Password"
@ -21,6 +26,11 @@ export const Register = () => {
type="password"
autoComplete="new-password"
disabled
sx={{
input: {
cursor: 'not-allowed'
}
}}
/>
<TextField
label="Confirm password"
@ -30,6 +40,11 @@ export const Register = () => {
type="password"
autoComplete="new-password"
disabled
sx={{
input: {
cursor: 'not-allowed'
}
}}
/>
<Button variant="contained" fullWidth disabled>

View File

@ -2,24 +2,22 @@ import AccountCircleIcon from '@mui/icons-material/AccountCircle'
import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos'
import CachedIcon from '@mui/icons-material/Cached'
import RouterIcon from '@mui/icons-material/Router'
import { useTheme } from '@mui/material'
import { ListItem, useTheme } from '@mui/material'
import List from '@mui/material/List'
import ListItemButton from '@mui/material/ListItemButton'
import ListItemIcon from '@mui/material/ListItemIcon'
import ListItemText from '@mui/material/ListItemText'
import ListSubheader from '@mui/material/ListSubheader'
import { useSelector } from 'react-redux'
import { useNavigate } from 'react-router-dom'
import { useAppSelector } from '../../hooks/store'
import { Link } from 'react-router-dom'
import { appPrivateRoutes, getProfileSettingsRoute } from '../../routes'
import { State } from '../../store/rootReducer'
import { Container } from '../../components/Container'
import { Footer } from '../../components/Footer/Footer'
import ExtensionIcon from '@mui/icons-material/Extension'
import { LoginMethod } from '../../store/auth/types'
export const SettingsPage = () => {
const theme = useTheme()
const navigate = useNavigate()
const usersPubkey = useSelector((state: State) => state.auth.usersPubkey)
const { usersPubkey, loginMethod } = useAppSelector((state) => state.auth)
const listItem = (label: string, disabled = false) => {
return (
<>
@ -43,56 +41,56 @@ 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
}}
>
Settings
</ListSubheader>
}
>
<ListItemButton
onClick={() => {
navigate(getProfileSettingsRoute(usersPubkey!))
<>
<Container>
<List
sx={{
width: '100%',
bgcolor: 'background.paper'
}}
subheader={
<ListSubheader
sx={{
fontSize: '1.5rem',
borderBottom: '0.5px solid',
paddingBottom: 2,
paddingTop: 2,
zIndex: 2
}}
>
Settings
</ListSubheader>
}
>
<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>
<ListItem component={Link} to={getProfileSettingsRoute(usersPubkey!)}>
<ListItemIcon>
<AccountCircleIcon />
</ListItemIcon>
{listItem('Profile')}
</ListItem>
<ListItem component={Link} to={appPrivateRoutes.relays}>
<ListItemIcon>
<RouterIcon />
</ListItemIcon>
{listItem('Relays')}
</ListItem>
<ListItem component={Link} to={appPrivateRoutes.cacheSettings}>
<ListItemIcon>
<CachedIcon />
</ListItemIcon>
{listItem('Local Cache')}
</ListItem>
{loginMethod === LoginMethod.nostrLogin && (
<ListItem component={Link} to={appPrivateRoutes.nostrLogin}>
<ListItemIcon>
<ExtensionIcon />
</ListItemIcon>
{listItem('Nostr Login')}
</ListItem>
)}
</List>
</Container>
<Footer />
</>
)
}

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,52 @@ 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,
zIndex: 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

@ -0,0 +1,78 @@
import {
List,
ListItemButton,
ListItemIcon,
ListItemText,
ListSubheader,
useTheme
} from '@mui/material'
import { launch as launchNostrLoginDialog } from 'nostr-login'
import { Container } from '../../../components/Container'
import PeopleIcon from '@mui/icons-material/People'
import ImportExportIcon from '@mui/icons-material/ImportExport'
import { useAppSelector } from '../../../hooks/store'
import { NostrLoginAuthMethod } from '../../../store/auth/types'
export const NostrLoginPage = () => {
const theme = useTheme()
const nostrLoginAuthMethod = useAppSelector(
(state) => state.auth?.nostrLoginAuthMethod
)
return (
<Container>
<List
sx={{
width: '100%',
bgcolor: 'background.paper'
}}
subheader={
<ListSubheader
sx={{
fontSize: '1.5rem',
borderBottom: '0.5px solid',
paddingBottom: 2,
paddingTop: 2,
zIndex: 2
}}
>
Nostr Settings
</ListSubheader>
}
>
<ListItemButton
onClick={() => {
launchNostrLoginDialog('switch-account')
}}
>
<ListItemIcon>
<PeopleIcon />
</ListItemIcon>
<ListItemText
primary={'Nostr Login Accounts'}
sx={{
color: theme.palette.text.primary
}}
/>
</ListItemButton>
{nostrLoginAuthMethod === NostrLoginAuthMethod.Local && (
<ListItemButton
onClick={() => {
launchNostrLoginDialog('import')
}}
>
<ListItemIcon>
<ImportExportIcon />
</ListItemIcon>
<ListItemText
primary={'Import / Export Keys'}
sx={{
color: theme.palette.text.primary
}}
/>
</ListItemButton>
)}
</List>
</Container>
)
}

View File

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

View File

@ -13,25 +13,40 @@ import Switch from '@mui/material/Switch'
import { useEffect, useState } from 'react'
import { toast } from 'react-toastify'
import { Container } from '../../../components/Container'
import { relayController } from '../../../controllers'
import { useAppDispatch, useAppSelector, useDidMount } from '../../../hooks'
import {
useAppDispatch,
useAppSelector,
useDidMount,
useDvm,
useNDKContext
} from '../../../hooks'
import { setRelayMapAction } from '../../../store/actions'
import { RelayConnectionState, RelayFee, RelayInfo } from '../../../types'
import {
capitalizeFirstLetter,
compareObjects,
getRelayInfo,
getRelayMap,
getRelayMapFromNDKRelayList,
hexToNpub,
publishRelayMap,
shorten
shorten,
timeout
} from '../../../utils'
import styles from './style.module.scss'
import { Footer } from '../../../components/Footer/Footer'
import {
getRelayListForUser,
NDKRelayList,
NDKRelayStatus
} from '@nostr-dev-kit/ndk'
export const RelaysPage = () => {
const dispatch = useAppDispatch()
const { ndk, publish } = useNDKContext()
const { getRelayInfo } = useDvm()
const usersPubkey = useAppSelector((state) => state.auth?.usersPubkey)
const dispatch = useAppDispatch()
const [ndkRelayList, setNDKRelayList] = useState<NDKRelayList | null>(null)
const [newRelayURI, setNewRelayURI] = useState<string>()
const [newRelayURIerror, setNewRelayURIerror] = useState<string>()
@ -39,24 +54,56 @@ export const RelaysPage = () => {
const relayMap = useAppSelector((state) => state.relays?.map)
const relaysInfo = useAppSelector((state) => state.relays?.info)
const webSocketPrefix = 'wss://'
const webSocketPrefix =
newRelayURI?.startsWith('wss://') || newRelayURI?.startsWith('ws://')
? ''
: 'wss://'
useDidMount(() => {
// fetch relay list from relays
useEffect(() => {
if (usersPubkey) {
getRelayMap(usersPubkey).then((newRelayMap) => {
if (!compareObjects(relayMap, newRelayMap.map)) {
dispatch(setRelayMapAction(newRelayMap.map))
}
})
Promise.race([getRelayListForUser(usersPubkey, ndk), timeout(10000)])
.then((res) => {
setNDKRelayList(res)
})
.catch((err) => {
toast.error(
`An error occurred in fetching user relay list: ${
err.message || err
}`
)
setNDKRelayList(new NDKRelayList(ndk))
})
}
})
}, [usersPubkey, ndk])
// construct the RelayMap from newly received NDKRelayList event
// and compare it with existing relay map in redux store
// if there are any differences then update the redux store with
// new relay map
useEffect(() => {
if (ndkRelayList) {
const newRelayMap = getRelayMapFromNDKRelayList(ndkRelayList)
if (!compareObjects(relayMap, newRelayMap)) {
dispatch(setRelayMapAction(newRelayMap))
}
}
// we want to run this effect only when ndkRelayList is changed
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ndkRelayList])
useEffect(() => {
if (!relayMap) return
// Display notification if an empty relay map has been received
if (relayMap && Object.keys(relayMap).length === 0) {
if (Object.keys(relayMap).length === 0) {
relayRequirementWarning()
} else {
getRelayInfo(Object.keys(relayMap))
}
}, [relayMap])
}, [relayMap, getRelayInfo])
const relayRequirementWarning = () =>
toast.warning('At least one write relay is needed for SIGit to work.')
@ -84,7 +131,8 @@ export const RelaysPage = () => {
const relayMapPublishingRes = await publishRelayMap(
relayMapCopy,
usersPubkey,
[relay]
ndk,
publish
).catch((err) => handlePublishRelayMapError(err))
if (relayMapPublishingRes) {
@ -131,7 +179,9 @@ export const RelaysPage = () => {
// Publish updated relay map
const relayMapPublishingRes = await publishRelayMap(
relayMapCopy,
usersPubkey
usersPubkey,
ndk,
publish
).catch((err) => handlePublishRelayMapError(err))
if (relayMapPublishingRes) {
@ -150,7 +200,7 @@ export const RelaysPage = () => {
// Check if new relay URI is a valid string
if (
relayURI &&
!/^wss:\/\/[-a-zA-Z0-9@:%._\\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}/.test(
!/^wss?:\/\/[-a-zA-Z0-9@:%._\\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}/.test(
relayURI
)
) {
@ -160,9 +210,10 @@ export const RelaysPage = () => {
)
}
} else if (relayURI && usersPubkey) {
const relay = await relayController.connectRelay(relayURI)
const ndkRelay = ndk.pool.getRelay(relayURI)
await ndkRelay.connect(5000)
if (relay && relay.connected) {
if (ndkRelay.status >= NDKRelayStatus.CONNECTED) {
const relayMapCopy = JSON.parse(JSON.stringify(relayMap))
relayMapCopy[relayURI] = { write: true, read: true }
@ -170,7 +221,9 @@ export const RelaysPage = () => {
// Publish updated relay map
const relayMapPublishingRes = await publishRelayMap(
relayMapCopy,
usersPubkey
usersPubkey,
ndk,
publish
).catch((err) => handlePublishRelayMapError(err))
if (relayMapPublishingRes) {
@ -210,7 +263,13 @@ export const RelaysPage = () => {
}}
className={styles.relayURItextfield}
/>
<Button variant="contained" onClick={() => handleAddNewRelay()}>
<Button
sx={{
height: '56px'
}}
variant="contained"
onClick={() => handleAddNewRelay()}
>
Add
</Button>
</Box>
@ -232,6 +291,7 @@ export const RelaysPage = () => {
))}
</Box>
)}
<Footer />
</Container>
)
}
@ -254,177 +314,196 @@ const RelayItem = ({
handleLeaveRelay,
handleRelayWriteChange
}: RelayItemProp) => {
const { ndk } = useNDKContext()
const [relayConnectionStatus, setRelayConnectionStatus] =
useState<RelayConnectionState>()
const [displayRelayInfo, setDisplayRelayInfo] = useState(false)
useDidMount(() => {
relayController.connectRelay(relayURI).then((relay) => {
if (relay && relay.connected) {
const ndkPool = ndk.pool
ndkPool.on('relay:connect', (relay) => {
if (relay.url === relayURI) {
setRelayConnectionStatus(RelayConnectionState.Connected)
} else {
}
})
ndkPool.on('relay:disconnect', (relay) => {
if (relay.url === relayURI) {
setRelayConnectionStatus(RelayConnectionState.NotConnected)
}
})
const relay = ndkPool.getRelay(relayURI)
if (relay) {
setRelayConnectionStatus(
relay.status >= NDKRelayStatus.CONNECTED
? RelayConnectionState.Connected
: RelayConnectionState.NotConnected
)
}
})
return (
<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

@ -12,6 +12,7 @@
flex-direction: row;
gap: 10px;
width: 100%;
align-items: start;
}
.sectionIcon {

View File

@ -1,82 +1,78 @@
import { Box, Button, Typography } from '@mui/material'
import axios from 'axios'
import saveAs from 'file-saver'
import JSZip from 'jszip'
import _ from 'lodash'
import { MuiFileInput } from 'mui-file-input'
import { Event, verifyEvent } from 'nostr-tools'
import { useCallback, useEffect, useState } from 'react'
import { useSelector } from 'react-redux'
import { useLocation, useNavigate } from 'react-router-dom'
import { useEffect, useMemo, useState } from 'react'
import { useAppSelector } from '../../hooks'
import { useLocation, useNavigate, useParams } from 'react-router-dom'
import { toast } from 'react-toastify'
import { LoadingSpinner } from '../../components/LoadingSpinner'
import { NostrController } from '../../controllers'
import { appPublicRoutes } from '../../routes'
import { State } from '../../store/rootReducer'
import { CreateSignatureEventContent, Meta, SignedEvent } from '../../types'
import {
decryptArrayBuffer,
encryptArrayBuffer,
extractMarksFromSignedMeta,
extractZipUrlAndEncryptionKey,
generateEncryptionKey,
generateKeysFile,
filterMarksByPubkey,
findOtherUserMarks,
getCurrentUserFiles,
getCurrentUserMarks,
getHash,
hexToNpub,
isOnline,
loadZip,
unixNow,
npubToHex,
parseJson,
encryptAndUploadMarks,
readContentOfZipEntry,
sendNotification,
signEventForMetaFile,
updateUsersAppData,
findOtherUserMarks
unixNow,
updateMarks,
uploadMetaToFileStorage
} from '../../utils'
import { Container } from '../../components/Container'
import { DisplayMeta } from './internal/displayMeta'
import styles from './style.module.scss'
import { CurrentUserMark, Mark } from '../../types/mark.ts'
import { getLastSignersSig, isFullySigned } from '../../utils/sign.ts'
import {
filterMarksByPubkey,
getCurrentUserMarks,
isCurrentUserMarksComplete,
updateMarks
} from '../../utils'
import PdfMarking from '../../components/PDFView/PdfMarking.tsx'
import {
convertToSigitFile,
getZipWithFiles,
SigitFile
} from '../../utils/file.ts'
import { ARRAY_BUFFER, DEFLATE } from '../../utils/const.ts'
enum SignedStatus {
Fully_Signed,
User_Is_Next_Signer,
User_Is_Not_Next_Signer
}
import { convertToSigitFile, SigitFile } from '../../utils/file.ts'
import { generateTimestamp } from '../../utils/opentimestamps.ts'
import { MARK_TYPE_CONFIG } from '../../components/MarkTypeStrategy/MarkStrategy.tsx'
import { useNDK } from '../../hooks/useNDK.ts'
export const SignPage = () => {
const navigate = useNavigate()
const location = useLocation()
const params = useParams()
const { updateUsersAppData, sendNotification } = useNDK()
const usersAppData = useAppSelector((state) => state.userAppData)
/**
* uploadedZip will be received from home page when a user uploads a sigit zip wrapper that contains keys.json
* arrayBuffer will be received in navigation from create page in offline mode
* meta will be received in navigation from create & home page in online mode
* In the online mode, Sigit ID can be obtained either from the router state
* using location or from UsersAppData
*/
const {
meta: metaInNavState,
arrayBuffer: decryptedArrayBuffer,
uploadedZip
} = location.state || {}
const metaInNavState = useMemo(() => {
if (usersAppData) {
const sigitCreateId = params.id
const [displayInput, setDisplayInput] = useState(false)
if (sigitCreateId) {
const sigit = usersAppData.sigits[sigitCreateId]
const [selectedFile, setSelectedFile] = useState<File | null>(null)
if (sigit) {
return sigit
}
}
}
return location?.state?.meta || undefined
}, [location, usersAppData, params.id])
/**
* Received from `location.state`
*
* arrayBuffer (decryptedArrayBuffer) will be received in navigation from create page in offline mode
*/
const { arrayBuffer: decryptedArrayBuffer, uploadedZip } = location.state || {
decryptedArrayBuffer: undefined
}
const [files, setFiles] = useState<{ [filename: string]: SigitFile }>({})
@ -84,7 +80,6 @@ export const SignPage = () => {
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
const [meta, setMeta] = useState<Meta | null>(null)
const [signedStatus, setSignedStatus] = useState<SignedStatus>()
const [submittedBy, setSubmittedBy] = useState<string>()
@ -98,67 +93,14 @@ export const SignPage = () => {
[key: string]: string | null
}>({})
const [signedBy, setSignedBy] = useState<`npub1${string}`[]>([])
const usersPubkey = useAppSelector((state) => state.auth.usersPubkey)
const [nextSinger, setNextSinger] = useState<string>()
// This state variable indicates whether the logged-in user is a signer, a creator, or neither.
const [isSignerOrCreator, setIsSignerOrCreator] = useState(false)
const usersPubkey = useSelector((state: State) => state.auth.usersPubkey)
const [authUrl, setAuthUrl] = useState<string>()
const nostrController = NostrController.getInstance()
const [currentUserMarks, setCurrentUserMarks] = useState<CurrentUserMark[]>(
[]
)
const [isMarksCompleted, setIsMarksCompleted] = useState(false)
const [otherUserMarks, setOtherUserMarks] = useState<Mark[]>([])
useEffect(() => {
if (signers.length > 0) {
// check if all signers have signed then its fully signed
if (isFullySigned(signers, signedBy)) {
setSignedStatus(SignedStatus.Fully_Signed)
} else {
for (const signer of signers) {
if (!signedBy.includes(signer)) {
// signers in meta.json are in npub1 format
// so, convert it to hex before setting to nextSigner
setNextSinger(npubToHex(signer)!)
const usersNpub = hexToNpub(usersPubkey!)
if (signer === usersNpub) {
// logged in user is the next signer
setSignedStatus(SignedStatus.User_Is_Next_Signer)
} else {
setSignedStatus(SignedStatus.User_Is_Not_Next_Signer)
}
break
}
}
}
} else {
// there's no signer just viewers. So its fully signed
setSignedStatus(SignedStatus.Fully_Signed)
}
// Determine and set the status of the user
if (submittedBy && usersPubkey && submittedBy === usersPubkey) {
// If the submission was made by the user, set the status to true
setIsSignerOrCreator(true)
} else if (usersPubkey) {
// Convert the user's public key from hex to npub format
const usersNpub = hexToNpub(usersPubkey)
if (signers.includes(usersNpub)) {
// If the user's npub is in the list of signers, set the status to true
setIsSignerOrCreator(true)
}
}
}, [signers, signedBy, usersPubkey, submittedBy])
useEffect(() => {
const handleUpdatedMeta = async (meta: Meta) => {
const createSignatureEvent = await parseJson<Event>(
@ -214,116 +156,54 @@ export const SignPage = () => {
const signedMarks = extractMarksFromSignedMeta(meta)
const currentUserMarks = getCurrentUserMarks(metaMarks, signedMarks)
const otherUserMarks = findOtherUserMarks(signedMarks, usersPubkey!)
if (meta.keys) {
for (let i = 0; i < otherUserMarks.length; i++) {
const m = otherUserMarks[i]
const { sender, keys } = meta.keys
const usersNpub = hexToNpub(usersPubkey)
if (usersNpub in keys) {
const encryptionKey = await nostrController
.nip04Decrypt(sender, keys[usersNpub])
.catch((err) => {
console.log(
'An error occurred in decrypting encryption key',
err
)
return null
})
try {
const { fetchAndDecrypt } = MARK_TYPE_CONFIG[m.type] || {}
if (
typeof fetchAndDecrypt === 'function' &&
m.value &&
encryptionKey
) {
otherUserMarks[i].value = await fetchAndDecrypt(
m.value,
encryptionKey
)
}
} catch (error) {
console.error(`Error during mark fetchAndDecrypt phase`, error)
}
}
}
}
setOtherUserMarks(otherUserMarks)
setCurrentUserMarks(currentUserMarks)
setIsMarksCompleted(isCurrentUserMarksComplete(currentUserMarks))
}
setSignedBy(Object.keys(meta.docSignatures) as `npub1${string}`[])
}
if (meta) {
handleUpdatedMeta(meta)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [meta, usersPubkey])
const handleDownload = async () => {
if (Object.entries(files).length === 0 || !meta || !usersPubkey) return
setLoadingSpinnerDesc('Generating file')
try {
const zip = await getZipWithFiles(meta, files)
const arrayBuffer = await zip.generateAsync({
type: ARRAY_BUFFER,
compression: DEFLATE,
compressionOptions: {
level: 6
}
})
if (!arrayBuffer) return
const blob = new Blob([arrayBuffer])
saveAs(blob, `exported-${unixNow()}.sigit.zip`)
} catch (error) {
console.log('error in zip:>> ', error)
if (error instanceof Error) {
toast.error(error.message || 'Error occurred in generating zip file')
}
}
}
const decrypt = useCallback(
async (file: File) => {
setLoadingSpinnerDesc('Decrypting file')
const zip = await loadZip(file)
if (!zip) return
const parsedKeysJson = await parseKeysJson(zip)
if (!parsedKeysJson) return
const encryptedArrayBuffer = await readContentOfZipEntry(
zip,
'compressed.sigit',
'arraybuffer'
)
if (!encryptedArrayBuffer) return
const { keys, sender } = parsedKeysJson
for (const key of keys) {
// Set up event listener for authentication event
nostrController.on('nsecbunker-auth', (url) => {
setAuthUrl(url)
})
// Set up timeout promise to handle encryption timeout
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => {
reject(new Error('Timeout occurred'))
}, 60000) // Timeout duration = 60 seconds
})
// decrypt the encryptionKey, with timeout
const encryptionKey = await Promise.race([
nostrController.nip04Decrypt(sender, key),
timeoutPromise
])
.then((res) => {
return res
})
.catch((err) => {
console.log('err :>> ', err)
return null
})
.finally(() => {
setAuthUrl(undefined) // Clear authentication URL
})
// Return if encryption failed
if (!encryptionKey) continue
const arrayBuffer = await decryptArrayBuffer(
encryptedArrayBuffer,
encryptionKey
)
.catch((err) => {
console.log('err in decryption:>> ', err)
return null
})
.finally(() => {
setIsLoading(false)
})
if (arrayBuffer) return arrayBuffer
}
return null
},
[nostrController]
)
useEffect(() => {
// online mode - from create and home page views
if (metaInNavState) {
const processSigit = async () => {
setIsLoading(true)
@ -358,27 +238,20 @@ export const SignPage = () => {
}
processSigit()
} else if (decryptedArrayBuffer) {
handleDecryptedArrayBuffer(decryptedArrayBuffer).finally(() =>
setIsLoading(false)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
if (decryptedArrayBuffer || uploadedZip) {
handleDecryptedArrayBuffer(decryptedArrayBuffer || uploadedZip).finally(
() => setIsLoading(false)
)
} else if (uploadedZip) {
decrypt(uploadedZip)
.then((arrayBuffer) => {
if (arrayBuffer) handleDecryptedArrayBuffer(arrayBuffer)
})
.catch((err) => {
console.error(`error occurred in decryption`, err)
toast.error(err.message || `error occurred in decryption`)
})
.finally(() => {
setIsLoading(false)
})
} else {
setIsLoading(false)
setDisplayInput(true)
}
}, [decryptedArrayBuffer, uploadedZip, metaInNavState, decrypt])
}, [decryptedArrayBuffer, uploadedZip])
const handleArrayBufferFromBlossom = async (
arrayBuffer: ArrayBuffer,
@ -437,30 +310,12 @@ export const SignPage = () => {
setMarks(updatedMarks)
}
const parseKeysJson = async (zip: JSZip) => {
const keysFileContent = await readContentOfZipEntry(
zip,
'keys.json',
'string'
)
if (!keysFileContent) return null
return await parseJson<{ sender: string; keys: string[] }>(
keysFileContent
).catch((err) => {
console.log(`Error parsing content of keys.json:`, err)
toast.error(err.message || `Error parsing content of keys.json`)
return null
})
}
const handleDecryptedArrayBuffer = async (arrayBuffer: ArrayBuffer) => {
const decryptedZipFile = new File([arrayBuffer], 'decrypted.zip')
const handleDecryptedArrayBuffer = async (
decryptedArrayBuffer: ArrayBuffer
) => {
setLoadingSpinnerDesc('Parsing zip file')
const zip = await loadZip(decryptedZipFile)
const zip = await loadZip(decryptedArrayBuffer)
if (!zip) return
const files: { [filename: string]: SigitFile } = {}
@ -468,20 +323,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
@ -493,9 +348,6 @@ export const SignPage = () => {
setFiles(files)
setCurrentFileHashes(fileHashes)
setDisplayInput(false)
setLoadingSpinnerDesc('Parsing meta.json')
const metaFileContent = await readContentOfZipEntry(
@ -523,46 +375,130 @@ export const SignPage = () => {
setMeta(parsedMetaJson)
}
const handleDecrypt = async () => {
if (!selectedFile) return
setIsLoading(true)
const arrayBuffer = await decrypt(selectedFile)
if (!arrayBuffer) return
handleDecryptedArrayBuffer(arrayBuffer)
}
const handleSign = async () => {
const initializeSigning = async (type: 'online' | 'offline') => {
if (Object.entries(files).length === 0 || !meta) return
setIsLoading(true)
setLoadingSpinnerDesc('Signing nostr event')
const prevSig = getPrevSignersSig(hexToNpub(usersPubkey!))
const usersNpub = hexToNpub(usersPubkey!)
const prevSig = getPrevSignersSig(usersNpub)
if (!prevSig) {
setIsLoading(false)
toast.error('Previous signature is invalid')
return
}
const marks = getSignerMarksForMeta() || []
const signedEvent = await signEventForMeta({ prevSig, marks })
let encryptionKey: string | undefined
if (meta.keys) {
const { sender, keys } = meta.keys
encryptionKey = await nostrController
.nip04Decrypt(sender, keys[usersNpub])
.catch((err) => {
// Log and display an error message if decryption fails
console.log('An error occurred in decrypting encryption key', err)
toast.error('An error occurred in decrypting encryption key')
return undefined
})
}
const processedMarks =
type === 'online'
? await encryptAndUploadMarks(marks, encryptionKey)
: marks
const signedEvent = await signEventForMeta({
prevSig,
marks: processedMarks
})
if (!signedEvent) return
const updatedMeta = updateMetaSignatures(meta, signedEvent)
if (await isOnline()) {
await handleOnlineFlow(updatedMeta)
} else {
setMeta(updatedMeta)
setIsLoading(false)
return {
encryptionKey,
updatedMeta,
signedEvent
}
}
const handleSign = async () => {
const result = await initializeSigning('online')
if (!result) {
setIsLoading(false)
return
}
const { encryptionKey, updatedMeta, signedEvent } = result
setLoadingSpinnerDesc('Generating an open timestamp.')
const timestamp = await generateTimestamp(signedEvent.id)
if (timestamp) {
updatedMeta.timestamps = [...(updatedMeta.timestamps || []), timestamp]
updatedMeta.modifiedAt = unixNow()
}
await handleOnlineFlow(updatedMeta, encryptionKey)
const createSignature = JSON.parse(updatedMeta.createSignature)
navigate(`${appPublicRoutes.verify}/${createSignature.id}`)
}
const handleSignOffline = async () => {
const result = await initializeSigning('offline')
if (!result) {
setIsLoading(false)
return
}
const { updatedMeta } = result
const zip = new JSZip()
for (const [filename, value] of Object.entries(files)) {
zip.file(`files/${filename}`, await value.arrayBuffer())
}
const stringifiedMeta = JSON.stringify(updatedMeta, null, 2)
zip.file('meta.json', stringifiedMeta)
// Handle errors during zip file generation
const handleZipError = (err: unknown) => {
console.log('Error in zip:>> ', err)
setIsLoading(false)
if (err instanceof Error) {
toast.error(err.message || 'Error occurred in generating zip file')
}
return null
}
setLoadingSpinnerDesc('Generating zip file')
const arrayBuffer = await zip
.generateAsync({
type: 'arraybuffer',
compression: 'DEFLATE',
compressionOptions: { level: 6 }
})
.catch(handleZipError)
if (!arrayBuffer) {
setIsLoading(false)
return
}
// Create a File object with the Blob data
const blob = new Blob([arrayBuffer])
const file = new File([blob], `request-${unixNow()}.sigit.zip`, {
type: 'application/zip'
})
setIsLoading(false)
navigate(`${appPublicRoutes.verify}`, { state: { uploadedZip: file } })
}
// Sign the event for the meta file
const signEventForMeta = async (signerContent: {
prevSig: string
@ -591,85 +527,38 @@ export const SignPage = () => {
return metaCopy
}
// create final zip file
const createFinalZipFile = async (
encryptedArrayBuffer: ArrayBuffer,
encryptionKey: string
): Promise<File | null> => {
// Get the current timestamp in seconds
const blob = new Blob([encryptedArrayBuffer])
// Create a File object with the Blob data
const file = new File([blob], `compressed.sigit`, {
type: 'application/sigit'
})
const isLastSigner = checkIsLastSigner(signers)
const userSet = new Set<string>()
if (isLastSigner) {
if (submittedBy) {
userSet.add(submittedBy)
}
signers.forEach((signer) => {
userSet.add(npubToHex(signer)!)
})
viewers.forEach((viewer) => {
userSet.add(npubToHex(viewer)!)
})
} else {
const usersNpub = hexToNpub(usersPubkey!)
const signerIndex = signers.indexOf(usersNpub)
const nextSigner = signers[signerIndex + 1]
userSet.add(npubToHex(nextSigner)!)
}
const keysFileContent = await generateKeysFile(
Array.from(userSet),
encryptionKey
)
if (!keysFileContent) return null
const zip = new JSZip()
zip.file(`compressed.sigit`, file)
zip.file('keys.json', keysFileContent)
const arraybuffer = await zip
.generateAsync({
type: 'arraybuffer',
compression: 'DEFLATE',
compressionOptions: { level: 6 }
})
.catch(handleZipError)
if (!arraybuffer) return null
return new File([new Blob([arraybuffer])], `${unixNow()}.sigit.zip`, {
type: 'application/zip'
})
}
// Handle errors during zip file generation
const handleZipError = (err: unknown) => {
console.log('Error in zip:>> ', err)
setIsLoading(false)
if (err instanceof Error) {
toast.error(err.message || 'Error occurred in generating zip file')
}
return null
// Check if the current user is the last signer
const checkIsLastSigner = (signers: string[]): boolean => {
const usersNpub = hexToNpub(usersPubkey!)
const lastSignerIndex = signers.length - 1
const signerIndex = signers.indexOf(usersNpub)
return signerIndex === lastSignerIndex
}
// Handle the online flow: update users app data and send notifications
const handleOnlineFlow = async (meta: Meta) => {
const handleOnlineFlow = async (
meta: Meta,
encryptionKey: string | undefined
) => {
setLoadingSpinnerDesc('Updating users app data')
const updatedEvent = await updateUsersAppData(meta)
const updatedEvent = await updateUsersAppData([meta])
if (!updatedEvent) {
setIsLoading(false)
return
}
let metaUrl: string
try {
metaUrl = await uploadMetaToFileStorage(meta, encryptionKey)
} catch (error) {
if (error instanceof Error) {
toast.error(error.message)
}
console.error(error)
setIsLoading(false)
return
}
const userSet = new Set<`npub1${string}`>()
if (submittedBy && submittedBy !== usersPubkey) {
userSet.add(hexToNpub(submittedBy))
@ -702,7 +591,7 @@ export const SignPage = () => {
setLoadingSpinnerDesc('Sending notifications')
const users = Array.from(userSet)
const promises = users.map((user) =>
sendNotification(npubToHex(user)!, meta)
sendNotification(npubToHex(user)!, { metaUrl, keys: meta.keys })
)
await Promise.all(promises)
.then(() => {
@ -716,128 +605,6 @@ export const SignPage = () => {
setIsLoading(false)
}
// Check if the current user is the last signer
const checkIsLastSigner = (signers: string[]): boolean => {
const usersNpub = hexToNpub(usersPubkey!)
const lastSignerIndex = signers.length - 1
const signerIndex = signers.indexOf(usersNpub)
return signerIndex === lastSignerIndex
}
const handleExport = async () => {
if (Object.entries(files).length === 0 || !meta || !usersPubkey) return
const usersNpub = hexToNpub(usersPubkey)
if (
!signers.includes(usersNpub) &&
!viewers.includes(usersNpub) &&
submittedBy !== usersNpub
)
return
setIsLoading(true)
setLoadingSpinnerDesc('Signing nostr event')
if (!meta) return
const prevSig = getLastSignersSig(meta, signers)
if (!prevSig) return
const signedEvent = await signEventForMetaFile(
JSON.stringify({
prevSig
}),
nostrController,
setIsLoading
)
if (!signedEvent) return
const exportSignature = JSON.stringify(signedEvent, null, 2)
const stringifiedMeta = JSON.stringify(
{
...meta,
exportSignature
},
null,
2
)
const zip = new JSZip()
zip.file('meta.json', stringifiedMeta)
for (const [fileName, file] of Object.entries(files)) {
zip.file(`files/${fileName}`, await file.arrayBuffer())
}
const arrayBuffer = await zip
.generateAsync({
type: 'arraybuffer',
compression: 'DEFLATE',
compressionOptions: {
level: 6
}
})
.catch((err) => {
console.log('err in zip:>> ', err)
setIsLoading(false)
toast.error(err.message || 'Error occurred in generating zip file')
return null
})
if (!arrayBuffer) return
const blob = new Blob([arrayBuffer])
saveAs(blob, `exported-${unixNow()}.sigit.zip`)
setIsLoading(false)
navigate(appPublicRoutes.verify)
}
const handleExportSigit = async () => {
if (Object.entries(files).length === 0 || !meta) return
const zip = new JSZip()
const stringifiedMeta = JSON.stringify(meta, null, 2)
zip.file('meta.json', stringifiedMeta)
for (const [fileName, file] of Object.entries(files)) {
zip.file(`files/${fileName}`, await file.arrayBuffer())
}
const arrayBuffer = await zip
.generateAsync({
type: 'arraybuffer',
compression: 'DEFLATE',
compressionOptions: {
level: 6
}
})
.catch((err) => {
console.log('err in zip:>> ', err)
setIsLoading(false)
toast.error(err.message || 'Error occurred in generating zip file')
return null
})
if (!arrayBuffer) return
const key = await generateEncryptionKey()
setLoadingSpinnerDesc('Encrypting zip file')
const encryptedArrayBuffer = await encryptArrayBuffer(arrayBuffer, key)
const finalZipFile = await createFinalZipFile(encryptedArrayBuffer, key)
if (!finalZipFile) return
saveAs(finalZipFile, `exported-${unixNow()}.sigit.zip`)
}
/**
* This function accepts an npub of a signer and return the signature of its previous signer.
* This prevSig will be used in the content of the provided signer's signedEvent
@ -871,105 +638,20 @@ export const SignPage = () => {
}
}
if (authUrl) {
return (
<iframe
title="Nsecbunker auth"
src={authUrl}
width="100%"
height="500px"
/>
)
}
if (isLoading) {
return <LoadingSpinner desc={loadingSpinnerDesc} />
}
if (!isMarksCompleted && signedStatus === SignedStatus.User_Is_Next_Signer) {
return (
<PdfMarking
files={getCurrentUserFiles(files, currentFileHashes, creatorFileHashes)}
currentUserMarks={currentUserMarks}
setIsMarksCompleted={setIsMarksCompleted}
setCurrentUserMarks={setCurrentUserMarks}
setUpdatedMarks={setUpdatedMarks}
handleDownload={handleDownload}
otherUserMarks={otherUserMarks}
meta={meta}
/>
)
}
return (
<>
<Container className={styles.container}>
{displayInput && (
<>
<Typography component="label" variant="h6">
Select sigit file
</Typography>
<Box className={styles.inputBlock}>
<MuiFileInput
placeholder="Select file"
inputProps={{ accept: '.sigit.zip' }}
value={selectedFile}
onChange={(value) => setSelectedFile(value)}
/>
</Box>
{selectedFile && (
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'center' }}>
<Button onClick={handleDecrypt} variant="contained">
Decrypt
</Button>
</Box>
)}
</>
)}
{submittedBy && Object.entries(files).length > 0 && meta && (
<>
<DisplayMeta
meta={meta}
files={files}
submittedBy={submittedBy}
signers={signers}
viewers={viewers}
creatorFileHashes={creatorFileHashes}
currentFileHashes={currentFileHashes}
signedBy={signedBy}
nextSigner={nextSinger}
getPrevSignersSig={getPrevSignersSig}
/>
{signedStatus === SignedStatus.Fully_Signed && (
<Box sx={{ mt: 1, display: 'flex', justifyContent: 'center' }}>
<Button onClick={handleExport} variant="contained">
Export
</Button>
</Box>
)}
{signedStatus === SignedStatus.User_Is_Next_Signer && (
<Box sx={{ mt: 1, display: 'flex', justifyContent: 'center' }}>
<Button onClick={handleSign} variant="contained">
Sign
</Button>
</Box>
)}
{isSignerOrCreator && (
<Box sx={{ mt: 1, display: 'flex', justifyContent: 'center' }}>
<Button onClick={handleExportSigit} variant="contained">
Export Sigit
</Button>
</Box>
)}
</>
)}
</Container>
</>
<PdfMarking
files={getCurrentUserFiles(files, currentFileHashes, creatorFileHashes)}
currentUserMarks={currentUserMarks}
setCurrentUserMarks={setCurrentUserMarks}
setUpdatedMarks={setUpdatedMarks}
handleSign={handleSign}
handleSignOffline={handleSignOffline}
otherUserMarks={otherUserMarks}
meta={meta}
/>
)
}

View File

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

@ -14,6 +14,7 @@
border-bottom: 0.5px solid;
padding: 8px 16px;
font-size: 1.5rem;
z-index: 2;
}
.filesWrapper {
@ -62,7 +63,6 @@
display: flex;
justify-content: center;
align-items: center;
//z-index: 200;
}
.fixedBottomForm input[type='text'] {

File diff suppressed because it is too large Load Diff

View File

@ -53,14 +53,10 @@
.mark {
position: absolute;
display: flex;
justify-content: center;
align-items: center;
}
[data-dev='true'] {
.mark {
border: 1px dotted black;
outline: 1px dotted black;
}
}

View File

@ -1,19 +1,4 @@
import { Modal } from '../layouts/modal'
import { CreatePage } from '../pages/create'
import { HomePage } from '../pages/home'
import { LandingPage } from '../pages/landing'
import { Login } from '../pages/login'
import { Nostr } from '../pages/nostr'
import { ProfilePage } from '../pages/profile'
import { Register } from '../pages/register'
import { SettingsPage } from '../pages/settings/Settings'
import { CacheSettingsPage } from '../pages/settings/cache'
import { ProfileSettingsPage } from '../pages/settings/profile'
import { RelaysPage } from '../pages/settings/relays'
import { SignPage } from '../pages/sign'
import { VerifyPage } from '../pages/verify'
import { hexToNpub } from '../utils'
import { Route, RouteProps } from 'react-router-dom'
export const appPrivateRoutes = {
homePage: '/',
@ -22,7 +7,8 @@ export const appPrivateRoutes = {
settings: '/settings',
profileSettings: '/settings/profile/:npub',
cacheSettings: '/settings/cache',
relays: '/settings/relays'
relays: '/settings/relays',
nostrLogin: '/settings/nostrLogin'
}
export const appPublicRoutes = {
@ -41,111 +27,3 @@ export const getProfileRoute = (hexKey: string) =>
export const getProfileSettingsRoute = (hexKey: string) =>
appPrivateRoutes.profileSettings.replace(':npub', hexToNpub(hexKey))
/**
* Helper type allows for extending react-router-dom's **RouteProps** with generic type
*/
type CustomRouteProps<T> = T &
Omit<RouteProps, 'children'> & {
children?: Array<CustomRouteProps<T>>
}
/**
* This function maps over nested routes with optional condition for rendering
* @param {CustomRouteProps<T>[]} routes - routes list
* @param {RenderConditionCallbackFn} renderConditionCallbackFn - render condition callback (default true)
*/
export function recursiveRouteRenderer<T>(
routes?: CustomRouteProps<T>[],
renderConditionCallbackFn: (route: CustomRouteProps<T>) => boolean = () =>
true
) {
if (!routes) return null
// Callback allows us to pass arbitrary conditions for each route's rendering
// Skipping the callback will by default evaluate to true (show route)
return routes.map((route, index) =>
renderConditionCallbackFn(route) ? (
<Route
key={`${route.path}${index}`}
path={route.path}
element={route.element}
>
{recursiveRouteRenderer(route.children, renderConditionCallbackFn)}
</Route>
) : null
)
}
type PublicRouteProps = CustomRouteProps<{
hiddenWhenLoggedIn?: boolean
}>
export const publicRoutes: PublicRouteProps[] = [
{
path: appPublicRoutes.landingPage,
hiddenWhenLoggedIn: true,
element: <LandingPage />,
children: [
{
element: <Modal />,
children: [
{
path: appPublicRoutes.login,
hiddenWhenLoggedIn: true,
element: <Login />
},
{
path: appPublicRoutes.register,
hiddenWhenLoggedIn: true,
element: <Register />
},
{
path: appPublicRoutes.nostr,
hiddenWhenLoggedIn: true,
element: <Nostr />
}
]
}
]
},
{
path: appPublicRoutes.profile,
element: <ProfilePage />
},
{
path: appPublicRoutes.verify,
element: <VerifyPage />
}
]
export const privateRoutes = [
{
path: appPrivateRoutes.homePage,
element: <HomePage />
},
{
path: appPrivateRoutes.create,
element: <CreatePage />
},
{
path: appPrivateRoutes.sign,
element: <SignPage />
},
{
path: appPrivateRoutes.settings,
element: <SettingsPage />
},
{
path: appPrivateRoutes.profileSettings,
element: <ProfileSettingsPage />
},
{
path: appPrivateRoutes.cacheSettings,
element: <CacheSettingsPage />
},
{
path: appPrivateRoutes.relays,
element: <RelaysPage />
}
]

103
src/routes/util.tsx Normal file
View File

@ -0,0 +1,103 @@
import { Route, RouteProps } from 'react-router-dom'
import { appPrivateRoutes, appPublicRoutes } from '.'
import { CreatePage } from '../pages/create'
import { HomePage } from '../pages/home'
import { LandingPage } from '../pages/landing'
import { ProfilePage } from '../pages/profile'
import { CacheSettingsPage } from '../pages/settings/cache'
import { NostrLoginPage } from '../pages/settings/nostrLogin'
import { ProfileSettingsPage } from '../pages/settings/profile'
import { RelaysPage } from '../pages/settings/relays'
import { SettingsPage } from '../pages/settings/Settings'
import { SignPage } from '../pages/sign'
import { VerifyPage } from '../pages/verify'
/**
* Helper type allows for extending react-router-dom's **RouteProps** with generic type
*/
type CustomRouteProps<T> = T &
Omit<RouteProps, 'children'> & {
children?: Array<CustomRouteProps<T>>
}
/**
* This function maps over nested routes with optional condition for rendering
* @param {CustomRouteProps<T>[]} routes - routes list
* @param {RenderConditionCallbackFn} renderConditionCallbackFn - render condition callback (default true)
*/
export function recursiveRouteRenderer<T>(
routes?: CustomRouteProps<T>[],
renderConditionCallbackFn: (route: CustomRouteProps<T>) => boolean = () =>
true
) {
if (!routes) return null
// Callback allows us to pass arbitrary conditions for each route's rendering
// Skipping the callback will by default evaluate to true (show route)
return routes.map((route, index) =>
renderConditionCallbackFn(route) ? (
<Route
key={`${route.path}${index}`}
path={route.path}
element={route.element}
>
{recursiveRouteRenderer(route.children, renderConditionCallbackFn)}
</Route>
) : null
)
}
type PublicRouteProps = CustomRouteProps<{
hiddenWhenLoggedIn?: boolean
}>
export const publicRoutes: PublicRouteProps[] = [
{
path: appPublicRoutes.landingPage,
hiddenWhenLoggedIn: true,
element: <LandingPage />
},
{
path: appPublicRoutes.profile,
element: <ProfilePage />
},
{
path: `${appPublicRoutes.verify}/:id?`,
element: <VerifyPage />
}
]
export const privateRoutes = [
{
path: appPrivateRoutes.homePage,
element: <HomePage />
},
{
path: appPrivateRoutes.create,
element: <CreatePage />
},
{
path: `${appPrivateRoutes.sign}/:id?`,
element: <SignPage />
},
{
path: appPrivateRoutes.settings,
element: <SettingsPage />
},
{
path: appPrivateRoutes.profileSettings,
element: <ProfileSettingsPage />
},
{
path: appPrivateRoutes.cacheSettings,
element: <CacheSettingsPage />
},
{
path: appPrivateRoutes.relays,
element: <RelaysPage />
},
{
path: appPrivateRoutes.nostrLogin,
element: <NostrLoginPage />
}
]

View File

@ -0,0 +1,81 @@
import { Event, UnsignedEvent, EventTemplate, NostrEvent } from 'nostr-tools'
import { SignedEvent } from '../../types'
import { LoginMethodStrategy } from './loginMethodStrategy'
import { WindowNostr } from 'nostr-tools/nip07'
/**
* Login Method Strategy when using nostr-login package.
*
* This class extends {@link LoginMethodStrategy base strategy} and implements all login method operations
* @see {@link LoginMethodStrategy}
*/
export class NostrLoginStrategy extends LoginMethodStrategy {
private nostr: WindowNostr
constructor() {
super()
if (!window.nostr) {
throw new Error(
`window.nostr object not present. Make sure you have an nostr extension installed/working properly.`
)
}
this.nostr = window.nostr as WindowNostr
}
async nip04Encrypt(receiver: string, content: string): Promise<string> {
if (!this.nostr.nip04) {
throw new Error(
`Your nostr extension does not support nip04 encryption & decryption`
)
}
const encrypted = await this.nostr.nip04.encrypt(receiver, content)
return encrypted
}
async nip04Decrypt(sender: string, content: string): Promise<string> {
if (!this.nostr.nip04) {
throw new Error(
`Your nostr extension does not support nip04 encryption & decryption`
)
}
const decrypted = await this.nostr.nip04.decrypt(sender, content)
return decrypted
}
async nip44Encrypt(receiver: string, content: string): Promise<string> {
if (!this.nostr.nip44) {
throw new Error(
`Your nostr extension does not support nip44 encryption & decryption`
)
}
// Encrypt the content using NIP-44 provided by the nostr extension.
const encrypted = await this.nostr.nip44.encrypt(receiver, content)
return encrypted as string
}
async nip44Decrypt(sender: string, content: string): Promise<string> {
if (!this.nostr.nip44) {
throw new Error(
`Your nostr extension does not support nip44 encryption & decryption`
)
}
// Decrypt the content using NIP-44 provided by the nostr extension.
const decrypted = await this.nostr.nip44.decrypt(sender, content)
return decrypted as string
}
async signEvent(event: UnsignedEvent | EventTemplate): Promise<SignedEvent> {
return (await this.nostr
.signEvent(event as NostrEvent)
.catch((err: unknown) => {
console.log('Error while signing event: ', err)
throw err
})) as Event
}
}

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