Compare commits
94 Commits
fix/modal-
...
fix/enable
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3813cef605 | ||
|
|
46336d817f | ||
|
|
a500a2e2a5 | ||
|
|
1e6bf8679c | ||
|
|
6acd00ca3b | ||
|
|
6186f3dd3d | ||
|
|
87ec23c737 | ||
|
|
04c425c32c | ||
|
|
aac537c7a2 | ||
|
|
2058b900ac | ||
|
|
32c097c1ee | ||
|
|
43e375efe9 | ||
|
|
8b349c0350 | ||
|
|
0be2159efb | ||
|
|
1a9dc0da82 | ||
|
|
676eaf6191 | ||
|
|
97c3bcc16d | ||
|
|
a5f7bf2a58 | ||
|
|
0b56813ece | ||
|
|
8d205d9d93 | ||
|
|
9a18e79862 | ||
|
|
ab2df05d50 | ||
|
|
163de16a84 | ||
|
|
e9b290db30 | ||
|
|
544ac18b59 | ||
|
|
2551022d5e | ||
|
|
45c39ca904 | ||
|
|
041b84eb0b | ||
|
|
69166ff501 | ||
|
|
043e159e53 | ||
|
|
13d0a62fec | ||
|
|
d11cccec35 | ||
|
|
81b8624bd1 | ||
|
|
f45300583c | ||
|
|
0cf042e5d9 | ||
|
|
ec544a0592 | ||
|
|
72d561f8c9 | ||
|
|
f408fd1b38 | ||
|
|
977a4b5c93 | ||
|
|
8ccdc06f49 | ||
|
|
6589a98d52 | ||
|
|
fed1ece2d4 | ||
|
|
2b6a1e1e5d | ||
|
|
104404b04c | ||
|
|
e4fdb7794a | ||
|
|
e7e3b871e4 | ||
|
|
1566592683 | ||
|
|
063213cb89 | ||
|
|
52b119b424 | ||
|
|
0bf6fafb3e | ||
|
|
12afbaa76b | ||
|
|
14a83ec721 | ||
|
|
4aa4f7f175 | ||
|
|
7aaea89f21 | ||
|
|
dfb8889b9d | ||
|
|
89fc5b0ae0 | ||
|
|
48c07ad1c0 | ||
|
|
b24e3d31b0 | ||
|
|
caf8f9a82b | ||
|
|
b27fb5ec07 | ||
|
|
449bdb79ce | ||
|
|
d16c3cd9b0 | ||
|
|
d00e16139e | ||
|
|
fe4705afc8 | ||
|
|
326d824451 | ||
|
|
9d565ddbde | ||
|
|
c5c5843cb8 | ||
|
|
cc9840760b | ||
|
|
34bf3f7c12 | ||
|
|
be8cfcb3a5 | ||
|
|
14940a4345 | ||
|
|
d3ab9174e1 | ||
|
|
fa4c5d3532 | ||
|
|
8faccc383b | ||
|
|
e80a41bfa0 | ||
|
|
1305af6896 | ||
|
|
6c2a12c924 | ||
|
|
8aabb45917 | ||
|
|
593fafd9f8 | ||
|
|
5b57b42111 | ||
|
|
2ba1eaef65 | ||
|
|
9c18310fd9 | ||
|
|
c5af7d377d | ||
|
|
f2e70a998d | ||
|
|
b2e1a43f1b | ||
|
|
878bae6c2f | ||
|
|
1c6947d549 | ||
|
|
fabc920563 | ||
|
|
020ab18e56 | ||
|
|
41de75ff6e | ||
|
|
8ae416047d | ||
|
|
cddf0b7805 | ||
|
|
c28ef815ac | ||
|
|
50e31ceb1c |
5
.env
@@ -2,5 +2,6 @@
|
|||||||
# change if you're using a different noauthd server
|
# change if you're using a different noauthd server
|
||||||
REACT_APP_WEB_PUSH_PUBKEY=BNW_39YcKbV4KunFxFhvMW5JUs8AljfFnGUeZpaerO-gwCoWyQat5ol0xOGB8MLaqqCbz0iptd2Qv3SToSGynMk
|
REACT_APP_WEB_PUSH_PUBKEY=BNW_39YcKbV4KunFxFhvMW5JUs8AljfFnGUeZpaerO-gwCoWyQat5ol0xOGB8MLaqqCbz0iptd2Qv3SToSGynMk
|
||||||
#REACT_APP_NOAUTHD_URL=http://localhost:8000
|
#REACT_APP_NOAUTHD_URL=http://localhost:8000
|
||||||
REACT_APP_NOAUTHD_URL=https://noauthd.login.nostrapps.org
|
REACT_APP_NOAUTHD_URL=https://noauthd.nsec.app
|
||||||
REACT_APP_DOMAIN=nsec.app
|
REACT_APP_DOMAIN=nsec.app
|
||||||
|
REACT_APP_RELAY=wss://relay.nsec.app
|
||||||
1
.prettierignore
Normal file
@@ -0,0 +1 @@
|
|||||||
|
node_modules/
|
||||||
9
.prettierrc
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"tabWidth": 2,
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 120,
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"endOfLine": "lf"
|
||||||
|
}
|
||||||
23
README
@@ -1,23 +0,0 @@
|
|||||||
Noauth - Nostr key manager
|
|
||||||
--------------------------
|
|
||||||
|
|
||||||
THIS IS BETA SOFTWARE, DON'T USE WITH REAL KEYS!
|
|
||||||
|
|
||||||
This is a web-based nostr signer app, it uses nip46 signer
|
|
||||||
running inside a service worker, if SW is not running -
|
|
||||||
a noauthd server sends a push message and wakes SW up. Also,
|
|
||||||
keys can be saved to server and fetched later in an end-to-end
|
|
||||||
encrypted way. Keys are encrypted with user-defined password,
|
|
||||||
a good key derivation function is used to resist brute force.
|
|
||||||
|
|
||||||
This app works in Chrome on desktop and Android out of the box,
|
|
||||||
try it with snort.social (use bunker:/... string as 'login string').
|
|
||||||
|
|
||||||
On iOS web push notifications are still experimental, eventually
|
|
||||||
it will work on iOS out of the box too.
|
|
||||||
|
|
||||||
It works across devices, but that's unreliable, especially if
|
|
||||||
signer is on mobile - if smartphone is locked then service worker might
|
|
||||||
not wake up. Thanks to cloud sync/recovery of keys users can import
|
|
||||||
their keys into this app on every device and then it works well.
|
|
||||||
|
|
||||||
95
README.md
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
Noauth - Nostr key manager
|
||||||
|
--------------------------
|
||||||
|
|
||||||
|
Nsec.app is a web app to store your Nostr keys
|
||||||
|
and provide remote access to keys using nip46.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- non-custodial store for your keys
|
||||||
|
- can store many keys
|
||||||
|
- provides nip46 access to apps
|
||||||
|
- permission management for connected apps
|
||||||
|
- works in any browser or platform
|
||||||
|
- background operation even if app tab is closed
|
||||||
|
- cloud e2ee sync for your keys
|
||||||
|
- support for OAuth-like signin flow
|
||||||
|
|
||||||
|
|
||||||
|
How it works
|
||||||
|
------------
|
||||||
|
|
||||||
|
This is a web-based nostr signer app, it uses nip46 signer
|
||||||
|
running inside a service worker, if SW is not running -
|
||||||
|
a noauthd server sends a push message and wakes SW up. Also,
|
||||||
|
keys can be saved to server and fetched later in an end-to-end
|
||||||
|
encrypted way. Keys are encrypted with user-defined password,
|
||||||
|
a good key derivation function is used to resist brute force.
|
||||||
|
|
||||||
|
It works across devices, but that's unreliable, especially if
|
||||||
|
signer is on mobile - if your phone is locked then service worker might
|
||||||
|
not wake up. Thanks to cloud sync/recovery of keys users can import
|
||||||
|
their keys into this app on every device and then it works well.
|
||||||
|
|
||||||
|
How to self-host
|
||||||
|
----------------
|
||||||
|
|
||||||
|
This app is non-custodial, so there isn't much need for
|
||||||
|
self-hosting. However, if you'd like to run your own version of
|
||||||
|
it, here is how to do it:
|
||||||
|
|
||||||
|
Create web push keys (https://github.com/web-push-libs/web-push):
|
||||||
|
```
|
||||||
|
npm install web-push;
|
||||||
|
web-push generate-vapid-keys --json
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit .end in noauth:
|
||||||
|
```
|
||||||
|
REACT_APP_WEB_PUSH_PUBKEY=web push public key,
|
||||||
|
REACT_APP_NOAUTHD_URL=address of the noauthd server (see below)
|
||||||
|
REACT_APP_DOMAIN=domain name of your bunker (i.e. nsec.app)
|
||||||
|
REACT_APP_RELAY=relay that you'll use, can use wss://relay.nsec.app - don't use public general-purpose relays, you'll hit rate limits very fast
|
||||||
|
```
|
||||||
|
|
||||||
|
Then do:
|
||||||
|
```
|
||||||
|
npm install;
|
||||||
|
npm run build;
|
||||||
|
```
|
||||||
|
The app is in the `build` folder.
|
||||||
|
|
||||||
|
To run the noauthd server (https://github.com/nostrband/noauthd),
|
||||||
|
edit .env in noauthd:
|
||||||
|
```
|
||||||
|
PUSH_PUBKEY=web push public key, same as above
|
||||||
|
PUSH_SECRET=web push private key that you generated above
|
||||||
|
ORIGIN=address of the server itself, like http://localhost:8000
|
||||||
|
DATABASE_URL="file:./prod.db"
|
||||||
|
BUNKER_NSEC=nsec of the bunker (needed for create_account methods)
|
||||||
|
BUNKER_RELAY="wss://relay.nsec.app" - same as above
|
||||||
|
BUNKER_DOMAIN="nsec.app" - same as above
|
||||||
|
BUNKER_ORIGIN=where noauth is hosted
|
||||||
|
```
|
||||||
|
|
||||||
|
Then init the database and launch:
|
||||||
|
```
|
||||||
|
npx prisma migrate deploy
|
||||||
|
node -r dotenv/config src/index.js dotenv_config_path=.env
|
||||||
|
```
|
||||||
|
|
||||||
|
TODO
|
||||||
|
----
|
||||||
|
|
||||||
|
- Show details of requested operations
|
||||||
|
- Publish a profile for new sign ups
|
||||||
|
- Sync processed reqs across devices
|
||||||
|
- Sync connected apps and perms across devices
|
||||||
|
- Sync app activity across devices
|
||||||
|
- Group apps by domain
|
||||||
|
- Encrypt local nsec in Safari
|
||||||
|
- Add WebAuthn to the mix
|
||||||
|
- Add LN address to new profiles
|
||||||
|
- Confirm relay/contact list pruning requests
|
||||||
|
- Transfer/change nip05 name
|
||||||
|
- Better notifs with activity summaries
|
||||||
|
- How to send auth_url to new device if all other devices are down?
|
||||||
62886
package-lock.json
generated
193
package.json
@@ -1,96 +1,101 @@
|
|||||||
{
|
{
|
||||||
"name": "noauth",
|
"name": "noauth",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.11.1",
|
"@emotion/react": "^11.11.1",
|
||||||
"@emotion/styled": "^11.11.0",
|
"@emotion/styled": "^11.11.0",
|
||||||
"@mui/icons-material": "^5.14.19",
|
"@hookform/resolvers": "^3.3.4",
|
||||||
"@mui/material": "^5.14.20",
|
"@mui/icons-material": "^5.14.19",
|
||||||
"@nostr-dev-kit/ndk": "^2.0.5",
|
"@mui/material": "^5.14.20",
|
||||||
"@reduxjs/toolkit": "^2.0.1",
|
"@nostr-dev-kit/ndk": "^2.4.0",
|
||||||
"@testing-library/jest-dom": "^5.17.0",
|
"@reduxjs/toolkit": "^2.0.1",
|
||||||
"@testing-library/react": "^13.4.0",
|
"@testing-library/jest-dom": "^5.17.0",
|
||||||
"@testing-library/user-event": "^13.5.0",
|
"@testing-library/react": "^13.4.0",
|
||||||
"@types/jest": "^27.5.2",
|
"@testing-library/user-event": "^13.5.0",
|
||||||
"@types/node": "^17.0.45",
|
"@types/jest": "^27.5.2",
|
||||||
"@types/react": "^18.2.38",
|
"@types/node": "^17.0.45",
|
||||||
"@types/react-copy-to-clipboard": "^5.0.7",
|
"@types/react": "^18.2.38",
|
||||||
"@types/react-dom": "^18.2.17",
|
"@types/react-copy-to-clipboard": "^5.0.7",
|
||||||
"crypto": "^1.0.1",
|
"@types/react-dom": "^18.2.17",
|
||||||
"date-fns": "^3.3.1",
|
"crypto": "^1.0.1",
|
||||||
"dexie": "^3.2.4",
|
"date-fns": "^3.3.1",
|
||||||
"dexie-react-hooks": "^1.1.7",
|
"dexie": "^3.2.4",
|
||||||
"lodash.isequal": "^4.5.0",
|
"dexie-react-hooks": "^1.1.7",
|
||||||
"memoize-one": "^6.0.0",
|
"lodash.isequal": "^4.5.0",
|
||||||
"nostr-tools": "^1.17.0",
|
"memoize-one": "^6.0.0",
|
||||||
"notistack": "^3.0.1",
|
"nostr-tools": "^1.17.0",
|
||||||
"react": "^18.2.0",
|
"notistack": "^3.0.1",
|
||||||
"react-copy-to-clipboard": "^5.1.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-copy-to-clipboard": "^5.1.0",
|
||||||
"react-redux": "^9.0.3",
|
"react-dom": "^18.2.0",
|
||||||
"react-router-dom": "^6.20.1",
|
"react-hook-form": "^7.50.0",
|
||||||
"react-scripts": "5.0.1",
|
"react-redux": "^9.0.3",
|
||||||
"redux-persist": "^6.0.0",
|
"react-router-dom": "^6.20.1",
|
||||||
"typescript": "^5.3.2",
|
"react-scripts": "5.0.1",
|
||||||
"use-debounce": "^10.0.0",
|
"redux-persist": "^6.0.0",
|
||||||
"web-vitals": "^2.1.4",
|
"typescript": "^5.3.2",
|
||||||
"workbox-background-sync": "^6.6.0",
|
"use-debounce": "^10.0.0",
|
||||||
"workbox-broadcast-update": "^6.6.0",
|
"web-vitals": "^2.1.4",
|
||||||
"workbox-cacheable-response": "^6.5.4",
|
"workbox-background-sync": "^6.6.0",
|
||||||
"workbox-core": "^6.6.0",
|
"workbox-broadcast-update": "^6.6.0",
|
||||||
"workbox-expiration": "^6.6.0",
|
"workbox-cacheable-response": "^6.5.4",
|
||||||
"workbox-google-analytics": "^6.6.0",
|
"workbox-core": "^6.6.0",
|
||||||
"workbox-navigation-preload": "^6.6.0",
|
"workbox-expiration": "^6.6.0",
|
||||||
"workbox-precaching": "^6.6.0",
|
"workbox-google-analytics": "^6.6.0",
|
||||||
"workbox-range-requests": "^6.6.0",
|
"workbox-navigation-preload": "^6.6.0",
|
||||||
"workbox-routing": "^6.6.0",
|
"workbox-precaching": "^6.6.0",
|
||||||
"workbox-strategies": "^6.6.0",
|
"workbox-range-requests": "^6.6.0",
|
||||||
"workbox-streams": "^6.6.0"
|
"workbox-routing": "^6.6.0",
|
||||||
},
|
"workbox-strategies": "^6.6.0",
|
||||||
"overrides": {
|
"workbox-streams": "^6.6.0",
|
||||||
"react-scripts": {
|
"yup": "^1.3.3"
|
||||||
"typescript": "^5.3.2"
|
},
|
||||||
}
|
"overrides": {
|
||||||
},
|
"react-scripts": {
|
||||||
"scripts": {
|
"typescript": "^5.3.2"
|
||||||
"start": "react-app-rewired start",
|
}
|
||||||
"build": "react-app-rewired build",
|
},
|
||||||
"test": "react-app-rewired test",
|
"scripts": {
|
||||||
"eject": "react-app-rewired eject",
|
"start": "react-app-rewired start",
|
||||||
"serve": "npm run build && serve -s build"
|
"build": "react-app-rewired build",
|
||||||
},
|
"test": "react-app-rewired test",
|
||||||
"eslintConfig": {
|
"eject": "react-app-rewired eject",
|
||||||
"extends": [
|
"serve": "npm run build && serve -s build",
|
||||||
"react-app",
|
"format": "npx prettier --write src"
|
||||||
"react-app/jest"
|
},
|
||||||
]
|
"eslintConfig": {
|
||||||
},
|
"extends": [
|
||||||
"browserslist": {
|
"react-app",
|
||||||
"production": [
|
"react-app/jest"
|
||||||
">0.2%",
|
]
|
||||||
"not dead",
|
},
|
||||||
"not op_mini all"
|
"browserslist": {
|
||||||
],
|
"production": [
|
||||||
"development": [
|
">0.2%",
|
||||||
"last 1 chrome version",
|
"not dead",
|
||||||
"last 1 firefox version",
|
"not op_mini all"
|
||||||
"last 1 safari version"
|
],
|
||||||
]
|
"development": [
|
||||||
},
|
"last 1 chrome version",
|
||||||
"devDependencies": {
|
"last 1 firefox version",
|
||||||
"@types/lodash.isequal": "^4.5.8",
|
"last 1 safari version"
|
||||||
"assert": "^2.1.0",
|
]
|
||||||
"buffer": "^6.0.3",
|
},
|
||||||
"crypto-browserify": "^3.12.0",
|
"devDependencies": {
|
||||||
"customize-cra": "^1.0.0",
|
"@types/lodash.isequal": "^4.5.8",
|
||||||
"https-browserify": "^1.0.0",
|
"assert": "^2.1.0",
|
||||||
"os-browserify": "^0.3.0",
|
"buffer": "^6.0.3",
|
||||||
"process": "^0.11.10",
|
"crypto-browserify": "^3.12.0",
|
||||||
"react-app-rewired": "^2.2.1",
|
"customize-cra": "^1.0.0",
|
||||||
"serve": "^14.2.1",
|
"https-browserify": "^1.0.0",
|
||||||
"stream-browserify": "^3.0.0",
|
"os-browserify": "^0.3.0",
|
||||||
"stream-http": "^3.2.0",
|
"prettier": "^3.2.5",
|
||||||
"url": "^0.11.3"
|
"process": "^0.11.10",
|
||||||
}
|
"react-app-rewired": "^2.2.1",
|
||||||
|
"serve": "^14.2.1",
|
||||||
|
"stream-browserify": "^3.0.0",
|
||||||
|
"stream-http": "^3.2.0",
|
||||||
|
"url": "^0.11.3"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 502 B After Width: | Height: | Size: 536 B |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -1,39 +1,22 @@
|
|||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="theme-color" content="#000000" />
|
<meta name="theme-color" content="#000000" />
|
||||||
<meta
|
<meta name="description" content="Web site created using create-react-app" />
|
||||||
name="description"
|
<link rel="apple-touch-icon" sizes="180x180" href="%PUBLIC_URL%/apple-touch-icon.png" />
|
||||||
content="Web site created using create-react-app"
|
<link rel="icon" type="image/png" sizes="32x32" href="%PUBLIC_URL%/favicon-32x32.png" />
|
||||||
/>
|
<link rel="icon" type="image/png" sizes="16x16" href="%PUBLIC_URL%/favicon-16x16.png" />
|
||||||
<link
|
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||||
rel="apple-touch-icon"
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
sizes="180x180"
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
href="%PUBLIC_URL%/apple-touch-icon.png"
|
<link
|
||||||
/>
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@200;300;400;500;600;700&display=swap"
|
||||||
<link
|
rel="stylesheet"
|
||||||
rel="icon"
|
/>
|
||||||
type="image/png"
|
<!--
|
||||||
sizes="32x32"
|
|
||||||
href="%PUBLIC_URL%/favicon-32x32.png"
|
|
||||||
/>
|
|
||||||
<link
|
|
||||||
rel="icon"
|
|
||||||
type="image/png"
|
|
||||||
sizes="16x16"
|
|
||||||
href="%PUBLIC_URL%/favicon-16x16.png"
|
|
||||||
/>
|
|
||||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
||||||
<link
|
|
||||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@200;300;400;500;600;700&display=swap"
|
|
||||||
rel="stylesheet"
|
|
||||||
/>
|
|
||||||
<!--
|
|
||||||
Notice the use of %PUBLIC_URL% in the tags above.
|
Notice the use of %PUBLIC_URL% in the tags above.
|
||||||
It will be replaced with the URL of the `public` folder during the build.
|
It will be replaced with the URL of the `public` folder during the build.
|
||||||
Only files inside the `public` folder can be referenced from the HTML.
|
Only files inside the `public` folder can be referenced from the HTML.
|
||||||
@@ -42,12 +25,12 @@
|
|||||||
work correctly both with client-side routing and a non-root public URL.
|
work correctly both with client-side routing and a non-root public URL.
|
||||||
Learn how to configure a non-root public URL by running `npm run build`.
|
Learn how to configure a non-root public URL by running `npm run build`.
|
||||||
-->
|
-->
|
||||||
<title>Nsec.app</title>
|
<title>Nsec.app</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<!--
|
<!--
|
||||||
This HTML file is a template.
|
This HTML file is a template.
|
||||||
If you open it directly in the browser, you will see an empty page.
|
If you open it directly in the browser, you will see an empty page.
|
||||||
|
|
||||||
@@ -56,5 +39,6 @@
|
|||||||
|
|
||||||
To begin the development, run `npm start` or `yarn start`.
|
To begin the development, run `npm start` or `yarn start`.
|
||||||
To create a production bundle, use `npm run build` or `yarn build`.
|
To create a production bundle, use `npm run build` or `yarn build`.
|
||||||
--></body>
|
-->
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
{
|
{
|
||||||
"name": "Noauth",
|
"name": "Nsec.app",
|
||||||
"short_name": "Noauth Nostr key manager",
|
"short_name": "Nsec.app - Nostr key management tool",
|
||||||
"icons": [
|
"start_url": ".",
|
||||||
{
|
"icons": [
|
||||||
"src": "/android-chrome-192x192.png",
|
{
|
||||||
"sizes": "192x192",
|
"src": "/android-chrome-192x192.png",
|
||||||
"type": "image/png"
|
"sizes": "192x192",
|
||||||
},
|
"type": "image/png"
|
||||||
{
|
},
|
||||||
"src": "/android-chrome-512x512.png",
|
{
|
||||||
"sizes": "512x512",
|
"src": "/android-chrome-512x512.png",
|
||||||
"type": "image/png"
|
"sizes": "512x512",
|
||||||
}
|
"type": "image/png"
|
||||||
],
|
}
|
||||||
"start_url": ".",
|
],
|
||||||
"theme_color": "#000000",
|
"theme_color": "#000000",
|
||||||
"background_color": "#ffffff",
|
"background_color": "#ffffff",
|
||||||
"display": "standalone"
|
"display": "standalone"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import React from 'react';
|
import React from 'react'
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react'
|
||||||
import App from './App';
|
import App from './App'
|
||||||
|
|
||||||
test('renders learn react link', () => {
|
test('renders learn react link', () => {
|
||||||
render(<App />);
|
render(<App />)
|
||||||
const linkElement = screen.getByText(/learn react/i);
|
const linkElement = screen.getByText(/learn react/i)
|
||||||
expect(linkElement).toBeInTheDocument();
|
expect(linkElement).toBeInTheDocument()
|
||||||
});
|
})
|
||||||
|
|||||||
136
src/App.tsx
@@ -2,102 +2,90 @@ import { DbKey, dbi } from './modules/db'
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { swicOnRender } from './modules/swic'
|
import { swicOnRender } from './modules/swic'
|
||||||
import { useAppDispatch } from './store/hooks/redux'
|
import { useAppDispatch } from './store/hooks/redux'
|
||||||
import {
|
import { setApps, setKeys, setPending, setPerms } from './store/reducers/content.slice'
|
||||||
setApps,
|
|
||||||
setKeys,
|
|
||||||
setPending,
|
|
||||||
setPerms,
|
|
||||||
} from './store/reducers/content.slice'
|
|
||||||
import AppRoutes from './routes/AppRoutes'
|
import AppRoutes from './routes/AppRoutes'
|
||||||
import { fetchProfile, ndk } from './modules/nostr'
|
import { fetchProfile, ndk } from './modules/nostr'
|
||||||
import { useModalSearchParams } from './hooks/useModalSearchParams'
|
|
||||||
import { MODAL_PARAMS_KEYS } from './types/modal'
|
|
||||||
import { ModalInitial } from './components/Modal/ModalInitial/ModalInitial'
|
import { ModalInitial } from './components/Modal/ModalInitial/ModalInitial'
|
||||||
import { ModalImportKeys } from './components/Modal/ModalImportKeys/ModalImportKeys'
|
import { ModalImportKeys } from './components/Modal/ModalImportKeys/ModalImportKeys'
|
||||||
import { ModalSignUp } from './components/Modal/ModalSignUp/ModalSignUp'
|
import { ModalSignUp } from './components/Modal/ModalSignUp/ModalSignUp'
|
||||||
import { ModalLogin } from './components/Modal/ModalLogin/ModalLogin'
|
import { ModalLogin } from './components/Modal/ModalLogin/ModalLogin'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [render, setRender] = useState(0)
|
const [render, setRender] = useState(0)
|
||||||
const { handleOpen } = useModalSearchParams()
|
const dispatch = useAppDispatch()
|
||||||
const dispatch = useAppDispatch()
|
|
||||||
|
|
||||||
const [isConnected, setIsConnected] = useState(false)
|
const [isConnected, setIsConnected] = useState(false)
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
const keys: DbKey[] = await dbi.listKeys()
|
const keys: DbKey[] = await dbi.listKeys()
|
||||||
console.log(keys, 'keys')
|
// console.log(keys, 'keys')
|
||||||
|
|
||||||
dispatch(setKeys({ keys }))
|
dispatch(setKeys({ keys }))
|
||||||
const loadProfiles = async () => {
|
const loadProfiles = async () => {
|
||||||
const newKeys = []
|
const newKeys = []
|
||||||
|
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
// make it async
|
// make it async
|
||||||
const response = await fetchProfile(key.npub)
|
const response = await fetchProfile(key.npub)
|
||||||
if (!response) {
|
if (!response) {
|
||||||
newKeys.push(key)
|
newKeys.push(key)
|
||||||
} else {
|
} else {
|
||||||
newKeys.push({ ...key, profile: response })
|
newKeys.push({ ...key, profile: response })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dispatch(setKeys({ keys: newKeys }))
|
dispatch(setKeys({ keys: newKeys }))
|
||||||
}
|
}
|
||||||
// async load to avoid blocking main code below
|
// async load to avoid blocking main code below
|
||||||
loadProfiles()
|
loadProfiles()
|
||||||
|
|
||||||
const apps = await dbi.listApps()
|
const apps = await dbi.listApps()
|
||||||
dispatch(
|
dispatch(
|
||||||
setApps({
|
setApps({
|
||||||
apps: apps.map((app) => ({
|
apps,
|
||||||
...app,
|
})
|
||||||
// MOCK IMAGE
|
)
|
||||||
icon: 'https://nostr.band/android-chrome-192x192.png',
|
|
||||||
})),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const perms = await dbi.listPerms()
|
const perms = await dbi.listPerms()
|
||||||
dispatch(setPerms({ perms }))
|
dispatch(setPerms({ perms }))
|
||||||
|
|
||||||
const pending = await dbi.listPending()
|
const pending = await dbi.listPending()
|
||||||
dispatch(setPending({ pending }))
|
dispatch(setPending({ pending }))
|
||||||
|
|
||||||
// rerender
|
// rerender
|
||||||
// setRender((r) => r + 1)
|
// setRender((r) => r + 1)
|
||||||
|
|
||||||
if (!keys.length) handleOpen(MODAL_PARAMS_KEYS.INITIAL)
|
// if (!keys.length) handleOpen(MODAL_PARAMS_KEYS.INITIAL)
|
||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
}, [dispatch])
|
}, [dispatch])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isConnected) load()
|
if (isConnected) load()
|
||||||
}, [render, isConnected, load])
|
}, [render, isConnected, load])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
ndk.connect().then(() => {
|
ndk.connect().then(() => {
|
||||||
console.log('NDK connected', { ndk })
|
console.log('NDK connected')
|
||||||
setIsConnected(true)
|
setIsConnected(true)
|
||||||
})
|
})
|
||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// subscribe to updates from the service worker
|
// subscribe to updates from the service worker
|
||||||
swicOnRender(() => {
|
swicOnRender(() => {
|
||||||
console.log('render')
|
console.log('render')
|
||||||
setRender((r) => r + 1)
|
setRender((r) => r + 1)
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AppRoutes />
|
<AppRoutes />
|
||||||
<ModalInitial />
|
<ModalInitial />
|
||||||
<ModalImportKeys />
|
<ModalImportKeys />
|
||||||
<ModalSignUp />
|
<ModalSignUp />
|
||||||
<ModalLogin />
|
<ModalLogin />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App
|
export default App
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="32" height="36" viewBox="0 0 32 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d="M25.5479 20.2413C24.4588 19.0649 22.6437 18.9473 21.3126 19.7708L18.8925 17.8885C19.8606 16.2416 19.9816 14.2417 19.0135 12.5948L21.4337 10.8302C23.0067 12.1242 25.3059 12.0066 26.7579 10.5949C28.331 9.06555 28.452 6.59511 26.8789 5.06579C25.3059 3.53647 22.7647 3.41883 21.1916 4.94815C19.9816 6.12455 19.6186 7.88915 20.3446 9.41847L18.0455 11.1831C16.1094 9.41847 13.0842 9.18319 11.0271 10.5949L8.72796 8.35971C10.059 6.59511 9.93803 4.00703 8.24394 2.36007C6.42884 0.477835 3.28267 0.477835 1.46757 2.24243C-0.468534 4.00703 -0.468534 7.06567 1.34656 8.83027C3.04066 10.4772 5.58179 10.7125 7.5179 9.53611L9.81702 11.7713C8.36494 13.7712 8.36495 16.4769 9.93803 18.3591L7.27589 21.1825C6.06582 20.5943 4.61374 20.8295 3.64569 21.7707C2.43562 22.9471 2.31462 24.9469 3.52468 26.1233C4.73475 27.2997 6.79186 27.4174 8.00192 26.241C9.09098 25.1822 9.21199 23.6529 8.48595 22.4765L11.0271 19.6531C13.0842 20.9472 15.8674 20.8295 17.6824 19.1826L20.1026 21.0648C19.6186 22.2412 19.7396 23.6529 20.7076 24.594C21.9177 25.8881 24.0958 25.8881 25.3059 24.7117C26.7579 23.5353 26.7579 21.5354 25.5479 20.2413Z" fill="currentColor"/>
|
<path d="M28.2711 21.2958C27.1084 21.2958 26.0749 21.8372 25.4037 22.6797L21.8702 20.6405C22.2577 19.8106 22.4755 18.8846 22.4755 17.908C22.4755 16.9314 22.2577 16.0053 21.8702 15.1755L25.3404 13.1742C26.0091 14.0648 27.0704 14.6442 28.2711 14.6442C30.2949 14.6442 31.9363 13.0047 31.9363 10.9831C31.9363 8.96158 30.2949 7.32208 28.2711 7.32208C26.2472 7.32208 24.6058 8.96158 24.6058 10.9831C24.6058 11.4006 24.6793 11.8003 24.8084 12.1748L21.3028 14.1963C20.2338 12.6732 18.5241 11.6333 16.5635 11.4638V7.274C18.3189 7.00076 19.6639 5.49029 19.6639 3.66104C19.6639 1.6395 18.0225 0 15.9987 0C13.9748 0 12.3334 1.6395 12.3334 3.66104C12.3334 5.49029 13.6784 7.00329 15.4338 7.274V11.4638C13.4733 11.6333 11.7635 12.6732 10.6946 14.1963L7.1889 12.1748C7.31808 11.8003 7.39154 11.4006 7.39154 10.9831C7.39154 8.96158 5.75015 7.32208 3.72629 7.32208C1.70242 7.32208 0.0610352 8.96158 0.0610352 10.9831C0.0610352 13.0047 1.70242 14.6442 3.72629 14.6442C4.92693 14.6442 5.98825 14.0648 6.65697 13.1742L10.1272 15.1755C9.73963 16.0053 9.52179 16.9314 9.52179 17.908C9.52179 18.8846 9.73963 19.8106 10.1272 20.643L6.59364 22.6822C5.9224 21.8397 4.88893 21.2983 3.72629 21.2983C1.70242 21.2983 0.0610352 22.9378 0.0610352 24.9593C0.0610352 26.9809 1.70242 28.6204 3.72629 28.6204C5.75015 28.6204 7.39154 26.9809 7.39154 24.9593C7.39154 24.5039 7.30542 24.0687 7.1509 23.6639L10.6946 21.6196C11.3329 22.5279 12.1992 23.2667 13.2098 23.7499V32.3497C13.2098 32.9721 13.5569 33.8551 13.9799 34.3131L15.2286 35.6565C15.6516 36.1145 16.3457 36.1145 16.7712 35.6565L18.02 34.3131C18.443 33.8551 18.79 32.9721 18.79 32.3497V23.7499C19.8007 23.2667 20.667 22.5304 21.3053 21.6196L24.849 23.6639C24.697 24.0662 24.6083 24.5014 24.6083 24.9593C24.6083 26.9809 26.2497 28.6204 28.2736 28.6204C30.2975 28.6204 31.9388 26.9809 31.9388 24.9593C31.9388 22.9378 30.2975 21.2983 28.2736 21.2983L28.2711 21.2958Z" fill="white"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 2.0 KiB |
@@ -10,14 +10,14 @@ import { ReactComponent as UnchekedLightIcon } from './icons/unchecked-light.svg
|
|||||||
import { default as AddImageIcon } from './icons/add-image.svg'
|
import { default as AddImageIcon } from './icons/add-image.svg'
|
||||||
|
|
||||||
export {
|
export {
|
||||||
AppLogo,
|
AppLogo,
|
||||||
ShareIcon,
|
ShareIcon,
|
||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
CopyIcon,
|
CopyIcon,
|
||||||
CheckmarkIcon,
|
CheckmarkIcon,
|
||||||
CheckedIcon,
|
CheckedIcon,
|
||||||
CheckedLightIcon,
|
CheckedLightIcon,
|
||||||
UnchekedIcon,
|
UnchekedIcon,
|
||||||
UnchekedLightIcon,
|
UnchekedLightIcon,
|
||||||
AddImageIcon,
|
AddImageIcon,
|
||||||
}
|
}
|
||||||
|
|||||||
175
src/components/Modal/ModalAppDetails/ModalAppDetails.tsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
|
||||||
|
import { Button } from '@/shared/Button/Button'
|
||||||
|
import { Input } from '@/shared/Input/Input'
|
||||||
|
import { Modal } from '@/shared/Modal/Modal'
|
||||||
|
import { MODAL_PARAMS_KEYS } from '@/types/modal'
|
||||||
|
import { Autocomplete, Stack, Typography } from '@mui/material'
|
||||||
|
import { StyledInput } from './styled'
|
||||||
|
import { FormEvent, useEffect, useState } from 'react'
|
||||||
|
import { isEmptyString } from '@/utils/helpers/helpers'
|
||||||
|
import { useParams } from 'react-router-dom'
|
||||||
|
import { useAppDispatch, useAppSelector } from '@/store/hooks/redux'
|
||||||
|
import { selectApps } from '@/store'
|
||||||
|
import { dbi } from '@/modules/db'
|
||||||
|
import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar'
|
||||||
|
import { setApps } from '@/store/reducers/content.slice'
|
||||||
|
import { LoadingSpinner } from '@/shared/LoadingSpinner/LoadingSpinner'
|
||||||
|
|
||||||
|
export const ModalAppDetails = () => {
|
||||||
|
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
|
||||||
|
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.APP_DETAILS)
|
||||||
|
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.APP_DETAILS)
|
||||||
|
|
||||||
|
const { appNpub = '' } = useParams()
|
||||||
|
const apps = useAppSelector(selectApps)
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
|
||||||
|
const notify = useEnqueueSnackbar()
|
||||||
|
|
||||||
|
const [details, setDetails] = useState({
|
||||||
|
url: '',
|
||||||
|
name: '',
|
||||||
|
icon: '',
|
||||||
|
})
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const currentApp = apps.find((app) => app.appNpub === appNpub)
|
||||||
|
if (!currentApp) return
|
||||||
|
|
||||||
|
setDetails({
|
||||||
|
icon: currentApp.icon || '',
|
||||||
|
name: currentApp.name || '',
|
||||||
|
url: currentApp.url || '',
|
||||||
|
})
|
||||||
|
|
||||||
|
// eslint-disable-next-line
|
||||||
|
}, [appNpub, isModalOpened])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (isModalOpened) {
|
||||||
|
// modal closed
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isModalOpened])
|
||||||
|
|
||||||
|
const isAppNpubExists = appNpub.trim().length && apps.some((app) => app.appNpub === appNpub)
|
||||||
|
|
||||||
|
if (isModalOpened && !isAppNpubExists) {
|
||||||
|
handleCloseModal()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const { icon, name, url } = details
|
||||||
|
|
||||||
|
const handleInputBlur = () => {
|
||||||
|
if (isEmptyString(url)) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
const u = new URL(url)
|
||||||
|
|
||||||
|
if (isEmptyString(name)) setDetails((prev) => ({ ...prev, name: u.hostname }))
|
||||||
|
if (isEmptyString(icon)) {
|
||||||
|
const iconUrl = `https://${u.hostname}/favicon.ico`
|
||||||
|
setDetails((prev) => ({ ...prev, icon: iconUrl }))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* empty */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleInputChange = (key: string) => (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setDetails((prevState) => {
|
||||||
|
return { ...prevState, [key]: e.target.value }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAutocompletInputChange = (e: unknown, value: string) => {
|
||||||
|
setDetails((prevState) => {
|
||||||
|
return { ...prevState, url: value }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitHandler = async (e: FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (isLoading) return undefined
|
||||||
|
try {
|
||||||
|
setIsLoading(true)
|
||||||
|
const updatedApp = {
|
||||||
|
url,
|
||||||
|
name,
|
||||||
|
icon,
|
||||||
|
appNpub,
|
||||||
|
}
|
||||||
|
await dbi.updateApp(updatedApp)
|
||||||
|
const apps = await dbi.listApps()
|
||||||
|
dispatch(
|
||||||
|
setApps({
|
||||||
|
apps,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
notify(`App successfully updated!`, 'success')
|
||||||
|
setIsLoading(false)
|
||||||
|
handleCloseModal()
|
||||||
|
} catch (error: any) {
|
||||||
|
setIsLoading(false)
|
||||||
|
notify(error?.message || 'Something went wrong!', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFormValid = !isEmptyString(name)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={isModalOpened} onClose={handleCloseModal}>
|
||||||
|
<Stack gap={'1rem'} component={'form'} onSubmit={submitHandler}>
|
||||||
|
<Stack alignItems={'center'}>
|
||||||
|
<Typography fontWeight={600} variant="h5">
|
||||||
|
App details
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Name"
|
||||||
|
fullWidth
|
||||||
|
placeholder="Enter app name"
|
||||||
|
onChange={handleInputChange('name')}
|
||||||
|
value={details.name}
|
||||||
|
/>
|
||||||
|
<Autocomplete
|
||||||
|
options={[]}
|
||||||
|
freeSolo
|
||||||
|
onBlur={handleInputBlur}
|
||||||
|
onInputChange={handleAutocompletInputChange}
|
||||||
|
inputValue={details.url}
|
||||||
|
renderInput={({ inputProps, disabled, id, InputProps }) => {
|
||||||
|
return (
|
||||||
|
<StyledInput
|
||||||
|
{...InputProps}
|
||||||
|
className="input"
|
||||||
|
inputProps={inputProps}
|
||||||
|
disabled={disabled}
|
||||||
|
label="URL"
|
||||||
|
fullWidth
|
||||||
|
placeholder="Enter URL"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Icon"
|
||||||
|
fullWidth
|
||||||
|
placeholder="Enter app icon url"
|
||||||
|
onChange={handleInputChange('icon')}
|
||||||
|
value={details.icon}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button varianttype="secondary" type="submit" fullWidth disabled={!isFormValid || isLoading}>
|
||||||
|
Save changes {isLoading && <LoadingSpinner />}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
11
src/components/Modal/ModalAppDetails/styled.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { AppInputProps, Input } from '@/shared/Input/Input'
|
||||||
|
import { styled } from '@mui/material'
|
||||||
|
import { forwardRef } from 'react'
|
||||||
|
|
||||||
|
export const StyledInput = styled(
|
||||||
|
forwardRef<HTMLInputElement, AppInputProps>((props, ref) => <Input {...props} ref={ref} />)
|
||||||
|
)(() => ({
|
||||||
|
'& .MuiAutocomplete-endAdornment': {
|
||||||
|
right: '1rem',
|
||||||
|
},
|
||||||
|
}))
|
||||||
@@ -1,147 +1,230 @@
|
|||||||
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
|
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
|
||||||
import { Modal } from '@/shared/Modal/Modal'
|
import { Modal } from '@/shared/Modal/Modal'
|
||||||
import { MODAL_PARAMS_KEYS } from '@/types/modal'
|
import { MODAL_PARAMS_KEYS } from '@/types/modal'
|
||||||
import { call, getShortenNpub } from '@/utils/helpers/helpers'
|
import {
|
||||||
|
askNotificationPermission,
|
||||||
|
call,
|
||||||
|
getAppIconTitle,
|
||||||
|
getDomain,
|
||||||
|
getReferrerAppUrl,
|
||||||
|
getShortenNpub,
|
||||||
|
} from '@/utils/helpers/helpers'
|
||||||
import { Avatar, Box, Stack, Typography } from '@mui/material'
|
import { Avatar, Box, Stack, Typography } from '@mui/material'
|
||||||
import { useParams, useSearchParams } from 'react-router-dom'
|
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||||
import { useAppSelector } from '@/store/hooks/redux'
|
import { useAppSelector } from '@/store/hooks/redux'
|
||||||
import { selectAppsByNpub } from '@/store'
|
import { selectAppsByNpub, selectKeys, selectPendingsByNpub } from '@/store'
|
||||||
import { StyledButton, StyledToggleButtonsGroup } from './styled'
|
import { StyledButton, StyledToggleButtonsGroup } from './styled'
|
||||||
import { ActionToggleButton } from './сomponents/ActionToggleButton'
|
import { ActionToggleButton } from './сomponents/ActionToggleButton'
|
||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { swicCall } from '@/modules/swic'
|
import { swicCall, swicWaitStarted } from '@/modules/swic'
|
||||||
import { ACTION_TYPE } from '@/utils/consts'
|
import { ACTION_TYPE } from '@/utils/consts'
|
||||||
|
import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar'
|
||||||
|
|
||||||
export const ModalConfirmConnect = () => {
|
export const ModalConfirmConnect = () => {
|
||||||
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
|
const keys = useAppSelector(selectKeys)
|
||||||
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.CONFIRM_CONNECT)
|
|
||||||
|
|
||||||
const { npub = '' } = useParams<{ npub: string }>()
|
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
|
||||||
const apps = useAppSelector((state) => selectAppsByNpub(state, npub))
|
const notify = useEnqueueSnackbar()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.CONFIRM_CONNECT)
|
||||||
|
|
||||||
const [selectedActionType, setSelectedActionType] = useState<ACTION_TYPE>(
|
const [searchParams] = useSearchParams()
|
||||||
ACTION_TYPE.BASIC,
|
const paramNpub = searchParams.get('npub') || ''
|
||||||
)
|
const { npub = paramNpub } = useParams<{ npub: string }>()
|
||||||
|
const apps = useAppSelector((state) => selectAppsByNpub(state, npub))
|
||||||
|
const pending = useAppSelector((state) => selectPendingsByNpub(state, npub))
|
||||||
|
|
||||||
const [searchParams] = useSearchParams()
|
const [selectedActionType, setSelectedActionType] = useState<ACTION_TYPE>(ACTION_TYPE.BASIC)
|
||||||
const appNpub = searchParams.get('appNpub') || ''
|
const [isLoaded, setIsLoaded] = useState(false)
|
||||||
const pendingReqId = searchParams.get('reqId') || ''
|
|
||||||
|
|
||||||
const triggerApp = apps.find((app) => app.appNpub === appNpub)
|
const appNpub = searchParams.get('appNpub') || ''
|
||||||
const { name, icon = '' } = triggerApp || {}
|
const pendingReqId = searchParams.get('reqId') || ''
|
||||||
const appName = name || getShortenNpub(appNpub)
|
const isPopup = searchParams.get('popup') === 'true'
|
||||||
|
const token = searchParams.get('token') || ''
|
||||||
|
|
||||||
const handleActionTypeChange = (_: any, value: ACTION_TYPE | null) => {
|
const triggerApp = apps.find((app) => app.appNpub === appNpub)
|
||||||
if (!value) return undefined
|
const { name, url = '', icon = '' } = triggerApp || {}
|
||||||
return setSelectedActionType(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCloseModal = createHandleCloseReplace(
|
const appUrl = url || searchParams.get('appUrl') || getReferrerAppUrl()
|
||||||
MODAL_PARAMS_KEYS.CONFIRM_CONNECT,
|
const appDomain = getDomain(appUrl)
|
||||||
{
|
const appName = name || appDomain || getShortenNpub(appNpub)
|
||||||
onClose: async (sp) => {
|
const appAvatarTitle = getAppIconTitle(name || appDomain, appNpub)
|
||||||
sp.delete('appNpub')
|
const appIcon = icon || (appDomain ? `https://${appDomain}/favicon.ico` : '')
|
||||||
sp.delete('reqId')
|
|
||||||
await swicCall('confirm', pendingReqId, false, false)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
const closeModalAfterRequest = createHandleCloseReplace(
|
|
||||||
MODAL_PARAMS_KEYS.CONFIRM_CONNECT,
|
|
||||||
{
|
|
||||||
onClose: (sp) => {
|
|
||||||
sp.delete('appNpub')
|
|
||||||
sp.delete('reqId')
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
async function confirmPending(
|
const closeModalAfterRequest = createHandleCloseReplace(MODAL_PARAMS_KEYS.CONFIRM_CONNECT, {
|
||||||
id: string,
|
onClose: (sp) => {
|
||||||
allow: boolean,
|
sp.delete('appNpub')
|
||||||
remember: boolean,
|
sp.delete('reqId')
|
||||||
options?: any
|
sp.delete('popup')
|
||||||
) {
|
sp.delete('npub')
|
||||||
call(async () => {
|
sp.delete('appUrl')
|
||||||
await swicCall('confirm', id, allow, remember, options)
|
},
|
||||||
console.log('confirmed', id, allow, remember, options)
|
})
|
||||||
closeModalAfterRequest()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const allow = () => {
|
// NOTE: when opened directly to this modal using authUrl,
|
||||||
const options: any = {};
|
// we might not have pending requests visible yet bcs we haven't
|
||||||
if (selectedActionType === ACTION_TYPE.BASIC)
|
// loaded them yet, which means this modal will be closed with
|
||||||
options.perm = ACTION_TYPE.BASIC;
|
// the logic below. So now if it's popup then we wait for SW
|
||||||
confirmPending(pendingReqId, true, true, options)
|
// and then wait a little more to give it time to fetch
|
||||||
}
|
// pending reqs from db. Same logic implemented in confirm-event.
|
||||||
|
|
||||||
return (
|
// FIXME move to a separate hook and reuse?
|
||||||
<Modal open={isModalOpened} onClose={handleCloseModal}>
|
|
||||||
<Stack gap={'1rem'} paddingTop={'1rem'}>
|
useEffect(() => {
|
||||||
<Stack
|
if (isModalOpened) {
|
||||||
direction={'row'}
|
if (isPopup) {
|
||||||
gap={'1rem'}
|
console.log("waiting for sw")
|
||||||
alignItems={'center'}
|
// wait for SW to start
|
||||||
marginBottom={'1rem'}
|
swicWaitStarted().then(() => {
|
||||||
>
|
// give it some time to load the pending reqs etc
|
||||||
<Avatar
|
console.log("waiting for sw done")
|
||||||
variant='square'
|
setTimeout(() => setIsLoaded(true), 500)
|
||||||
sx={{
|
})
|
||||||
width: 56,
|
} else {
|
||||||
height: 56,
|
setIsLoaded(true)
|
||||||
}}
|
}
|
||||||
src={icon}
|
} else {
|
||||||
/>
|
setIsLoaded(false)
|
||||||
<Box>
|
}
|
||||||
<Typography variant='h5' fontWeight={600}>
|
}, [isModalOpened, isPopup])
|
||||||
{appName}
|
|
||||||
</Typography>
|
if (isLoaded) {
|
||||||
<Typography variant='body2' color={'GrayText'}>
|
const isNpubExists = npub.trim().length && keys.some((key) => key.npub === npub)
|
||||||
Would like to connect to your account
|
// NOTE: app doesn't exist yet!
|
||||||
</Typography>
|
// const isAppNpubExists = appNpub.trim().length && apps.some((app) => app.appNpub === appNpub)
|
||||||
</Box>
|
const isPendingReqIdExists = pendingReqId.trim().length && pending.some((p) => p.id === pendingReqId)
|
||||||
</Stack>
|
// console.log("pending", {isModalOpened, isPendingReqIdExists, isNpubExists, /*isAppNpubExists,*/ pendingReqId, pending});
|
||||||
<StyledToggleButtonsGroup
|
if (isModalOpened && (!isNpubExists /*|| !isAppNpubExists*/ || (pendingReqId && !isPendingReqIdExists))) {
|
||||||
value={selectedActionType}
|
if (isPopup) window.close()
|
||||||
onChange={handleActionTypeChange}
|
else closeModalAfterRequest()
|
||||||
exclusive
|
return null
|
||||||
>
|
}
|
||||||
<ActionToggleButton
|
}
|
||||||
value={ACTION_TYPE.BASIC}
|
|
||||||
title='Basic permissions'
|
const handleActionTypeChange = (_: any, value: ACTION_TYPE | null) => {
|
||||||
description='Read your public key, sign notes and reactions'
|
if (!value) return undefined
|
||||||
// hasinfo
|
return setSelectedActionType(value)
|
||||||
/>
|
}
|
||||||
{/* <ActionToggleButton
|
|
||||||
value={ACTION_TYPE.ADVANCED}
|
async function confirmPending(id: string, allow: boolean, remember: boolean, options?: any) {
|
||||||
title='Advanced'
|
call(async () => {
|
||||||
description='Use for trusted apps only'
|
await swicCall('confirm', id, allow, remember, options)
|
||||||
hasinfo
|
console.log('confirmed', id, allow, remember, options)
|
||||||
/> */}
|
closeModalAfterRequest()
|
||||||
<ActionToggleButton
|
})
|
||||||
value={ACTION_TYPE.CUSTOM}
|
if (isPopup) window.close()
|
||||||
title='On demand'
|
}
|
||||||
description='Assign permissions when the app asks for them'
|
|
||||||
/>
|
const allow = async () => {
|
||||||
</StyledToggleButtonsGroup>
|
let perms = ['connect', 'get_public_key']
|
||||||
<Stack direction={'row'} gap={'1rem'}>
|
if (selectedActionType === ACTION_TYPE.BASIC) perms = [ACTION_TYPE.BASIC]
|
||||||
<StyledButton
|
|
||||||
onClick={() => confirmPending(pendingReqId, false, true)}
|
if (pendingReqId) {
|
||||||
varianttype='secondary'
|
const options = { perms, appUrl }
|
||||||
>
|
await confirmPending(pendingReqId, true, true, options)
|
||||||
Disallow
|
} else {
|
||||||
</StyledButton>
|
|
||||||
<StyledButton
|
try {
|
||||||
fullWidth
|
await askNotificationPermission()
|
||||||
onClick={allow}
|
const result = await swicCall('enablePush')
|
||||||
>
|
if (!result) throw new Error('Failed to activate the push subscription')
|
||||||
{/* Allow {selectedActionType} actions */}
|
console.log('enablePush done')
|
||||||
Connect
|
} catch (e: any) {
|
||||||
</StyledButton>
|
console.log('error', e)
|
||||||
</Stack>
|
notify('Please enable Notifications in website settings!', 'error')
|
||||||
</Stack>
|
// keep going
|
||||||
</Modal>
|
}
|
||||||
)
|
|
||||||
|
try {
|
||||||
|
await swicCall('connectApp', { npub, appNpub, appUrl, perms })
|
||||||
|
console.log('connectApp done', npub, appNpub, appUrl, perms)
|
||||||
|
} catch (e: any) {
|
||||||
|
notify(e.toString(), 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
try {
|
||||||
|
await swicCall('redeemToken', npub, token)
|
||||||
|
console.log('redeemToken done')
|
||||||
|
} catch (e) {
|
||||||
|
console.log('error', e)
|
||||||
|
notify('App did not reply. Please try to log in now.', 'error')
|
||||||
|
navigate(`/key/${npub}`, { replace: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
notify('App connected! Closing...', 'success')
|
||||||
|
|
||||||
|
if (isPopup) setTimeout(() => window.close(), 3000)
|
||||||
|
else navigate(`/key/${npub}`, { replace: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const disallow = () => {
|
||||||
|
if (pendingReqId) confirmPending(pendingReqId, false, true)
|
||||||
|
else closeModalAfterRequest()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPopup) {
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.visibilityState === 'hidden') {
|
||||||
|
disallow()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title="Connection request" open={isModalOpened} withCloseButton={false}>
|
||||||
|
<Stack gap={'1rem'} paddingTop={'1rem'}>
|
||||||
|
{!pendingReqId && (
|
||||||
|
<Typography variant="body1" color={'GrayText'}>
|
||||||
|
You will be asked to <b>enable notifications</b>, this is needed for a reliable communication with Nostr
|
||||||
|
apps.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<Stack direction={'row'} gap={'1rem'} alignItems={'center'} marginBottom={'1rem'}>
|
||||||
|
<Avatar
|
||||||
|
variant="rounded"
|
||||||
|
sx={{
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
}}
|
||||||
|
src={appIcon}
|
||||||
|
>
|
||||||
|
{appAvatarTitle}
|
||||||
|
</Avatar>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h5" fontWeight={600}>
|
||||||
|
{appName}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color={'GrayText'}>
|
||||||
|
New app would like to connect
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
<StyledToggleButtonsGroup value={selectedActionType} onChange={handleActionTypeChange} exclusive>
|
||||||
|
<ActionToggleButton
|
||||||
|
value={ACTION_TYPE.BASIC}
|
||||||
|
title="Basic permissions"
|
||||||
|
description="Read your public key, sign notes, reactions, zaps, etc"
|
||||||
|
/>
|
||||||
|
<ActionToggleButton
|
||||||
|
value={ACTION_TYPE.CUSTOM}
|
||||||
|
title="On demand"
|
||||||
|
description="Confirm permissions when the app asks for them"
|
||||||
|
/>
|
||||||
|
</StyledToggleButtonsGroup>
|
||||||
|
<Stack direction={'row'} gap={'1rem'}>
|
||||||
|
<StyledButton onClick={disallow} varianttype="secondary">
|
||||||
|
Ignore
|
||||||
|
</StyledButton>
|
||||||
|
<StyledButton fullWidth onClick={allow}>
|
||||||
|
Connect
|
||||||
|
</StyledButton>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,25 @@
|
|||||||
import { AppButtonProps, Button } from '@/shared/Button/Button'
|
import { AppButtonProps, Button } from '@/shared/Button/Button'
|
||||||
import {
|
import { ToggleButtonGroup, ToggleButtonGroupProps, styled } from '@mui/material'
|
||||||
ToggleButtonGroup,
|
|
||||||
ToggleButtonGroupProps,
|
|
||||||
styled,
|
|
||||||
} from '@mui/material'
|
|
||||||
|
|
||||||
export const StyledButton = styled((props: AppButtonProps) => (
|
export const StyledButton = styled((props: AppButtonProps) => <Button {...props} />)(() => ({
|
||||||
<Button {...props} />
|
borderRadius: '19px',
|
||||||
))(() => ({
|
fontWeight: 600,
|
||||||
borderRadius: '19px',
|
padding: '0.75rem 1rem',
|
||||||
fontWeight: 600,
|
maxHeight: '41px',
|
||||||
padding: '0.75rem 1rem',
|
|
||||||
maxHeight: '41px',
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
export const StyledToggleButtonsGroup = styled(
|
export const StyledToggleButtonsGroup = styled((props: ToggleButtonGroupProps) => <ToggleButtonGroup {...props} />)(
|
||||||
(props: ToggleButtonGroupProps) => <ToggleButtonGroup {...props} />,
|
() => ({
|
||||||
)(() => ({
|
gap: '0.75rem',
|
||||||
gap: '0.75rem',
|
marginBottom: '1rem',
|
||||||
marginBottom: '1rem',
|
justifyContent: 'space-between',
|
||||||
justifyContent: 'space-between',
|
'&.MuiToggleButtonGroup-root .MuiToggleButtonGroup-grouped:not(:first-of-type)': {
|
||||||
'&.MuiToggleButtonGroup-root .MuiToggleButtonGroup-grouped:not(:first-of-type)':
|
margin: '0',
|
||||||
{
|
border: 'initial',
|
||||||
margin: '0',
|
},
|
||||||
border: 'initial',
|
'&.MuiToggleButtonGroup-root .MuiToggleButtonGroup-grouped': {
|
||||||
},
|
border: 'initial',
|
||||||
'&.MuiToggleButtonGroup-root .MuiToggleButtonGroup-grouped': {
|
borderRadius: '1rem',
|
||||||
border: 'initial',
|
},
|
||||||
borderRadius: '1rem',
|
})
|
||||||
},
|
)
|
||||||
}))
|
|
||||||
|
|||||||
@@ -3,30 +3,23 @@ import { ToggleButtonProps, Typography } from '@mui/material'
|
|||||||
import { StyledToggleButton } from './styled'
|
import { StyledToggleButton } from './styled'
|
||||||
|
|
||||||
type ActionToggleButtonProps = ToggleButtonProps & {
|
type ActionToggleButtonProps = ToggleButtonProps & {
|
||||||
description?: string
|
description?: string
|
||||||
hasinfo?: boolean
|
hasinfo?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ActionToggleButton: FC<ActionToggleButtonProps> = ({
|
export const ActionToggleButton: FC<ActionToggleButtonProps> = ({ hasinfo = false, ...props }) => {
|
||||||
hasinfo = false,
|
const { title, description = '' } = props
|
||||||
...props
|
return (
|
||||||
}) => {
|
<StyledToggleButton {...props}>
|
||||||
const { title, description = '' } = props
|
<Typography variant="body2">{title}</Typography>
|
||||||
return (
|
<Typography className="description" variant="caption" color={'GrayText'}>
|
||||||
<StyledToggleButton {...props}>
|
{description}
|
||||||
<Typography variant='body2'>{title}</Typography>
|
</Typography>
|
||||||
<Typography
|
{hasinfo && (
|
||||||
className='description'
|
<Typography className="info" color={'GrayText'}>
|
||||||
variant='caption'
|
Info
|
||||||
color={'GrayText'}
|
</Typography>
|
||||||
>
|
)}
|
||||||
{description}
|
</StyledToggleButton>
|
||||||
</Typography>
|
)
|
||||||
{hasinfo && (
|
|
||||||
<Typography className='info' color={'GrayText'}>
|
|
||||||
Info
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</StyledToggleButton>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,32 @@
|
|||||||
import { ToggleButton, ToggleButtonProps, styled } from '@mui/material'
|
import { ToggleButton, ToggleButtonProps, styled } from '@mui/material'
|
||||||
|
|
||||||
export const StyledToggleButton = styled((props: ToggleButtonProps) => (
|
export const StyledToggleButton = styled((props: ToggleButtonProps) => (
|
||||||
<ToggleButton classes={{ selected: 'selected' }} {...props} />
|
<ToggleButton classes={{ selected: 'selected' }} {...props} />
|
||||||
))(({ theme }) => ({
|
))(({ theme }) => ({
|
||||||
'&:is(&, :hover, :active)': {
|
'&:is(&, :hover, :active)': {
|
||||||
background: theme.palette.backgroundSecondary.default,
|
background: theme.palette.backgroundSecondary.default,
|
||||||
},
|
},
|
||||||
color: theme.palette.text.primary,
|
color: theme.palette.text.primary,
|
||||||
flex: '1 0 6.25rem',
|
flex: '1 0 6.25rem',
|
||||||
height: '100px',
|
height: '100px',
|
||||||
borderRadius: '1rem',
|
borderRadius: '1rem',
|
||||||
border: `2px solid transparent !important`,
|
border: `2px solid transparent !important`,
|
||||||
'&.selected': {
|
'&.selected': {
|
||||||
border: `2px solid ${theme.palette.text.primary} !important`,
|
border: `2px solid ${theme.palette.text.primary} !important`,
|
||||||
},
|
},
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
alignItems: 'flex-start',
|
alignItems: 'flex-start',
|
||||||
justifyContent: 'flex-start',
|
justifyContent: 'flex-start',
|
||||||
textTransform: 'initial',
|
textTransform: 'initial',
|
||||||
'& .description': {
|
'& .description': {
|
||||||
display: 'inline-block',
|
display: 'inline-block',
|
||||||
textAlign: 'left',
|
textAlign: 'left',
|
||||||
lineHeight: '15px',
|
lineHeight: '15px',
|
||||||
margin: '0.5rem 0 0.25rem',
|
margin: '0.5rem 0 0.25rem',
|
||||||
},
|
},
|
||||||
'& .info': {
|
'& .info': {
|
||||||
fontSize: '10px',
|
fontSize: '10px',
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -1,224 +1,186 @@
|
|||||||
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
|
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
|
||||||
import { Modal } from '@/shared/Modal/Modal'
|
import { Modal } from '@/shared/Modal/Modal'
|
||||||
import { MODAL_PARAMS_KEYS } from '@/types/modal'
|
import { MODAL_PARAMS_KEYS } from '@/types/modal'
|
||||||
import { call, getShortenNpub, getSignReqKind } from '@/utils/helpers/helpers'
|
import { call, getAppIconTitle, getReqActionName, getShortenNpub } from '@/utils/helpers/helpers'
|
||||||
import {
|
import { Avatar, Box, List, ListItem, ListItemIcon, ListItemText, Stack, Typography } from '@mui/material'
|
||||||
Avatar,
|
|
||||||
Box,
|
|
||||||
List,
|
|
||||||
ListItem,
|
|
||||||
ListItemIcon,
|
|
||||||
ListItemText,
|
|
||||||
Stack,
|
|
||||||
Typography,
|
|
||||||
} from '@mui/material'
|
|
||||||
import { useParams, useSearchParams } from 'react-router-dom'
|
import { useParams, useSearchParams } from 'react-router-dom'
|
||||||
import { useAppSelector } from '@/store/hooks/redux'
|
import { useAppSelector } from '@/store/hooks/redux'
|
||||||
import { selectAppsByNpub } from '@/store'
|
import { selectAppsByNpub, selectKeys } from '@/store'
|
||||||
import { ActionToggleButton } from './сomponents/ActionToggleButton'
|
import { ActionToggleButton } from './сomponents/ActionToggleButton'
|
||||||
import { FC, useEffect, useMemo, useState } from 'react'
|
import { FC, useEffect, useMemo, useState } from 'react'
|
||||||
import {
|
import { StyledActionsListContainer, StyledButton, StyledToggleButtonsGroup } from './styled'
|
||||||
StyledActionsListContainer,
|
|
||||||
StyledButton,
|
|
||||||
StyledToggleButtonsGroup,
|
|
||||||
} from './styled'
|
|
||||||
import { SectionTitle } from '@/shared/SectionTitle/SectionTitle'
|
import { SectionTitle } from '@/shared/SectionTitle/SectionTitle'
|
||||||
import { swicCall } from '@/modules/swic'
|
import { swicCall, swicWaitStarted } from '@/modules/swic'
|
||||||
import { Checkbox } from '@/shared/Checkbox/Checkbox'
|
import { Checkbox } from '@/shared/Checkbox/Checkbox'
|
||||||
import { DbPending } from '@/modules/db'
|
import { DbPending } from '@/modules/db'
|
||||||
import { ACTIONS } from '@/utils/consts'
|
|
||||||
import { IPendingsByAppNpub } from '@/pages/KeyPage/hooks/useTriggerConfirmModal'
|
import { IPendingsByAppNpub } from '@/pages/KeyPage/hooks/useTriggerConfirmModal'
|
||||||
|
|
||||||
enum ACTION_TYPE {
|
enum ACTION_TYPE {
|
||||||
ALWAYS = 'ALWAYS',
|
ALWAYS = 'ALWAYS',
|
||||||
ONCE = 'ONCE',
|
ONCE = 'ONCE',
|
||||||
ALLOW_ALL = 'ALLOW_ALL',
|
ALLOW_ALL = 'ALLOW_ALL',
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACTION_LABELS = {
|
const ACTION_LABELS = {
|
||||||
[ACTION_TYPE.ALWAYS]: 'Always',
|
[ACTION_TYPE.ALWAYS]: 'Always',
|
||||||
[ACTION_TYPE.ONCE]: 'Just Once',
|
[ACTION_TYPE.ONCE]: 'Just Once',
|
||||||
[ACTION_TYPE.ALLOW_ALL]: 'All Advanced Actions',
|
[ACTION_TYPE.ALLOW_ALL]: 'All Advanced Actions',
|
||||||
}
|
}
|
||||||
|
|
||||||
type ModalConfirmEventProps = {
|
type ModalConfirmEventProps = {
|
||||||
confirmEventReqs: IPendingsByAppNpub
|
confirmEventReqs: IPendingsByAppNpub
|
||||||
}
|
}
|
||||||
|
|
||||||
type PendingRequest = DbPending & { checked: boolean }
|
type PendingRequest = DbPending & { checked: boolean }
|
||||||
|
|
||||||
export const ModalConfirmEvent: FC<ModalConfirmEventProps> = ({
|
export const ModalConfirmEvent: FC<ModalConfirmEventProps> = ({ confirmEventReqs }) => {
|
||||||
confirmEventReqs,
|
const keys = useAppSelector(selectKeys)
|
||||||
}) => {
|
|
||||||
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
|
|
||||||
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.CONFIRM_EVENT)
|
|
||||||
const [searchParams] = useSearchParams()
|
|
||||||
|
|
||||||
const appNpub = searchParams.get('appNpub') || ''
|
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
|
||||||
|
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.CONFIRM_EVENT)
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
|
||||||
const { npub = '' } = useParams<{ npub: string }>()
|
const appNpub = searchParams.get('appNpub') || ''
|
||||||
const apps = useAppSelector((state) => selectAppsByNpub(state, npub))
|
const isPopup = searchParams.get('popup') === 'true'
|
||||||
|
const { npub = '' } = useParams<{ npub: string }>()
|
||||||
|
const apps = useAppSelector((state) => selectAppsByNpub(state, npub))
|
||||||
|
|
||||||
const [selectedActionType, setSelectedActionType] = useState<ACTION_TYPE>(
|
const [selectedActionType, setSelectedActionType] = useState<ACTION_TYPE>(ACTION_TYPE.ALWAYS)
|
||||||
ACTION_TYPE.ALWAYS,
|
const [pendingRequests, setPendingRequests] = useState<PendingRequest[]>([])
|
||||||
)
|
const [isLoaded, setIsLoaded] = useState(false)
|
||||||
const [pendingRequests, setPendingRequests] = useState<PendingRequest[]>([])
|
|
||||||
|
|
||||||
const currentAppPendingReqs = useMemo(
|
const currentAppPendingReqs = useMemo(() => confirmEventReqs[appNpub]?.pending || [], [confirmEventReqs, appNpub])
|
||||||
() => confirmEventReqs[appNpub]?.pending || [],
|
|
||||||
[confirmEventReqs, appNpub],
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPendingRequests(
|
setPendingRequests(currentAppPendingReqs.map((pr) => ({ ...pr, checked: true })))
|
||||||
currentAppPendingReqs.map((pr) => ({ ...pr, checked: true })),
|
}, [currentAppPendingReqs])
|
||||||
)
|
|
||||||
}, [currentAppPendingReqs])
|
|
||||||
|
|
||||||
const triggerApp = apps.find((app) => app.appNpub === appNpub)
|
const closeModalAfterRequest = createHandleCloseReplace(MODAL_PARAMS_KEYS.CONFIRM_EVENT, {
|
||||||
const { name, icon = '' } = triggerApp || {}
|
onClose: (sp) => {
|
||||||
const appName = name || getShortenNpub(appNpub)
|
sp.delete('appNpub')
|
||||||
|
sp.delete('reqId')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const handleActionTypeChange = (_: any, value: ACTION_TYPE | null) => {
|
useEffect(() => {
|
||||||
if (!value) return undefined
|
if (isModalOpened) {
|
||||||
return setSelectedActionType(value)
|
if (isPopup) {
|
||||||
}
|
// wait for SW to start
|
||||||
|
swicWaitStarted().then(() => {
|
||||||
|
// give it some time to load the pending reqs etc
|
||||||
|
setTimeout(() => setIsLoaded(true), 500)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setIsLoaded(true)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setIsLoaded(false)
|
||||||
|
}
|
||||||
|
}, [isModalOpened, isPopup])
|
||||||
|
|
||||||
const selectedPendingRequests = pendingRequests.filter((pr) => pr.checked)
|
if (isLoaded) {
|
||||||
|
const isNpubExists = npub.trim().length && keys.some((key) => key.npub === npub)
|
||||||
|
const isAppNpubExists = appNpub.trim().length && apps.some((app) => app.appNpub === appNpub)
|
||||||
|
// console.log("confirm event", { confirmEventReqs, isModalOpened, isNpubExists, isAppNpubExists });
|
||||||
|
if (isModalOpened && (!currentAppPendingReqs.length || !isNpubExists || !isAppNpubExists)) {
|
||||||
|
if (isPopup) window.close()
|
||||||
|
else closeModalAfterRequest()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleCloseModal = createHandleCloseReplace(
|
const triggerApp = apps.find((app) => app.appNpub === appNpub)
|
||||||
MODAL_PARAMS_KEYS.CONFIRM_EVENT,
|
const { name, icon = '' } = triggerApp || {}
|
||||||
{
|
const appName = name || getShortenNpub(appNpub)
|
||||||
onClose: (sp) => {
|
const appAvatarTitle = getAppIconTitle(name, appNpub)
|
||||||
sp.delete('appNpub')
|
|
||||||
sp.delete('reqId')
|
|
||||||
selectedPendingRequests.forEach(
|
|
||||||
async (req) => await swicCall('confirm', req.id, false, false),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
const closeModalAfterRequest = createHandleCloseReplace(
|
const handleActionTypeChange = (_: any, value: ACTION_TYPE | null) => {
|
||||||
MODAL_PARAMS_KEYS.CONFIRM_EVENT,
|
if (!value) return undefined
|
||||||
{
|
return setSelectedActionType(value)
|
||||||
onClose: (sp) => {
|
}
|
||||||
sp.delete('appNpub')
|
|
||||||
sp.delete('reqId')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
async function confirmPending(allow: boolean) {
|
const selectedPendingRequests = pendingRequests.filter((pr) => pr.checked)
|
||||||
selectedPendingRequests.forEach((req) => {
|
|
||||||
call(async () => {
|
|
||||||
const remember = selectedActionType !== ACTION_TYPE.ONCE
|
|
||||||
await swicCall('confirm', req.id, allow, remember)
|
|
||||||
console.log('confirmed', req.id, selectedActionType, allow)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
closeModalAfterRequest()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleChangeCheckbox = (reqId: string) => () => {
|
async function confirmPending(allow: boolean) {
|
||||||
const newPendingRequests = pendingRequests.map((req) => {
|
selectedPendingRequests.forEach((req) => {
|
||||||
if (req.id === reqId) return { ...req, checked: !req.checked }
|
call(async () => {
|
||||||
return req
|
const remember = selectedActionType !== ACTION_TYPE.ONCE
|
||||||
})
|
await swicCall('confirm', req.id, allow, remember)
|
||||||
setPendingRequests(newPendingRequests)
|
console.log('confirmed', req.id, selectedActionType, allow)
|
||||||
}
|
})
|
||||||
|
})
|
||||||
|
closeModalAfterRequest()
|
||||||
|
if (isPopup) window.close()
|
||||||
|
}
|
||||||
|
|
||||||
const getAction = (req: PendingRequest) => {
|
const handleChangeCheckbox = (reqId: string) => () => {
|
||||||
const action = ACTIONS[req.method]
|
const newPendingRequests = pendingRequests.map((req) => {
|
||||||
if (req.method === 'sign_event') {
|
if (req.id === reqId) return { ...req, checked: !req.checked }
|
||||||
const kind = getSignReqKind(req)
|
return req
|
||||||
if (kind !== undefined) return `${action} of kind ${kind}`
|
})
|
||||||
}
|
setPendingRequests(newPendingRequests)
|
||||||
return action
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
if (isPopup) {
|
||||||
<Modal open={isModalOpened} onClose={handleCloseModal}>
|
document.addEventListener('visibilitychange', () => {
|
||||||
<Stack gap={'1rem'} paddingTop={'1rem'}>
|
if (document.visibilityState === 'hidden') {
|
||||||
<Stack
|
confirmPending(false)
|
||||||
direction={'row'}
|
}
|
||||||
gap={'1rem'}
|
})
|
||||||
alignItems={'center'}
|
}
|
||||||
marginBottom={'1rem'}
|
|
||||||
>
|
|
||||||
<Avatar
|
|
||||||
variant='square'
|
|
||||||
sx={{
|
|
||||||
width: 56,
|
|
||||||
height: 56,
|
|
||||||
borderRadius: '12px',
|
|
||||||
}}
|
|
||||||
src={icon}
|
|
||||||
/>
|
|
||||||
<Box>
|
|
||||||
<Typography variant='h5' fontWeight={600}>
|
|
||||||
{appName}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant='body2' color={'GrayText'}>
|
|
||||||
Would like your permission to
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<StyledActionsListContainer marginBottom={'1rem'}>
|
return (
|
||||||
<SectionTitle>Actions</SectionTitle>
|
<Modal title="Permission request" open={isModalOpened} withCloseButton={false}>
|
||||||
<List>
|
<Stack gap={'1rem'} paddingTop={'1rem'}>
|
||||||
{pendingRequests.map((req) => {
|
<Stack direction={'row'} gap={'1rem'} alignItems={'center'} marginBottom={'1rem'}>
|
||||||
return (
|
<Avatar
|
||||||
<ListItem key={req.id}>
|
variant="square"
|
||||||
<ListItemIcon>
|
sx={{
|
||||||
<Checkbox
|
width: 56,
|
||||||
checked={req.checked}
|
height: 56,
|
||||||
onChange={handleChangeCheckbox(
|
borderRadius: '12px',
|
||||||
req.id,
|
}}
|
||||||
)}
|
src={icon}
|
||||||
/>
|
>
|
||||||
</ListItemIcon>
|
{appAvatarTitle}
|
||||||
<ListItemText>
|
</Avatar>
|
||||||
{getAction(req)}
|
<Box>
|
||||||
</ListItemText>
|
<Typography variant="h5" fontWeight={600}>
|
||||||
</ListItem>
|
{appName}
|
||||||
)
|
</Typography>
|
||||||
})}
|
<Typography variant="body2" color={'GrayText'}>
|
||||||
</List>
|
App wants to perform these actions
|
||||||
</StyledActionsListContainer>
|
</Typography>
|
||||||
<StyledToggleButtonsGroup
|
</Box>
|
||||||
value={selectedActionType}
|
</Stack>
|
||||||
onChange={handleActionTypeChange}
|
|
||||||
exclusive
|
|
||||||
>
|
|
||||||
<ActionToggleButton
|
|
||||||
value={ACTION_TYPE.ALWAYS}
|
|
||||||
title='Always'
|
|
||||||
/>
|
|
||||||
<ActionToggleButton
|
|
||||||
value={ACTION_TYPE.ONCE}
|
|
||||||
title='Just once'
|
|
||||||
/>
|
|
||||||
{/* <ActionToggleButton
|
|
||||||
value={ACTION_TYPE.ALLOW_ALL}
|
|
||||||
title='Allow All Advanced Actions'
|
|
||||||
hasinfo
|
|
||||||
/> */}
|
|
||||||
</StyledToggleButtonsGroup>
|
|
||||||
|
|
||||||
<Stack direction={'row'} gap={'1rem'}>
|
<StyledActionsListContainer marginBottom={'1rem'}>
|
||||||
<StyledButton
|
<SectionTitle>Actions</SectionTitle>
|
||||||
onClick={() => confirmPending(false)}
|
<List>
|
||||||
varianttype='secondary'
|
{pendingRequests.map((req) => {
|
||||||
>
|
return (
|
||||||
Disallow {ACTION_LABELS[selectedActionType]}
|
<ListItem key={req.id}>
|
||||||
</StyledButton>
|
<ListItemIcon>
|
||||||
<StyledButton onClick={() => confirmPending(true)}>
|
<Checkbox checked={req.checked} onChange={handleChangeCheckbox(req.id)} />
|
||||||
Allow {ACTION_LABELS[selectedActionType]}
|
</ListItemIcon>
|
||||||
</StyledButton>
|
<ListItemText>{getReqActionName(req)}</ListItemText>
|
||||||
</Stack>
|
</ListItem>
|
||||||
</Stack>
|
)
|
||||||
</Modal>
|
})}
|
||||||
)
|
</List>
|
||||||
|
</StyledActionsListContainer>
|
||||||
|
<StyledToggleButtonsGroup value={selectedActionType} onChange={handleActionTypeChange} exclusive>
|
||||||
|
<ActionToggleButton value={ACTION_TYPE.ALWAYS} title="Always" />
|
||||||
|
<ActionToggleButton value={ACTION_TYPE.ONCE} title="Just once" />
|
||||||
|
</StyledToggleButtonsGroup>
|
||||||
|
|
||||||
|
<Stack direction={'row'} gap={'1rem'}>
|
||||||
|
<StyledButton onClick={() => confirmPending(false)} varianttype="secondary">
|
||||||
|
Disallow {ACTION_LABELS[selectedActionType]}
|
||||||
|
</StyledButton>
|
||||||
|
<StyledButton onClick={() => confirmPending(true)}>Allow {ACTION_LABELS[selectedActionType]}</StyledButton>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +1,31 @@
|
|||||||
import { AppButtonProps, Button } from '@/shared/Button/Button'
|
import { AppButtonProps, Button } from '@/shared/Button/Button'
|
||||||
import {
|
import { Stack, StackProps, ToggleButtonGroup, ToggleButtonGroupProps, styled } from '@mui/material'
|
||||||
Stack,
|
|
||||||
StackProps,
|
|
||||||
ToggleButtonGroup,
|
|
||||||
ToggleButtonGroupProps,
|
|
||||||
styled,
|
|
||||||
} from '@mui/material'
|
|
||||||
|
|
||||||
export const StyledButton = styled((props: AppButtonProps) => (
|
export const StyledButton = styled((props: AppButtonProps) => <Button {...props} />)(() => ({
|
||||||
<Button {...props} />
|
borderRadius: '19px',
|
||||||
))(() => ({
|
fontWeight: 600,
|
||||||
borderRadius: '19px',
|
padding: '0.75rem 1rem',
|
||||||
fontWeight: 600,
|
maxHeight: '41px',
|
||||||
padding: '0.75rem 1rem',
|
|
||||||
maxHeight: '41px',
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
export const StyledToggleButtonsGroup = styled(
|
export const StyledToggleButtonsGroup = styled((props: ToggleButtonGroupProps) => <ToggleButtonGroup {...props} />)(
|
||||||
(props: ToggleButtonGroupProps) => <ToggleButtonGroup {...props} />,
|
() => ({
|
||||||
)(() => ({
|
gap: '0.75rem',
|
||||||
gap: '0.75rem',
|
marginBottom: '1rem',
|
||||||
marginBottom: '1rem',
|
justifyContent: 'space-between',
|
||||||
justifyContent: 'space-between',
|
'&.MuiToggleButtonGroup-root .MuiToggleButtonGroup-grouped:not(:first-of-type)': {
|
||||||
'&.MuiToggleButtonGroup-root .MuiToggleButtonGroup-grouped:not(:first-of-type)':
|
margin: '0',
|
||||||
{
|
border: 'initial',
|
||||||
margin: '0',
|
},
|
||||||
border: 'initial',
|
'&.MuiToggleButtonGroup-root .MuiToggleButtonGroup-grouped': {
|
||||||
},
|
border: 'initial',
|
||||||
'&.MuiToggleButtonGroup-root .MuiToggleButtonGroup-grouped': {
|
borderRadius: '1rem',
|
||||||
border: 'initial',
|
},
|
||||||
borderRadius: '1rem',
|
})
|
||||||
},
|
)
|
||||||
}))
|
|
||||||
|
|
||||||
export const StyledActionsListContainer = styled((props: StackProps) => (
|
export const StyledActionsListContainer = styled((props: StackProps) => <Stack {...props} />)(({ theme }) => ({
|
||||||
<Stack {...props} />
|
padding: '0.75rem',
|
||||||
))(({ theme }) => ({
|
background: theme.palette.backgroundSecondary.default,
|
||||||
padding: '0.75rem',
|
borderRadius: '1rem',
|
||||||
background: theme.palette.backgroundSecondary.default,
|
|
||||||
borderRadius: '1rem',
|
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -3,22 +3,19 @@ import { ToggleButtonProps, Typography } from '@mui/material'
|
|||||||
import { StyledToggleButton } from './styled'
|
import { StyledToggleButton } from './styled'
|
||||||
|
|
||||||
type ActionToggleButtonProps = ToggleButtonProps & {
|
type ActionToggleButtonProps = ToggleButtonProps & {
|
||||||
hasinfo?: boolean
|
hasinfo?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ActionToggleButton: FC<ActionToggleButtonProps> = ({
|
export const ActionToggleButton: FC<ActionToggleButtonProps> = ({ hasinfo = false, ...props }) => {
|
||||||
hasinfo = false,
|
const { title } = props
|
||||||
...props
|
return (
|
||||||
}) => {
|
<StyledToggleButton {...props}>
|
||||||
const { title } = props
|
<Typography variant="body2">{title}</Typography>
|
||||||
return (
|
{hasinfo && (
|
||||||
<StyledToggleButton {...props}>
|
<Typography className="info" color={'GrayText'}>
|
||||||
<Typography variant='body2'>{title}</Typography>
|
Info
|
||||||
{hasinfo && (
|
</Typography>
|
||||||
<Typography className='info' color={'GrayText'}>
|
)}
|
||||||
Info
|
</StyledToggleButton>
|
||||||
</Typography>
|
)
|
||||||
)}
|
|
||||||
</StyledToggleButton>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,33 @@
|
|||||||
import { ToggleButton, ToggleButtonProps, styled } from '@mui/material'
|
import { ToggleButton, ToggleButtonProps, styled } from '@mui/material'
|
||||||
|
|
||||||
export const StyledToggleButton = styled((props: ToggleButtonProps) => (
|
export const StyledToggleButton = styled((props: ToggleButtonProps) => (
|
||||||
<ToggleButton classes={{ selected: 'selected' }} {...props} />
|
<ToggleButton classes={{ selected: 'selected' }} {...props} />
|
||||||
))(({ theme }) => ({
|
))(({ theme }) => ({
|
||||||
'&:is(&, :hover, :active)': {
|
'&:is(&, :hover, :active)': {
|
||||||
background: theme.palette.backgroundSecondary.default,
|
background: theme.palette.backgroundSecondary.default,
|
||||||
},
|
},
|
||||||
color: theme.palette.text.primary,
|
color: theme.palette.text.primary,
|
||||||
flex: '1 0 6.25rem',
|
flex: '1 0 6.25rem',
|
||||||
height: '100px',
|
height: '100px',
|
||||||
borderRadius: '1rem',
|
borderRadius: '1rem',
|
||||||
border: `2px solid transparent !important`,
|
border: `2px solid transparent !important`,
|
||||||
'&.selected': {
|
'&.selected': {
|
||||||
border: `2px solid ${theme.palette.text.primary} !important`,
|
border: `2px solid ${theme.palette.text.primary} !important`,
|
||||||
},
|
},
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
alignItems: 'flex-start',
|
alignItems: 'flex-start',
|
||||||
justifyContent: 'flex-start',
|
justifyContent: 'flex-start',
|
||||||
textTransform: 'initial',
|
textTransform: 'initial',
|
||||||
textAlign: 'left',
|
textAlign: 'left',
|
||||||
'& .description': {
|
'& .description': {
|
||||||
display: 'inline-block',
|
display: 'inline-block',
|
||||||
textAlign: 'left',
|
textAlign: 'left',
|
||||||
lineHeight: '15px',
|
lineHeight: '15px',
|
||||||
margin: '0.5rem 0 0.25rem',
|
margin: '0.5rem 0 0.25rem',
|
||||||
},
|
},
|
||||||
'& .info': {
|
'& .info': {
|
||||||
fontSize: '10px',
|
fontSize: '10px',
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -5,88 +5,81 @@ import { Button } from '@/shared/Button/Button'
|
|||||||
import { Input } from '@/shared/Input/Input'
|
import { Input } from '@/shared/Input/Input'
|
||||||
import { InputCopyButton } from '@/shared/InputCopyButton/InputCopyButton'
|
import { InputCopyButton } from '@/shared/InputCopyButton/InputCopyButton'
|
||||||
import { Modal } from '@/shared/Modal/Modal'
|
import { Modal } from '@/shared/Modal/Modal'
|
||||||
import { MODAL_PARAMS_KEYS } from '@/types/modal'
|
import { selectKeys } from '@/store'
|
||||||
|
import { useAppSelector } from '@/store/hooks/redux'
|
||||||
|
import { EXPLANATION_MODAL_KEYS, MODAL_PARAMS_KEYS } from '@/types/modal'
|
||||||
import { getBunkerLink } from '@/utils/helpers/helpers'
|
import { getBunkerLink } from '@/utils/helpers/helpers'
|
||||||
import { Stack, Typography } from '@mui/material'
|
import { Stack, Typography } from '@mui/material'
|
||||||
import { useRef } from 'react'
|
import { useRef } from 'react'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
|
|
||||||
export const ModalConnectApp = () => {
|
export const ModalConnectApp = () => {
|
||||||
const { getModalOpened, createHandleCloseReplace, handleOpen } = useModalSearchParams()
|
const keys = useAppSelector(selectKeys)
|
||||||
const timerRef = useRef<NodeJS.Timeout>()
|
|
||||||
|
|
||||||
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.CONNECT_APP)
|
const timerRef = useRef<NodeJS.Timeout>()
|
||||||
const handleCloseModal = createHandleCloseReplace(
|
const notify = useEnqueueSnackbar()
|
||||||
MODAL_PARAMS_KEYS.CONNECT_APP,
|
const { npub = '' } = useParams<{ npub: string }>()
|
||||||
{
|
const bunkerStr = getBunkerLink(npub)
|
||||||
onClose: () => {
|
|
||||||
clearTimeout(timerRef.current)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
const notify = useEnqueueSnackbar()
|
const { getModalOpened, createHandleCloseReplace, handleOpen } = useModalSearchParams()
|
||||||
|
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.CONNECT_APP)
|
||||||
|
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.CONNECT_APP, {
|
||||||
|
onClose: () => {
|
||||||
|
clearTimeout(timerRef.current)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const { npub = '' } = useParams<{ npub: string }>()
|
const isNpubExists = npub.trim().length && keys.some((key) => key.npub === npub)
|
||||||
|
if (isModalOpened && !isNpubExists) {
|
||||||
|
handleCloseModal()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const bunkerStr = getBunkerLink(npub)
|
const handleShareBunker = async () => {
|
||||||
|
const shareData = {
|
||||||
|
text: bunkerStr,
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (navigator.share && navigator.canShare(shareData)) {
|
||||||
|
await navigator.share(shareData)
|
||||||
|
} else {
|
||||||
|
navigator.clipboard.writeText(bunkerStr)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err)
|
||||||
|
notify('Your browser does not support sharing data', 'warning')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleShareBunker = async () => {
|
const handleCopy = () => {
|
||||||
const shareData = {
|
timerRef.current = setTimeout(() => {
|
||||||
text: bunkerStr,
|
handleCloseModal()
|
||||||
}
|
}, 3000)
|
||||||
try {
|
}
|
||||||
if (navigator.share && navigator.canShare(shareData)) {
|
|
||||||
await navigator.share(shareData)
|
|
||||||
} else {
|
|
||||||
navigator.clipboard.writeText(bunkerStr)
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.log(err)
|
|
||||||
notify('Your browser does not support sharing data', 'warning')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCopy = () => {
|
return (
|
||||||
timerRef.current = setTimeout(() => {
|
<Modal open={isModalOpened} title="Share your profile" onClose={handleCloseModal}>
|
||||||
handleCloseModal()
|
<Stack gap={'1rem'} alignItems={'center'}>
|
||||||
}, 3000)
|
<Typography variant="caption">Please, copy this code and paste it into the app to log in</Typography>
|
||||||
}
|
<Input
|
||||||
|
sx={{
|
||||||
return (
|
gap: '0.5rem',
|
||||||
<Modal
|
}}
|
||||||
open={isModalOpened}
|
fullWidth
|
||||||
title='Share your profile'
|
value={bunkerStr}
|
||||||
onClose={handleCloseModal}
|
endAdornment={<InputCopyButton value={bunkerStr} onCopy={handleCopy} />}
|
||||||
>
|
/>
|
||||||
<Stack gap={'1rem'} alignItems={'center'}>
|
<AppLink
|
||||||
<Typography variant='caption'>
|
title="What is this?"
|
||||||
Please, copy this code and paste it into the app to log in
|
onClick={() => handleOpen(MODAL_PARAMS_KEYS.EXPLANATION, { search: { type: EXPLANATION_MODAL_KEYS.BUNKER } })}
|
||||||
</Typography>
|
/>
|
||||||
<Input
|
<Button fullWidth onClick={handleShareBunker}>
|
||||||
sx={{
|
Share it
|
||||||
gap: '0.5rem',
|
</Button>
|
||||||
}}
|
<Button fullWidth onClick={handleCloseModal}>
|
||||||
fullWidth
|
Done
|
||||||
value={bunkerStr}
|
</Button>
|
||||||
endAdornment={
|
</Stack>
|
||||||
<InputCopyButton
|
</Modal>
|
||||||
value={bunkerStr}
|
)
|
||||||
onCopy={handleCopy}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<AppLink
|
|
||||||
title='What is this?'
|
|
||||||
onClick={() => handleOpen(MODAL_PARAMS_KEYS.EXPLANATION)}
|
|
||||||
/>
|
|
||||||
<Button fullWidth onClick={handleShareBunker}>
|
|
||||||
Share it
|
|
||||||
</Button>
|
|
||||||
<Button fullWidth onClick={handleCloseModal}>
|
|
||||||
Done
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Modal>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +1,98 @@
|
|||||||
import { FC } from 'react'
|
import { FC } from 'react'
|
||||||
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
|
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
|
||||||
import { Modal } from '@/shared/Modal/Modal'
|
import { Modal } from '@/shared/Modal/Modal'
|
||||||
import { MODAL_PARAMS_KEYS } from '@/types/modal'
|
import { EXPLANATION_MODAL_KEYS, MODAL_PARAMS_KEYS } from '@/types/modal'
|
||||||
import { Stack, Typography } from '@mui/material'
|
import { Stack, Typography } from '@mui/material'
|
||||||
import { Button } from '@/shared/Button/Button'
|
import { Button } from '@/shared/Button/Button'
|
||||||
import { useSearchParams } from 'react-router-dom'
|
import { useSearchParams } from 'react-router-dom'
|
||||||
|
|
||||||
type ModalExplanationProps = {
|
type ModalExplanationProps = {
|
||||||
explanationText?: string
|
explanationText?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ModalExplanation: FC<ModalExplanationProps> = ({
|
export const ModalExplanation: FC<ModalExplanationProps> = () => {
|
||||||
explanationText = '',
|
const { getModalOpened } = useModalSearchParams()
|
||||||
}) => {
|
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.EXPLANATION)
|
||||||
const { getModalOpened } = useModalSearchParams()
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.EXPLANATION)
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams()
|
|
||||||
|
|
||||||
const handleCloseModal = () => {
|
const handleCloseModal = () => {
|
||||||
searchParams.delete('type')
|
searchParams.delete('type')
|
||||||
searchParams.delete(MODAL_PARAMS_KEYS.EXPLANATION)
|
searchParams.delete(MODAL_PARAMS_KEYS.EXPLANATION)
|
||||||
setSearchParams(searchParams)
|
setSearchParams(searchParams, { replace: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const type = searchParams.get('type')
|
||||||
<Modal
|
|
||||||
title='What is this?'
|
let title = ''
|
||||||
open={isModalOpened}
|
let explanationText
|
||||||
onClose={handleCloseModal}
|
switch (type) {
|
||||||
PaperProps={{
|
case EXPLANATION_MODAL_KEYS.NPUB: {
|
||||||
sx: {
|
title = 'What is NPUB?'
|
||||||
minHeight: '60%',
|
explanationText = (
|
||||||
},
|
<>
|
||||||
}}
|
NPUB is your Nostr PUBlic key.
|
||||||
>
|
<br />
|
||||||
<Stack height={'100%'}>
|
<br />
|
||||||
<Typography flex={1}>{explanationText}</Typography>
|
It is your global unique identifier on the Nostr network, and is derived from your private key.
|
||||||
<Button fullWidth onClick={handleCloseModal}>
|
<br />
|
||||||
Got it!
|
<br />
|
||||||
</Button>
|
You can share your NPUB with other people so that they could unambiguously find you on the network.
|
||||||
</Stack>
|
</>
|
||||||
</Modal>
|
)
|
||||||
)
|
break
|
||||||
|
}
|
||||||
|
case EXPLANATION_MODAL_KEYS.LOGIN: {
|
||||||
|
title = 'What is Login?'
|
||||||
|
explanationText = (
|
||||||
|
<>
|
||||||
|
Login (username) is your human-readable name on the Nostr network.
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
Unlike your NPUB, which is a long string of random symbols, your login is a meaningful name tied to a website
|
||||||
|
address (like name@nsec.app).
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
Use your username to log in to Nostr apps.
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
You can have many usernames all pointing to your NPUB. People also refer to these names as nostr-addresses or
|
||||||
|
NIP05 names.
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case EXPLANATION_MODAL_KEYS.BUNKER: {
|
||||||
|
title = 'What is Bunker URL?'
|
||||||
|
explanationText = (
|
||||||
|
<>
|
||||||
|
Bunker URL is a string used to connect to Nostr apps.
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
Some apps require bunker URL to connect to your keys. Paste it to the app and then confirm a connection
|
||||||
|
request.
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={title}
|
||||||
|
open={isModalOpened}
|
||||||
|
onClose={handleCloseModal}
|
||||||
|
withCloseButton={false}
|
||||||
|
PaperProps={{
|
||||||
|
sx: {
|
||||||
|
minHeight: '60%',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack height={'100%'} gap={2}>
|
||||||
|
<Typography flex={1}>{explanationText}</Typography>
|
||||||
|
<Button fullWidth onClick={handleCloseModal}>
|
||||||
|
Got it!
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,61 +5,200 @@ import { Button } from '@/shared/Button/Button'
|
|||||||
import { Input } from '@/shared/Input/Input'
|
import { Input } from '@/shared/Input/Input'
|
||||||
import { Modal } from '@/shared/Modal/Modal'
|
import { Modal } from '@/shared/Modal/Modal'
|
||||||
import { MODAL_PARAMS_KEYS } from '@/types/modal'
|
import { MODAL_PARAMS_KEYS } from '@/types/modal'
|
||||||
import { Stack, Typography } from '@mui/material'
|
import { Stack, Typography, useTheme } from '@mui/material'
|
||||||
import React, { ChangeEvent, FormEvent, useState } from 'react'
|
|
||||||
import { StyledAppLogo } from './styled'
|
import { StyledAppLogo } from './styled'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { useForm } from 'react-hook-form'
|
||||||
|
import { FormInputType, schema } from './const'
|
||||||
|
import { yupResolver } from '@hookform/resolvers/yup'
|
||||||
|
import { usePassword } from '@/hooks/usePassword'
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { useDebounce } from 'use-debounce'
|
||||||
|
import { fetchNip05 } from '@/utils/helpers/helpers'
|
||||||
|
import { DOMAIN } from '@/utils/consts'
|
||||||
|
import { CheckmarkIcon } from '@/assets'
|
||||||
|
import { getPublicKey, nip19 } from 'nostr-tools'
|
||||||
|
import { LoadingSpinner } from '@/shared/LoadingSpinner/LoadingSpinner'
|
||||||
|
|
||||||
|
const FORM_DEFAULT_VALUES = {
|
||||||
|
username: '',
|
||||||
|
nsec: '',
|
||||||
|
}
|
||||||
|
|
||||||
export const ModalImportKeys = () => {
|
export const ModalImportKeys = () => {
|
||||||
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
|
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
|
||||||
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.IMPORT_KEYS)
|
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.IMPORT_KEYS)
|
||||||
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.IMPORT_KEYS)
|
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.IMPORT_KEYS)
|
||||||
|
const { hidePassword, inputProps } = usePassword()
|
||||||
|
const theme = useTheme()
|
||||||
|
|
||||||
const notify = useEnqueueSnackbar()
|
const {
|
||||||
const navigate = useNavigate()
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
register,
|
||||||
|
formState: { errors },
|
||||||
|
watch,
|
||||||
|
} = useForm<FormInputType>({
|
||||||
|
defaultValues: FORM_DEFAULT_VALUES,
|
||||||
|
resolver: yupResolver(schema),
|
||||||
|
mode: 'onSubmit',
|
||||||
|
})
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [nameNpub, setNameNpub] = useState('')
|
||||||
|
const [isTakenByNsec, setIsTakenByNsec] = useState(false)
|
||||||
|
const [isBadNsec, setIsBadNsec] = useState(false)
|
||||||
|
const enteredUsername = watch('username')
|
||||||
|
const enteredNsec = watch('nsec')
|
||||||
|
const [debouncedUsername] = useDebounce(enteredUsername, 100)
|
||||||
|
const [debouncedNsec] = useDebounce(enteredNsec, 100)
|
||||||
|
|
||||||
const [enteredNsec, setEnteredNsec] = useState('')
|
const checkIsUsernameAvailable = useCallback(async () => {
|
||||||
|
if (!debouncedUsername.trim().length) return undefined
|
||||||
|
const npubNip05 = await fetchNip05(`${debouncedUsername}@${DOMAIN}`)
|
||||||
|
setNameNpub(npubNip05 || '')
|
||||||
|
}, [debouncedUsername])
|
||||||
|
|
||||||
const handleNsecChange = (e: ChangeEvent<HTMLInputElement>) => {
|
useEffect(() => {
|
||||||
setEnteredNsec(e.target.value)
|
checkIsUsernameAvailable()
|
||||||
}
|
}, [checkIsUsernameAvailable])
|
||||||
|
|
||||||
const handleSubmit = async (e: FormEvent) => {
|
const checkNsecUsername = useCallback(async () => {
|
||||||
e.preventDefault()
|
if (!debouncedNsec.trim().length) {
|
||||||
try {
|
setIsTakenByNsec(false)
|
||||||
if (!enteredNsec.trim().length) return
|
setIsBadNsec(false)
|
||||||
const k: any = await swicCall('importKey', enteredNsec)
|
return
|
||||||
notify('Key imported!', 'success')
|
}
|
||||||
navigate(`/key/${k.npub}`)
|
try {
|
||||||
} catch (error: any) {
|
const { type, data } = nip19.decode(debouncedNsec)
|
||||||
notify(error.message, 'error')
|
const ok = type === 'nsec'
|
||||||
}
|
setIsBadNsec(!ok)
|
||||||
}
|
if (ok) {
|
||||||
|
const npub = nip19.npubEncode(
|
||||||
|
// @ts-ignore
|
||||||
|
getPublicKey(data)
|
||||||
|
)
|
||||||
|
setIsTakenByNsec(!!nameNpub && nameNpub === npub)
|
||||||
|
} else {
|
||||||
|
setIsTakenByNsec(false)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setIsBadNsec(true)
|
||||||
|
setIsTakenByNsec(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line
|
||||||
|
}, [debouncedNsec])
|
||||||
|
|
||||||
return (
|
useEffect(() => {
|
||||||
<Modal open={isModalOpened} onClose={handleCloseModal}>
|
checkNsecUsername()
|
||||||
<Stack gap={'1rem'} component={'form'} onSubmit={handleSubmit}>
|
}, [checkNsecUsername])
|
||||||
<Stack
|
|
||||||
direction={'row'}
|
const cleanUpStates = useCallback(() => {
|
||||||
gap={'1rem'}
|
hidePassword()
|
||||||
alignItems={'center'}
|
reset()
|
||||||
alignSelf={'flex-start'}
|
setIsLoading(false)
|
||||||
>
|
setNameNpub('')
|
||||||
<StyledAppLogo />
|
setIsTakenByNsec(false)
|
||||||
<Typography fontWeight={600} variant='h5'>
|
setIsBadNsec(false)
|
||||||
Import keys
|
}, [reset, hidePassword])
|
||||||
</Typography>
|
|
||||||
</Stack>
|
const notify = useEnqueueSnackbar()
|
||||||
<Input
|
const navigate = useNavigate()
|
||||||
label='Enter a NSEC'
|
|
||||||
placeholder='Your NSEC'
|
const submitHandler = async (values: FormInputType) => {
|
||||||
value={enteredNsec}
|
if (isLoading) return undefined
|
||||||
onChange={handleNsecChange}
|
try {
|
||||||
fullWidth
|
const { nsec, username } = values
|
||||||
type='password'
|
if (!nsec || !username) throw new Error('Enter username and nsec')
|
||||||
/>
|
if (nameNpub && !isTakenByNsec) throw new Error('Name taken')
|
||||||
<Button type='submit'>Import nsec</Button>
|
setIsLoading(true)
|
||||||
</Stack>
|
const k: any = await swicCall('importKey', username, nsec)
|
||||||
</Modal>
|
notify('Key imported!', 'success')
|
||||||
)
|
navigate(`/key/${k.npub}`)
|
||||||
|
cleanUpStates()
|
||||||
|
} catch (error: any) {
|
||||||
|
notify(error?.message || 'Something went wrong!', 'error')
|
||||||
|
cleanUpStates()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
isModalOpened && cleanUpStates()
|
||||||
|
}
|
||||||
|
}, [isModalOpened, cleanUpStates])
|
||||||
|
|
||||||
|
const getNameHelperText = () => {
|
||||||
|
if (!enteredUsername) return "Don't worry, username can be changed later."
|
||||||
|
if (isTakenByNsec) return 'Name matches your key'
|
||||||
|
if (isBadNsec) return 'Invalid nsec'
|
||||||
|
if (nameNpub) return 'Already taken'
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<CheckmarkIcon /> Available
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getNsecHelperText = () => {
|
||||||
|
if (isBadNsec) return 'Invalid nsec'
|
||||||
|
return 'Keys stay on your device.'
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameHelperText = getNameHelperText()
|
||||||
|
const nsecHelperText = getNsecHelperText()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={isModalOpened} onClose={handleCloseModal}>
|
||||||
|
<Stack gap={'1rem'} component={'form'} onSubmit={handleSubmit(submitHandler)}>
|
||||||
|
<Stack direction={'row'} gap={'1rem'} alignItems={'center'} alignSelf={'flex-start'}>
|
||||||
|
<StyledAppLogo />
|
||||||
|
<Typography fontWeight={600} variant="h5">
|
||||||
|
Import key
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
<Input
|
||||||
|
label="Choose a username"
|
||||||
|
fullWidth
|
||||||
|
placeholder="Enter a Username"
|
||||||
|
endAdornment={<Typography color={'#FFFFFFA8'}>@{DOMAIN}</Typography>}
|
||||||
|
{...register('username')}
|
||||||
|
error={!!errors.username}
|
||||||
|
helperText={nameHelperText}
|
||||||
|
helperTextProps={{
|
||||||
|
sx: {
|
||||||
|
'&.helper_text': {
|
||||||
|
color:
|
||||||
|
enteredUsername && (isTakenByNsec || !nameNpub)
|
||||||
|
? theme.palette.success.main
|
||||||
|
: enteredUsername && nameNpub
|
||||||
|
? theme.palette.error.main
|
||||||
|
: theme.palette.textSecondaryDecorate.main,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Paste your private key"
|
||||||
|
placeholder="nsec1..."
|
||||||
|
fullWidth
|
||||||
|
{...register('nsec')}
|
||||||
|
error={!!errors.nsec}
|
||||||
|
{...inputProps}
|
||||||
|
helperText={nsecHelperText}
|
||||||
|
helperTextProps={{
|
||||||
|
sx: {
|
||||||
|
'&.helper_text': {
|
||||||
|
color: isBadNsec ? theme.palette.error.main : theme.palette.textSecondaryDecorate.main,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type="submit" disabled={isLoading}>
|
||||||
|
Import key {isLoading && <LoadingSpinner />}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
8
src/components/Modal/ModalImportKeys/const.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import * as yup from 'yup'
|
||||||
|
|
||||||
|
export const schema = yup.object().shape({
|
||||||
|
username: yup.string().required(),
|
||||||
|
nsec: yup.string().required(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type FormInputType = yup.InferType<typeof schema>
|
||||||
@@ -2,13 +2,13 @@ import { AppLogo } from '@/assets'
|
|||||||
import { Box, styled } from '@mui/material'
|
import { Box, styled } from '@mui/material'
|
||||||
|
|
||||||
export const StyledAppLogo = styled((props) => (
|
export const StyledAppLogo = styled((props) => (
|
||||||
<Box {...props}>
|
<Box {...props}>
|
||||||
<AppLogo />
|
<AppLogo />
|
||||||
</Box>
|
</Box>
|
||||||
))({
|
))(() => ({
|
||||||
background: '#00000054',
|
background: '#0d0d0d',
|
||||||
padding: '0.75rem',
|
padding: '0.75rem',
|
||||||
borderRadius: '16px',
|
borderRadius: '16px',
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
placeItems: 'center',
|
placeItems: 'center',
|
||||||
})
|
}))
|
||||||
|
|||||||
@@ -1,58 +1,37 @@
|
|||||||
import React, { useEffect, useState } from 'react'
|
// import { useEffect } from 'react'
|
||||||
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
|
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
|
||||||
import { Button } from '@/shared/Button/Button'
|
import { Button } from '@/shared/Button/Button'
|
||||||
import { Modal } from '@/shared/Modal/Modal'
|
import { Modal } from '@/shared/Modal/Modal'
|
||||||
import { MODAL_PARAMS_KEYS } from '@/types/modal'
|
import { MODAL_PARAMS_KEYS } from '@/types/modal'
|
||||||
import { Fade, Stack } from '@mui/material'
|
import { Stack } from '@mui/material'
|
||||||
import { AppLink } from '@/shared/AppLink/AppLink'
|
// import { AppLink } from '@/shared/AppLink/AppLink'
|
||||||
|
|
||||||
export const ModalInitial = () => {
|
export const ModalInitial = () => {
|
||||||
const { getModalOpened, createHandleCloseReplace, handleOpen } = useModalSearchParams()
|
const { getModalOpened, createHandleCloseReplace, handleOpen } = useModalSearchParams()
|
||||||
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.INITIAL)
|
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.INITIAL)
|
||||||
|
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.INITIAL)
|
||||||
|
|
||||||
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.INITIAL)
|
// const [showAdvancedContent, setShowAdvancedContent] = useState(false)
|
||||||
|
|
||||||
const [showAdvancedContent, setShowAdvancedContent] = useState(false)
|
// const handleShowAdvanced = () => {
|
||||||
|
// setShowAdvancedContent(true)
|
||||||
|
// }
|
||||||
|
|
||||||
const handleShowAdvanced = () => {
|
// useEffect(() => {
|
||||||
setShowAdvancedContent(true)
|
// return () => {
|
||||||
}
|
// if (isModalOpened) {
|
||||||
|
// setShowAdvancedContent(false)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }, [isModalOpened])
|
||||||
|
|
||||||
useEffect(() => {
|
return (
|
||||||
return () => {
|
<Modal open={isModalOpened} onClose={handleCloseModal}>
|
||||||
if (isModalOpened) {
|
<Stack paddingTop={'0.5rem'} gap={'1rem'}>
|
||||||
setShowAdvancedContent(false)
|
<Button onClick={() => handleOpen(MODAL_PARAMS_KEYS.SIGN_UP)}>Sign up</Button>
|
||||||
}
|
<Button onClick={() => handleOpen(MODAL_PARAMS_KEYS.LOGIN)}>Login</Button>
|
||||||
}
|
<Button onClick={() => handleOpen(MODAL_PARAMS_KEYS.IMPORT_KEYS)}>Import key</Button>
|
||||||
}, [isModalOpened])
|
</Stack>
|
||||||
|
</Modal>
|
||||||
return (
|
)
|
||||||
<Modal open={isModalOpened} onClose={handleCloseModal}>
|
|
||||||
<Stack paddingTop={'0.5rem'} gap={'1rem'}>
|
|
||||||
<Button onClick={() => handleOpen(MODAL_PARAMS_KEYS.SIGN_UP)}>
|
|
||||||
Sign up
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => handleOpen(MODAL_PARAMS_KEYS.LOGIN)}>
|
|
||||||
Login
|
|
||||||
</Button>
|
|
||||||
<AppLink
|
|
||||||
title='Advanced'
|
|
||||||
alignSelf={'center'}
|
|
||||||
onClick={handleShowAdvanced}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{showAdvancedContent && (
|
|
||||||
<Fade in>
|
|
||||||
<Button
|
|
||||||
onClick={() =>
|
|
||||||
handleOpen(MODAL_PARAMS_KEYS.IMPORT_KEYS)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Import keys
|
|
||||||
</Button>
|
|
||||||
</Fade>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Modal>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,115 +1,151 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar'
|
import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar'
|
||||||
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
|
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
|
||||||
import { swicCall } from '@/modules/swic'
|
import { swicCall } from '@/modules/swic'
|
||||||
import { Modal } from '@/shared/Modal/Modal'
|
import { Modal } from '@/shared/Modal/Modal'
|
||||||
import { MODAL_PARAMS_KEYS } from '@/types/modal'
|
import { MODAL_PARAMS_KEYS } from '@/types/modal'
|
||||||
import { IconButton, Stack, Typography } from '@mui/material'
|
import { Stack, Typography } from '@mui/material'
|
||||||
import React, { ChangeEvent, useState } from 'react'
|
|
||||||
import { StyledAppLogo } from './styled'
|
import { StyledAppLogo } from './styled'
|
||||||
import { nip19 } from 'nostr-tools'
|
|
||||||
import { Input } from '@/shared/Input/Input'
|
import { Input } from '@/shared/Input/Input'
|
||||||
import { Button } from '@/shared/Button/Button'
|
import { Button } from '@/shared/Button/Button'
|
||||||
import VisibilityOffOutlinedIcon from '@mui/icons-material/VisibilityOffOutlined'
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
import VisibilityOutlinedIcon from '@mui/icons-material/VisibilityOutlined'
|
import { useForm } from 'react-hook-form'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { FormInputType, schema } from './const'
|
||||||
|
import { yupResolver } from '@hookform/resolvers/yup'
|
||||||
|
import { DOMAIN } from '@/utils/consts'
|
||||||
|
import { fetchNip05, fetchNpubNames } from '@/utils/helpers/helpers'
|
||||||
|
import { usePassword } from '@/hooks/usePassword'
|
||||||
|
import { dbi } from '@/modules/db'
|
||||||
|
import { LoadingSpinner } from '@/shared/LoadingSpinner/LoadingSpinner'
|
||||||
|
|
||||||
|
const FORM_DEFAULT_VALUES = {
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
}
|
||||||
|
|
||||||
export const ModalLogin = () => {
|
export const ModalLogin = () => {
|
||||||
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
|
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
|
||||||
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.LOGIN)
|
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.LOGIN)
|
||||||
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.LOGIN)
|
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.LOGIN)
|
||||||
|
|
||||||
const notify = useEnqueueSnackbar()
|
const notify = useEnqueueSnackbar()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { hidePassword, inputProps } = usePassword()
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
const isPopup = searchParams.get('popup') === 'true'
|
||||||
|
|
||||||
const navigate = useNavigate()
|
const {
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
register,
|
||||||
|
setValue,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<FormInputType>({
|
||||||
|
defaultValues: FORM_DEFAULT_VALUES,
|
||||||
|
resolver: yupResolver(schema),
|
||||||
|
mode: 'onSubmit',
|
||||||
|
})
|
||||||
|
|
||||||
const [enteredUsername, setEnteredUsername] = useState('')
|
const cleanUpStates = useCallback(() => {
|
||||||
const [enteredPassword, setEnteredPassword] = useState('')
|
hidePassword()
|
||||||
const [isPasswordShown, setIsPasswordShown] = useState(false)
|
reset()
|
||||||
|
setIsLoading(false)
|
||||||
|
}, [reset, hidePassword])
|
||||||
|
|
||||||
const handleUsernameChange = (e: ChangeEvent<HTMLInputElement>) => {
|
const submitHandler = async (values: FormInputType) => {
|
||||||
setEnteredUsername(e.target.value)
|
if (isLoading) return undefined
|
||||||
}
|
|
||||||
|
|
||||||
const handlePasswordChange = (e: ChangeEvent<HTMLInputElement>) => {
|
try {
|
||||||
setEnteredPassword(e.target.value)
|
setIsLoading(true)
|
||||||
}
|
let npub = values.username
|
||||||
|
let name = ''
|
||||||
|
|
||||||
const handlePasswordTypeChange = () =>
|
if (!npub.startsWith('npub1')) {
|
||||||
setIsPasswordShown((prevState) => !prevState)
|
name = npub
|
||||||
|
if (!npub.includes('@')) {
|
||||||
|
npub += '@' + DOMAIN
|
||||||
|
} else {
|
||||||
|
const nameDomain = npub.split('@')
|
||||||
|
if (nameDomain[1] === DOMAIN) name = nameDomain[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (npub.includes('@')) {
|
||||||
|
const npubNip05 = await fetchNip05(npub)
|
||||||
|
if (!npubNip05) throw new Error(`Username ${npub} not found`)
|
||||||
|
npub = npubNip05
|
||||||
|
}
|
||||||
|
const passphrase = values.password
|
||||||
|
|
||||||
const isFormValid =
|
console.log('fetch', npub, name)
|
||||||
enteredUsername.trim().length > 0 && enteredPassword.trim().length > 0
|
const k: any = await swicCall('fetchKey', npub, passphrase, name)
|
||||||
|
notify(`Fetched ${k.npub}`, 'success')
|
||||||
|
dbi.addSynced(k.npub)
|
||||||
|
cleanUpStates()
|
||||||
|
setTimeout(() => {
|
||||||
|
// give frontend time to read the new key first
|
||||||
|
navigate(`/key/${k.npub}${isPopup ? '?popup=true' : ''}`)
|
||||||
|
}, 300)
|
||||||
|
} catch (error: any) {
|
||||||
|
console.log('error', error)
|
||||||
|
notify(error?.message || 'Something went wrong!', 'error')
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
useEffect(() => {
|
||||||
e.preventDefault()
|
if (isModalOpened) {
|
||||||
if (!isFormValid) return undefined
|
const npub = searchParams.get('npub') || ''
|
||||||
try {
|
const appNpub = searchParams.get('appNpub') || ''
|
||||||
const [username, domain] = enteredUsername.split('@')
|
if (isPopup && isModalOpened) {
|
||||||
const response = await fetch(
|
swicCall('fetchPendingRequests', npub, appNpub)
|
||||||
`https://${domain}/.well-known/nostr.json?name=${username}`,
|
|
||||||
)
|
|
||||||
const getNpub: {
|
|
||||||
names: {
|
|
||||||
[name: string]: string
|
|
||||||
}
|
|
||||||
} = await response.json()
|
|
||||||
|
|
||||||
const pubkey = getNpub.names[username]
|
fetchNpubNames(npub).then(names => {
|
||||||
const npub = nip19.npubEncode(pubkey)
|
if (names.length) {
|
||||||
const passphrase = enteredPassword
|
setValue('username', `${names[0]}@${DOMAIN}`)
|
||||||
console.log('fetch', npub, passphrase)
|
}
|
||||||
const k: any = await swicCall('fetchKey', npub, passphrase)
|
})
|
||||||
notify(`Fetched ${k.npub}`, 'success')
|
}
|
||||||
navigate(`/key/${k.npub}`)
|
}
|
||||||
} catch (error: any) {
|
}, [searchParams, isModalOpened, isPopup, setValue])
|
||||||
notify(error.message, 'error')
|
|
||||||
}
|
useEffect(() => {
|
||||||
}
|
return () => {
|
||||||
return (
|
if (isModalOpened) {
|
||||||
<Modal open={isModalOpened} onClose={handleCloseModal}>
|
// modal closed
|
||||||
<Stack gap={'1rem'} component={'form'} onSubmit={handleSubmit}>
|
cleanUpStates()
|
||||||
<Stack
|
}
|
||||||
direction={'row'}
|
}
|
||||||
gap={'1rem'}
|
}, [isModalOpened, cleanUpStates])
|
||||||
alignItems={'center'}
|
|
||||||
alignSelf={'flex-start'}
|
return (
|
||||||
>
|
<Modal open={isModalOpened} onClose={handleCloseModal}>
|
||||||
<StyledAppLogo />
|
<Stack gap={'1rem'} component={'form'} onSubmit={handleSubmit(submitHandler)}>
|
||||||
<Typography fontWeight={600} variant='h5'>
|
<Stack direction={'row'} gap={'1rem'} alignItems={'center'} alignSelf={'flex-start'}>
|
||||||
Login
|
<StyledAppLogo />
|
||||||
</Typography>
|
<Typography fontWeight={600} variant="h5">
|
||||||
</Stack>
|
Login
|
||||||
<Input
|
</Typography>
|
||||||
label='Enter a Username'
|
</Stack>
|
||||||
fullWidth
|
<Input
|
||||||
placeholder='user@nsec.app'
|
label="Username or nip05 or npub"
|
||||||
onChange={handleUsernameChange}
|
fullWidth
|
||||||
value={enteredUsername}
|
placeholder="name or name@domain.com or npub1..."
|
||||||
/>
|
{...register('username')}
|
||||||
<Input
|
error={!!errors.username}
|
||||||
label='Password'
|
/>
|
||||||
fullWidth
|
<Input
|
||||||
placeholder='Your password'
|
label="Password"
|
||||||
onChange={handlePasswordChange}
|
fullWidth
|
||||||
value={enteredPassword}
|
placeholder="Your password"
|
||||||
endAdornment={
|
{...register('password')}
|
||||||
<IconButton
|
{...inputProps}
|
||||||
size='small'
|
error={!!errors.password}
|
||||||
onClick={handlePasswordTypeChange}
|
/>
|
||||||
>
|
<Button type="submit" fullWidth disabled={isLoading}>
|
||||||
{isPasswordShown ? (
|
Add account {isLoading && <LoadingSpinner />}
|
||||||
<VisibilityOffOutlinedIcon />
|
</Button>
|
||||||
) : (
|
</Stack>
|
||||||
<VisibilityOutlinedIcon />
|
</Modal>
|
||||||
)}
|
)
|
||||||
</IconButton>
|
|
||||||
}
|
|
||||||
type={isPasswordShown ? 'text' : 'password'}
|
|
||||||
/>
|
|
||||||
<Button type='submit' fullWidth disabled={!isFormValid}>
|
|
||||||
Login
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Modal>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
8
src/components/Modal/ModalLogin/const.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import * as yup from 'yup'
|
||||||
|
|
||||||
|
export const schema = yup.object().shape({
|
||||||
|
username: yup.string().required(),
|
||||||
|
password: yup.string().required().min(4),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type FormInputType = yup.InferType<typeof schema>
|
||||||
@@ -2,13 +2,13 @@ import { AppLogo } from '@/assets'
|
|||||||
import { Box, styled } from '@mui/material'
|
import { Box, styled } from '@mui/material'
|
||||||
|
|
||||||
export const StyledAppLogo = styled((props) => (
|
export const StyledAppLogo = styled((props) => (
|
||||||
<Box {...props}>
|
<Box {...props}>
|
||||||
<AppLogo />
|
<AppLogo />
|
||||||
</Box>
|
</Box>
|
||||||
))({
|
))({
|
||||||
background: '#00000054',
|
background: '#0d0d0d',
|
||||||
padding: '0.75rem',
|
padding: '0.75rem',
|
||||||
borderRadius: '16px',
|
borderRadius: '16px',
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
placeItems: 'center',
|
placeItems: 'center',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,170 +1,151 @@
|
|||||||
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
|
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
|
||||||
import { Button } from '@/shared/Button/Button'
|
|
||||||
import { Modal } from '@/shared/Modal/Modal'
|
import { Modal } from '@/shared/Modal/Modal'
|
||||||
import { MODAL_PARAMS_KEYS } from '@/types/modal'
|
import { MODAL_PARAMS_KEYS } from '@/types/modal'
|
||||||
import {
|
import { Box, Stack, Typography } from '@mui/material'
|
||||||
Box,
|
import { StyledButton, StyledSettingContainer, StyledSynchedText } from './styled'
|
||||||
CircularProgress,
|
|
||||||
IconButton,
|
|
||||||
Stack,
|
|
||||||
Typography,
|
|
||||||
} from '@mui/material'
|
|
||||||
import {
|
|
||||||
StyledButton,
|
|
||||||
StyledSettingContainer,
|
|
||||||
StyledSynchedText,
|
|
||||||
} from './styled'
|
|
||||||
import { SectionTitle } from '@/shared/SectionTitle/SectionTitle'
|
import { SectionTitle } from '@/shared/SectionTitle/SectionTitle'
|
||||||
import { CheckmarkIcon } from '@/assets'
|
import { CheckmarkIcon } from '@/assets'
|
||||||
import { Input } from '@/shared/Input/Input'
|
import { Input } from '@/shared/Input/Input'
|
||||||
import VisibilityOffOutlinedIcon from '@mui/icons-material/VisibilityOffOutlined'
|
|
||||||
import VisibilityOutlinedIcon from '@mui/icons-material/VisibilityOutlined'
|
|
||||||
import { ChangeEvent, FC, useEffect, useState } from 'react'
|
import { ChangeEvent, FC, useEffect, useState } from 'react'
|
||||||
import { Checkbox } from '@/shared/Checkbox/Checkbox'
|
import { Checkbox } from '@/shared/Checkbox/Checkbox'
|
||||||
import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar'
|
import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar'
|
||||||
import { swicCall } from '@/modules/swic'
|
import { swicCall } from '@/modules/swic'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
import { dbi } from '@/modules/db'
|
import { dbi } from '@/modules/db'
|
||||||
|
import { usePassword } from '@/hooks/usePassword'
|
||||||
|
import { useAppSelector } from '@/store/hooks/redux'
|
||||||
|
import { selectKeys } from '@/store'
|
||||||
|
import { isValidPassphase, isWeakPassphase } from '@/modules/keys'
|
||||||
|
import { LoadingSpinner } from '@/shared/LoadingSpinner/LoadingSpinner'
|
||||||
|
|
||||||
type ModalSettingsProps = {
|
type ModalSettingsProps = {
|
||||||
isSynced: boolean
|
isSynced: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ModalSettings: FC<ModalSettingsProps> = ({ isSynced }) => {
|
export const ModalSettings: FC<ModalSettingsProps> = ({ isSynced }) => {
|
||||||
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
|
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
|
||||||
const { npub = '' } = useParams<{ npub: string }>()
|
const { npub = '' } = useParams<{ npub: string }>()
|
||||||
|
const keys = useAppSelector(selectKeys)
|
||||||
|
|
||||||
const notify = useEnqueueSnackbar()
|
const notify = useEnqueueSnackbar()
|
||||||
|
|
||||||
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.SETTINGS)
|
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.SETTINGS)
|
||||||
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.SETTINGS)
|
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.SETTINGS)
|
||||||
|
|
||||||
const [enteredPassword, setEnteredPassword] = useState('')
|
const { hidePassword, inputProps } = usePassword()
|
||||||
const [isPasswordShown, setIsPasswordShown] = useState(false)
|
|
||||||
const [isPasswordInvalid, setIsPasswordInvalid] = useState(false)
|
|
||||||
|
|
||||||
const [isChecked, setIsChecked] = useState(false)
|
const [enteredPassword, setEnteredPassword] = useState('')
|
||||||
|
const [isPasswordInvalid, setIsPasswordInvalid] = useState(false)
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isChecked, setIsChecked] = useState(false)
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => setIsChecked(isSynced), [isModalOpened, isSynced])
|
||||||
|
|
||||||
useEffect(() => setIsChecked(isSynced), [isModalOpened, isSynced])
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (isModalOpened) {
|
||||||
|
// modal closed
|
||||||
|
hidePassword()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [hidePassword, isModalOpened])
|
||||||
|
|
||||||
const handlePasswordChange = (e: ChangeEvent<HTMLInputElement>) => {
|
const isNpubExists = npub.trim().length && keys.some((key) => key.npub === npub)
|
||||||
setIsPasswordInvalid(false)
|
|
||||||
setEnteredPassword(e.target.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePasswordTypeChange = () =>
|
if (isModalOpened && !isNpubExists) {
|
||||||
setIsPasswordShown((prevState) => !prevState)
|
handleCloseModal()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const onClose = () => {
|
const handlePasswordChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
handleCloseModal()
|
const password = e.target.value
|
||||||
setEnteredPassword('')
|
setIsPasswordInvalid(!!password && !isValidPassphase(password))
|
||||||
setIsPasswordInvalid(false)
|
setEnteredPassword(password)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleChangeCheckbox = (e: unknown, checked: boolean) => {
|
const onClose = () => {
|
||||||
setIsChecked(checked)
|
handleCloseModal()
|
||||||
}
|
setEnteredPassword('')
|
||||||
|
setIsPasswordInvalid(false)
|
||||||
|
}
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleChangeCheckbox = (e: unknown, checked: boolean) => {
|
||||||
e.preventDefault()
|
setIsChecked(checked)
|
||||||
setIsPasswordInvalid(false)
|
}
|
||||||
|
|
||||||
if (enteredPassword.trim().length < 6) {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
return setIsPasswordInvalid(true)
|
e.preventDefault()
|
||||||
}
|
setIsPasswordInvalid(false)
|
||||||
try {
|
|
||||||
setIsLoading(true)
|
|
||||||
await swicCall('saveKey', npub, enteredPassword)
|
|
||||||
notify('Key saved', 'success')
|
|
||||||
dbi.addSynced(npub) // Sync npub
|
|
||||||
setEnteredPassword('')
|
|
||||||
setIsPasswordInvalid(false)
|
|
||||||
setIsLoading(false)
|
|
||||||
} catch (error) {
|
|
||||||
setIsPasswordInvalid(false)
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
if (!isValidPassphase(enteredPassword)) {
|
||||||
<Modal open={isModalOpened} onClose={onClose} title='Settings'>
|
return setIsPasswordInvalid(true)
|
||||||
<Stack gap={'1rem'}>
|
}
|
||||||
<StyledSettingContainer onSubmit={handleSubmit}>
|
try {
|
||||||
<Stack direction={'row'} justifyContent={'space-between'}>
|
setIsLoading(true)
|
||||||
<SectionTitle>Cloud sync</SectionTitle>
|
await swicCall('saveKey', npub, enteredPassword)
|
||||||
{isSynced && (
|
notify('Key saved', 'success')
|
||||||
<StyledSynchedText>
|
dbi.addSynced(npub) // Sync npub
|
||||||
<CheckmarkIcon /> Synched
|
setEnteredPassword('')
|
||||||
</StyledSynchedText>
|
setIsPasswordInvalid(false)
|
||||||
)}
|
setIsLoading(false)
|
||||||
</Stack>
|
} catch (error) {
|
||||||
<Box>
|
setIsPasswordInvalid(false)
|
||||||
<Checkbox
|
setIsLoading(false)
|
||||||
onChange={handleChangeCheckbox}
|
}
|
||||||
checked={isChecked}
|
}
|
||||||
/>
|
|
||||||
<Typography variant='caption'>
|
return (
|
||||||
Use this key on multiple devices
|
<Modal open={isModalOpened} onClose={onClose} title="Settings">
|
||||||
</Typography>
|
<Stack gap={'1rem'}>
|
||||||
</Box>
|
<StyledSettingContainer onSubmit={handleSubmit}>
|
||||||
<Input
|
<Stack direction={'row'} justifyContent={'space-between'}>
|
||||||
fullWidth
|
<SectionTitle>Cloud sync</SectionTitle>
|
||||||
endAdornment={
|
{isSynced && (
|
||||||
<IconButton
|
<StyledSynchedText>
|
||||||
size='small'
|
<CheckmarkIcon /> Synched
|
||||||
onClick={handlePasswordTypeChange}
|
</StyledSynchedText>
|
||||||
>
|
)}
|
||||||
{isPasswordShown ? (
|
</Stack>
|
||||||
<VisibilityOffOutlinedIcon htmlColor='#6b6b6b' />
|
<Box>
|
||||||
) : (
|
<Checkbox onChange={handleChangeCheckbox} checked={isChecked} />
|
||||||
<VisibilityOutlinedIcon htmlColor='#6b6b6b' />
|
<Typography variant="caption">Use this key on multiple devices</Typography>
|
||||||
)}
|
</Box>
|
||||||
</IconButton>
|
<Input
|
||||||
}
|
fullWidth
|
||||||
type={isPasswordShown ? 'text' : 'password'}
|
{...inputProps}
|
||||||
onChange={handlePasswordChange}
|
onChange={handlePasswordChange}
|
||||||
value={enteredPassword}
|
value={enteredPassword}
|
||||||
helperText={
|
placeholder="Enter a password"
|
||||||
isPasswordInvalid ? 'Invalid password' : ''
|
disabled={!isChecked}
|
||||||
}
|
/>
|
||||||
placeholder='Enter a password'
|
{isPasswordInvalid ? (
|
||||||
helperTextProps={{
|
<Typography variant="body2" color={'red'}>
|
||||||
sx: {
|
Password must include 6+ English letters, numbers or punctuation marks.
|
||||||
'&.helper_text': {
|
</Typography>
|
||||||
color: 'red',
|
) : !!enteredPassword && isWeakPassphase(enteredPassword) ? (
|
||||||
},
|
<Typography variant="body2" color={'orange'}>
|
||||||
},
|
Weak password
|
||||||
}}
|
</Typography>
|
||||||
disabled={!isChecked}
|
) : !!enteredPassword && !isPasswordInvalid ? (
|
||||||
/>
|
<Typography variant="body2" color={'green'}>
|
||||||
{isSynced ? (
|
Good password
|
||||||
<Typography variant='body2' color={'GrayText'}>
|
</Typography>
|
||||||
To change your password, type a new one and sync.
|
) : isSynced ? (
|
||||||
</Typography>
|
<Typography variant="body2" color={'GrayText'}>
|
||||||
) : (
|
To change your password, type a new one and sync.
|
||||||
<Typography variant='body2' color={'GrayText'}>
|
</Typography>
|
||||||
This key will be encrypted and stored on our server. You can use the password to download this key onto another device.
|
) : (
|
||||||
</Typography>
|
<Typography variant="body2" color={'GrayText'}>
|
||||||
)}
|
This key will be encrypted and stored on our server. You can use the password to download this key onto
|
||||||
<StyledButton
|
another device.
|
||||||
type='submit'
|
</Typography>
|
||||||
fullWidth
|
)}
|
||||||
disabled={!isChecked}
|
<StyledButton type="submit" fullWidth disabled={!isChecked}>
|
||||||
>
|
Sync {isLoading && <LoadingSpinner mode="secondary" />}
|
||||||
Sync{' '}
|
</StyledButton>
|
||||||
{isLoading && (
|
</StyledSettingContainer>
|
||||||
<CircularProgress
|
</Stack>
|
||||||
sx={{ marginLeft: '0.5rem' }}
|
</Modal>
|
||||||
size={'1rem'}
|
)
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</StyledButton>
|
|
||||||
</StyledSettingContainer>
|
|
||||||
<Button onClick={onClose}>Done</Button>
|
|
||||||
</Stack>
|
|
||||||
</Modal>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +1,31 @@
|
|||||||
import { Button } from '@/shared/Button/Button'
|
import { Button } from '@/shared/Button/Button'
|
||||||
import {
|
import { Stack, StackProps, Typography, TypographyProps, styled } from '@mui/material'
|
||||||
Stack,
|
|
||||||
StackProps,
|
|
||||||
Typography,
|
|
||||||
TypographyProps,
|
|
||||||
styled,
|
|
||||||
} from '@mui/material'
|
|
||||||
|
|
||||||
export const StyledSettingContainer = styled((props: StackProps) => (
|
export const StyledSettingContainer = styled((props: StackProps) => (
|
||||||
<Stack gap={'0.75rem'} component={'form'} {...props} />
|
<Stack gap={'0.75rem'} component={'form'} {...props} />
|
||||||
))(({ theme }) => ({
|
))(({ theme }) => ({
|
||||||
padding: '1rem',
|
padding: '1rem',
|
||||||
borderRadius: '1rem',
|
borderRadius: '1rem',
|
||||||
background: theme.palette.background.default,
|
background: theme.palette.background.default,
|
||||||
color: theme.palette.text.primary,
|
color: theme.palette.text.primary,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
export const StyledButton = styled(Button)(({ theme }) => {
|
export const StyledButton = styled(Button)(({ theme }) => {
|
||||||
return {
|
return {
|
||||||
'&.button:is(:hover, :active, &)': {
|
'&.button:is(:hover, :active, &)': {
|
||||||
background: theme.palette.secondary.main,
|
background: theme.palette.secondary.main,
|
||||||
color: theme.palette.text.primary,
|
color: theme.palette.text.primary,
|
||||||
},
|
},
|
||||||
':disabled': {
|
':disabled': {
|
||||||
cursor: 'not-allowed',
|
cursor: 'not-allowed',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
export const StyledSynchedText = styled((props: TypographyProps) => (
|
export const StyledSynchedText = styled((props: TypographyProps) => <Typography variant="caption" {...props} />)(({
|
||||||
<Typography variant='caption' {...props} />
|
theme,
|
||||||
))(({ theme }) => {
|
}) => {
|
||||||
return {
|
return {
|
||||||
color: theme.palette.success.main,
|
color: theme.palette.success.main,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,93 +3,120 @@ import { useModalSearchParams } from '@/hooks/useModalSearchParams'
|
|||||||
import { Modal } from '@/shared/Modal/Modal'
|
import { Modal } from '@/shared/Modal/Modal'
|
||||||
import { MODAL_PARAMS_KEYS } from '@/types/modal'
|
import { MODAL_PARAMS_KEYS } from '@/types/modal'
|
||||||
import { Stack, Typography, useTheme } from '@mui/material'
|
import { Stack, Typography, useTheme } from '@mui/material'
|
||||||
import React, { ChangeEvent, useState } from 'react'
|
import React, { ChangeEvent, useEffect, useState } from 'react'
|
||||||
import { StyledAppLogo } from './styled'
|
import { StyledAppLogo } from './styled'
|
||||||
import { Input } from '@/shared/Input/Input'
|
import { Input } from '@/shared/Input/Input'
|
||||||
import { Button } from '@/shared/Button/Button'
|
import { Button } from '@/shared/Button/Button'
|
||||||
import { CheckmarkIcon } from '@/assets'
|
import { CheckmarkIcon } from '@/assets'
|
||||||
import { swicCall } from '@/modules/swic'
|
import { swicCall } from '@/modules/swic'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { DOMAIN } from '@/utils/consts'
|
||||||
|
import { fetchNip05 } from '@/utils/helpers/helpers'
|
||||||
|
import { LoadingSpinner } from '@/shared/LoadingSpinner/LoadingSpinner'
|
||||||
|
|
||||||
export const ModalSignUp = () => {
|
export const ModalSignUp = () => {
|
||||||
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
|
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
|
||||||
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.SIGN_UP)
|
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.SIGN_UP)
|
||||||
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.SIGN_UP)
|
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.SIGN_UP)
|
||||||
const notify = useEnqueueSnackbar()
|
const notify = useEnqueueSnackbar()
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const [enteredValue, setEnteredValue] = useState('')
|
const [enteredValue, setEnteredValue] = useState('')
|
||||||
|
const [isAvailable, setIsAvailable] = useState(false)
|
||||||
|
|
||||||
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
setEnteredValue(e.target.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
const isAvailable = enteredValue.trim().length > 2
|
const handleInputChange = async (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setEnteredValue(e.target.value)
|
||||||
|
const name = e.target.value.trim()
|
||||||
|
if (name) {
|
||||||
|
const npubNip05 = await fetchNip05(`${name}@${DOMAIN}`)
|
||||||
|
setIsAvailable(!npubNip05)
|
||||||
|
} else {
|
||||||
|
setIsAvailable(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const inputHelperText = isAvailable ? (
|
const getInputHelperText = () => {
|
||||||
<>
|
if (!enteredValue) return "Don't worry, username can be changed later."
|
||||||
<CheckmarkIcon /> Available
|
if (!isAvailable) return 'Already taken'
|
||||||
</>
|
return (
|
||||||
) : (
|
<>
|
||||||
"Don't worry, username can be changed later."
|
<CheckmarkIcon /> Available
|
||||||
)
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const inputHelperText = getInputHelperText()
|
||||||
e.preventDefault()
|
|
||||||
try {
|
|
||||||
const k: any = await swicCall('generateKey')
|
|
||||||
notify(`New key ${k.npub}`, 'success')
|
|
||||||
navigate(`/key/${k.npub}`)
|
|
||||||
} catch (error: any) {
|
|
||||||
notify(error.message, 'error')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
<Modal open={isModalOpened} onClose={handleCloseModal}>
|
e.preventDefault()
|
||||||
<Stack
|
if (isLoading || !isAvailable) return undefined
|
||||||
paddingTop={'1rem'}
|
|
||||||
gap={'1rem'}
|
const name = enteredValue.trim()
|
||||||
component={'form'}
|
if (!name.length) return
|
||||||
onSubmit={handleSubmit}
|
|
||||||
>
|
try {
|
||||||
<Stack
|
setIsLoading(true)
|
||||||
direction={'row'}
|
const k: any = await swicCall('generateKey', name)
|
||||||
gap={'1rem'}
|
notify(`Account created for "${name}"`, 'success')
|
||||||
alignItems={'center'}
|
setIsLoading(false)
|
||||||
alignSelf={'flex-start'}
|
setTimeout(() => {
|
||||||
>
|
// give frontend time to read the new key first
|
||||||
<StyledAppLogo />
|
navigate(`/key/${k.npub}`)
|
||||||
<Typography fontWeight={600} variant='h5'>
|
}, 300)
|
||||||
Sign up
|
} catch (error: any) {
|
||||||
</Typography>
|
notify(error?.message || 'Something went wrong!', 'error')
|
||||||
</Stack>
|
setIsLoading(false)
|
||||||
<Input
|
}
|
||||||
label='Enter a Username'
|
}
|
||||||
fullWidth
|
|
||||||
placeholder='Username'
|
useEffect(() => {
|
||||||
helperText={inputHelperText}
|
return () => {
|
||||||
endAdornment={
|
if (isModalOpened) {
|
||||||
<Typography color={'#FFFFFFA8'}>@nsec.app</Typography>
|
// modal closed
|
||||||
}
|
setIsLoading(false)
|
||||||
onChange={handleInputChange}
|
setIsAvailable(false)
|
||||||
value={enteredValue}
|
}
|
||||||
helperTextProps={{
|
}
|
||||||
sx: {
|
}, [isModalOpened])
|
||||||
'&.helper_text': {
|
|
||||||
color: isAvailable
|
return (
|
||||||
? theme.palette.success.main
|
<Modal open={isModalOpened} onClose={handleCloseModal}>
|
||||||
: theme.palette.textSecondaryDecorate.main,
|
<Stack paddingTop={'1rem'} gap={'1rem'} component={'form'} onSubmit={handleSubmit}>
|
||||||
},
|
<Stack direction={'row'} gap={'1rem'} alignItems={'center'} alignSelf={'flex-start'}>
|
||||||
},
|
<StyledAppLogo />
|
||||||
}}
|
<Typography fontWeight={600} variant="h5">
|
||||||
/>
|
Sign up
|
||||||
<Button fullWidth type='submit'>
|
</Typography>
|
||||||
Sign up
|
</Stack>
|
||||||
</Button>
|
<Input
|
||||||
</Stack>
|
label="Username"
|
||||||
</Modal>
|
fullWidth
|
||||||
)
|
placeholder="Enter a Username"
|
||||||
|
helperText={inputHelperText}
|
||||||
|
endAdornment={<Typography color={'#FFFFFFA8'}>@{DOMAIN}</Typography>}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
value={enteredValue}
|
||||||
|
helperTextProps={{
|
||||||
|
sx: {
|
||||||
|
'&.helper_text': {
|
||||||
|
color:
|
||||||
|
enteredValue && isAvailable
|
||||||
|
? theme.palette.success.main
|
||||||
|
: enteredValue && !isAvailable
|
||||||
|
? theme.palette.error.main
|
||||||
|
: theme.palette.textSecondaryDecorate.main,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button fullWidth type="submit" disabled={isLoading}>
|
||||||
|
Create account {isLoading && <LoadingSpinner />}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import { AppLogo } from '@/assets'
|
|||||||
import { Box, styled } from '@mui/material'
|
import { Box, styled } from '@mui/material'
|
||||||
|
|
||||||
export const StyledAppLogo = styled((props) => (
|
export const StyledAppLogo = styled((props) => (
|
||||||
<Box {...props}>
|
<Box {...props}>
|
||||||
<AppLogo />
|
<AppLogo />
|
||||||
</Box>
|
</Box>
|
||||||
))({
|
))({
|
||||||
background: '#00000054',
|
background: '#0d0d0d',
|
||||||
padding: '0.75rem',
|
padding: '0.75rem',
|
||||||
borderRadius: '16px',
|
borderRadius: '16px',
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
placeItems: 'center',
|
placeItems: 'center',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,21 +5,19 @@ import CloseIcon from '@mui/icons-material/Close'
|
|||||||
import { NotificationProps } from './types'
|
import { NotificationProps } from './types'
|
||||||
import { StyledAlert, StyledContainer } from './styled'
|
import { StyledAlert, StyledContainer } from './styled'
|
||||||
|
|
||||||
export const Notification = forwardRef<HTMLDivElement, NotificationProps>(
|
export const Notification = forwardRef<HTMLDivElement, NotificationProps>(({ message, alertvariant, id }, ref) => {
|
||||||
({ message, alertvariant, id }, ref) => {
|
const { closeSnackbar } = useSnackbar()
|
||||||
const { closeSnackbar } = useSnackbar()
|
|
||||||
|
|
||||||
const closeSnackBarHandler = () => closeSnackbar(id)
|
const closeSnackBarHandler = () => closeSnackbar(id)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledAlert alertvariant={alertvariant} ref={ref}>
|
<StyledAlert alertvariant={alertvariant} ref={ref}>
|
||||||
<StyledContainer>
|
<StyledContainer>
|
||||||
<Typography variant='body1'>{message}</Typography>
|
<Typography variant="body1">{message}</Typography>
|
||||||
<IconButton onClick={closeSnackBarHandler} color='inherit'>
|
<IconButton onClick={closeSnackBarHandler} color="inherit">
|
||||||
<CloseIcon color='inherit' />
|
<CloseIcon color="inherit" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</StyledContainer>
|
</StyledContainer>
|
||||||
</StyledAlert>
|
</StyledAlert>
|
||||||
)
|
)
|
||||||
},
|
})
|
||||||
)
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { VariantType } from 'notistack'
|
|||||||
type Variant = Exclude<VariantType, 'default' | 'info'>
|
type Variant = Exclude<VariantType, 'default' | 'info'>
|
||||||
|
|
||||||
export const BORDER_STYLES: Record<Variant, string> = {
|
export const BORDER_STYLES: Record<Variant, string> = {
|
||||||
error: '#b90e0a',
|
error: '#b90e0a',
|
||||||
success: '#32cd32',
|
success: '#32cd32',
|
||||||
warning: '#FF9500',
|
warning: '#FF9500',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,43 +4,41 @@ import { BORDER_STYLES } from './const'
|
|||||||
import { forwardRef } from 'react'
|
import { forwardRef } from 'react'
|
||||||
|
|
||||||
export const StyledAlert = styled(
|
export const StyledAlert = styled(
|
||||||
forwardRef<HTMLDivElement, StyledAlertProps>((props, ref) => (
|
forwardRef<HTMLDivElement, StyledAlertProps>((props, ref) => <Alert {...props} ref={ref} icon={false} />)
|
||||||
<Alert {...props} ref={ref} icon={false} />
|
|
||||||
)),
|
|
||||||
)(({ alertvariant }) => ({
|
)(({ alertvariant }) => ({
|
||||||
width: '100%',
|
width: '100%',
|
||||||
maxHeight: 56,
|
maxHeight: 56,
|
||||||
padding: '0.5rem 1rem',
|
padding: '0.5rem 1rem',
|
||||||
backgroundColor: '#FFF',
|
backgroundColor: '#FFF',
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
border: `solid ${BORDER_STYLES[alertvariant]} 1px`,
|
border: `solid ${BORDER_STYLES[alertvariant]} 1px`,
|
||||||
color: BORDER_STYLES[alertvariant],
|
color: BORDER_STYLES[alertvariant],
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
'& .MuiAlert-message': {
|
'& .MuiAlert-message': {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
minWidth: '100%',
|
minWidth: '100%',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
padding: 0,
|
padding: 0,
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
export const StyledContainer = styled(Box)(() => ({
|
export const StyledContainer = styled(Box)(() => ({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '1rem',
|
gap: '1rem',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
'& > .MuiTypography-root': {
|
'& > .MuiTypography-root': {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
width: '100%',
|
width: '100%',
|
||||||
wordBreak: 'break-word',
|
wordBreak: 'break-word',
|
||||||
display: '-webkit-box',
|
display: '-webkit-box',
|
||||||
WebkitLineClamp: 2,
|
WebkitLineClamp: 2,
|
||||||
WebkitBoxOrient: 'vertical',
|
WebkitBoxOrient: 'vertical',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
textOverflow: 'ellipsis',
|
textOverflow: 'ellipsis',
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ import { AlertProps } from '@mui/material'
|
|||||||
import { SnackbarKey, VariantType } from 'notistack'
|
import { SnackbarKey, VariantType } from 'notistack'
|
||||||
|
|
||||||
export type StyledAlertProps = Omit<AlertProps, 'id'> & {
|
export type StyledAlertProps = Omit<AlertProps, 'id'> & {
|
||||||
alertvariant: Exclude<VariantType, 'default' | 'info'>
|
alertvariant: Exclude<VariantType, 'default' | 'info'>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type NotificationProps = {
|
export type NotificationProps = {
|
||||||
message: string
|
message: string
|
||||||
id: SnackbarKey
|
id: SnackbarKey
|
||||||
} & StyledAlertProps
|
} & StyledAlertProps
|
||||||
|
|||||||
@@ -1,19 +1,27 @@
|
|||||||
import React, { FC, ReactNode } from 'react'
|
import { FC, ReactNode } from 'react'
|
||||||
import { IconContainer, StyledContainer } from './styled'
|
import { IconContainer, StyledContainer } from './styled'
|
||||||
import { BoxProps, Typography } from '@mui/material'
|
import { BoxProps, Stack, Typography } from '@mui/material'
|
||||||
|
|
||||||
type WarningProps = {
|
type WarningProps = {
|
||||||
message: string | ReactNode
|
message?: string | ReactNode
|
||||||
Icon?: ReactNode
|
hint?: string | ReactNode
|
||||||
|
icon?: ReactNode
|
||||||
} & BoxProps
|
} & BoxProps
|
||||||
|
|
||||||
export const Warning: FC<WarningProps> = ({ message, Icon, ...restProps }) => {
|
export const Warning: FC<WarningProps> = ({ hint, message, icon, ...restProps }) => {
|
||||||
return (
|
return (
|
||||||
<StyledContainer {...restProps}>
|
<StyledContainer {...restProps}>
|
||||||
{Icon && <IconContainer>{Icon}</IconContainer>}
|
{icon && <IconContainer>{icon}</IconContainer>}
|
||||||
<Typography flex={1} noWrap>
|
<Stack flex={1} direction={'column'} gap={'0.2rem'}>
|
||||||
{message}
|
<Typography noWrap>
|
||||||
</Typography>
|
{message}
|
||||||
</StyledContainer>
|
</Typography>
|
||||||
)
|
{hint && (
|
||||||
|
<Typography>
|
||||||
|
{hint}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</StyledContainer>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,22 @@
|
|||||||
import { Box, BoxProps, styled } from '@mui/material'
|
import { Box, BoxProps, styled } from '@mui/material'
|
||||||
|
|
||||||
export const StyledContainer = styled((props: BoxProps) => <Box {...props} />)(
|
export const StyledContainer = styled((props: BoxProps) => <Box {...props} />)(() => {
|
||||||
() => {
|
return {
|
||||||
return {
|
borderRadius: '4px',
|
||||||
borderRadius: '4px',
|
border: '1px solid grey',
|
||||||
border: '1px solid grey',
|
padding: '0.5rem',
|
||||||
padding: '0.5rem',
|
display: 'flex',
|
||||||
display: 'flex',
|
alignItems: 'center',
|
||||||
alignItems: 'center',
|
gap: '1rem',
|
||||||
gap: '1rem',
|
cursor: 'pointer',
|
||||||
cursor: 'pointer',
|
}
|
||||||
}
|
})
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
export const IconContainer = styled((props: BoxProps) => <Box {...props} />)(
|
export const IconContainer = styled((props: BoxProps) => <Box {...props} />)(() => ({
|
||||||
() => ({
|
width: '40px',
|
||||||
width: '40px',
|
height: '40px',
|
||||||
height: '40px',
|
borderRadius: '50%',
|
||||||
borderRadius: '50%',
|
background: 'grey',
|
||||||
background: 'blue',
|
display: 'grid',
|
||||||
display: 'grid',
|
placeItems: 'center',
|
||||||
placeItems: 'center',
|
}))
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,33 +1,20 @@
|
|||||||
import {
|
import { useSnackbar as useDefaultSnackbar, OptionsObject, VariantType } from 'notistack'
|
||||||
useSnackbar as useDefaultSnackbar,
|
|
||||||
OptionsObject,
|
|
||||||
VariantType,
|
|
||||||
} from 'notistack'
|
|
||||||
import { Notification } from '../components/Notification/Notification'
|
import { Notification } from '../components/Notification/Notification'
|
||||||
|
|
||||||
export const useEnqueueSnackbar = () => {
|
export const useEnqueueSnackbar = () => {
|
||||||
const { enqueueSnackbar } = useDefaultSnackbar()
|
const { enqueueSnackbar } = useDefaultSnackbar()
|
||||||
|
|
||||||
const showSnackbar = (
|
const showSnackbar = (message: string, variant: Exclude<VariantType, 'default' | 'info'> = 'success') => {
|
||||||
message: string,
|
enqueueSnackbar(message, {
|
||||||
variant: Exclude<VariantType, 'default' | 'info'> = 'success',
|
anchorOrigin: {
|
||||||
) => {
|
vertical: 'top',
|
||||||
enqueueSnackbar(message, {
|
horizontal: 'right',
|
||||||
anchorOrigin: {
|
},
|
||||||
vertical: 'top',
|
content: (id) => {
|
||||||
horizontal: 'right',
|
return <Notification id={id} message={message} alertvariant={variant} />
|
||||||
},
|
},
|
||||||
content: (id) => {
|
} as OptionsObject)
|
||||||
return (
|
}
|
||||||
<Notification
|
|
||||||
id={id}
|
|
||||||
message={message}
|
|
||||||
alertvariant={variant}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
} as OptionsObject)
|
|
||||||
}
|
|
||||||
|
|
||||||
return showSnackbar
|
return showSnackbar
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,16 +8,15 @@ import { useState, useEffect } from 'react'
|
|||||||
const iOSRegex = /iPad|iPhone|iPod/
|
const iOSRegex = /iPad|iPhone|iPod/
|
||||||
|
|
||||||
function useIsIOS() {
|
function useIsIOS() {
|
||||||
const [isIOS, setIsIOS] = useState(false)
|
const [isIOS, setIsIOS] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const isIOSUserAgent =
|
const isIOSUserAgent =
|
||||||
iOSRegex.test(navigator.userAgent) ||
|
iOSRegex.test(navigator.userAgent) || (navigator.userAgent.includes('Mac') && 'ontouchend' in document)
|
||||||
(navigator.userAgent.includes('Mac') && 'ontouchend' in document)
|
setIsIOS(isIOSUserAgent)
|
||||||
setIsIOS(isIOSUserAgent)
|
}, [])
|
||||||
}, [])
|
|
||||||
|
|
||||||
return isIOS
|
return isIOS
|
||||||
}
|
}
|
||||||
|
|
||||||
export default useIsIOS
|
export default useIsIOS
|
||||||
|
|||||||
@@ -1,97 +1,84 @@
|
|||||||
import { MODAL_PARAMS_KEYS } from '@/types/modal'
|
import { MODAL_PARAMS_KEYS } from '@/types/modal'
|
||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
import {
|
import { createSearchParams, useLocation, useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
createSearchParams,
|
|
||||||
useLocation,
|
|
||||||
useNavigate,
|
|
||||||
useSearchParams,
|
|
||||||
} from 'react-router-dom'
|
|
||||||
|
|
||||||
type SearchParamsType = {
|
type SearchParamsType = {
|
||||||
[key: string]: string
|
[key: string]: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type IExtraOptions = {
|
export type IExtraOptions = {
|
||||||
search?: SearchParamsType
|
search?: SearchParamsType
|
||||||
replace?: boolean
|
replace?: boolean
|
||||||
append?: boolean
|
append?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type IExtraCloseOptions = {
|
export type IExtraCloseOptions = {
|
||||||
replace?: boolean
|
replace?: boolean
|
||||||
onClose?: (s: URLSearchParams) => void
|
onClose?: (s: URLSearchParams) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useModalSearchParams = () => {
|
export const useModalSearchParams = () => {
|
||||||
const [searchParams, setSearchParams] = useSearchParams()
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const getEnumParam = useCallback((modal: MODAL_PARAMS_KEYS) => {
|
const getEnumParam = useCallback((modal: MODAL_PARAMS_KEYS) => {
|
||||||
return Object.values(MODAL_PARAMS_KEYS)[
|
return Object.values(MODAL_PARAMS_KEYS)[Object.values(MODAL_PARAMS_KEYS).indexOf(modal)]
|
||||||
Object.values(MODAL_PARAMS_KEYS).indexOf(modal)
|
}, [])
|
||||||
]
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const createHandleClose =
|
const createHandleClose = (modal: MODAL_PARAMS_KEYS, extraOptions?: IExtraCloseOptions) => () => {
|
||||||
(modal: MODAL_PARAMS_KEYS, extraOptions?: IExtraCloseOptions) =>
|
const enumKey = getEnumParam(modal)
|
||||||
() => {
|
searchParams.delete(enumKey)
|
||||||
const enumKey = getEnumParam(modal)
|
extraOptions?.onClose && extraOptions?.onClose(searchParams)
|
||||||
searchParams.delete(enumKey)
|
// console.log({ searchParams })
|
||||||
extraOptions?.onClose && extraOptions?.onClose(searchParams)
|
setSearchParams(searchParams, { replace: !!extraOptions?.replace })
|
||||||
console.log({ searchParams })
|
}
|
||||||
setSearchParams(searchParams, { replace: !!extraOptions?.replace })
|
|
||||||
}
|
|
||||||
|
|
||||||
const createHandleCloseReplace =
|
const createHandleCloseReplace = (modal: MODAL_PARAMS_KEYS, extraOptions: IExtraCloseOptions = {}) => {
|
||||||
(modal: MODAL_PARAMS_KEYS, extraOptions?: IExtraCloseOptions) =>
|
return createHandleClose(modal, { ...extraOptions, replace: true })
|
||||||
() => {
|
}
|
||||||
return createHandleClose(modal, { ...extraOptions, replace: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleOpen = useCallback(
|
const handleOpen = useCallback(
|
||||||
(modal: MODAL_PARAMS_KEYS, extraOptions?: IExtraOptions) => {
|
(modal: MODAL_PARAMS_KEYS, extraOptions?: IExtraOptions) => {
|
||||||
const enumKey = getEnumParam(modal)
|
const enumKey = getEnumParam(modal)
|
||||||
|
|
||||||
let searchParamsData: SearchParamsType = { [enumKey]: 'true' }
|
let searchParamsData: SearchParamsType = { [enumKey]: 'true' }
|
||||||
if (extraOptions?.search) {
|
if (extraOptions?.search) {
|
||||||
searchParamsData = {
|
searchParamsData = {
|
||||||
...searchParamsData,
|
...searchParamsData,
|
||||||
...extraOptions.search,
|
...extraOptions.search,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const searchString = !extraOptions?.append
|
const searchString = !extraOptions?.append
|
||||||
? createSearchParams(searchParamsData).toString()
|
? createSearchParams(searchParamsData).toString()
|
||||||
: `${location.search}&${createSearchParams(
|
: `${location.search}&${createSearchParams(searchParamsData).toString()}`
|
||||||
searchParamsData,
|
|
||||||
).toString()}`
|
|
||||||
|
|
||||||
navigate(
|
navigate(
|
||||||
{
|
{
|
||||||
pathname: location.pathname,
|
pathname: location.pathname,
|
||||||
search: searchString,
|
search: searchString,
|
||||||
},
|
},
|
||||||
{ replace: !!extraOptions?.replace },
|
{ replace: !!extraOptions?.replace }
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
[location, navigate, getEnumParam],
|
[location, navigate, getEnumParam]
|
||||||
)
|
)
|
||||||
|
|
||||||
const getModalOpened = useCallback(
|
const getModalOpened = useCallback(
|
||||||
(modal: MODAL_PARAMS_KEYS) => {
|
(modal: MODAL_PARAMS_KEYS) => {
|
||||||
const enumKey = getEnumParam(modal)
|
const enumKey = getEnumParam(modal)
|
||||||
const modalOpened = searchParams.get(enumKey) === 'true'
|
const modalOpened = searchParams.get(enumKey) === 'true'
|
||||||
return modalOpened
|
return modalOpened
|
||||||
},
|
},
|
||||||
[getEnumParam, searchParams],
|
[getEnumParam, searchParams]
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getModalOpened,
|
getModalOpened,
|
||||||
createHandleClose,
|
createHandleClose,
|
||||||
createHandleCloseReplace,
|
createHandleCloseReplace,
|
||||||
handleOpen,
|
handleOpen,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
|
|
||||||
export const useOpenMenu = () => {
|
export const useOpenMenu = () => {
|
||||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
|
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
|
||||||
|
|
||||||
const open = Boolean(anchorEl)
|
const open = Boolean(anchorEl)
|
||||||
|
|
||||||
const handleOpen = (event: React.MouseEvent<HTMLElement>) => {
|
const handleOpen = (event: React.MouseEvent<HTMLElement>) => {
|
||||||
setAnchorEl(event.currentTarget)
|
setAnchorEl(event.currentTarget)
|
||||||
}
|
}
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
setAnchorEl(null)
|
setAnchorEl(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
open,
|
open,
|
||||||
handleOpen,
|
handleOpen,
|
||||||
handleClose,
|
handleClose,
|
||||||
anchorEl,
|
anchorEl,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
30
src/hooks/usePassword.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { useCallback, useMemo, useState } from 'react'
|
||||||
|
import { IconButton } from '@mui/material'
|
||||||
|
import VisibilityOffOutlinedIcon from '@mui/icons-material/VisibilityOffOutlined'
|
||||||
|
import VisibilityOutlinedIcon from '@mui/icons-material/VisibilityOutlined'
|
||||||
|
|
||||||
|
export const usePassword = () => {
|
||||||
|
const [isPasswordShown, setIsPasswordShown] = useState(false)
|
||||||
|
|
||||||
|
const handlePasswordTypeChange = useCallback(() => setIsPasswordShown((prevState) => !prevState), [])
|
||||||
|
|
||||||
|
const hidePassword = useCallback(() => setIsPasswordShown(false), [])
|
||||||
|
|
||||||
|
const inputProps = useMemo(
|
||||||
|
() => ({
|
||||||
|
endAdornment: (
|
||||||
|
<IconButton size="small" onClick={handlePasswordTypeChange}>
|
||||||
|
{isPasswordShown ? (
|
||||||
|
<VisibilityOffOutlinedIcon htmlColor="#6b6b6b" />
|
||||||
|
) : (
|
||||||
|
<VisibilityOutlinedIcon htmlColor="#6b6b6b" />
|
||||||
|
)}
|
||||||
|
</IconButton>
|
||||||
|
),
|
||||||
|
type: isPasswordShown ? 'text' : 'password',
|
||||||
|
}),
|
||||||
|
[handlePasswordTypeChange, isPasswordShown]
|
||||||
|
)
|
||||||
|
|
||||||
|
return { inputProps, hidePassword }
|
||||||
|
}
|
||||||
41
src/hooks/useProfile.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { fetchProfile } from '@/modules/nostr'
|
||||||
|
import { MetaEvent } from '@/types/meta-event'
|
||||||
|
import { getProfileUsername, getShortenNpub } from '@/utils/helpers/helpers'
|
||||||
|
import { useAppSelector } from '@/store/hooks/redux'
|
||||||
|
import { selectKeyByNpub } from '@/store'
|
||||||
|
|
||||||
|
const getFirstLetter = (text: string | undefined): string | null => {
|
||||||
|
if (!text || text.trim().length === 0) return null
|
||||||
|
return text.substring(0, 1).toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useProfile = (npub: string) => {
|
||||||
|
const [profile, setProfile] = useState<MetaEvent | null>(null)
|
||||||
|
const currentKey = useAppSelector((state) => selectKeyByNpub(state, npub))
|
||||||
|
|
||||||
|
const userName = getProfileUsername(profile) || currentKey?.name
|
||||||
|
const userAvatar = profile?.info?.picture || ''
|
||||||
|
const avatarTitle = getFirstLetter(userName)
|
||||||
|
|
||||||
|
const loadProfile = useCallback(async () => {
|
||||||
|
if (!npub) return undefined
|
||||||
|
try {
|
||||||
|
const response = await fetchProfile(npub)
|
||||||
|
setProfile(response)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch profile:', error)
|
||||||
|
}
|
||||||
|
}, [npub])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadProfile()
|
||||||
|
}, [loadProfile])
|
||||||
|
|
||||||
|
return {
|
||||||
|
profile,
|
||||||
|
userName: userName || getShortenNpub(npub),
|
||||||
|
userAvatar,
|
||||||
|
avatarTitle,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
import { useCallback, useState } from 'react'
|
import { useCallback, useState } from 'react'
|
||||||
|
|
||||||
export const useToggleConfirm = () => {
|
export const useToggleConfirm = () => {
|
||||||
const [showConfirm, setShowConfirm] = useState(false)
|
const [showConfirm, setShowConfirm] = useState(false)
|
||||||
|
|
||||||
const handleShow = useCallback(() => setShowConfirm(true), [])
|
const handleShow = useCallback(() => setShowConfirm(true), [])
|
||||||
|
|
||||||
const handleClose = useCallback(() => setShowConfirm(false), [])
|
const handleClose = useCallback(() => setShowConfirm(false), [])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
open: showConfirm,
|
open: showConfirm,
|
||||||
handleShow,
|
handleShow,
|
||||||
handleClose,
|
handleClose,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,21 @@
|
|||||||
* {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto',
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans',
|
||||||
'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans',
|
'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||||
'Helvetica Neue', sans-serif;
|
-webkit-font-smoothing: antialiased;
|
||||||
-webkit-font-smoothing: antialiased;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
code {
|
code {
|
||||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
|
||||||
monospace;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body,
|
body,
|
||||||
#root {
|
#root {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,19 +13,19 @@ import { SnackbarProvider } from 'notistack'
|
|||||||
|
|
||||||
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement)
|
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement)
|
||||||
root.render(
|
root.render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
<PersistGate loading={null} persistor={persistor}>
|
<PersistGate loading={null} persistor={persistor}>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<SnackbarProvider maxSnack={3} autoHideDuration={3000}>
|
<SnackbarProvider maxSnack={3} autoHideDuration={3000}>
|
||||||
<App />
|
<App />
|
||||||
</SnackbarProvider>
|
</SnackbarProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</PersistGate>
|
</PersistGate>
|
||||||
</Provider>
|
</Provider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>
|
||||||
)
|
)
|
||||||
|
|
||||||
// If you want your app to work offline and load faster, you can change
|
// If you want your app to work offline and load faster, you can change
|
||||||
|
|||||||
@@ -1,66 +1,61 @@
|
|||||||
import { Avatar, Stack, Toolbar, Typography } from '@mui/material'
|
import { Avatar, Stack, Toolbar, Typography } from '@mui/material'
|
||||||
import { AppLogo } from '../../assets'
|
import { StyledAppBar, StyledAppLogo, StyledAppName, StyledProfileContainer, StyledThemeButton } from './styled'
|
||||||
import { StyledAppBar, StyledAppName } from './styled'
|
|
||||||
import { Menu } from './components/Menu'
|
import { Menu } from './components/Menu'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
|
||||||
import { MetaEvent } from '@/types/meta-event'
|
|
||||||
import { fetchProfile } from '@/modules/nostr'
|
|
||||||
import { ProfileMenu } from './components/ProfileMenu'
|
import { ProfileMenu } from './components/ProfileMenu'
|
||||||
import { getShortenNpub } from '@/utils/helpers/helpers'
|
import { useProfile } from '@/hooks/useProfile'
|
||||||
|
import DarkModeIcon from '@mui/icons-material/DarkMode'
|
||||||
|
import LightModeIcon from '@mui/icons-material/LightMode'
|
||||||
|
import { useAppDispatch, useAppSelector } from '@/store/hooks/redux'
|
||||||
|
import { setThemeMode } from '@/store/reducers/ui.slice'
|
||||||
|
|
||||||
export const Header = () => {
|
export const Header = () => {
|
||||||
const { npub = '' } = useParams<{ npub: string }>()
|
const themeMode = useAppSelector((state) => state.ui.themeMode)
|
||||||
const [profile, setProfile] = useState<MetaEvent | null>(null)
|
const navigate = useNavigate()
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const { npub = '' } = useParams<{ npub: string }>()
|
||||||
if (!npub) return setProfile(null)
|
const { userName, userAvatar, avatarTitle } = useProfile(npub)
|
||||||
|
const showProfile = Boolean(npub)
|
||||||
|
|
||||||
try {
|
const handleNavigate = () => {
|
||||||
const response = await fetchProfile(npub)
|
navigate(`/key/${npub}`)
|
||||||
setProfile(response as any)
|
}
|
||||||
} catch (e) {
|
|
||||||
return setProfile(null)
|
|
||||||
}
|
|
||||||
}, [npub])
|
|
||||||
|
|
||||||
useEffect(() => {
|
const isDarkMode = themeMode === 'dark'
|
||||||
load()
|
const themeIcon = isDarkMode ? <LightModeIcon htmlColor="#fff" /> : <DarkModeIcon htmlColor="#000" />
|
||||||
}, [load])
|
|
||||||
|
|
||||||
const showProfile = Boolean(npub || profile)
|
const handleChangeMode = () => {
|
||||||
const userName = profile?.info?.name || getShortenNpub(npub)
|
dispatch(setThemeMode({ mode: isDarkMode ? 'light' : 'dark' }))
|
||||||
const userAvatar = profile?.info?.picture || ''
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledAppBar position='fixed'>
|
<StyledAppBar position="fixed">
|
||||||
<Toolbar sx={{ padding: '12px' }}>
|
<Toolbar sx={{ padding: '12px' }}>
|
||||||
<Stack
|
<Stack direction={'row'} justifyContent={'space-between'} alignItems={'center'} width={'100%'}>
|
||||||
direction={'row'}
|
{showProfile && (
|
||||||
justifyContent={'space-between'}
|
<StyledProfileContainer>
|
||||||
alignItems={'center'}
|
<Avatar src={userAvatar} alt={userName} onClick={handleNavigate} className="avatar">
|
||||||
width={'100%'}
|
{avatarTitle}
|
||||||
>
|
</Avatar>
|
||||||
{showProfile ? (
|
<Typography fontWeight={600} onClick={handleNavigate} className="username">
|
||||||
<Stack
|
{userName}
|
||||||
gap={'1rem'}
|
</Typography>
|
||||||
direction={'row'}
|
</StyledProfileContainer>
|
||||||
alignItems={'center'}
|
)}
|
||||||
flex={1}
|
|
||||||
>
|
|
||||||
<Avatar src={userAvatar} alt={userName} />
|
|
||||||
<Typography fontWeight={600}>{userName}</Typography>
|
|
||||||
</Stack>
|
|
||||||
) : (
|
|
||||||
<StyledAppName>
|
|
||||||
<AppLogo />
|
|
||||||
<span>Nsec.app</span>
|
|
||||||
</StyledAppName>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showProfile ? <ProfileMenu /> : <Menu />}
|
{!showProfile && (
|
||||||
</Stack>
|
<StyledAppName>
|
||||||
</Toolbar>
|
<StyledAppLogo />
|
||||||
</StyledAppBar>
|
<span>Nsec.app</span>
|
||||||
)
|
</StyledAppName>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<StyledThemeButton onClick={handleChangeMode}>{themeIcon}</StyledThemeButton>
|
||||||
|
|
||||||
|
{showProfile ? <ProfileMenu /> : <Menu />}
|
||||||
|
</Stack>
|
||||||
|
</Toolbar>
|
||||||
|
</StyledAppBar>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
31
src/layout/Header/components/ListItemProfile.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { useProfile } from '@/hooks/useProfile'
|
||||||
|
import { DbKey } from '@/modules/db'
|
||||||
|
import { Avatar, ListItemIcon, MenuItem, Typography } from '@mui/material'
|
||||||
|
import React, { FC } from 'react'
|
||||||
|
|
||||||
|
type ListItemProfileProps = {
|
||||||
|
onClickItem: () => void
|
||||||
|
} & DbKey
|
||||||
|
|
||||||
|
export const ListItemProfile: FC<ListItemProfileProps> = ({
|
||||||
|
onClickItem,
|
||||||
|
npub,
|
||||||
|
}) => {
|
||||||
|
const { userName, userAvatar, avatarTitle } = useProfile(npub)
|
||||||
|
return (
|
||||||
|
<MenuItem sx={{ gap: '0.5rem' }} onClick={onClickItem}>
|
||||||
|
<ListItemIcon>
|
||||||
|
<Avatar
|
||||||
|
src={userAvatar}
|
||||||
|
alt={userName}
|
||||||
|
sx={{ width: 36, height: 36 }}
|
||||||
|
>
|
||||||
|
{avatarTitle}
|
||||||
|
</Avatar>
|
||||||
|
</ListItemIcon>
|
||||||
|
<Typography variant='body2' noWrap>
|
||||||
|
{userName}
|
||||||
|
</Typography>
|
||||||
|
</MenuItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,48 +1,19 @@
|
|||||||
import { DbKey } from '@/modules/db'
|
import { DbKey } from '@/modules/db'
|
||||||
import { getShortenNpub } from '@/utils/helpers/helpers'
|
import { Stack } from '@mui/material'
|
||||||
import {
|
import { FC } from 'react'
|
||||||
Avatar,
|
import { ListItemProfile } from './ListItemProfile'
|
||||||
ListItemIcon,
|
|
||||||
MenuItem,
|
|
||||||
Stack,
|
|
||||||
Typography,
|
|
||||||
} from '@mui/material'
|
|
||||||
import React, { FC } from 'react'
|
|
||||||
|
|
||||||
type ListProfilesProps = {
|
type ListProfilesProps = {
|
||||||
keys: DbKey[]
|
keys: DbKey[]
|
||||||
onClickItem: (key: DbKey) => void
|
onClickItem: (key: DbKey) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ListProfiles: FC<ListProfilesProps> = ({
|
export const ListProfiles: FC<ListProfilesProps> = ({ keys = [], onClickItem }) => {
|
||||||
keys = [],
|
return (
|
||||||
onClickItem,
|
<Stack maxHeight={'10rem'} overflow={'auto'}>
|
||||||
}) => {
|
{keys.map((key) => {
|
||||||
return (
|
return <ListItemProfile {...key} key={key.npub} onClickItem={() => onClickItem(key)} />
|
||||||
<Stack maxHeight={'10rem'} overflow={'auto'}>
|
})}
|
||||||
{keys.map((key) => {
|
</Stack>
|
||||||
const userName =
|
)
|
||||||
key?.profile?.info?.name || getShortenNpub(key.npub)
|
|
||||||
const userAvatar = key?.profile?.info?.picture || ''
|
|
||||||
return (
|
|
||||||
<MenuItem
|
|
||||||
sx={{ gap: '0.5rem' }}
|
|
||||||
onClick={() => onClickItem(key)}
|
|
||||||
key={key.npub}
|
|
||||||
>
|
|
||||||
<ListItemIcon>
|
|
||||||
<Avatar
|
|
||||||
src={userAvatar}
|
|
||||||
alt={userName}
|
|
||||||
sx={{ width: 36, height: 36 }}
|
|
||||||
/>
|
|
||||||
</ListItemIcon>
|
|
||||||
<Typography variant='body2' noWrap>
|
|
||||||
{userName}
|
|
||||||
</Typography>
|
|
||||||
</MenuItem>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</Stack>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { Menu as MuiMenu } from '@mui/material'
|
import { Menu as MuiMenu } from '@mui/material'
|
||||||
import DarkModeIcon from '@mui/icons-material/DarkMode'
|
|
||||||
import LightModeIcon from '@mui/icons-material/LightMode'
|
|
||||||
import LoginIcon from '@mui/icons-material/Login'
|
import LoginIcon from '@mui/icons-material/Login'
|
||||||
import PersonAddAltRoundedIcon from '@mui/icons-material/PersonAddAltRounded'
|
import PersonAddAltRoundedIcon from '@mui/icons-material/PersonAddAltRounded'
|
||||||
import { setThemeMode } from '@/store/reducers/ui.slice'
|
import { useAppSelector } from '@/store/hooks/redux'
|
||||||
import { useAppDispatch, useAppSelector } from '@/store/hooks/redux'
|
|
||||||
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
|
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
|
||||||
import { MODAL_PARAMS_KEYS } from '@/types/modal'
|
import { MODAL_PARAMS_KEYS } from '@/types/modal'
|
||||||
import { MenuButton } from './styled'
|
import { MenuButton } from './styled'
|
||||||
@@ -14,61 +11,36 @@ import MenuRoundedIcon from '@mui/icons-material/MenuRounded'
|
|||||||
import { selectKeys } from '@/store'
|
import { selectKeys } from '@/store'
|
||||||
|
|
||||||
export const Menu = () => {
|
export const Menu = () => {
|
||||||
const themeMode = useAppSelector((state) => state.ui.themeMode)
|
const keys = useAppSelector(selectKeys)
|
||||||
const keys = useAppSelector(selectKeys)
|
const { handleOpen } = useModalSearchParams()
|
||||||
const dispatch = useAppDispatch()
|
const { anchorEl, handleClose, handleOpen: handleOpenMenu, open } = useOpenMenu()
|
||||||
const { handleOpen } = useModalSearchParams()
|
|
||||||
|
|
||||||
const isDarkMode = themeMode === 'dark'
|
const isNoKeys = !keys || keys.length === 0
|
||||||
const isNoKeys = !keys || keys.length === 0
|
|
||||||
|
|
||||||
const {
|
const handleNavigateToAuth = () => {
|
||||||
anchorEl,
|
handleOpen(MODAL_PARAMS_KEYS.INITIAL)
|
||||||
handleClose,
|
handleClose()
|
||||||
handleOpen: handleOpenMenu,
|
}
|
||||||
open,
|
|
||||||
} = useOpenMenu()
|
|
||||||
|
|
||||||
const handleChangeMode = () => {
|
return (
|
||||||
dispatch(setThemeMode({ mode: isDarkMode ? 'light' : 'dark' }))
|
<>
|
||||||
}
|
<MenuButton onClick={handleOpenMenu}>
|
||||||
const handleNavigateToAuth = () => {
|
<MenuRoundedIcon color="inherit" />
|
||||||
handleOpen(MODAL_PARAMS_KEYS.INITIAL)
|
</MenuButton>
|
||||||
handleClose()
|
<MuiMenu
|
||||||
}
|
anchorEl={anchorEl}
|
||||||
|
open={open}
|
||||||
const themeIcon = isDarkMode ? (
|
onClose={handleClose}
|
||||||
<DarkModeIcon htmlColor='#fff' />
|
sx={{
|
||||||
) : (
|
zIndex: 1302,
|
||||||
<LightModeIcon htmlColor='#feb94a' />
|
}}
|
||||||
)
|
>
|
||||||
|
<MenuItem
|
||||||
return (
|
Icon={isNoKeys ? <LoginIcon /> : <PersonAddAltRoundedIcon />}
|
||||||
<>
|
onClick={handleNavigateToAuth}
|
||||||
<MenuButton onClick={handleOpenMenu}>
|
title={isNoKeys ? 'Sign up' : 'Add account'}
|
||||||
<MenuRoundedIcon color='inherit' />
|
/>
|
||||||
</MenuButton>
|
</MuiMenu>
|
||||||
<MuiMenu
|
</>
|
||||||
anchorEl={anchorEl}
|
)
|
||||||
open={open}
|
|
||||||
onClose={handleClose}
|
|
||||||
sx={{
|
|
||||||
zIndex: 1302,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MenuItem
|
|
||||||
Icon={
|
|
||||||
isNoKeys ? <LoginIcon /> : <PersonAddAltRoundedIcon />
|
|
||||||
}
|
|
||||||
onClick={handleNavigateToAuth}
|
|
||||||
title={isNoKeys ? 'Sign up' : 'Add account'}
|
|
||||||
/>
|
|
||||||
<MenuItem
|
|
||||||
Icon={themeIcon}
|
|
||||||
onClick={handleChangeMode}
|
|
||||||
title='Change theme'
|
|
||||||
/>
|
|
||||||
</MuiMenu>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,20 @@
|
|||||||
import React, { FC, ReactNode } from 'react'
|
import React, { FC, ReactNode } from 'react'
|
||||||
import { StyledMenuItem } from './styled'
|
import { StyledMenuItem } from './styled'
|
||||||
import {
|
import { ListItemIcon, MenuItemProps as MuiMenuItemProps, Typography } from '@mui/material'
|
||||||
ListItemIcon,
|
|
||||||
MenuItemProps as MuiMenuItemProps,
|
|
||||||
Typography,
|
|
||||||
} from '@mui/material'
|
|
||||||
|
|
||||||
type MenuItemProps = {
|
type MenuItemProps = {
|
||||||
onClick: () => void
|
onClick: () => void
|
||||||
title: string
|
title: string
|
||||||
Icon: ReactNode
|
Icon: ReactNode
|
||||||
} & MuiMenuItemProps
|
} & MuiMenuItemProps
|
||||||
|
|
||||||
export const MenuItem: FC<MenuItemProps> = ({ onClick, Icon, title }) => {
|
export const MenuItem: FC<MenuItemProps> = ({ onClick, Icon, title }) => {
|
||||||
return (
|
return (
|
||||||
<StyledMenuItem onClick={onClick}>
|
<StyledMenuItem onClick={onClick}>
|
||||||
<ListItemIcon>{Icon}</ListItemIcon>
|
<ListItemIcon>{Icon}</ListItemIcon>
|
||||||
<Typography fontWeight={500} variant='body2' noWrap>
|
<Typography fontWeight={500} variant="body2" noWrap>
|
||||||
{title}
|
{title}
|
||||||
</Typography>
|
</Typography>
|
||||||
</StyledMenuItem>
|
</StyledMenuItem>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,95 +9,58 @@ import LoginIcon from '@mui/icons-material/Login'
|
|||||||
import KeyboardArrowDownRoundedIcon from '@mui/icons-material/KeyboardArrowDownRounded'
|
import KeyboardArrowDownRoundedIcon from '@mui/icons-material/KeyboardArrowDownRounded'
|
||||||
import HomeRoundedIcon from '@mui/icons-material/HomeRounded'
|
import HomeRoundedIcon from '@mui/icons-material/HomeRounded'
|
||||||
import PersonAddAltRoundedIcon from '@mui/icons-material/PersonAddAltRounded'
|
import PersonAddAltRoundedIcon from '@mui/icons-material/PersonAddAltRounded'
|
||||||
import { useAppDispatch, useAppSelector } from '@/store/hooks/redux'
|
import { useAppSelector } from '@/store/hooks/redux'
|
||||||
import { selectKeys } from '@/store'
|
import { selectKeys } from '@/store'
|
||||||
import { setThemeMode } from '@/store/reducers/ui.slice'
|
|
||||||
import DarkModeIcon from '@mui/icons-material/DarkMode'
|
|
||||||
import LightModeIcon from '@mui/icons-material/LightMode'
|
|
||||||
import { ListProfiles } from './ListProfiles'
|
import { ListProfiles } from './ListProfiles'
|
||||||
import { DbKey } from '@/modules/db'
|
import { DbKey } from '@/modules/db'
|
||||||
|
|
||||||
export const ProfileMenu = () => {
|
export const ProfileMenu = () => {
|
||||||
const {
|
const { anchorEl, handleOpen: handleOpenMenu, open, handleClose } = useOpenMenu()
|
||||||
anchorEl,
|
const { handleOpen } = useModalSearchParams()
|
||||||
handleOpen: handleOpenMenu,
|
|
||||||
open,
|
|
||||||
handleClose,
|
|
||||||
} = useOpenMenu()
|
|
||||||
const { handleOpen } = useModalSearchParams()
|
|
||||||
|
|
||||||
const keys = useAppSelector(selectKeys)
|
const keys = useAppSelector(selectKeys)
|
||||||
const isNoKeys = !keys || keys.length === 0
|
const isNoKeys = !keys || keys.length === 0
|
||||||
const themeMode = useAppSelector((state) => state.ui.themeMode)
|
|
||||||
const isDarkMode = themeMode === 'dark'
|
|
||||||
|
|
||||||
const dispatch = useAppDispatch()
|
const navigate = useNavigate()
|
||||||
const navigate = useNavigate()
|
|
||||||
|
|
||||||
const handleNavigateToAuth = () => {
|
const handleNavigateToAuth = () => {
|
||||||
handleOpen(MODAL_PARAMS_KEYS.INITIAL)
|
handleOpen(MODAL_PARAMS_KEYS.INITIAL)
|
||||||
handleClose()
|
handleClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleNavigateHome = () => {
|
const handleNavigateHome = () => {
|
||||||
navigate('/home')
|
navigate('/home')
|
||||||
handleClose()
|
handleClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleChangeMode = () => {
|
const handleNavigateToKeyInnerPage = (key: DbKey) => {
|
||||||
dispatch(setThemeMode({ mode: isDarkMode ? 'light' : 'dark' }))
|
navigate('/key/' + key.npub)
|
||||||
}
|
handleClose()
|
||||||
|
}
|
||||||
|
|
||||||
const handleNavigateToKeyInnerPage = (key: DbKey) => {
|
return (
|
||||||
navigate('/key/' + key.npub)
|
<>
|
||||||
handleClose()
|
<MenuButton onClick={handleOpenMenu}>
|
||||||
}
|
<KeyboardArrowDownRoundedIcon color="inherit" fontSize="large" />
|
||||||
|
</MenuButton>
|
||||||
const themeIcon = isDarkMode ? (
|
<Menu
|
||||||
<DarkModeIcon htmlColor='#fff' />
|
open={open}
|
||||||
) : (
|
anchorEl={anchorEl}
|
||||||
<LightModeIcon htmlColor='#feb94a' />
|
onClose={handleClose}
|
||||||
)
|
sx={{
|
||||||
|
zIndex: 1302,
|
||||||
return (
|
}}
|
||||||
<>
|
>
|
||||||
<MenuButton onClick={handleOpenMenu}>
|
<ListProfiles keys={keys} onClickItem={handleNavigateToKeyInnerPage} />
|
||||||
<KeyboardArrowDownRoundedIcon
|
<Divider />
|
||||||
color='inherit'
|
<MenuItem Icon={<HomeRoundedIcon />} onClick={handleNavigateHome} title="Home" />
|
||||||
fontSize='large'
|
<MenuItem
|
||||||
/>
|
Icon={isNoKeys ? <LoginIcon /> : <PersonAddAltRoundedIcon />}
|
||||||
</MenuButton>
|
onClick={handleNavigateToAuth}
|
||||||
<Menu
|
title={isNoKeys ? 'Sign up' : 'Add account'}
|
||||||
open={open}
|
/>
|
||||||
anchorEl={anchorEl}
|
</Menu>
|
||||||
onClose={handleClose}
|
</>
|
||||||
sx={{
|
)
|
||||||
zIndex: 1302,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ListProfiles
|
|
||||||
keys={keys}
|
|
||||||
onClickItem={handleNavigateToKeyInnerPage}
|
|
||||||
/>
|
|
||||||
<Divider />
|
|
||||||
<MenuItem
|
|
||||||
Icon={<HomeRoundedIcon />}
|
|
||||||
onClick={handleNavigateHome}
|
|
||||||
title='Home'
|
|
||||||
/>
|
|
||||||
<MenuItem
|
|
||||||
Icon={
|
|
||||||
isNoKeys ? <LoginIcon /> : <PersonAddAltRoundedIcon />
|
|
||||||
}
|
|
||||||
onClick={handleNavigateToAuth}
|
|
||||||
title={isNoKeys ? 'Sign up' : 'Add account'}
|
|
||||||
/>
|
|
||||||
<MenuItem
|
|
||||||
Icon={themeIcon}
|
|
||||||
onClick={handleChangeMode}
|
|
||||||
title='Change theme'
|
|
||||||
/>
|
|
||||||
</Menu>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,16 @@
|
|||||||
import {
|
import { IconButton, IconButtonProps, MenuItem, MenuItemProps, styled } from '@mui/material'
|
||||||
IconButton,
|
|
||||||
IconButtonProps,
|
|
||||||
MenuItem,
|
|
||||||
MenuItemProps,
|
|
||||||
styled,
|
|
||||||
} from '@mui/material'
|
|
||||||
|
|
||||||
export const MenuButton = styled((props: IconButtonProps) => (
|
export const MenuButton = styled((props: IconButtonProps) => <IconButton {...props} />)(({ theme }) => {
|
||||||
<IconButton {...props} />
|
const isDark = theme.palette.mode === 'dark'
|
||||||
))(({ theme }) => {
|
return {
|
||||||
const isDark = theme.palette.mode === 'dark'
|
borderRadius: '1rem',
|
||||||
return {
|
background: isDark ? '#333333A8' : 'transparent',
|
||||||
borderRadius: '1rem',
|
color: isDark ? '#FFFFFFA8' : 'initial',
|
||||||
background: isDark ? '#333333A8' : 'transparent',
|
width: 42,
|
||||||
color: isDark ? '#FFFFFFA8' : 'initial',
|
height: 42,
|
||||||
width: 42,
|
}
|
||||||
height: 42,
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export const StyledMenuItem = styled((props: MenuItemProps) => (
|
export const StyledMenuItem = styled((props: MenuItemProps) => <MenuItem {...props} />)(() => ({
|
||||||
<MenuItem {...props} />
|
padding: '0.5rem 1rem',
|
||||||
))(() => ({
|
|
||||||
padding: '0.5rem 1rem',
|
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -1,31 +1,56 @@
|
|||||||
import { AppBar, Typography, TypographyProps, styled } from '@mui/material'
|
import { AppLogo } from '@/assets'
|
||||||
|
import { AppBar, IconButton, Stack, StackProps, Typography, TypographyProps, styled } from '@mui/material'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
export const StyledAppBar = styled(AppBar)(({ theme }) => {
|
export const StyledAppBar = styled(AppBar)(({ theme }) => {
|
||||||
return {
|
return {
|
||||||
color: theme.palette.primary.main,
|
color: theme.palette.primary.main,
|
||||||
boxShadow: 'none',
|
boxShadow: 'none',
|
||||||
marginBottom: '1rem',
|
marginBottom: '1rem',
|
||||||
background: theme.palette.background.default,
|
background: theme.palette.background.default,
|
||||||
zIndex: 1301,
|
zIndex: 1301,
|
||||||
maxWidth: 'inherit',
|
maxWidth: 'inherit',
|
||||||
left: '50%',
|
left: '50%',
|
||||||
transform: 'translateX(-50%)',
|
transform: 'translateX(-50%)',
|
||||||
}
|
borderRadius: '8px',
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
export const StyledAppName = styled((props: TypographyProps) => (
|
export const StyledAppName = styled((props: TypographyProps) => (
|
||||||
<Typography component={Link} to={'/'} flexGrow={1} {...props} />
|
<Typography component={Link} to={'/'} flexGrow={1} {...props} />
|
||||||
))(() => ({
|
))(() => ({
|
||||||
'&:not(:hover)': {
|
'&:not(:hover)': {
|
||||||
textDecoration: 'initial',
|
textDecoration: 'initial',
|
||||||
},
|
},
|
||||||
color: 'inherit',
|
color: 'inherit',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '0.75rem',
|
gap: '0.75rem',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
fontSize: '1rem',
|
fontSize: '1rem',
|
||||||
lineHeight: '22.4px',
|
lineHeight: '22.4px',
|
||||||
marginLeft: '0.5rem',
|
marginLeft: '0.5rem',
|
||||||
|
}))
|
||||||
|
|
||||||
|
export const StyledProfileContainer = styled((props: StackProps) => <Stack {...props} />)(() => ({
|
||||||
|
gap: '1rem',
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
flex: 1,
|
||||||
|
'& .avatar': {
|
||||||
|
cursor: 'pointer',
|
||||||
|
},
|
||||||
|
'& .username': {
|
||||||
|
cursor: 'pointer',
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
export const StyledThemeButton = styled(IconButton)({
|
||||||
|
margin: '0 0.5rem',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const StyledAppLogo = styled(AppLogo)(({ theme }) => ({
|
||||||
|
'& path': {
|
||||||
|
fill: theme.palette.text.primary,
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -1,45 +1,37 @@
|
|||||||
import { FC } from 'react'
|
import { FC } from 'react'
|
||||||
import { Outlet } from 'react-router-dom'
|
import { Outlet } from 'react-router-dom'
|
||||||
import { Header } from './Header/Header'
|
import { Header } from './Header/Header'
|
||||||
import {
|
import { Container, ContainerProps, Divider, DividerProps, styled } from '@mui/material'
|
||||||
Container,
|
|
||||||
ContainerProps,
|
|
||||||
Divider,
|
|
||||||
DividerProps,
|
|
||||||
styled,
|
|
||||||
} from '@mui/material'
|
|
||||||
|
|
||||||
export const Layout: FC = () => {
|
export const Layout: FC = () => {
|
||||||
return (
|
return (
|
||||||
<StyledContainer maxWidth='md'>
|
<StyledContainer maxWidth="md">
|
||||||
<Header />
|
<Header />
|
||||||
<StyledDivider />
|
<StyledDivider />
|
||||||
<main>
|
<main>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</StyledContainer>
|
</StyledContainer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const StyledContainer = styled((props: ContainerProps) => (
|
const StyledContainer = styled((props: ContainerProps) => <Container maxWidth="sm" {...props} />)({
|
||||||
<Container maxWidth='sm' {...props} />
|
height: '100%',
|
||||||
))({
|
display: 'flex',
|
||||||
height: '100%',
|
flexDirection: 'column',
|
||||||
display: 'flex',
|
paddingBottom: '1rem',
|
||||||
flexDirection: 'column',
|
position: 'relative',
|
||||||
paddingBottom: '1rem',
|
'& > main': {
|
||||||
position: 'relative',
|
flex: 1,
|
||||||
'& > main': {
|
maxHeight: '100%',
|
||||||
flex: 1,
|
paddingTop: 'calc(66px + 1rem)',
|
||||||
maxHeight: '100%',
|
},
|
||||||
paddingTop: 'calc(66px + 1rem)',
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const StyledDivider = styled((props: DividerProps) => <Divider {...props} />)({
|
const StyledDivider = styled((props: DividerProps) => <Divider {...props} />)({
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: '66px',
|
top: '66px',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
left: 0,
|
left: 0,
|
||||||
height: '2px',
|
height: '2px',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,217 +2,223 @@ import { MetaEvent } from '@/types/meta-event'
|
|||||||
import Dexie from 'dexie'
|
import Dexie from 'dexie'
|
||||||
|
|
||||||
export interface DbKey {
|
export interface DbKey {
|
||||||
npub: string
|
npub: string
|
||||||
nip05?: string
|
nip05?: string
|
||||||
name?: string
|
name?: string
|
||||||
avatar?: string
|
avatar?: string
|
||||||
relays?: string[]
|
relays?: string[]
|
||||||
enckey: string
|
enckey: string
|
||||||
profile?: MetaEvent | null
|
profile?: MetaEvent | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DbApp {
|
export interface DbApp {
|
||||||
appNpub: string
|
appNpub: string
|
||||||
npub: string
|
npub: string
|
||||||
name: string
|
name: string
|
||||||
icon: string
|
icon: string
|
||||||
url: string
|
url: string
|
||||||
timestamp: number
|
timestamp: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DbPerm {
|
export interface DbPerm {
|
||||||
id: string
|
id: string
|
||||||
npub: string
|
npub: string
|
||||||
appNpub: string
|
appNpub: string
|
||||||
perm: string
|
perm: string
|
||||||
value: string
|
value: string
|
||||||
timestamp: number
|
timestamp: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DbPending {
|
export interface DbPending {
|
||||||
id: string
|
id: string
|
||||||
npub: string
|
npub: string
|
||||||
appNpub: string
|
appNpub: string
|
||||||
timestamp: number
|
timestamp: number
|
||||||
method: string
|
method: string
|
||||||
params: string
|
params: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DbHistory {
|
export interface DbHistory {
|
||||||
id: string
|
id: string
|
||||||
npub: string
|
npub: string
|
||||||
appNpub: string
|
appNpub: string
|
||||||
timestamp: number
|
timestamp: number
|
||||||
method: string
|
method: string
|
||||||
params: string
|
params: string
|
||||||
allowed: boolean
|
allowed: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DbSyncHistory {
|
export interface DbSyncHistory {
|
||||||
npub: string
|
npub: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DbSchema extends Dexie {
|
export interface DbSchema extends Dexie {
|
||||||
keys: Dexie.Table<DbKey, string>
|
keys: Dexie.Table<DbKey, string>
|
||||||
apps: Dexie.Table<DbApp, string>
|
apps: Dexie.Table<DbApp, string>
|
||||||
perms: Dexie.Table<DbPerm, string>
|
perms: Dexie.Table<DbPerm, string>
|
||||||
pending: Dexie.Table<DbPending, string>
|
pending: Dexie.Table<DbPending, string>
|
||||||
history: Dexie.Table<DbHistory, string>
|
history: Dexie.Table<DbHistory, string>
|
||||||
syncHistory: Dexie.Table<DbSyncHistory, string>
|
syncHistory: Dexie.Table<DbSyncHistory, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const db = new Dexie('noauthdb') as DbSchema
|
export const db = new Dexie('noauthdb') as DbSchema
|
||||||
|
|
||||||
db.version(8).stores({
|
db.version(8).stores({
|
||||||
keys: 'npub',
|
keys: 'npub',
|
||||||
apps: 'appNpub,npub,name,timestamp',
|
apps: 'appNpub,npub,name,timestamp',
|
||||||
perms: 'id,npub,appNpub,perm,value,timestamp',
|
perms: 'id,npub,appNpub,perm,value,timestamp',
|
||||||
pending: 'id,npub,appNpub,timestamp,method',
|
pending: 'id,npub,appNpub,timestamp,method',
|
||||||
history: 'id,npub,appNpub,timestamp,method,allowed',
|
history: 'id,npub,appNpub,timestamp,method,allowed',
|
||||||
requestHistory: 'id',
|
requestHistory: 'id',
|
||||||
syncHistory: 'npub',
|
syncHistory: 'npub',
|
||||||
})
|
})
|
||||||
|
|
||||||
export const dbi = {
|
export const dbi = {
|
||||||
addKey: async (key: DbKey) => {
|
addKey: async (key: DbKey) => {
|
||||||
try {
|
try {
|
||||||
await db.keys.add(key)
|
await db.keys.add(key)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(`db addKey error: ${error}`)
|
console.log(`db addKey error: ${error}`)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
listKeys: async (): Promise<DbKey[]> => {
|
listKeys: async (): Promise<DbKey[]> => {
|
||||||
try {
|
try {
|
||||||
return await db.keys.toArray()
|
return await db.keys.toArray()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(`db listKeys error: ${error}`)
|
console.log(`db listKeys error: ${error}`)
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
getApp: async (appNpub: string) => {
|
getApp: async (appNpub: string) => {
|
||||||
try {
|
try {
|
||||||
return await db.apps.get(appNpub)
|
return await db.apps.get(appNpub)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(`db getApp error: ${error}`)
|
console.log(`db getApp error: ${error}`)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
addApp: async (app: DbApp) => {
|
addApp: async (app: DbApp) => {
|
||||||
try {
|
try {
|
||||||
await db.apps.add(app)
|
await db.apps.add(app)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(`db addApp error: ${error}`)
|
console.log(`db addApp error: ${error}`)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
listApps: async (): Promise<DbApp[]> => {
|
updateApp: async (app: Omit<DbApp, 'npub' | 'timestamp'>) => {
|
||||||
try {
|
try {
|
||||||
return await db.apps.toArray()
|
await db.apps.where({ appNpub: app.appNpub }).modify({
|
||||||
} catch (error) {
|
name: app.name,
|
||||||
console.log(`db listApps error: ${error}`)
|
icon: app.icon,
|
||||||
return []
|
url: app.url,
|
||||||
}
|
})
|
||||||
},
|
} catch (error) {
|
||||||
removeApp: async (appNpub: string) => {
|
console.log(`db updateApp error: ${error}`)
|
||||||
try {
|
}
|
||||||
return await db.apps.delete(appNpub)
|
},
|
||||||
} catch (error) {
|
listApps: async (): Promise<DbApp[]> => {
|
||||||
console.log(`db removeApp error: ${error}`)
|
try {
|
||||||
}
|
return await db.apps.toArray()
|
||||||
},
|
} catch (error) {
|
||||||
addPerm: async (perm: DbPerm) => {
|
console.log(`db listApps error: ${error}`)
|
||||||
try {
|
return []
|
||||||
await db.perms.add(perm)
|
}
|
||||||
} catch (error) {
|
},
|
||||||
console.log(`db addPerm error: ${error}`)
|
removeApp: async (appNpub: string) => {
|
||||||
}
|
try {
|
||||||
},
|
return await db.apps.delete(appNpub)
|
||||||
listPerms: async (): Promise<DbPerm[]> => {
|
} catch (error) {
|
||||||
try {
|
console.log(`db removeApp error: ${error}`)
|
||||||
return await db.perms.toArray()
|
}
|
||||||
} catch (error) {
|
},
|
||||||
console.log(`db listPerms error: ${error}`)
|
addPerm: async (perm: DbPerm) => {
|
||||||
return []
|
try {
|
||||||
}
|
await db.perms.add(perm)
|
||||||
},
|
} catch (error) {
|
||||||
removePerm: async (id: string) => {
|
console.log(`db addPerm error: ${error}`)
|
||||||
try {
|
}
|
||||||
return await db.perms.delete(id)
|
},
|
||||||
} catch (error) {
|
listPerms: async (): Promise<DbPerm[]> => {
|
||||||
console.log(`db removePerm error: ${error}`)
|
try {
|
||||||
}
|
return await db.perms.toArray()
|
||||||
},
|
} catch (error) {
|
||||||
removeAppPerms: async (appNpub: string) => {
|
console.log(`db listPerms error: ${error}`)
|
||||||
try {
|
return []
|
||||||
return await db.perms.where({ appNpub }).delete()
|
}
|
||||||
} catch (error) {
|
},
|
||||||
console.log(`db removeAppPerms error: ${error}`)
|
removePerm: async (id: string) => {
|
||||||
}
|
try {
|
||||||
},
|
return await db.perms.delete(id)
|
||||||
addPending: async (r: DbPending) => {
|
} catch (error) {
|
||||||
try {
|
console.log(`db removePerm error: ${error}`)
|
||||||
return db.transaction('rw', db.pending, db.history, async () => {
|
}
|
||||||
const exists =
|
},
|
||||||
(await db.pending.where('id').equals(r.id).toArray())
|
removeAppPerms: async (appNpub: string) => {
|
||||||
.length > 0 ||
|
try {
|
||||||
(await db.history.where('id').equals(r.id).toArray())
|
return await db.perms.where({ appNpub }).delete()
|
||||||
.length > 0
|
} catch (error) {
|
||||||
if (exists) return false
|
console.log(`db removeAppPerms error: ${error}`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
addPending: async (r: DbPending) => {
|
||||||
|
try {
|
||||||
|
return db.transaction('rw', db.pending, db.history, async () => {
|
||||||
|
const exists =
|
||||||
|
(await db.pending.where('id').equals(r.id).toArray()).length > 0 ||
|
||||||
|
(await db.history.where('id').equals(r.id).toArray()).length > 0
|
||||||
|
if (exists) return false
|
||||||
|
|
||||||
await db.pending.add(r)
|
await db.pending.add(r)
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(`db addPending error: ${error}`)
|
console.log(`db addPending error: ${error}`)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
removePending: async (id: string) => {
|
removePending: async (id: string) => {
|
||||||
try {
|
try {
|
||||||
return await db.pending.delete(id)
|
return await db.pending.delete(id)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(`db removePending error: ${error}`)
|
console.log(`db removePending error: ${error}`)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
listPending: async (): Promise<DbPending[]> => {
|
listPending: async (): Promise<DbPending[]> => {
|
||||||
try {
|
try {
|
||||||
return await db.pending.toArray()
|
return await db.pending.toArray()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(`db listPending error: ${error}`)
|
console.log(`db listPending error: ${error}`)
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
confirmPending: async (id: string, allowed: boolean) => {
|
confirmPending: async (id: string, allowed: boolean) => {
|
||||||
try {
|
try {
|
||||||
db.transaction('rw', db.pending, db.history, async () => {
|
db.transaction('rw', db.pending, db.history, async () => {
|
||||||
const r: DbPending | undefined = await db.pending
|
const r: DbPending | undefined = await db.pending.where('id').equals(id).first()
|
||||||
.where('id')
|
if (!r) throw new Error('Pending not found ' + id)
|
||||||
.equals(id)
|
const h: DbHistory = {
|
||||||
.first()
|
...r,
|
||||||
if (!r) throw new Error('Pending not found ' + id)
|
allowed,
|
||||||
const h: DbHistory = {
|
}
|
||||||
...r,
|
await db.pending.delete(id)
|
||||||
allowed,
|
await db.history.add(h)
|
||||||
}
|
})
|
||||||
await db.pending.delete(id)
|
} catch (error) {
|
||||||
await db.history.add(h)
|
console.log(`db addPending error: ${error}`)
|
||||||
})
|
}
|
||||||
} catch (error) {
|
},
|
||||||
console.log(`db addPending error: ${error}`)
|
addConfirmed: async (r: DbHistory) => {
|
||||||
}
|
try {
|
||||||
},
|
await db.history.add(r)
|
||||||
addConfirmed: async (r: DbHistory) => {
|
} catch (error) {
|
||||||
try {
|
console.log(`db addConfirmed error: ${error}`)
|
||||||
await db.history.add(r)
|
return false
|
||||||
} catch (error) {
|
}
|
||||||
console.log(`db addConfirmed error: ${error}`)
|
},
|
||||||
return false
|
addSynced: async (npub: string) => {
|
||||||
}
|
try {
|
||||||
},
|
await db.syncHistory.add({ npub })
|
||||||
addSynced: async (npub: string) => {
|
} catch (error) {
|
||||||
try {
|
console.log(`db addSynced error: ${error}`)
|
||||||
await db.syncHistory.add({ npub })
|
return false
|
||||||
} catch (error) {
|
}
|
||||||
console.log(`db addSynced error: ${error}`)
|
},
|
||||||
return false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,123 +4,92 @@ ende stands for encryption decryption
|
|||||||
import { secp256k1 as secp } from '@noble/curves/secp256k1'
|
import { secp256k1 as secp } from '@noble/curves/secp256k1'
|
||||||
//import * as secp from "./vendor/secp256k1.js";
|
//import * as secp from "./vendor/secp256k1.js";
|
||||||
|
|
||||||
export async function encrypt(
|
export async function encrypt(publicKey: string, message: string, privateKey: string): Promise<string> {
|
||||||
publicKey: string,
|
const key = secp.getSharedSecret(privateKey, '02' + publicKey)
|
||||||
message: string,
|
const normalizedKey = getNormalizedX(key)
|
||||||
privateKey: string,
|
const encoder = new TextEncoder()
|
||||||
): Promise<string> {
|
const iv = Uint8Array.from(randomBytes(16))
|
||||||
const key = secp.getSharedSecret(privateKey, "02" + publicKey);
|
const plaintext = encoder.encode(message)
|
||||||
const normalizedKey = getNormalizedX(key);
|
const cryptoKey = await crypto.subtle.importKey('raw', normalizedKey, { name: 'AES-CBC' }, false, ['encrypt'])
|
||||||
const encoder = new TextEncoder();
|
const ciphertext = await crypto.subtle.encrypt({ name: 'AES-CBC', iv }, cryptoKey, plaintext)
|
||||||
const iv = Uint8Array.from(randomBytes(16));
|
|
||||||
const plaintext = encoder.encode(message);
|
|
||||||
const cryptoKey = await crypto.subtle.importKey(
|
|
||||||
"raw",
|
|
||||||
normalizedKey,
|
|
||||||
{ name: "AES-CBC" },
|
|
||||||
false,
|
|
||||||
["encrypt"],
|
|
||||||
);
|
|
||||||
const ciphertext = await crypto.subtle.encrypt(
|
|
||||||
{ name: "AES-CBC", iv },
|
|
||||||
cryptoKey,
|
|
||||||
plaintext,
|
|
||||||
);
|
|
||||||
|
|
||||||
const ctb64 = toBase64(new Uint8Array(ciphertext));
|
const ctb64 = toBase64(new Uint8Array(ciphertext))
|
||||||
const ivb64 = toBase64(new Uint8Array(iv.buffer));
|
const ivb64 = toBase64(new Uint8Array(iv.buffer))
|
||||||
return `${ctb64}?iv=${ivb64}`;
|
return `${ctb64}?iv=${ivb64}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function decrypt(
|
export async function decrypt(privateKey: string, publicKey: string, data: string): Promise<string | Error> {
|
||||||
privateKey: string,
|
const key = secp.getSharedSecret(privateKey, '02' + publicKey) // this line is very slow
|
||||||
publicKey: string,
|
return decrypt_with_shared_secret(data, key)
|
||||||
data: string,
|
|
||||||
): Promise<string | Error> {
|
|
||||||
const key = secp.getSharedSecret(privateKey, "02" + publicKey); // this line is very slow
|
|
||||||
return decrypt_with_shared_secret(data, key);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function decrypt_with_shared_secret(
|
export async function decrypt_with_shared_secret(data: string, sharedSecret: Uint8Array): Promise<string | Error> {
|
||||||
data: string,
|
const [ctb64, ivb64] = data.split('?iv=')
|
||||||
sharedSecret: Uint8Array,
|
const normalizedKey = getNormalizedX(sharedSecret)
|
||||||
): Promise<string | Error> {
|
|
||||||
const [ctb64, ivb64] = data.split("?iv=");
|
|
||||||
const normalizedKey = getNormalizedX(sharedSecret);
|
|
||||||
|
|
||||||
const cryptoKey = await crypto.subtle.importKey(
|
const cryptoKey = await crypto.subtle.importKey('raw', normalizedKey, { name: 'AES-CBC' }, false, ['decrypt'])
|
||||||
"raw",
|
let ciphertext: BufferSource
|
||||||
normalizedKey,
|
let iv: BufferSource
|
||||||
{ name: "AES-CBC" },
|
try {
|
||||||
false,
|
ciphertext = decodeBase64(ctb64)
|
||||||
["decrypt"],
|
iv = decodeBase64(ivb64)
|
||||||
);
|
} catch (e) {
|
||||||
let ciphertext: BufferSource;
|
return new Error(`failed to decode, ${e}`)
|
||||||
let iv: BufferSource;
|
}
|
||||||
try {
|
|
||||||
ciphertext = decodeBase64(ctb64);
|
|
||||||
iv = decodeBase64(ivb64);
|
|
||||||
} catch (e) {
|
|
||||||
return new Error(`failed to decode, ${e}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const plaintext = await crypto.subtle.decrypt(
|
const plaintext = await crypto.subtle.decrypt({ name: 'AES-CBC', iv }, cryptoKey, ciphertext)
|
||||||
{ name: "AES-CBC", iv },
|
const text = utf8Decode(plaintext)
|
||||||
cryptoKey,
|
return text
|
||||||
ciphertext,
|
} catch (e) {
|
||||||
);
|
return new Error(`failed to decrypt, ${e}`)
|
||||||
const text = utf8Decode(plaintext);
|
}
|
||||||
return text;
|
|
||||||
} catch (e) {
|
|
||||||
return new Error(`failed to decrypt, ${e}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function utf8Encode(str: string) {
|
export function utf8Encode(str: string) {
|
||||||
let encoder = new TextEncoder();
|
let encoder = new TextEncoder()
|
||||||
return encoder.encode(str);
|
return encoder.encode(str)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function utf8Decode(bin: Uint8Array | ArrayBuffer): string {
|
export function utf8Decode(bin: Uint8Array | ArrayBuffer): string {
|
||||||
let decoder = new TextDecoder();
|
let decoder = new TextDecoder()
|
||||||
return decoder.decode(bin);
|
return decoder.decode(bin)
|
||||||
}
|
}
|
||||||
|
|
||||||
function toBase64(uInt8Array: Uint8Array) {
|
function toBase64(uInt8Array: Uint8Array) {
|
||||||
let strChunks = new Array(uInt8Array.length);
|
let strChunks = new Array(uInt8Array.length)
|
||||||
let i = 0;
|
let i = 0
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
for (let byte of uInt8Array) {
|
for (let byte of uInt8Array) {
|
||||||
strChunks[i] = String.fromCharCode(byte); // bytes to utf16 string
|
strChunks[i] = String.fromCharCode(byte) // bytes to utf16 string
|
||||||
i++;
|
i++
|
||||||
}
|
}
|
||||||
return btoa(strChunks.join(""));
|
return btoa(strChunks.join(''))
|
||||||
}
|
}
|
||||||
|
|
||||||
function decodeBase64(base64String: string) {
|
function decodeBase64(base64String: string) {
|
||||||
const binaryString = atob(base64String);
|
const binaryString = atob(base64String)
|
||||||
const length = binaryString.length;
|
const length = binaryString.length
|
||||||
const bytes = new Uint8Array(length);
|
const bytes = new Uint8Array(length)
|
||||||
|
|
||||||
for (let i = 0; i < length; i++) {
|
for (let i = 0; i < length; i++) {
|
||||||
bytes[i] = binaryString.charCodeAt(i);
|
bytes[i] = binaryString.charCodeAt(i)
|
||||||
}
|
}
|
||||||
return bytes;
|
return bytes
|
||||||
}
|
}
|
||||||
|
|
||||||
function getNormalizedX(key: Uint8Array): Uint8Array {
|
function getNormalizedX(key: Uint8Array): Uint8Array {
|
||||||
return key.slice(1, 33);
|
return key.slice(1, 33)
|
||||||
}
|
}
|
||||||
|
|
||||||
function randomBytes(bytesLength: number = 32) {
|
function randomBytes(bytesLength: number = 32) {
|
||||||
return crypto.getRandomValues(new Uint8Array(bytesLength));
|
return crypto.getRandomValues(new Uint8Array(bytesLength))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function utf16Encode(str: string): number[] {
|
export function utf16Encode(str: string): number[] {
|
||||||
let array = new Array(str.length);
|
let array = new Array(str.length)
|
||||||
for (let i = 0; i < str.length; i++) {
|
for (let i = 0; i < str.length; i++) {
|
||||||
array[i] = str.charCodeAt(i);
|
array[i] = str.charCodeAt(i)
|
||||||
}
|
}
|
||||||
return array;
|
return array
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import crypto, { pbkdf2 } from 'crypto';
|
import crypto, { pbkdf2 } from 'crypto'
|
||||||
import { getPublicKey, nip19 } from 'nostr-tools';
|
import { getPublicKey, nip19 } from 'nostr-tools'
|
||||||
|
|
||||||
// encrypted keys have a prefix and version
|
// encrypted keys have a prefix and version
|
||||||
// so that we'd be able to switch to a better
|
// so that we'd be able to switch to a better
|
||||||
@@ -17,14 +17,34 @@ const ITERATIONS_PWH = 100000
|
|||||||
const HASH_SIZE = 32
|
const HASH_SIZE = 32
|
||||||
const HASH_ALGO = 'sha256'
|
const HASH_ALGO = 'sha256'
|
||||||
// encryption
|
// encryption
|
||||||
const ALGO = 'aes-256-cbc';
|
const ALGO = 'aes-256-cbc'
|
||||||
const IV_SIZE = 16
|
const IV_SIZE = 16
|
||||||
|
|
||||||
// valid passwords are a limited ASCII only, see notes below
|
// valid passwords are a limited ASCII only, see notes below
|
||||||
const ASCII_REGEX = /^[A-Za-z0-9!@#$%^&*()]{4,}$/
|
const ASCII_REGEX = /^[A-Za-z0-9!@#$%^&*()\-_]{6,}$/
|
||||||
|
|
||||||
const ALGO_LOCAL = 'AES-CBC';
|
const ALGO_LOCAL = 'AES-CBC'
|
||||||
const KEY_SIZE_LOCAL = 256;
|
const KEY_SIZE_LOCAL = 256
|
||||||
|
|
||||||
|
export function isValidPassphase(passphrase: string): boolean {
|
||||||
|
return ASCII_REGEX.test(passphrase)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWeakPassphase(passphrase: string): boolean {
|
||||||
|
const BIG_LETTER_REGEX = /[A-Z]+/
|
||||||
|
const SMALL_LETTER_REGEX = /[a-z]+/
|
||||||
|
const NUMBER_REGEX = /[0-9]+/
|
||||||
|
const PUNCT_REGEX = /[!@#$%^&*()\-_]+/
|
||||||
|
const big = BIG_LETTER_REGEX.test(passphrase) ? 1 : 0
|
||||||
|
const small = SMALL_LETTER_REGEX.test(passphrase) ? 1 : 0
|
||||||
|
const number = NUMBER_REGEX.test(passphrase) ? 1 : 0
|
||||||
|
const punct = PUNCT_REGEX.test(passphrase) ? 1 : 0
|
||||||
|
const base = big * 26 + small * 26 + number * 10 + punct * 12
|
||||||
|
const compl = Math.pow(base, passphrase.length)
|
||||||
|
const thresh = Math.pow(11, 14)
|
||||||
|
// console.log({ big, small, number, punct, base, compl, thresh });
|
||||||
|
return compl < thresh;
|
||||||
|
}
|
||||||
|
|
||||||
export class Keys {
|
export class Keys {
|
||||||
subtle: any
|
subtle: any
|
||||||
@@ -33,13 +53,7 @@ export class Keys {
|
|||||||
this.subtle = cryptoSubtle
|
this.subtle = cryptoSubtle
|
||||||
}
|
}
|
||||||
|
|
||||||
public isValidPassphase(passphrase: string): boolean {
|
public async generatePassKey(pubkey: string, passphrase: string): Promise<{ passkey: Buffer; pwh: string }> {
|
||||||
return ASCII_REGEX.test(passphrase)
|
|
||||||
}
|
|
||||||
|
|
||||||
public async generatePassKey(pubkey: string, passphrase: string)
|
|
||||||
: Promise<{ passkey: Buffer, pwh: string }> {
|
|
||||||
|
|
||||||
const salt = Buffer.from(pubkey, 'hex')
|
const salt = Buffer.from(pubkey, 'hex')
|
||||||
|
|
||||||
// https://nodejs.org/api/crypto.html#using-strings-as-inputs-to-cryptographic-apis
|
// https://nodejs.org/api/crypto.html#using-strings-as-inputs-to-cryptographic-apis
|
||||||
@@ -47,7 +61,7 @@ export class Keys {
|
|||||||
// We could use string.normalize() to make sure all JS implementations
|
// We could use string.normalize() to make sure all JS implementations
|
||||||
// are compatible, but since we're looking to make this thing a standard
|
// are compatible, but since we're looking to make this thing a standard
|
||||||
// then the simplest way is to exclude unicode and only work with ASCII
|
// then the simplest way is to exclude unicode and only work with ASCII
|
||||||
if (!this.isValidPassphase(passphrase)) throw new Error("Password must be 4+ ASCII chars")
|
if (!isValidPassphase(passphrase)) throw new Error('Password must be 4+ ASCII chars')
|
||||||
|
|
||||||
return new Promise((ok, fail) => {
|
return new Promise((ok, fail) => {
|
||||||
// NOTE: we should use Argon2 or scrypt later, for now
|
// NOTE: we should use Argon2 or scrypt later, for now
|
||||||
@@ -57,7 +71,11 @@ export class Keys {
|
|||||||
else {
|
else {
|
||||||
pbkdf2(key, passphrase, ITERATIONS_PWH, HASH_SIZE, HASH_ALGO, (err, hash) => {
|
pbkdf2(key, passphrase, ITERATIONS_PWH, HASH_SIZE, HASH_ALGO, (err, hash) => {
|
||||||
if (err) fail(err)
|
if (err) fail(err)
|
||||||
else ok({ passkey: key, pwh: hash.toString('hex') })
|
else
|
||||||
|
ok({
|
||||||
|
passkey: key,
|
||||||
|
pwh: hash.toString('hex'),
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -65,8 +83,8 @@ export class Keys {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private isSafari() {
|
private isSafari() {
|
||||||
const chrome = navigator.userAgent.indexOf("Chrome") > -1;
|
const chrome = navigator.userAgent.indexOf('Chrome') > -1
|
||||||
const safari = navigator.userAgent.indexOf("Safari") > -1;
|
const safari = navigator.userAgent.indexOf('Safari') > -1
|
||||||
return safari && !chrome
|
return safari && !chrome
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,8 +99,8 @@ export class Keys {
|
|||||||
{ name: ALGO_LOCAL, length: KEY_SIZE_LOCAL },
|
{ name: ALGO_LOCAL, length: KEY_SIZE_LOCAL },
|
||||||
// NOTE: important to make sure it's not visible in
|
// NOTE: important to make sure it's not visible in
|
||||||
// dev console in IndexedDB
|
// dev console in IndexedDB
|
||||||
/*extractable*/false,
|
/*extractable*/ false,
|
||||||
["encrypt", "decrypt"]
|
['encrypt', 'decrypt']
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,25 +112,30 @@ export class Keys {
|
|||||||
return `${PREFIX_LOCAL}:${VERSION_LOCAL}:${iv.toString('hex')}:${Buffer.from(encrypted).toString('hex')}}`
|
return `${PREFIX_LOCAL}:${VERSION_LOCAL}:${iv.toString('hex')}:${Buffer.from(encrypted).toString('hex')}}`
|
||||||
}
|
}
|
||||||
|
|
||||||
public async decryptKeyLocal({ enckey, localKey }: { enckey: string, localKey: CryptoKey | {} }): Promise<string> {
|
public async decryptKeyLocal({ enckey, localKey }: { enckey: string; localKey: CryptoKey | {} }): Promise<string> {
|
||||||
if (this.isSafari()) return enckey
|
if (this.isSafari()) return enckey
|
||||||
const parts = enckey.split(':')
|
const parts = enckey.split(':')
|
||||||
if (parts.length !== 4) throw new Error("Bad encrypted key")
|
if (parts.length !== 4) throw new Error('Bad encrypted key')
|
||||||
if (parts[0] !== PREFIX_LOCAL) throw new Error("Bad encrypted key prefix")
|
if (parts[0] !== PREFIX_LOCAL) throw new Error('Bad encrypted key prefix')
|
||||||
if (parts[1] !== VERSION_LOCAL) throw new Error("Bad encrypted key version")
|
if (parts[1] !== VERSION_LOCAL) throw new Error('Bad encrypted key version')
|
||||||
if (parts[2].length !== IV_SIZE * 2) throw new Error("Bad encrypted key iv")
|
if (parts[2].length !== IV_SIZE * 2) throw new Error('Bad encrypted key iv')
|
||||||
if (parts[3].length < 30) throw new Error("Bad encrypted key data")
|
if (parts[3].length < 30) throw new Error('Bad encrypted key data')
|
||||||
const iv = Buffer.from(parts[2], 'hex');
|
const iv = Buffer.from(parts[2], 'hex')
|
||||||
const data = Buffer.from(parts[3], 'hex');
|
const data = Buffer.from(parts[3], 'hex')
|
||||||
const decrypted = await this.subtle.decrypt({ name: ALGO_LOCAL, iv }, localKey, data)
|
const decrypted = await this.subtle.decrypt({ name: ALGO_LOCAL, iv }, localKey, data)
|
||||||
const { type, data: value } = nip19.decode(Buffer.from(decrypted).toString())
|
const { type, data: value } = nip19.decode(Buffer.from(decrypted).toString())
|
||||||
if (type !== "nsec") throw new Error("Bad encrypted key payload type")
|
if (type !== 'nsec') throw new Error('Bad encrypted key payload type')
|
||||||
if ((value as string).length !== 64) throw new Error("Bad encrypted key payload length")
|
if ((value as string).length !== 64) throw new Error('Bad encrypted key payload length')
|
||||||
return (value as string)
|
return value as string
|
||||||
}
|
}
|
||||||
|
|
||||||
public async encryptKeyPass({ key, passphrase }: { key: string, passphrase: string })
|
public async encryptKeyPass({
|
||||||
: Promise<{ enckey: string, pwh: string }> {
|
key,
|
||||||
|
passphrase,
|
||||||
|
}: {
|
||||||
|
key: string
|
||||||
|
passphrase: string
|
||||||
|
}): Promise<{ enckey: string; pwh: string }> {
|
||||||
const start = Date.now()
|
const start = Date.now()
|
||||||
const nsec = nip19.nsecEncode(key)
|
const nsec = nip19.nsecEncode(key)
|
||||||
const pubkey = getPublicKey(key)
|
const pubkey = getPublicKey(key)
|
||||||
@@ -120,21 +143,29 @@ export class Keys {
|
|||||||
const iv = crypto.randomBytes(IV_SIZE)
|
const iv = crypto.randomBytes(IV_SIZE)
|
||||||
const cipher = crypto.createCipheriv(ALGO, passkey, iv)
|
const cipher = crypto.createCipheriv(ALGO, passkey, iv)
|
||||||
const encrypted = Buffer.concat([cipher.update(nsec), cipher.final()])
|
const encrypted = Buffer.concat([cipher.update(nsec), cipher.final()])
|
||||||
console.log("encrypted key in ", Date.now() - start)
|
console.log('encrypted key in ', Date.now() - start)
|
||||||
return {
|
return {
|
||||||
enckey: `${PREFIX}:${VERSION}:${iv.toString('hex')}:${encrypted.toString('hex')}}`,
|
enckey: `${PREFIX}:${VERSION}:${iv.toString('hex')}:${encrypted.toString('hex')}`,
|
||||||
pwh
|
pwh,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async decryptKeyPass({ pubkey, enckey, passphrase }: { pubkey: string, enckey: string, passphrase: string }): Promise<string> {
|
public async decryptKeyPass({
|
||||||
|
pubkey,
|
||||||
|
enckey,
|
||||||
|
passphrase,
|
||||||
|
}: {
|
||||||
|
pubkey: string
|
||||||
|
enckey: string
|
||||||
|
passphrase: string
|
||||||
|
}): Promise<string> {
|
||||||
const start = Date.now()
|
const start = Date.now()
|
||||||
const parts = enckey.split(':')
|
const parts = enckey.split(':')
|
||||||
if (parts.length !== 4) throw new Error("Bad encrypted key")
|
if (parts.length !== 4) throw new Error('Bad encrypted key')
|
||||||
if (parts[0] !== PREFIX) throw new Error("Bad encrypted key prefix")
|
if (parts[0] !== PREFIX) throw new Error('Bad encrypted key prefix')
|
||||||
if (parts[1] !== VERSION) throw new Error("Bad encrypted key version")
|
if (parts[1] !== VERSION) throw new Error('Bad encrypted key version')
|
||||||
if (parts[2].length !== IV_SIZE * 2) throw new Error("Bad encrypted key iv")
|
if (parts[2].length !== IV_SIZE * 2) throw new Error('Bad encrypted key iv')
|
||||||
if (parts[3].length < 30) throw new Error("Bad encrypted key data")
|
if (parts[3].length < 30) throw new Error('Bad encrypted key data')
|
||||||
const { passkey } = await this.generatePassKey(pubkey, passphrase)
|
const { passkey } = await this.generatePassKey(pubkey, passphrase)
|
||||||
const iv = Buffer.from(parts[2], 'hex')
|
const iv = Buffer.from(parts[2], 'hex')
|
||||||
const data = Buffer.from(parts[3], 'hex')
|
const data = Buffer.from(parts[3], 'hex')
|
||||||
@@ -142,9 +173,9 @@ export class Keys {
|
|||||||
const decrypted = Buffer.concat([decipher.update(data), decipher.final()])
|
const decrypted = Buffer.concat([decipher.update(data), decipher.final()])
|
||||||
const nsec = decrypted.toString()
|
const nsec = decrypted.toString()
|
||||||
const { type, data: value } = nip19.decode(nsec)
|
const { type, data: value } = nip19.decode(nsec)
|
||||||
if (type !== "nsec") throw new Error("Bad encrypted key payload type")
|
if (type !== 'nsec') throw new Error('Bad encrypted key payload type')
|
||||||
if (value.length !== 64) throw new Error("Bad encrypted key payload length")
|
if (value.length !== 64) throw new Error('Bad encrypted key payload length')
|
||||||
console.log("decrypted key in ", Date.now() - start)
|
console.log('decrypted key in ', Date.now() - start)
|
||||||
return nsec;
|
return nsec
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,25 +7,25 @@ export const utf8Decoder = new TextDecoder('utf-8')
|
|||||||
export const utf8Encoder = new TextEncoder()
|
export const utf8Encoder = new TextEncoder()
|
||||||
|
|
||||||
function toBase64(uInt8Array: Uint8Array) {
|
function toBase64(uInt8Array: Uint8Array) {
|
||||||
let strChunks = new Array(uInt8Array.length);
|
let strChunks = new Array(uInt8Array.length)
|
||||||
let i = 0;
|
let i = 0
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
for (let byte of uInt8Array) {
|
for (let byte of uInt8Array) {
|
||||||
strChunks[i] = String.fromCharCode(byte); // bytes to utf16 string
|
strChunks[i] = String.fromCharCode(byte) // bytes to utf16 string
|
||||||
i++;
|
i++
|
||||||
}
|
}
|
||||||
return btoa(strChunks.join(""));
|
return btoa(strChunks.join(''))
|
||||||
}
|
}
|
||||||
|
|
||||||
function fromBase64(base64String: string) {
|
function fromBase64(base64String: string) {
|
||||||
const binaryString = atob(base64String);
|
const binaryString = atob(base64String)
|
||||||
const length = binaryString.length;
|
const length = binaryString.length
|
||||||
const bytes = new Uint8Array(length);
|
const bytes = new Uint8Array(length)
|
||||||
|
|
||||||
for (let i = 0; i < length; i++) {
|
for (let i = 0; i < length; i++) {
|
||||||
bytes[i] = binaryString.charCodeAt(i);
|
bytes[i] = binaryString.charCodeAt(i)
|
||||||
}
|
}
|
||||||
return bytes;
|
return bytes
|
||||||
}
|
}
|
||||||
|
|
||||||
function getNormalizedX(key: Uint8Array): Uint8Array {
|
function getNormalizedX(key: Uint8Array): Uint8Array {
|
||||||
@@ -65,7 +65,7 @@ export class Nip04 {
|
|||||||
// let ctb64 = toBase64(new Uint8Array(ciphertext))
|
// let ctb64 = toBase64(new Uint8Array(ciphertext))
|
||||||
// let ivb64 = toBase64(new Uint8Array(iv.buffer))
|
// let ivb64 = toBase64(new Uint8Array(iv.buffer))
|
||||||
|
|
||||||
console.log("nip04_encrypt", text, "t1", t2 - t1, "t2", t3 - t2, "t3", Date.now() - t3)
|
console.log('nip04_encrypt', text, 't1', t2 - t1, 't2', t3 - t2, 't3', Date.now() - t3)
|
||||||
|
|
||||||
return `${ctb64}?iv=${ivb64}`
|
return `${ctb64}?iv=${ivb64}`
|
||||||
}
|
}
|
||||||
@@ -85,7 +85,4 @@ export class Nip04 {
|
|||||||
let text = utf8Decoder.decode(plaintext)
|
let text = utf8Decoder.decode(plaintext)
|
||||||
return text
|
return text
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,91 +5,79 @@ import NDK, { NDKEvent, NostrEvent } from '@nostr-dev-kit/ndk'
|
|||||||
import { nip19 } from 'nostr-tools'
|
import { nip19 } from 'nostr-tools'
|
||||||
|
|
||||||
export const ndk = new NDK({
|
export const ndk = new NDK({
|
||||||
explicitRelayUrls: [
|
explicitRelayUrls: ['wss://relay.nostr.band/all', 'wss://relay.nostr.band', 'wss://relay.damus.io', 'wss://nos.lol'],
|
||||||
'wss://relay.nostr.band/all',
|
|
||||||
'wss://relay.nostr.band',
|
|
||||||
'wss://relay.damus.io',
|
|
||||||
'wss://nos.lol',
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export function nostrEvent(e: Required<NDKEvent>) {
|
export function nostrEvent(e: Required<NDKEvent>) {
|
||||||
return {
|
return {
|
||||||
id: e.id,
|
id: e.id,
|
||||||
created_at: e.created_at,
|
created_at: e.created_at,
|
||||||
pubkey: e.pubkey,
|
pubkey: e.pubkey,
|
||||||
kind: e.kind,
|
kind: e.kind,
|
||||||
tags: e.tags,
|
tags: e.tags,
|
||||||
content: e.content,
|
content: e.content,
|
||||||
sig: e.sig,
|
sig: e.sig,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function rawEvent(e: Required<NDKEvent>): AugmentedEvent {
|
function rawEvent(e: Required<NDKEvent>): AugmentedEvent {
|
||||||
return {
|
return {
|
||||||
...nostrEvent(e),
|
...nostrEvent(e),
|
||||||
identifier: getTagValue(e as NDKEvent, 'd'),
|
identifier: getTagValue(e as NDKEvent, 'd'),
|
||||||
order: e.created_at as number,
|
order: e.created_at as number,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseContentJson(c: string): object {
|
function parseContentJson(c: string): object {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(c)
|
return JSON.parse(c)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log('Bad json: ', c, e)
|
console.log('Bad json: ', c, e)
|
||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTags(
|
export function getTags(e: AugmentedEvent | NDKEvent | MetaEvent, name: string): string[][] {
|
||||||
e: AugmentedEvent | NDKEvent | MetaEvent,
|
return e.tags.filter((t: string[]) => t.length > 0 && t[0] === name)
|
||||||
name: string,
|
|
||||||
): string[][] {
|
|
||||||
return e.tags.filter((t: string[]) => t.length > 0 && t[0] === name)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTag(
|
export function getTag(e: AugmentedEvent | NDKEvent | MetaEvent, name: string): string[] | null {
|
||||||
e: AugmentedEvent | NDKEvent | MetaEvent,
|
const tags = getTags(e, name)
|
||||||
name: string,
|
if (tags.length === 0) return null
|
||||||
): string[] | null {
|
return tags[0]
|
||||||
const tags = getTags(e, name)
|
|
||||||
if (tags.length === 0) return null
|
|
||||||
return tags[0]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTagValue(
|
export function getTagValue(
|
||||||
e: AugmentedEvent | NDKEvent | MetaEvent,
|
e: AugmentedEvent | NDKEvent | MetaEvent,
|
||||||
name: string,
|
name: string,
|
||||||
index: number = 0,
|
index: number = 0,
|
||||||
def: string = '',
|
def: string = ''
|
||||||
): string {
|
): string {
|
||||||
const tag = getTag(e, name)
|
const tag = getTag(e, name)
|
||||||
if (tag === null || !tag.length || (index && index >= tag.length))
|
if (tag === null || !tag.length || (index && index >= tag.length)) return def
|
||||||
return def
|
return tag[1 + index]
|
||||||
return tag[1 + index]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseProfileJson(e: NostrEvent): Meta {
|
export function parseProfileJson(e: NostrEvent): Meta {
|
||||||
// all meta fields are optional so 'as' works fine
|
// all meta fields are optional so 'as' works fine
|
||||||
const profile = createMeta(parseContentJson(e.content))
|
const profile = createMeta(parseContentJson(e.content))
|
||||||
profile.pubkey = e.pubkey
|
profile.pubkey = e.pubkey
|
||||||
profile.npub = nip19.npubEncode(e.pubkey)
|
profile.npub = nip19.npubEncode(e.pubkey)
|
||||||
return profile
|
return profile
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchProfile(npub: string): Promise<MetaEvent | null> {
|
export async function fetchProfile(npub: string): Promise<MetaEvent | null> {
|
||||||
const npubToken = npub.includes('#') ? npub.split('#')[0] : npub
|
const npubToken = npub.includes('#') ? npub.split('#')[0] : npub
|
||||||
const { type, data: pubkey } = nip19.decode(npubToken)
|
const { type, data: pubkey } = nip19.decode(npubToken)
|
||||||
if (type !== 'npub') return null
|
if (type !== 'npub') return null
|
||||||
|
|
||||||
const event = await ndk.fetchEvent({ kinds: [0], authors: [pubkey] })
|
const event = await ndk.fetchEvent({ kinds: [0], authors: [pubkey] })
|
||||||
|
|
||||||
if (event) {
|
if (event) {
|
||||||
const augmentedEvent = rawEvent(event as Required<NDKEvent>)
|
const augmentedEvent = rawEvent(event as Required<NDKEvent>)
|
||||||
const m = createMetaEvent(augmentedEvent)
|
const m = createMetaEvent(augmentedEvent)
|
||||||
m.info = parseProfileJson(m)
|
m.info = parseProfileJson(m)
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
return event
|
return event
|
||||||
}
|
}
|
||||||
|
|||||||
51
src/modules/pow.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
// based on https://git.v0l.io/Kieran/snort/src/branch/main/packages/system/src/pow-util.ts
|
||||||
|
|
||||||
|
import { sha256 } from '@noble/hashes/sha256'
|
||||||
|
import { bytesToHex } from '@noble/hashes/utils'
|
||||||
|
|
||||||
|
export interface NostrPowEvent {
|
||||||
|
id?: string
|
||||||
|
pubkey: string
|
||||||
|
created_at: number
|
||||||
|
kind?: number
|
||||||
|
tags: Array<Array<string>>
|
||||||
|
content: string
|
||||||
|
sig?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function minePow(e: NostrPowEvent, target: number) {
|
||||||
|
let ctr = 0
|
||||||
|
|
||||||
|
let nonceTagIdx = e.tags.findIndex((a) => a[0] === 'nonce')
|
||||||
|
if (nonceTagIdx === -1) {
|
||||||
|
nonceTagIdx = e.tags.length
|
||||||
|
e.tags.push(['nonce', ctr.toString(), target.toString()])
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
e.tags[nonceTagIdx][1] = (++ctr).toString()
|
||||||
|
e.id = createId(e)
|
||||||
|
} while (countLeadingZeros(e.id) < target)
|
||||||
|
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
function createId(e: NostrPowEvent) {
|
||||||
|
const payload = [0, e.pubkey, e.created_at, e.kind, e.tags, e.content]
|
||||||
|
return bytesToHex(sha256(JSON.stringify(payload)))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countLeadingZeros(hex: string) {
|
||||||
|
let count = 0
|
||||||
|
|
||||||
|
for (let i = 0; i < hex.length; i++) {
|
||||||
|
const nibble = parseInt(hex[i], 16)
|
||||||
|
if (nibble === 0) {
|
||||||
|
count += 4
|
||||||
|
} else {
|
||||||
|
count += Math.clz32(nibble) - 28
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return count
|
||||||
|
}
|
||||||
@@ -8,67 +8,63 @@ import { Nip04 } from './nip04'
|
|||||||
//import { decrypt, encrypt } from "./ende";
|
//import { decrypt, encrypt } from "./ende";
|
||||||
|
|
||||||
export class PrivateKeySigner implements NDKSigner {
|
export class PrivateKeySigner implements NDKSigner {
|
||||||
private _user: NDKUser | undefined
|
private _user: NDKUser | undefined
|
||||||
privateKey?: string
|
privateKey?: string
|
||||||
private nip04: Nip04
|
private nip04: Nip04
|
||||||
|
|
||||||
public constructor(privateKey?: string) {
|
public constructor(privateKey?: string) {
|
||||||
if (privateKey) {
|
if (privateKey) {
|
||||||
this.privateKey = privateKey
|
this.privateKey = privateKey
|
||||||
this._user = new NDKUser({
|
this._user = new NDKUser({
|
||||||
hexpubkey: getPublicKey(this.privateKey),
|
hexpubkey: getPublicKey(this.privateKey),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
this.nip04 = new Nip04()
|
this.nip04 = new Nip04()
|
||||||
}
|
}
|
||||||
|
|
||||||
public static generate() {
|
public static generate() {
|
||||||
const privateKey = generatePrivateKey()
|
const privateKey = generatePrivateKey()
|
||||||
return new PrivateKeySigner(privateKey)
|
return new PrivateKeySigner(privateKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
public async blockUntilReady(): Promise<NDKUser> {
|
public async blockUntilReady(): Promise<NDKUser> {
|
||||||
if (!this._user) {
|
if (!this._user) {
|
||||||
throw new Error('NDKUser not initialized')
|
throw new Error('NDKUser not initialized')
|
||||||
}
|
}
|
||||||
return this._user
|
return this._user
|
||||||
}
|
}
|
||||||
|
|
||||||
public async user(): Promise<NDKUser> {
|
public async user(): Promise<NDKUser> {
|
||||||
await this.blockUntilReady()
|
await this.blockUntilReady()
|
||||||
return this._user as NDKUser
|
return this._user as NDKUser
|
||||||
}
|
}
|
||||||
|
|
||||||
public async sign(event: NostrEvent): Promise<string> {
|
public async sign(event: NostrEvent): Promise<string> {
|
||||||
if (!this.privateKey) {
|
if (!this.privateKey) {
|
||||||
throw Error('Attempted to sign without a private key')
|
throw Error('Attempted to sign without a private key')
|
||||||
}
|
}
|
||||||
|
|
||||||
return getSignature(event as UnsignedEvent, this.privateKey)
|
return getSignature(event as UnsignedEvent, this.privateKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
public async encrypt(recipient: NDKUser, value: string): Promise<string> {
|
public async encrypt(recipient: NDKUser, value: string): Promise<string> {
|
||||||
if (!this.privateKey) {
|
if (!this.privateKey) {
|
||||||
throw Error('Attempted to encrypt without a private key')
|
throw Error('Attempted to encrypt without a private key')
|
||||||
}
|
}
|
||||||
|
|
||||||
const recipientHexPubKey = recipient.hexpubkey
|
const recipientHexPubKey = recipient.hexpubkey
|
||||||
return await this.nip04.encrypt(
|
return await this.nip04.encrypt(this.privateKey, recipientHexPubKey, value)
|
||||||
this.privateKey,
|
// return await encrypt(recipientHexPubKey, value, this.privateKey);
|
||||||
recipientHexPubKey,
|
}
|
||||||
value,
|
|
||||||
)
|
|
||||||
// return await encrypt(recipientHexPubKey, value, this.privateKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async decrypt(sender: NDKUser, value: string): Promise<string> {
|
public async decrypt(sender: NDKUser, value: string): Promise<string> {
|
||||||
if (!this.privateKey) {
|
if (!this.privateKey) {
|
||||||
throw Error('Attempted to decrypt without a private key')
|
throw Error('Attempted to decrypt without a private key')
|
||||||
}
|
}
|
||||||
|
|
||||||
const senderHexPubKey = sender.hexpubkey
|
const senderHexPubKey = sender.hexpubkey
|
||||||
// console.log("nip04_decrypt", value)
|
// console.log("nip04_decrypt", value)
|
||||||
return await this.nip04.decrypt(this.privateKey, senderHexPubKey, value)
|
return await this.nip04.decrypt(this.privateKey, senderHexPubKey, value)
|
||||||
// return await decrypt(this.privateKey, senderHexPubKey, value) as string;
|
// return await decrypt(this.privateKey, senderHexPubKey, value) as string;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,66 +5,91 @@ export let swr: ServiceWorkerRegistration | null = null
|
|||||||
const reqs = new Map<number, { ok: (r: any) => void; rej: (r: any) => void }>()
|
const reqs = new Map<number, { ok: (r: any) => void; rej: (r: any) => void }>()
|
||||||
let nextReqId = 1
|
let nextReqId = 1
|
||||||
let onRender: (() => void) | null = null
|
let onRender: (() => void) | null = null
|
||||||
|
const queue: (() => Promise<void> | void)[] = []
|
||||||
|
|
||||||
export async function swicRegister() {
|
export async function swicRegister() {
|
||||||
serviceWorkerRegistration.register({
|
serviceWorkerRegistration.register({
|
||||||
onSuccess(registration) {
|
onSuccess(registration) {
|
||||||
console.log('sw registered')
|
console.log('sw registered')
|
||||||
swr = registration
|
swr = registration
|
||||||
},
|
},
|
||||||
onError(e) {
|
onError(e) {
|
||||||
console.log(`error ${e}`)
|
console.log(`error ${e}`)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
navigator.serviceWorker.ready.then((r) => (swr = r))
|
navigator.serviceWorker.ready.then(async (r) => {
|
||||||
|
console.log('sw ready, queue', queue.length)
|
||||||
|
swr = r
|
||||||
|
if (navigator.serviceWorker.controller) {
|
||||||
|
console.log(`This page is currently controlled by: ${navigator.serviceWorker.controller}`)
|
||||||
|
} else {
|
||||||
|
console.log('This page is not currently controlled by a service worker.')
|
||||||
|
}
|
||||||
|
|
||||||
navigator.serviceWorker.addEventListener('message', (event) => {
|
while (queue.length)
|
||||||
onMessage((event as MessageEvent).data)
|
await (queue.shift()!)()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
navigator.serviceWorker.addEventListener('message', (event) => {
|
||||||
|
onMessage((event as MessageEvent).data)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function swicWaitStarted() {
|
||||||
|
return new Promise<void>(ok => {
|
||||||
|
if (swr && swr.active) ok()
|
||||||
|
else queue.push(ok)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function onMessage(data: any) {
|
function onMessage(data: any) {
|
||||||
const { id, result, error } = data
|
const { id, result, error } = data
|
||||||
console.log('SW message', id, result, error)
|
console.log('SW message', id, result, error)
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
if (onRender) onRender()
|
if (onRender) onRender()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const r = reqs.get(id)
|
const r = reqs.get(id)
|
||||||
if (!r) {
|
if (!r) {
|
||||||
console.log('Unexpected message from service worker', data)
|
console.log('Unexpected message from service worker', data)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
reqs.delete(id)
|
reqs.delete(id)
|
||||||
if (error) r.rej(error)
|
if (error) r.rej(error)
|
||||||
else r.ok(result)
|
else r.ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function swicCall(method: string, ...args: any[]) {
|
export async function swicCall(method: string, ...args: any[]) {
|
||||||
const id = nextReqId
|
const id = nextReqId
|
||||||
nextReqId++
|
nextReqId++
|
||||||
|
|
||||||
return new Promise((ok, rej) => {
|
return new Promise((ok, rej) => {
|
||||||
if (!swr || !swr.active) {
|
|
||||||
rej(new Error('No active service worker'))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
reqs.set(id, { ok, rej })
|
const call = async () => {
|
||||||
const msg = {
|
if (!swr || !swr.active) {
|
||||||
id,
|
rej(new Error('No active service worker'))
|
||||||
method,
|
return
|
||||||
args: [...args],
|
}
|
||||||
}
|
|
||||||
console.log('sending to SW', msg)
|
reqs.set(id, { ok, rej })
|
||||||
swr.active.postMessage(msg)
|
const msg = {
|
||||||
})
|
id,
|
||||||
|
method,
|
||||||
|
args: [...args],
|
||||||
|
}
|
||||||
|
console.log('sending to SW', msg)
|
||||||
|
swr.active.postMessage(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (swr && swr.active) call()
|
||||||
|
else queue.push(call)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function swicOnRender(cb: () => void) {
|
export function swicOnRender(cb: () => void) {
|
||||||
onRender = cb
|
onRender = cb
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,15 +4,15 @@ import { darkTheme, lightTheme } from './theme'
|
|||||||
import { useAppSelector } from '../../store/hooks/redux'
|
import { useAppSelector } from '../../store/hooks/redux'
|
||||||
|
|
||||||
const ThemeProvider: FC<PropsWithChildren> = ({ children }) => {
|
const ThemeProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||||
const themeMode = useAppSelector((state) => state.ui.themeMode)
|
const themeMode = useAppSelector((state) => state.ui.themeMode)
|
||||||
const isDarkMode = themeMode === 'dark'
|
const isDarkMode = themeMode === 'dark'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeMuiProvider theme={isDarkMode ? darkTheme : lightTheme}>
|
<ThemeMuiProvider theme={isDarkMode ? darkTheme : lightTheme}>
|
||||||
<CssBaseline />
|
<CssBaseline />
|
||||||
{children}
|
{children}
|
||||||
</ThemeMuiProvider>
|
</ThemeMuiProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ThemeProvider
|
export default ThemeProvider
|
||||||
|
|||||||
@@ -1,99 +1,99 @@
|
|||||||
import { createTheme, Theme } from '@mui/material'
|
import { createTheme, Theme } from '@mui/material'
|
||||||
|
|
||||||
declare module '@mui/material/styles' {
|
declare module '@mui/material/styles' {
|
||||||
interface Palette {
|
interface Palette {
|
||||||
textSecondaryDecorate: Palette['primary']
|
textSecondaryDecorate: Palette['primary']
|
||||||
backgroundSecondary: Palette['background']
|
backgroundSecondary: Palette['background']
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PaletteOptions {
|
interface PaletteOptions {
|
||||||
textSecondaryDecorate?: Palette['primary']
|
textSecondaryDecorate?: Palette['primary']
|
||||||
backgroundSecondary?: Palette['background']
|
backgroundSecondary?: Palette['background']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const commonTheme: Theme = createTheme({
|
const commonTheme: Theme = createTheme({
|
||||||
typography: {
|
typography: {
|
||||||
fontFamily: ['Inter', 'sans-serif'].join(','),
|
fontFamily: ['Inter', 'sans-serif'].join(','),
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
MuiButton: {
|
MuiButton: {
|
||||||
styleOverrides: {
|
styleOverrides: {
|
||||||
root: {
|
root: {
|
||||||
textTransform: 'initial',
|
textTransform: 'initial',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const lightTheme: Theme = createTheme({
|
const lightTheme: Theme = createTheme({
|
||||||
...commonTheme,
|
...commonTheme,
|
||||||
palette: {
|
palette: {
|
||||||
mode: 'light',
|
mode: 'light',
|
||||||
primary: {
|
primary: {
|
||||||
main: '#000000',
|
main: '#000000',
|
||||||
},
|
},
|
||||||
secondary: {
|
secondary: {
|
||||||
main: '#E8E9EB',
|
main: '#E8E9EB',
|
||||||
dark: '#ACACAC',
|
dark: '#ACACAC',
|
||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
main: '#f44336',
|
main: '#f44336',
|
||||||
},
|
},
|
||||||
background: {
|
background: {
|
||||||
default: '#f7f7f7',
|
default: '#f7f7f7',
|
||||||
paper: '#f7f7f7',
|
paper: '#f7f7f7',
|
||||||
},
|
},
|
||||||
backgroundSecondary: {
|
backgroundSecondary: {
|
||||||
default: '#E8E9EB',
|
default: '#E8E9EB',
|
||||||
paper: '#f7f7f7',
|
paper: '#f7f7f7',
|
||||||
},
|
},
|
||||||
text: {
|
text: {
|
||||||
primary: '#000000',
|
primary: '#000000',
|
||||||
secondary: '#ffffff',
|
secondary: '#ffffff',
|
||||||
},
|
},
|
||||||
textSecondaryDecorate: {
|
textSecondaryDecorate: {
|
||||||
main: '#6b6b6b',
|
main: '#6b6b6b',
|
||||||
light: '#000',
|
light: '#000',
|
||||||
dark: '#000',
|
dark: '#000',
|
||||||
contrastText: '#000',
|
contrastText: '#000',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const darkTheme: Theme = createTheme({
|
const darkTheme: Theme = createTheme({
|
||||||
...commonTheme,
|
...commonTheme,
|
||||||
palette: {
|
palette: {
|
||||||
mode: 'dark',
|
mode: 'dark',
|
||||||
primary: {
|
primary: {
|
||||||
main: '#FFFFFF',
|
main: '#FFFFFF',
|
||||||
},
|
},
|
||||||
secondary: {
|
secondary: {
|
||||||
main: '#222222',
|
main: '#222222',
|
||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
main: '#ef9a9a',
|
main: '#ef9a9a',
|
||||||
},
|
},
|
||||||
background: {
|
background: {
|
||||||
default: '#121212',
|
default: '#121212',
|
||||||
paper: '#28282B',
|
paper: '#28282B',
|
||||||
},
|
},
|
||||||
backgroundSecondary: {
|
backgroundSecondary: {
|
||||||
default: '#0d0d0d',
|
default: '#0d0d0d',
|
||||||
paper: '#28282B',
|
paper: '#28282B',
|
||||||
},
|
},
|
||||||
text: {
|
text: {
|
||||||
primary: '#ffffff',
|
primary: '#ffffff',
|
||||||
secondary: '#000000',
|
secondary: '#000000',
|
||||||
},
|
},
|
||||||
textSecondaryDecorate: {
|
textSecondaryDecorate: {
|
||||||
main: '#6b6b6b',
|
main: '#6b6b6b',
|
||||||
light: '#000',
|
light: '#000',
|
||||||
dark: '#000',
|
dark: '#000',
|
||||||
contrastText: '#000',
|
contrastText: '#000',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
export { lightTheme, darkTheme }
|
export { lightTheme, darkTheme }
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { useParams } from 'react-router'
|
import { useParams } from 'react-router'
|
||||||
import { useAppSelector } from '@/store/hooks/redux'
|
import { useAppSelector } from '@/store/hooks/redux'
|
||||||
import { selectAppByAppNpub, selectPermsByNpubAndAppNpub } from '@/store'
|
import { selectAppByAppNpub, selectKeys, selectPermsByNpubAndAppNpub } from '@/store'
|
||||||
import { Navigate, useNavigate } from 'react-router-dom'
|
import { Navigate, useNavigate } from 'react-router-dom'
|
||||||
import { formatTimestampDate } from '@/utils/helpers/date'
|
import { formatTimestampDate } from '@/utils/helpers/date'
|
||||||
import { Box, Stack, Typography } from '@mui/material'
|
import { Box, IconButton, Stack, Typography } from '@mui/material'
|
||||||
import { SectionTitle } from '@/shared/SectionTitle/SectionTitle'
|
import { SectionTitle } from '@/shared/SectionTitle/SectionTitle'
|
||||||
import { getShortenNpub } from '@/utils/helpers/helpers'
|
import { getAppIconTitle, getDomain, getShortenNpub } from '@/utils/helpers/helpers'
|
||||||
import { Button } from '@/shared/Button/Button'
|
import { Button } from '@/shared/Button/Button'
|
||||||
import { ACTION_TYPE } from '@/utils/consts'
|
import { ACTION_TYPE } from '@/utils/consts'
|
||||||
import { Permissions } from './components/Permissions/Permissions'
|
import { Permissions } from './components/Permissions/Permissions'
|
||||||
@@ -18,105 +18,103 @@ import { IOSBackButton } from '@/shared/IOSBackButton/IOSBackButton'
|
|||||||
import { ModalActivities } from './components/Activities/ModalActivities'
|
import { ModalActivities } from './components/Activities/ModalActivities'
|
||||||
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
|
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
|
||||||
import { MODAL_PARAMS_KEYS } from '@/types/modal'
|
import { MODAL_PARAMS_KEYS } from '@/types/modal'
|
||||||
|
import MoreIcon from '@mui/icons-material/MoreVertRounded'
|
||||||
|
import { ModalAppDetails } from '@/components/Modal/ModalAppDetails/ModalAppDetails'
|
||||||
|
|
||||||
const AppPage = () => {
|
const AppPage = () => {
|
||||||
const { appNpub = '', npub = '' } = useParams()
|
const keys = useAppSelector(selectKeys)
|
||||||
const navigate = useNavigate()
|
|
||||||
const notify = useEnqueueSnackbar()
|
|
||||||
|
|
||||||
const perms = useAppSelector((state) =>
|
const { appNpub = '', npub = '' } = useParams()
|
||||||
selectPermsByNpubAndAppNpub(state, npub, appNpub),
|
const currentApp = useAppSelector((state) => selectAppByAppNpub(state, appNpub))
|
||||||
)
|
const perms = useAppSelector((state) => selectPermsByNpubAndAppNpub(state, npub, appNpub))
|
||||||
const currentApp = useAppSelector((state) =>
|
|
||||||
selectAppByAppNpub(state, appNpub),
|
|
||||||
)
|
|
||||||
|
|
||||||
const { open, handleClose, handleShow } = useToggleConfirm()
|
const navigate = useNavigate()
|
||||||
const { handleOpen: handleOpenModal } = useModalSearchParams()
|
const notify = useEnqueueSnackbar()
|
||||||
|
const { open, handleClose, handleShow } = useToggleConfirm()
|
||||||
|
const { handleOpen: handleOpenModal } = useModalSearchParams()
|
||||||
|
|
||||||
const connectPerm = perms.find(
|
const connectPerm = perms.find((perm) => perm.perm === 'connect' || perm.perm === ACTION_TYPE.BASIC)
|
||||||
(perm) => perm.perm === 'connect' || perm.perm === ACTION_TYPE.BASIC,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!currentApp) {
|
const isNpubExists = npub.trim().length && keys.some((key) => key.npub === npub)
|
||||||
return <Navigate to={`/key/${npub}`} />
|
|
||||||
}
|
|
||||||
|
|
||||||
const { icon = '', name = '' } = currentApp || {}
|
if (!isNpubExists || !currentApp) {
|
||||||
const appName = name || getShortenNpub(appNpub)
|
return <Navigate to={`/key/${npub}`} />
|
||||||
const { timestamp } = connectPerm || {}
|
}
|
||||||
|
|
||||||
const connectedOn =
|
const { icon = '', name = '', url = '' } = currentApp || {}
|
||||||
connectPerm && timestamp
|
const appDomain = getDomain(url)
|
||||||
? `Connected at ${formatTimestampDate(timestamp)}`
|
const shortAppNpub = getShortenNpub(appNpub)
|
||||||
: 'Not connected'
|
const appName = name || appDomain || shortAppNpub
|
||||||
|
const appAvatarTitle = getAppIconTitle(name || appDomain, appNpub)
|
||||||
|
const isAppNameExists = !!name
|
||||||
|
|
||||||
const handleDeleteApp = async () => {
|
const { timestamp } = connectPerm || {}
|
||||||
try {
|
const connectedOn = connectPerm && timestamp ? `Connected at ${formatTimestampDate(timestamp)}` : 'Not connected'
|
||||||
await swicCall('deleteApp', appNpub)
|
|
||||||
notify(`App: «${appName}» successfully deleted!`, 'success')
|
|
||||||
navigate(`key/${npub}`)
|
|
||||||
} catch (error: any) {
|
|
||||||
notify(error?.message || 'Failed to delete app', 'error')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
const handleDeleteApp = async () => {
|
||||||
<>
|
try {
|
||||||
<Stack
|
await swicCall('deleteApp', appNpub)
|
||||||
maxHeight={'100%'}
|
notify(`App: «${appName}» successfully deleted!`, 'success')
|
||||||
overflow={'auto'}
|
navigate(`/key/${npub}`)
|
||||||
alignItems={'flex-start'}
|
} catch (error: any) {
|
||||||
height={'100%'}
|
notify(error?.message || 'Failed to delete app', 'error')
|
||||||
>
|
}
|
||||||
<IOSBackButton onNavigate={() => navigate(`key/${npub}`)} />
|
}
|
||||||
<Stack
|
|
||||||
marginBottom={'1rem'}
|
|
||||||
direction={'row'}
|
|
||||||
gap={'1rem'}
|
|
||||||
width={'100%'}
|
|
||||||
>
|
|
||||||
<StyledAppIcon src={icon} />
|
|
||||||
<Box flex={'1'} overflow={'hidden'}>
|
|
||||||
<Typography variant='h4' noWrap>
|
|
||||||
{appName}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant='body2' noWrap>
|
|
||||||
{connectedOn}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
|
||||||
<Box marginBottom={'1rem'}>
|
|
||||||
<SectionTitle marginBottom={'0.5rem'}>
|
|
||||||
Disconnect
|
|
||||||
</SectionTitle>
|
|
||||||
<Button fullWidth onClick={handleShow}>
|
|
||||||
Delete app
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
<Permissions perms={perms} />
|
|
||||||
|
|
||||||
<Button
|
const handleShowAppDetailsModal = () => handleOpenModal(MODAL_PARAMS_KEYS.APP_DETAILS)
|
||||||
fullWidth
|
|
||||||
onClick={() =>
|
|
||||||
handleOpenModal(MODAL_PARAMS_KEYS.ACTIVITY)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Activity
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<ConfirmModal
|
return (
|
||||||
open={open}
|
<>
|
||||||
headingText='Delete app'
|
<Stack maxHeight={'100%'} overflow={'auto'} alignItems={'flex-start'} height={'100%'}>
|
||||||
description='Are you sure you want to delete this app?'
|
<IOSBackButton onNavigate={() => navigate(`key/${npub}`)} />
|
||||||
onCancel={handleClose}
|
<Stack marginBottom={'1rem'} direction={'row'} gap={'1rem'} width={'100%'} alignItems={'center'}>
|
||||||
onConfirm={handleDeleteApp}
|
<StyledAppIcon src={icon}>{appAvatarTitle}</StyledAppIcon>
|
||||||
onClose={handleClose}
|
<Box flex={'1'} overflow={'hidden'}>
|
||||||
/>
|
<Stack direction={'row'} alignItems={'flex-start'} gap={'0.5rem'} marginBottom={'0.5rem'}>
|
||||||
<ModalActivities appNpub={appNpub} />
|
<Box display={'flex'} flexDirection={'column'} flex={1}>
|
||||||
</>
|
<Typography variant="h4" noWrap>
|
||||||
)
|
{appName}
|
||||||
|
</Typography>
|
||||||
|
{isAppNameExists && (
|
||||||
|
<Typography noWrap display={'block'} variant="body1" color={'GrayText'}>
|
||||||
|
{shortAppNpub}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<IconButton onClick={handleShowAppDetailsModal}>
|
||||||
|
<MoreIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Stack>
|
||||||
|
<Typography variant="body2" noWrap>
|
||||||
|
{connectedOn}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
<Box marginBottom={'1rem'}>
|
||||||
|
<SectionTitle marginBottom={'0.5rem'}>Disconnect</SectionTitle>
|
||||||
|
<Button fullWidth onClick={handleShow}>
|
||||||
|
Delete app
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
<Permissions perms={perms} />
|
||||||
|
|
||||||
|
<Button fullWidth onClick={() => handleOpenModal(MODAL_PARAMS_KEYS.ACTIVITY)}>
|
||||||
|
Activity
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<ConfirmModal
|
||||||
|
open={open}
|
||||||
|
headingText="Delete app"
|
||||||
|
description="Are you sure you want to delete this app?"
|
||||||
|
onCancel={handleClose}
|
||||||
|
onConfirm={handleDeleteApp}
|
||||||
|
onClose={handleClose}
|
||||||
|
/>
|
||||||
|
<ModalActivities appNpub={appNpub} />
|
||||||
|
<ModalAppDetails />
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default AppPage
|
export default AppPage
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { FC } from 'react'
|
import { FC } from 'react'
|
||||||
import { DbHistory } from '@/modules/db'
|
import { DbHistory } from '@/modules/db'
|
||||||
import { Box, IconButton, Typography } from '@mui/material'
|
import { Box, IconButton, Typography } from '@mui/material'
|
||||||
import { StyledActivityItem } from './styled'
|
import { StyledActivityItem } from './styled'
|
||||||
@@ -6,40 +6,24 @@ import { formatTimestampDate } from '@/utils/helpers/date'
|
|||||||
import ClearRoundedIcon from '@mui/icons-material/ClearRounded'
|
import ClearRoundedIcon from '@mui/icons-material/ClearRounded'
|
||||||
import DoneRoundedIcon from '@mui/icons-material/DoneRounded'
|
import DoneRoundedIcon from '@mui/icons-material/DoneRounded'
|
||||||
import MoreVertRoundedIcon from '@mui/icons-material/MoreVertRounded'
|
import MoreVertRoundedIcon from '@mui/icons-material/MoreVertRounded'
|
||||||
import { ACTIONS } from '@/utils/consts'
|
import { getReqActionName } from '@/utils/helpers/helpers'
|
||||||
|
|
||||||
type ItemActivityProps = DbHistory
|
type ItemActivityProps = DbHistory
|
||||||
|
|
||||||
export const ItemActivity: FC<ItemActivityProps> = ({
|
export const ItemActivity: FC<ItemActivityProps> = (req) => {
|
||||||
allowed,
|
const { allowed, timestamp } = req
|
||||||
method,
|
return (
|
||||||
timestamp,
|
<StyledActivityItem>
|
||||||
}) => {
|
<Box display={'flex'} flexDirection={'column'} gap={'0.5rem'} flex={1}>
|
||||||
return (
|
<Typography flex={1} fontWeight={700}>
|
||||||
<StyledActivityItem>
|
{getReqActionName(req)}
|
||||||
<Box
|
</Typography>
|
||||||
display={'flex'}
|
<Typography variant="body2">{formatTimestampDate(timestamp)}</Typography>
|
||||||
flexDirection={'column'}
|
</Box>
|
||||||
gap={'0.5rem'}
|
<Box>{allowed ? <DoneRoundedIcon htmlColor="green" /> : <ClearRoundedIcon htmlColor="red" />}</Box>
|
||||||
flex={1}
|
<IconButton>
|
||||||
>
|
<MoreVertRoundedIcon />
|
||||||
<Typography flex={1} fontWeight={700}>
|
</IconButton>
|
||||||
{ACTIONS[method] || method}
|
</StyledActivityItem>
|
||||||
</Typography>
|
)
|
||||||
<Typography variant='body2'>
|
|
||||||
{formatTimestampDate(timestamp)}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Box>
|
|
||||||
{allowed ? (
|
|
||||||
<DoneRoundedIcon htmlColor='green' />
|
|
||||||
) : (
|
|
||||||
<ClearRoundedIcon htmlColor='red' />
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
<IconButton>
|
|
||||||
<MoreVertRoundedIcon />
|
|
||||||
</IconButton>
|
|
||||||
</StyledActivityItem>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,32 +8,23 @@ import { useModalSearchParams } from '@/hooks/useModalSearchParams'
|
|||||||
import { MODAL_PARAMS_KEYS } from '@/types/modal'
|
import { MODAL_PARAMS_KEYS } from '@/types/modal'
|
||||||
|
|
||||||
type ModalActivitiesProps = {
|
type ModalActivitiesProps = {
|
||||||
appNpub: string
|
appNpub: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ModalActivities: FC<ModalActivitiesProps> = ({ appNpub }) => {
|
export const ModalActivities: FC<ModalActivitiesProps> = ({ appNpub }) => {
|
||||||
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
|
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
|
||||||
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.ACTIVITY)
|
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.ACTIVITY)
|
||||||
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.ACTIVITY)
|
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.ACTIVITY)
|
||||||
|
|
||||||
const history = useLiveQuery(
|
const history = useLiveQuery(getActivityHistoryQuerier(appNpub), [], HistoryDefaultValue)
|
||||||
getActivityHistoryQuerier(appNpub),
|
|
||||||
[],
|
|
||||||
HistoryDefaultValue,
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal open={isModalOpened} onClose={handleCloseModal} fixedHeight="calc(100% - 5rem)" title="Activity history">
|
||||||
open={isModalOpened}
|
<Box overflow={'auto'}>
|
||||||
onClose={handleCloseModal}
|
{history.map((item) => {
|
||||||
fixedHeight='calc(100% - 5rem)'
|
return <ItemActivity {...item} key={item.id} />
|
||||||
title='Activity history'
|
})}
|
||||||
>
|
</Box>
|
||||||
<Box overflow={'auto'}>
|
</Modal>
|
||||||
{history.map((item) => {
|
)
|
||||||
return <ItemActivity {...item} key={item.id} />
|
|
||||||
})}
|
|
||||||
</Box>
|
|
||||||
</Modal>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import styled from '@emotion/styled'
|
import styled from '@emotion/styled'
|
||||||
import { Box, BoxProps } from '@mui/material'
|
import { Box, BoxProps } from '@mui/material'
|
||||||
|
|
||||||
export const StyledActivityItem = styled((props: BoxProps) => (
|
export const StyledActivityItem = styled((props: BoxProps) => <Box {...props} />)(() => ({
|
||||||
<Box {...props} />
|
display: 'flex',
|
||||||
))(() => ({
|
gap: '0.5rem',
|
||||||
display: 'flex',
|
justifyContent: 'space-between',
|
||||||
gap: '0.5rem',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
padding: '0.25rem',
|
||||||
alignItems: 'center',
|
|
||||||
padding: '0.25rem',
|
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -2,58 +2,40 @@ import { FC } from 'react'
|
|||||||
import { Box, IconButton, Typography } from '@mui/material'
|
import { Box, IconButton, Typography } from '@mui/material'
|
||||||
import { DbPerm } from '@/modules/db'
|
import { DbPerm } from '@/modules/db'
|
||||||
import { formatTimestampDate } from '@/utils/helpers/date'
|
import { formatTimestampDate } from '@/utils/helpers/date'
|
||||||
import { ACTIONS } from '@/utils/consts'
|
|
||||||
import { StyledPermissionItem } from './styled'
|
import { StyledPermissionItem } from './styled'
|
||||||
import ClearRoundedIcon from '@mui/icons-material/ClearRounded'
|
import ClearRoundedIcon from '@mui/icons-material/ClearRounded'
|
||||||
import DoneRoundedIcon from '@mui/icons-material/DoneRounded'
|
import DoneRoundedIcon from '@mui/icons-material/DoneRounded'
|
||||||
import MoreVertRoundedIcon from '@mui/icons-material/MoreVertRounded'
|
import MoreVertRoundedIcon from '@mui/icons-material/MoreVertRounded'
|
||||||
import { ItemPermissionMenu } from './ItemPermissionMenu'
|
import { ItemPermissionMenu } from './ItemPermissionMenu'
|
||||||
import { useOpenMenu } from '@/hooks/useOpenMenu'
|
import { useOpenMenu } from '@/hooks/useOpenMenu'
|
||||||
|
import { getPermActionName } from '@/utils/helpers/helpers'
|
||||||
|
|
||||||
type ItemPermissionProps = {
|
type ItemPermissionProps = {
|
||||||
permission: DbPerm
|
permission: DbPerm
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ItemPermission: FC<ItemPermissionProps> = ({ permission }) => {
|
export const ItemPermission: FC<ItemPermissionProps> = ({ permission }) => {
|
||||||
const { perm, value, timestamp, id } = permission || {}
|
const { value, timestamp, id } = permission || {}
|
||||||
|
|
||||||
const { anchorEl, handleClose, handleOpen, open } = useOpenMenu()
|
const { anchorEl, handleClose, handleOpen, open } = useOpenMenu()
|
||||||
|
|
||||||
const isAllowed = value === '1'
|
const isAllowed = value === '1'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<StyledPermissionItem>
|
<StyledPermissionItem>
|
||||||
<Box
|
<Box display={'flex'} flexDirection={'column'} gap={'0.5rem'} flex={1}>
|
||||||
display={'flex'}
|
<Typography flex={1} fontWeight={700}>
|
||||||
flexDirection={'column'}
|
{getPermActionName(permission)}
|
||||||
gap={'0.5rem'}
|
</Typography>
|
||||||
flex={1}
|
<Typography variant="body2">{formatTimestampDate(timestamp)}</Typography>
|
||||||
>
|
</Box>
|
||||||
<Typography flex={1} fontWeight={700}>
|
<Box>{isAllowed ? <DoneRoundedIcon htmlColor="green" /> : <ClearRoundedIcon htmlColor="red" />}</Box>
|
||||||
{ACTIONS[perm] || perm}
|
<IconButton onClick={handleOpen}>
|
||||||
</Typography>
|
<MoreVertRoundedIcon />
|
||||||
<Typography variant='body2'>
|
</IconButton>
|
||||||
{formatTimestampDate(timestamp)}
|
</StyledPermissionItem>
|
||||||
</Typography>
|
<ItemPermissionMenu anchorEl={anchorEl} open={open} handleClose={handleClose} permId={id} />
|
||||||
</Box>
|
</>
|
||||||
<Box>
|
)
|
||||||
{isAllowed ? (
|
|
||||||
<DoneRoundedIcon htmlColor='green' />
|
|
||||||
) : (
|
|
||||||
<ClearRoundedIcon htmlColor='red' />
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
<IconButton onClick={handleOpen}>
|
|
||||||
<MoreVertRoundedIcon />
|
|
||||||
</IconButton>
|
|
||||||
</StyledPermissionItem>
|
|
||||||
<ItemPermissionMenu
|
|
||||||
anchorEl={anchorEl}
|
|
||||||
open={open}
|
|
||||||
handleClose={handleClose}
|
|
||||||
permId={id}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,58 +5,51 @@ import { swicCall } from '@/modules/swic'
|
|||||||
import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar'
|
import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar'
|
||||||
|
|
||||||
type ItemPermissionMenuProps = {
|
type ItemPermissionMenuProps = {
|
||||||
permId: string
|
permId: string
|
||||||
handleClose: () => void
|
handleClose: () => void
|
||||||
} & MenuProps
|
} & MenuProps
|
||||||
|
|
||||||
export const ItemPermissionMenu: FC<ItemPermissionMenuProps> = ({
|
export const ItemPermissionMenu: FC<ItemPermissionMenuProps> = ({ open, anchorEl, handleClose, permId }) => {
|
||||||
open,
|
const [showConfirm, setShowConfirm] = useState(false)
|
||||||
anchorEl,
|
const notify = useEnqueueSnackbar()
|
||||||
handleClose,
|
|
||||||
permId,
|
|
||||||
}) => {
|
|
||||||
const [showConfirm, setShowConfirm] = useState(false)
|
|
||||||
const notify = useEnqueueSnackbar()
|
|
||||||
|
|
||||||
const handleShowConfirm = () => {
|
const handleShowConfirm = () => {
|
||||||
setShowConfirm(true)
|
setShowConfirm(true)
|
||||||
handleClose()
|
handleClose()
|
||||||
}
|
}
|
||||||
const handleCloseConfirm = () => setShowConfirm(false)
|
const handleCloseConfirm = () => setShowConfirm(false)
|
||||||
|
|
||||||
const handleDeletePerm = async () => {
|
const handleDeletePerm = async () => {
|
||||||
try {
|
try {
|
||||||
await swicCall('deletePerm', permId)
|
await swicCall('deletePerm', permId)
|
||||||
notify('Permission successfully deleted!', 'success')
|
notify('Permission successfully deleted!', 'success')
|
||||||
handleCloseConfirm()
|
handleCloseConfirm()
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
notify(error?.message || 'Failed to delete permission', 'error')
|
notify(error?.message || 'Failed to delete permission', 'error')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Menu
|
<Menu
|
||||||
open={open}
|
open={open}
|
||||||
anchorEl={anchorEl}
|
anchorEl={anchorEl}
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
anchorOrigin={{
|
anchorOrigin={{
|
||||||
horizontal: 'left',
|
horizontal: 'left',
|
||||||
vertical: 'bottom',
|
vertical: 'bottom',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MenuItem onClick={handleShowConfirm}>
|
<MenuItem onClick={handleShowConfirm}>Delete permission</MenuItem>
|
||||||
Delete permission
|
</Menu>
|
||||||
</MenuItem>
|
<ConfirmModal
|
||||||
</Menu>
|
open={showConfirm}
|
||||||
<ConfirmModal
|
onClose={handleCloseConfirm}
|
||||||
open={showConfirm}
|
onCancel={handleCloseConfirm}
|
||||||
onClose={handleCloseConfirm}
|
headingText="Delete permission"
|
||||||
onCancel={handleCloseConfirm}
|
description="Are you sure you want to delete this permission?"
|
||||||
headingText='Delete permission'
|
onConfirm={handleDeletePerm}
|
||||||
description='Are you sure you want to delete this permission?'
|
/>
|
||||||
onConfirm={handleDeletePerm}
|
</>
|
||||||
/>
|
)
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,24 +5,18 @@ import { Box } from '@mui/material'
|
|||||||
import { ItemPermission } from './ItemPermission'
|
import { ItemPermission } from './ItemPermission'
|
||||||
|
|
||||||
type PermissionsProps = {
|
type PermissionsProps = {
|
||||||
perms: DbPerm[]
|
perms: DbPerm[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Permissions: FC<PermissionsProps> = ({ perms }) => {
|
export const Permissions: FC<PermissionsProps> = ({ perms }) => {
|
||||||
return (
|
return (
|
||||||
<Box width={'100%'} marginBottom={'1rem'} flex={1} overflow={'auto'}>
|
<Box width={'100%'} marginBottom={'1rem'} flex={1} overflow={'auto'}>
|
||||||
<SectionTitle marginBottom={'0.5rem'}>Permissions</SectionTitle>
|
<SectionTitle marginBottom={'0.5rem'}>Permissions</SectionTitle>
|
||||||
<Box
|
<Box flex={1} overflow={'auto'} display={'flex'} flexDirection={'column'} gap={'0.5rem'}>
|
||||||
flex={1}
|
{perms.map((perm) => {
|
||||||
overflow={'auto'}
|
return <ItemPermission key={perm.id} permission={perm} />
|
||||||
display={'flex'}
|
})}
|
||||||
flexDirection={'column'}
|
</Box>
|
||||||
gap={'0.5rem'}
|
</Box>
|
||||||
>
|
)
|
||||||
{perms.map((perm) => {
|
|
||||||
return <ItemPermission key={perm.id} permission={perm} />
|
|
||||||
})}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { Box, BoxProps, styled } from '@mui/material'
|
import { Box, BoxProps, styled } from '@mui/material'
|
||||||
|
|
||||||
export const StyledPermissionItem = styled((props: BoxProps) => (
|
export const StyledPermissionItem = styled((props: BoxProps) => <Box {...props} />)(() => ({
|
||||||
<Box {...props} />
|
display: 'flex',
|
||||||
))(() => ({
|
gap: '0.5rem',
|
||||||
display: 'flex',
|
justifyContent: 'space-between',
|
||||||
gap: '0.5rem',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
padding: '0.5rem',
|
||||||
alignItems: 'center',
|
|
||||||
padding: '0.5rem',
|
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -2,5 +2,5 @@ import { Button } from '@/shared/Button/Button'
|
|||||||
import { styled } from '@mui/material'
|
import { styled } from '@mui/material'
|
||||||
|
|
||||||
export const StyledButton = styled(Button)({
|
export const StyledButton = styled(Button)({
|
||||||
textTransform: 'capitalize',
|
textTransform: 'capitalize',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { Avatar, AvatarProps, styled } from '@mui/material'
|
import { Avatar, AvatarProps, styled } from '@mui/material'
|
||||||
|
|
||||||
export const StyledAppIcon = styled((props: AvatarProps) => (
|
export const StyledAppIcon = styled((props: AvatarProps) => <Avatar {...props} variant="rounded" />)(() => ({
|
||||||
<Avatar {...props} variant='rounded' />
|
width: 70,
|
||||||
))(() => ({
|
height: 70,
|
||||||
width: 70,
|
|
||||||
height: 70,
|
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import { DbHistory, db } from '@/modules/db'
|
import { DbHistory, db } from '@/modules/db'
|
||||||
|
|
||||||
export const getActivityHistoryQuerier = (appNpub: string) => () => {
|
export const getActivityHistoryQuerier = (appNpub: string) => () => {
|
||||||
if (!appNpub.trim().length) return []
|
if (!appNpub.trim().length) return []
|
||||||
|
|
||||||
const result = db.history
|
const result = db.history
|
||||||
.where('appNpub')
|
.where('appNpub')
|
||||||
.equals(appNpub)
|
.equals(appNpub)
|
||||||
.reverse()
|
.reverse()
|
||||||
.sortBy('timestamp')
|
.sortBy('timestamp')
|
||||||
.then(a => a.slice(0, 30))
|
.then((a) => a.slice(0, 30))
|
||||||
// .limit(30)
|
// .limit(30)
|
||||||
// .toArray()
|
// .toArray()
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HistoryDefaultValue: DbHistory[] = []
|
export const HistoryDefaultValue: DbHistory[] = []
|
||||||
|
|||||||
@@ -4,78 +4,70 @@ import { StyledAppLogo, StyledContent } from './styled'
|
|||||||
import { Button } from '@/shared/Button/Button'
|
import { Button } from '@/shared/Button/Button'
|
||||||
import { ChangeEvent, useState } from 'react'
|
import { ChangeEvent, useState } from 'react'
|
||||||
import { CheckmarkIcon } from '@/assets'
|
import { CheckmarkIcon } from '@/assets'
|
||||||
|
import { DOMAIN } from '@/utils/consts'
|
||||||
|
|
||||||
const AuthPage = () => {
|
const AuthPage = () => {
|
||||||
const isMobile = useMediaQuery('(max-width:600px)')
|
const isMobile = useMediaQuery('(max-width:600px)')
|
||||||
|
|
||||||
const [enteredValue, setEnteredValue] = useState('')
|
const [enteredValue, setEnteredValue] = useState('')
|
||||||
|
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
|
|
||||||
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
|
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
setEnteredValue(e.target.value)
|
setEnteredValue(e.target.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAvailable = enteredValue.trim().length > 2
|
const isAvailable = enteredValue.trim().length > 2
|
||||||
|
|
||||||
const inputHelperText = isAvailable ? (
|
const inputHelperText = isAvailable ? (
|
||||||
<>
|
<>
|
||||||
<CheckmarkIcon /> Available
|
<CheckmarkIcon /> Available
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
"Don't worry, username can be changed later."
|
"Don't worry, username can be changed later."
|
||||||
)
|
)
|
||||||
|
|
||||||
const mainContent = (
|
const mainContent = (
|
||||||
<>
|
<>
|
||||||
<Input
|
<Input
|
||||||
label='Enter a Username'
|
label="Enter a Username"
|
||||||
fullWidth
|
fullWidth
|
||||||
placeholder='Username'
|
placeholder="Username"
|
||||||
helperText={inputHelperText}
|
helperText={inputHelperText}
|
||||||
endAdornment={
|
endAdornment={<Typography color={'#FFFFFFA8'}>@{DOMAIN}</Typography>}
|
||||||
<Typography color={'#FFFFFFA8'}>@nsec.app</Typography>
|
onChange={handleInputChange}
|
||||||
}
|
value={enteredValue}
|
||||||
onChange={handleInputChange}
|
helperTextProps={{
|
||||||
value={enteredValue}
|
sx: {
|
||||||
helperTextProps={{
|
'&.helper_text': {
|
||||||
sx: {
|
color: isAvailable ? theme.palette.success.main : theme.palette.textSecondaryDecorate.main,
|
||||||
'&.helper_text': {
|
},
|
||||||
color: isAvailable
|
},
|
||||||
? theme.palette.success.main
|
}}
|
||||||
: theme.palette.textSecondaryDecorate.main,
|
/>
|
||||||
},
|
<Button fullWidth>Sign up</Button>
|
||||||
},
|
</>
|
||||||
}}
|
)
|
||||||
/>
|
|
||||||
<Button fullWidth>Sign up</Button>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack height={'100%'} position={'relative'}>
|
<Stack height={'100%'} position={'relative'}>
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
<StyledContent>
|
<StyledContent>
|
||||||
<Stack
|
<Stack direction={'row'} gap={'1rem'} alignItems={'center'} alignSelf={'flex-start'}>
|
||||||
direction={'row'}
|
<StyledAppLogo />
|
||||||
gap={'1rem'}
|
<Typography fontWeight={600} variant="h5">
|
||||||
alignItems={'center'}
|
Sign up
|
||||||
alignSelf={'flex-start'}
|
</Typography>
|
||||||
>
|
</Stack>
|
||||||
<StyledAppLogo />
|
{mainContent}
|
||||||
<Typography fontWeight={600} variant='h5'>
|
</StyledContent>
|
||||||
Sign up
|
) : (
|
||||||
</Typography>
|
<Stack gap={'1rem'} alignItems={'center'}>
|
||||||
</Stack>
|
{mainContent}
|
||||||
{mainContent}
|
</Stack>
|
||||||
</StyledContent>
|
)}
|
||||||
) : (
|
</Stack>
|
||||||
<Stack gap={'1rem'} alignItems={'center'}>
|
)
|
||||||
{mainContent}
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default AuthPage
|
export default AuthPage
|
||||||
|
|||||||
@@ -1,32 +1,32 @@
|
|||||||
import { AppLogo } from '@/assets'
|
import { AppLogo } from '@/assets'
|
||||||
import { Stack, styled, StackProps, Box } from '@mui/material'
|
import { Stack, styled, StackProps, Box } from '@mui/material'
|
||||||
|
|
||||||
export const StyledContent = styled((props: StackProps) => (
|
export const StyledContent = styled((props: StackProps) => <Stack {...props} gap={'1rem'} alignItems={'center'} />)(({
|
||||||
<Stack {...props} gap={'1rem'} alignItems={'center'} />
|
theme,
|
||||||
))(({ theme }) => {
|
}) => {
|
||||||
return {
|
return {
|
||||||
background: theme.palette.secondary.main,
|
background: theme.palette.secondary.main,
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: '-1rem',
|
bottom: '-1rem',
|
||||||
left: '-1rem',
|
left: '-1rem',
|
||||||
width: 'calc(100% + 2rem)',
|
width: 'calc(100% + 2rem)',
|
||||||
height: '70%',
|
height: '70%',
|
||||||
borderTopLeftRadius: '2rem',
|
borderTopLeftRadius: '2rem',
|
||||||
borderTopRightRadius: '2rem',
|
borderTopRightRadius: '2rem',
|
||||||
padding: '1rem',
|
padding: '1rem',
|
||||||
maxWidth: '50rem',
|
maxWidth: '50rem',
|
||||||
margin: '0 auto',
|
margin: '0 auto',
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
export const StyledAppLogo = styled((props) => (
|
export const StyledAppLogo = styled((props) => (
|
||||||
<Box {...props}>
|
<Box {...props}>
|
||||||
<AppLogo />
|
<AppLogo />
|
||||||
</Box>
|
</Box>
|
||||||
))({
|
))({
|
||||||
background: '#00000054',
|
background: '#0d0d0d',
|
||||||
padding: '0.75rem',
|
padding: '0.75rem',
|
||||||
borderRadius: '16px',
|
borderRadius: '16px',
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
placeItems: 'center',
|
placeItems: 'center',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
const ConfirmPage = () => {
|
const ConfirmPage = () => {
|
||||||
return <div>ConfirmPage</div>
|
return <div>ConfirmPage</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ConfirmPage
|
export default ConfirmPage
|
||||||
|
|||||||
122
src/pages/CreatePage/Create.Page.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { Stack, Typography } from '@mui/material'
|
||||||
|
import { GetStartedButton, LearnMoreButton } from './styled'
|
||||||
|
import { DOMAIN } from '@/utils/consts'
|
||||||
|
import { useSearchParams } from 'react-router-dom'
|
||||||
|
import { swicCall } from '@/modules/swic'
|
||||||
|
import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar'
|
||||||
|
import { ModalConfirmConnect } from '@/components/Modal/ModalConfirmConnect/ModalConfirmConnect'
|
||||||
|
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
|
||||||
|
import { MODAL_PARAMS_KEYS } from '@/types/modal'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { getReferrerAppUrl } from '@/utils/helpers/helpers'
|
||||||
|
import { LoadingSpinner } from '@/shared/LoadingSpinner/LoadingSpinner'
|
||||||
|
|
||||||
|
const CreatePage = () => {
|
||||||
|
const notify = useEnqueueSnackbar()
|
||||||
|
const { handleOpen } = useModalSearchParams()
|
||||||
|
const [created, setCreated] = useState(false)
|
||||||
|
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
|
const name = searchParams.get('name') || ''
|
||||||
|
const token = searchParams.get('token') || ''
|
||||||
|
const appNpub = searchParams.get('appNpub') || ''
|
||||||
|
const isValid = name && token && appNpub
|
||||||
|
|
||||||
|
const nip05 = `${name}@${DOMAIN}`
|
||||||
|
|
||||||
|
const handleLearnMore = () => {
|
||||||
|
// @ts-ignore
|
||||||
|
window.open(`https://${DOMAIN}`, '_blank').focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClickAddAccount = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true)
|
||||||
|
const key: any = await swicCall('generateKey', name)
|
||||||
|
|
||||||
|
const appUrl = getReferrerAppUrl()
|
||||||
|
|
||||||
|
console.log('Created', key.npub, 'app', appUrl)
|
||||||
|
setCreated(true)
|
||||||
|
setIsLoading(false)
|
||||||
|
|
||||||
|
handleOpen(MODAL_PARAMS_KEYS.CONFIRM_CONNECT, {
|
||||||
|
search: {
|
||||||
|
npub: key.npub,
|
||||||
|
appNpub,
|
||||||
|
appUrl,
|
||||||
|
token,
|
||||||
|
// needed for this screen itself
|
||||||
|
name,
|
||||||
|
// will close after all done
|
||||||
|
popup: 'true',
|
||||||
|
},
|
||||||
|
replace: true,
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
notify(error.message || error.toString(), 'error')
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
return (
|
||||||
|
<Stack maxHeight={'100%'} overflow={'auto'}>
|
||||||
|
<Typography textAlign={'center'} variant="h6" paddingTop="1em">
|
||||||
|
Bad parameters.
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack maxHeight={'100%'} overflow={'auto'}>
|
||||||
|
{created && (
|
||||||
|
<>
|
||||||
|
<Typography textAlign={'center'} variant="h4" paddingTop="0.5em">
|
||||||
|
Account created!
|
||||||
|
</Typography>
|
||||||
|
<Typography textAlign={'center'} variant="body1" paddingTop="0.5em">
|
||||||
|
User name: <b>{nip05}</b>
|
||||||
|
</Typography>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!created && (
|
||||||
|
<>
|
||||||
|
<Typography textAlign={'center'} variant="h4" paddingTop="0.5em">
|
||||||
|
Welcome to Nostr!
|
||||||
|
</Typography>
|
||||||
|
<Stack gap={'0.5rem'} overflow={'auto'}>
|
||||||
|
<Typography textAlign={'left'} variant="h6" paddingTop="0.5em">
|
||||||
|
Chosen name: <b>{nip05}</b>
|
||||||
|
</Typography>
|
||||||
|
<GetStartedButton onClick={handleClickAddAccount}>
|
||||||
|
Create account {isLoading && <LoadingSpinner />}
|
||||||
|
</GetStartedButton>
|
||||||
|
|
||||||
|
<Typography textAlign={'left'} variant="h5" paddingTop="1em">
|
||||||
|
What you need to know:
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<ol style={{ marginLeft: '1em' }}>
|
||||||
|
<li>Nostr accounts are based on cryptographic keys.</li>
|
||||||
|
<li>All your actions on Nostr will be signed by your keys.</li>
|
||||||
|
<li>Nsec.app is one of many services to manage Nostr keys.</li>
|
||||||
|
<li>When you create an account, a new key will be created.</li>
|
||||||
|
<li>This key can later be used with other Nostr websites.</li>
|
||||||
|
</ol>
|
||||||
|
<LearnMoreButton onClick={handleLearnMore}>Learn more</LearnMoreButton>
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
<ModalConfirmConnect />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CreatePage
|
||||||
26
src/pages/CreatePage/styled.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { AppButtonProps, Button } from '@/shared/Button/Button'
|
||||||
|
import { styled } from '@mui/material'
|
||||||
|
import PersonAddAltRoundedIcon from '@mui/icons-material/PersonAddAltRounded'
|
||||||
|
import PlayArrowOutlinedIcon from '@mui/icons-material/PlayArrowOutlined'
|
||||||
|
import HelpOutlineOutlinedIcon from '@mui/icons-material/HelpOutlineOutlined'
|
||||||
|
|
||||||
|
export const AddAccountButton = styled((props: AppButtonProps) => (
|
||||||
|
<Button {...props} startIcon={<PersonAddAltRoundedIcon />} />
|
||||||
|
))(() => ({
|
||||||
|
alignSelf: 'center',
|
||||||
|
padding: '0.35rem 1rem',
|
||||||
|
}))
|
||||||
|
|
||||||
|
export const GetStartedButton = styled((props: AppButtonProps) => (
|
||||||
|
<Button {...props} startIcon={<PlayArrowOutlinedIcon />} />
|
||||||
|
))(() => ({
|
||||||
|
alignSelf: 'left',
|
||||||
|
padding: '0.35rem 1rem',
|
||||||
|
}))
|
||||||
|
|
||||||
|
export const LearnMoreButton = styled((props: AppButtonProps) => (
|
||||||
|
<Button {...props} startIcon={<HelpOutlineOutlinedIcon />} />
|
||||||
|
))(() => ({
|
||||||
|
alignSelf: 'left',
|
||||||
|
padding: '0.35rem 1rem',
|
||||||
|
}))
|
||||||
@@ -10,61 +10,47 @@ import { MODAL_PARAMS_KEYS } from '@/types/modal'
|
|||||||
import { DOMAIN } from '@/utils/consts'
|
import { DOMAIN } from '@/utils/consts'
|
||||||
|
|
||||||
const HomePage = () => {
|
const HomePage = () => {
|
||||||
const keys = useAppSelector(selectKeys)
|
const keys = useAppSelector(selectKeys)
|
||||||
const isNoKeys = !keys || keys.length === 0
|
const isNoKeys = !keys || keys.length === 0
|
||||||
|
|
||||||
const { handleOpen } = useModalSearchParams()
|
const { handleOpen } = useModalSearchParams()
|
||||||
const handleClickAddAccount = () => handleOpen(MODAL_PARAMS_KEYS.INITIAL)
|
const handleClickAddAccount = () => handleOpen(MODAL_PARAMS_KEYS.INITIAL)
|
||||||
|
|
||||||
const handleLearnMore = () => {
|
const handleLearnMore = () => {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
window.open(`https://info.${DOMAIN}`, '_blank').focus();
|
window.open(`https://${DOMAIN}`, '_blank').focus()
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack maxHeight={'100%'} overflow={'auto'}>
|
<Stack maxHeight={'100%'} overflow={'auto'}>
|
||||||
<SectionTitle marginBottom={'0.5rem'}>
|
<SectionTitle marginBottom={'0.5rem'}>{isNoKeys ? 'Welcome' : 'Accounts:'}</SectionTitle>
|
||||||
{isNoKeys ? 'Welcome' : 'Keys:'}
|
<Stack gap={'0.5rem'} overflow={'auto'}>
|
||||||
</SectionTitle>
|
{isNoKeys && (
|
||||||
<Stack gap={'0.5rem'} overflow={'auto'}>
|
<>
|
||||||
{isNoKeys && (
|
<Typography textAlign={'left'} variant="h6" paddingTop="1em">
|
||||||
<>
|
Nsec.app is a novel key storage app for Nostr.
|
||||||
<Typography textAlign={'left'} variant='h6' paddingTop='1em'>
|
</Typography>
|
||||||
Nsec.app is a novel key storage app for Nostr.
|
<GetStartedButton onClick={handleClickAddAccount}>Get started</GetStartedButton>
|
||||||
</Typography>
|
<Typography textAlign={'left'} variant="h6" paddingTop="2em">
|
||||||
<GetStartedButton onClick={handleClickAddAccount}>
|
Your keys are stored in your browser and can be used in many Nostr apps without the need for a browser
|
||||||
Get started
|
extension.
|
||||||
</GetStartedButton>
|
</Typography>
|
||||||
<Typography textAlign={'left'} variant='h6' paddingTop='2em'>
|
<LearnMoreButton onClick={handleLearnMore}>Learn more</LearnMoreButton>
|
||||||
Your keys are stored in your browser and
|
</>
|
||||||
can be used in many Nostr apps without the
|
)}
|
||||||
need for a browser extension.
|
{!isNoKeys && (
|
||||||
</Typography>
|
<Fragment>
|
||||||
<LearnMoreButton onClick={handleLearnMore}>
|
<Box flex={1} overflow={'auto'} borderRadius={'8px'} padding={'0.25rem'}>
|
||||||
Learn more
|
{keys.map((key) => (
|
||||||
</LearnMoreButton>
|
<ItemKey {...key} key={key.npub} />
|
||||||
</>
|
))}
|
||||||
)}
|
</Box>
|
||||||
{!isNoKeys && (
|
<AddAccountButton onClick={handleClickAddAccount}>Add account</AddAccountButton>
|
||||||
<Fragment>
|
</Fragment>
|
||||||
<Box
|
)}
|
||||||
flex={1}
|
</Stack>
|
||||||
overflow={'auto'}
|
</Stack>
|
||||||
borderRadius={'8px'}
|
)
|
||||||
padding={'0.25rem'}
|
|
||||||
>
|
|
||||||
{keys.map((key) => (
|
|
||||||
<ItemKey {...key} key={key.npub} />
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
<AddAccountButton onClick={handleClickAddAccount}>
|
|
||||||
Add account
|
|
||||||
</AddAccountButton>
|
|
||||||
</Fragment>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default HomePage
|
export default HomePage
|
||||||
|
|||||||
@@ -1,61 +1,50 @@
|
|||||||
import { FC } from 'react'
|
import { FC } from 'react'
|
||||||
import { DbKey } from '../../../modules/db'
|
import { DbKey } from '../../../modules/db'
|
||||||
import {
|
import { Avatar, Stack, StackProps, Typography, TypographyProps, styled } from '@mui/material'
|
||||||
Avatar,
|
|
||||||
Stack,
|
|
||||||
StackProps,
|
|
||||||
Typography,
|
|
||||||
TypographyProps,
|
|
||||||
styled,
|
|
||||||
} from '@mui/material'
|
|
||||||
import { getShortenNpub } from '../../../utils/helpers/helpers'
|
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { useProfile } from '@/hooks/useProfile'
|
||||||
|
|
||||||
type ItemKeyProps = DbKey
|
type ItemKeyProps = DbKey
|
||||||
|
|
||||||
export const ItemKey: FC<ItemKeyProps> = (props) => {
|
export const ItemKey: FC<ItemKeyProps> = (props) => {
|
||||||
const { npub, profile } = props
|
const { npub } = props
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const { userName, userAvatar, avatarTitle } = useProfile(npub)
|
||||||
|
|
||||||
const handleNavigate = () => {
|
const handleNavigate = () => {
|
||||||
navigate('/key/' + npub)
|
navigate('/key/' + npub)
|
||||||
}
|
}
|
||||||
const { name = '', picture = '' } = profile?.info || {}
|
|
||||||
const userName = name || getShortenNpub(npub)
|
|
||||||
const userAvatar = picture || ''
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledKeyContainer onClick={handleNavigate}>
|
<StyledKeyContainer onClick={handleNavigate}>
|
||||||
<Stack direction={'row'} alignItems={'center'} gap='1rem'>
|
<Stack direction={'row'} alignItems={'center'} gap="1rem">
|
||||||
<Avatar src={userAvatar} alt={userName} />
|
<Avatar src={userAvatar} alt={userName}>
|
||||||
<StyledText variant='body1'>{userName}</StyledText>
|
{avatarTitle}
|
||||||
</Stack>
|
</Avatar>
|
||||||
</StyledKeyContainer>
|
<StyledText variant="body1">{userName}</StyledText>
|
||||||
)
|
</Stack>
|
||||||
|
</StyledKeyContainer>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const StyledKeyContainer = styled((props: StackProps) => (
|
const StyledKeyContainer = styled((props: StackProps) => <Stack marginBottom={'0.5rem'} gap={'0.25rem'} {...props} />)(
|
||||||
<Stack marginBottom={'0.5rem'} gap={'0.25rem'} {...props} />
|
({ theme }) => {
|
||||||
))(({ theme }) => {
|
return {
|
||||||
return {
|
boxShadow:
|
||||||
boxShadow:
|
theme.palette.mode === 'dark' ? '0px 1px 6px 0px rgba(92, 92, 92, 0.2)' : '0px 1px 6px 0px rgba(0, 0, 0, 0.2)',
|
||||||
theme.palette.mode === 'dark'
|
borderRadius: '12px',
|
||||||
? '0px 1px 6px 0px rgba(92, 92, 92, 0.2)'
|
padding: '0.5rem 1rem',
|
||||||
: '0px 1px 6px 0px rgba(0, 0, 0, 0.2)',
|
background: theme.palette.background.paper,
|
||||||
borderRadius: '12px',
|
':hover': {
|
||||||
padding: '0.5rem 1rem',
|
background: `${theme.palette.background.paper}95`,
|
||||||
background: theme.palette.background.paper,
|
},
|
||||||
':hover': {
|
cursor: 'pointer',
|
||||||
background: `${theme.palette.background.paper}95`,
|
}
|
||||||
},
|
}
|
||||||
cursor: 'pointer',
|
)
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
export const StyledText = styled((props: TypographyProps) => (
|
export const StyledText = styled((props: TypographyProps) => <Typography {...props} />)({
|
||||||
<Typography {...props} />
|
fontWeight: 500,
|
||||||
))({
|
width: '100%',
|
||||||
fontWeight: 500,
|
wordBreak: 'break-all',
|
||||||
width: '100%',
|
|
||||||
wordBreak: 'break-all',
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,22 +5,22 @@ import PlayArrowOutlinedIcon from '@mui/icons-material/PlayArrowOutlined'
|
|||||||
import HelpOutlineOutlinedIcon from '@mui/icons-material/HelpOutlineOutlined'
|
import HelpOutlineOutlinedIcon from '@mui/icons-material/HelpOutlineOutlined'
|
||||||
|
|
||||||
export const AddAccountButton = styled((props: AppButtonProps) => (
|
export const AddAccountButton = styled((props: AppButtonProps) => (
|
||||||
<Button {...props} startIcon={<PersonAddAltRoundedIcon />} />
|
<Button {...props} startIcon={<PersonAddAltRoundedIcon />} />
|
||||||
))(() => ({
|
))(() => ({
|
||||||
alignSelf: 'center',
|
alignSelf: 'center',
|
||||||
padding: '0.35rem 1rem',
|
padding: '0.35rem 1rem',
|
||||||
}))
|
}))
|
||||||
|
|
||||||
export const GetStartedButton = styled((props: AppButtonProps) => (
|
export const GetStartedButton = styled((props: AppButtonProps) => (
|
||||||
<Button {...props} startIcon={<PlayArrowOutlinedIcon />} />
|
<Button {...props} startIcon={<PlayArrowOutlinedIcon />} />
|
||||||
))(() => ({
|
))(() => ({
|
||||||
alignSelf: 'left',
|
alignSelf: 'left',
|
||||||
padding: '0.35rem 1rem',
|
padding: '0.35rem 1rem',
|
||||||
}))
|
}))
|
||||||
|
|
||||||
export const LearnMoreButton = styled((props: AppButtonProps) => (
|
export const LearnMoreButton = styled((props: AppButtonProps) => (
|
||||||
<Button {...props} startIcon={<HelpOutlineOutlinedIcon />} />
|
<Button {...props} startIcon={<HelpOutlineOutlinedIcon />} />
|
||||||
))(() => ({
|
))(() => ({
|
||||||
alignSelf: 'left',
|
alignSelf: 'left',
|
||||||
padding: '0.35rem 1rem',
|
padding: '0.35rem 1rem',
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useAppSelector } from '../../store/hooks/redux'
|
import { useAppSelector } from '../../store/hooks/redux'
|
||||||
import { useParams } from 'react-router-dom'
|
import { Navigate, useParams, useSearchParams } from 'react-router-dom'
|
||||||
import { Stack } from '@mui/material'
|
import { Stack } from '@mui/material'
|
||||||
import { StyledIconButton } from './styled'
|
import { StyledIconButton } from './styled'
|
||||||
import { SettingsIcon, ShareIcon } from '@/assets'
|
import { SettingsIcon, ShareIcon } from '@/assets'
|
||||||
@@ -11,84 +11,100 @@ import { ModalSettings } from '@/components/Modal/ModalSettings/ModalSettings'
|
|||||||
import { ModalExplanation } from '@/components/Modal/ModalExplanation/ModalExplanation'
|
import { ModalExplanation } from '@/components/Modal/ModalExplanation/ModalExplanation'
|
||||||
import { ModalConfirmConnect } from '@/components/Modal/ModalConfirmConnect/ModalConfirmConnect'
|
import { ModalConfirmConnect } from '@/components/Modal/ModalConfirmConnect/ModalConfirmConnect'
|
||||||
import { ModalConfirmEvent } from '@/components/Modal/ModalConfirmEvent/ModalConfirmEvent'
|
import { ModalConfirmEvent } from '@/components/Modal/ModalConfirmEvent/ModalConfirmEvent'
|
||||||
import { useProfile } from './hooks/useProfile'
|
|
||||||
import { useBackgroundSigning } from './hooks/useBackgroundSigning'
|
import { useBackgroundSigning } from './hooks/useBackgroundSigning'
|
||||||
import { BackgroundSigningWarning } from './components/BackgroundSigningWarning'
|
import { BackgroundSigningWarning } from './components/BackgroundSigningWarning'
|
||||||
import UserValueSection from './components/UserValueSection'
|
import UserValueSection from './components/UserValueSection'
|
||||||
import { useTriggerConfirmModal } from './hooks/useTriggerConfirmModal'
|
import { useTriggerConfirmModal } from './hooks/useTriggerConfirmModal'
|
||||||
import { useLiveQuery } from 'dexie-react-hooks'
|
import { useLiveQuery } from 'dexie-react-hooks'
|
||||||
import { checkNpubSyncQuerier } from './utils'
|
import { checkNpubSyncQuerier } from './utils'
|
||||||
|
import { DOMAIN } from '@/utils/consts'
|
||||||
|
import { useCallback, useState } from 'react'
|
||||||
|
|
||||||
const KeyPage = () => {
|
const KeyPage = () => {
|
||||||
const { npub = '' } = useParams<{ npub: string }>()
|
const { npub = '' } = useParams<{ npub: string }>()
|
||||||
const { apps, pending, perms } = useAppSelector((state) => state.content)
|
const { keys, apps, pending, perms } = useAppSelector((state) => state.content)
|
||||||
const isSynced = useLiveQuery(checkNpubSyncQuerier(npub), [npub], false)
|
const [searchParams] = useSearchParams()
|
||||||
|
|
||||||
const { handleOpen } = useModalSearchParams()
|
const [isCheckingSync, setIsChecking] = useState(true)
|
||||||
|
const handleStopChecking = () => setIsChecking(false)
|
||||||
|
|
||||||
const { userNameWithPrefix } = useProfile(npub)
|
const isSynced = useLiveQuery(checkNpubSyncQuerier(npub, handleStopChecking), [npub], false)
|
||||||
const { handleEnableBackground, showWarning, isEnabling } =
|
|
||||||
useBackgroundSigning()
|
|
||||||
|
|
||||||
const filteredApps = apps.filter((a) => a.npub === npub)
|
const { handleOpen } = useModalSearchParams()
|
||||||
const { prepareEventPendings } = useTriggerConfirmModal(
|
const { handleEnableBackground, showWarning, isEnabling } = useBackgroundSigning()
|
||||||
npub,
|
|
||||||
pending,
|
|
||||||
perms,
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleOpenConnectAppModal = () =>
|
const key = keys.find((k) => k.npub === npub)
|
||||||
handleOpen(MODAL_PARAMS_KEYS.CONNECT_APP)
|
|
||||||
|
|
||||||
const handleOpenSettingsModal = () => handleOpen(MODAL_PARAMS_KEYS.SETTINGS)
|
const getUsername = useCallback(() => {
|
||||||
|
if (!key || !key?.name) return ''
|
||||||
|
if (key.name.includes('@')) return key.name
|
||||||
|
return `${key?.name}@${DOMAIN}`
|
||||||
|
}, [key])
|
||||||
|
const username = getUsername()
|
||||||
|
|
||||||
return (
|
const filteredApps = apps.filter((a) => a.npub === npub)
|
||||||
<>
|
const { prepareEventPendings } = useTriggerConfirmModal(npub, pending, perms)
|
||||||
<Stack gap={'1rem'} height={'100%'}>
|
|
||||||
{showWarning && (
|
|
||||||
<BackgroundSigningWarning
|
|
||||||
isEnabling={isEnabling}
|
|
||||||
onEnableBackSigning={handleEnableBackground}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<UserValueSection
|
|
||||||
title='Your login'
|
|
||||||
value={userNameWithPrefix}
|
|
||||||
copyValue={npub + '@nsec.app'}
|
|
||||||
explanationType={EXPLANATION_MODAL_KEYS.NPUB}
|
|
||||||
/>
|
|
||||||
<UserValueSection
|
|
||||||
title='Your NPUB'
|
|
||||||
value={npub}
|
|
||||||
copyValue={npub}
|
|
||||||
explanationType={EXPLANATION_MODAL_KEYS.NPUB}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Stack direction={'row'} gap={'0.75rem'}>
|
const isKeyExists = npub.trim().length && key
|
||||||
<StyledIconButton onClick={handleOpenConnectAppModal}>
|
const isPopup = searchParams.get('popup') === 'true'
|
||||||
<ShareIcon />
|
// console.log({ isKeyExists, isPopup })
|
||||||
Connect app
|
|
||||||
</StyledIconButton>
|
|
||||||
|
|
||||||
<StyledIconButton
|
if (isPopup && !isKeyExists) {
|
||||||
bgcolor_variant='secondary'
|
searchParams.set('login', 'true')
|
||||||
onClick={handleOpenSettingsModal}
|
searchParams.set('npub', npub)
|
||||||
withBadge={!isSynced}
|
const url = `/home?${searchParams.toString()}`
|
||||||
>
|
return <Navigate to={url} />
|
||||||
<SettingsIcon />
|
}
|
||||||
Settings
|
|
||||||
</StyledIconButton>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Apps apps={filteredApps} npub={npub} />
|
if (!isKeyExists) return <Navigate to={`/home`} />
|
||||||
</Stack>
|
|
||||||
<ModalConnectApp />
|
const handleOpenConnectAppModal = () => handleOpen(MODAL_PARAMS_KEYS.CONNECT_APP)
|
||||||
<ModalSettings isSynced={isSynced} />
|
const handleOpenSettingsModal = () => handleOpen(MODAL_PARAMS_KEYS.SETTINGS)
|
||||||
<ModalExplanation />
|
|
||||||
<ModalConfirmConnect />
|
return (
|
||||||
<ModalConfirmEvent confirmEventReqs={prepareEventPendings} />
|
<>
|
||||||
</>
|
<Stack gap={'1rem'} height={'100%'}>
|
||||||
)
|
{showWarning && (
|
||||||
|
<BackgroundSigningWarning isEnabling={isEnabling} onEnableBackSigning={handleEnableBackground} />
|
||||||
|
)}
|
||||||
|
<UserValueSection
|
||||||
|
title="Your login"
|
||||||
|
value={username}
|
||||||
|
copyValue={username}
|
||||||
|
explanationType={EXPLANATION_MODAL_KEYS.LOGIN}
|
||||||
|
/>
|
||||||
|
<UserValueSection
|
||||||
|
title="Your NPUB"
|
||||||
|
value={npub}
|
||||||
|
copyValue={npub}
|
||||||
|
explanationType={EXPLANATION_MODAL_KEYS.NPUB}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Stack direction={'row'} gap={'0.75rem'}>
|
||||||
|
<StyledIconButton onClick={handleOpenConnectAppModal}>
|
||||||
|
<ShareIcon />
|
||||||
|
Connect app
|
||||||
|
</StyledIconButton>
|
||||||
|
|
||||||
|
<StyledIconButton
|
||||||
|
bgcolor_variant="secondary"
|
||||||
|
onClick={handleOpenSettingsModal}
|
||||||
|
withBadge={!isCheckingSync && !isSynced}
|
||||||
|
>
|
||||||
|
<SettingsIcon />
|
||||||
|
Settings
|
||||||
|
</StyledIconButton>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Apps apps={filteredApps} npub={npub} />
|
||||||
|
</Stack>
|
||||||
|
<ModalConnectApp />
|
||||||
|
<ModalSettings isSynced={isSynced} />
|
||||||
|
<ModalExplanation />
|
||||||
|
<ModalConfirmConnect />
|
||||||
|
<ModalConfirmEvent confirmEventReqs={prepareEventPendings} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default KeyPage
|
export default KeyPage
|
||||||
|
|||||||
@@ -11,57 +11,45 @@ import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar'
|
|||||||
import { ItemApp } from './ItemApp'
|
import { ItemApp } from './ItemApp'
|
||||||
|
|
||||||
type AppsProps = {
|
type AppsProps = {
|
||||||
apps: DbApp[]
|
apps: DbApp[]
|
||||||
npub: string
|
npub: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Apps: FC<AppsProps> = ({ apps = [], npub = '' }) => {
|
export const Apps: FC<AppsProps> = ({ apps = [] }) => {
|
||||||
const notify = useEnqueueSnackbar()
|
const notify = useEnqueueSnackbar()
|
||||||
|
|
||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
async function deletePerm(id: string) {
|
async function deletePerm(id: string) {
|
||||||
call(async () => {
|
call(async () => {
|
||||||
await swicCall('deletePerm', id)
|
await swicCall('deletePerm', id)
|
||||||
notify('Perm deleted!', 'success')
|
notify('Perm deleted!', 'success')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const openAppStore = () => {
|
||||||
<Box
|
window.open('https://nostrapp.link', '_blank')
|
||||||
flex={1}
|
}
|
||||||
marginBottom={'1rem'}
|
|
||||||
display={'flex'}
|
|
||||||
flexDirection={'column'}
|
|
||||||
overflow={'auto'}
|
|
||||||
>
|
|
||||||
<Stack
|
|
||||||
direction={'row'}
|
|
||||||
alignItems={'center'}
|
|
||||||
justifyContent={'space-between'}
|
|
||||||
marginBottom={'0.5rem'}
|
|
||||||
>
|
|
||||||
<SectionTitle>Connected apps</SectionTitle>
|
|
||||||
<AppLink title='Discover Apps' />
|
|
||||||
</Stack>
|
|
||||||
{!apps.length && (
|
|
||||||
<StyledEmptyAppsBox>
|
|
||||||
<Typography
|
|
||||||
className='message'
|
|
||||||
variant='h5'
|
|
||||||
fontWeight={600}
|
|
||||||
textAlign={'center'}
|
|
||||||
>
|
|
||||||
No connected apps
|
|
||||||
</Typography>
|
|
||||||
<Button>Discover Nostr Apps</Button>
|
|
||||||
</StyledEmptyAppsBox>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Stack gap={'0.5rem'} overflow={'auto'} flex={1}>
|
return (
|
||||||
{apps.map((a) => (
|
<Box flex={1} marginBottom={'1rem'} display={'flex'} flexDirection={'column'} overflow={'auto'}>
|
||||||
<ItemApp {...a} key={a.appNpub} />
|
<Stack direction={'row'} alignItems={'center'} justifyContent={'space-between'} marginBottom={'0.5rem'}>
|
||||||
))}
|
<SectionTitle>Connected apps</SectionTitle>
|
||||||
</Stack>
|
<AppLink title="Discover Apps" onClick={openAppStore} />
|
||||||
</Box>
|
</Stack>
|
||||||
)
|
{!apps.length && (
|
||||||
|
<StyledEmptyAppsBox>
|
||||||
|
<Typography className="message" variant="h5" fontWeight={600} textAlign={'center'}>
|
||||||
|
No connected apps
|
||||||
|
</Typography>
|
||||||
|
<Button onClick={openAppStore}>Discover Nostr Apps</Button>
|
||||||
|
</StyledEmptyAppsBox>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Stack gap={'0.5rem'} overflow={'auto'} flex={1}>
|
||||||
|
{apps.map((a) => (
|
||||||
|
<ItemApp {...a} key={a.appNpub} />
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,29 @@
|
|||||||
import React, { FC } from 'react'
|
import { FC } from 'react'
|
||||||
import { Warning } from '@/components/Warning/Warning'
|
import { Warning } from '@/components/Warning/Warning'
|
||||||
import { CircularProgress, Stack } from '@mui/material'
|
import { CircularProgress, Stack, Typography } from '@mui/material'
|
||||||
import GppMaybeIcon from '@mui/icons-material/GppMaybe'
|
import AutoModeOutlinedIcon from '@mui/icons-material/AutoModeOutlined'
|
||||||
|
|
||||||
type BackgroundSigningWarningProps = {
|
type BackgroundSigningWarningProps = {
|
||||||
isEnabling: boolean
|
isEnabling: boolean
|
||||||
onEnableBackSigning: () => void
|
onEnableBackSigning: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BackgroundSigningWarning: FC<BackgroundSigningWarningProps> = ({
|
export const BackgroundSigningWarning: FC<BackgroundSigningWarningProps> = ({ isEnabling, onEnableBackSigning }) => {
|
||||||
isEnabling,
|
return (
|
||||||
onEnableBackSigning,
|
<Warning
|
||||||
}) => {
|
message={
|
||||||
return (
|
<Stack direction={'row'} alignItems={'center'} gap={'1rem'}>
|
||||||
<Warning
|
Enable background service {isEnabling ? <CircularProgress size={'1.5rem'} /> : null}
|
||||||
message={
|
</Stack>
|
||||||
<Stack direction={'row'} alignItems={'center'} gap={'1rem'}>
|
}
|
||||||
Please enable push notifications{' '}
|
hint={
|
||||||
{isEnabling ? <CircularProgress size={'1.5rem'} /> : null}
|
<Typography variant='body2'>
|
||||||
</Stack>
|
Please allow notifications
|
||||||
}
|
for background operation.
|
||||||
Icon={<GppMaybeIcon htmlColor='white' />}
|
</Typography>
|
||||||
onClick={isEnabling ? undefined : onEnableBackSigning}
|
}
|
||||||
/>
|
icon={<AutoModeOutlinedIcon htmlColor="white" />}
|
||||||
)
|
onClick={isEnabling ? undefined : onEnableBackSigning}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,42 +2,44 @@ import { DbApp } from '@/modules/db'
|
|||||||
import { Avatar, Stack, Typography } from '@mui/material'
|
import { Avatar, Stack, Typography } from '@mui/material'
|
||||||
import { FC } from 'react'
|
import { FC } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
// import ImageOutlinedIcon from '@mui/icons-material/ImageOutlined'
|
import { getAppIconTitle, getDomain, getShortenNpub } from '@/utils/helpers/helpers'
|
||||||
import { getShortenNpub } from '@/utils/helpers/helpers'
|
|
||||||
import { StyledItemAppContainer } from './styled'
|
import { StyledItemAppContainer } from './styled'
|
||||||
|
|
||||||
type ItemAppProps = DbApp
|
type ItemAppProps = DbApp
|
||||||
|
|
||||||
export const ItemApp: FC<ItemAppProps> = ({ npub, appNpub, icon, name }) => {
|
export const ItemApp: FC<ItemAppProps> = ({ npub, appNpub, icon, name, url }) => {
|
||||||
const appName = name || getShortenNpub(appNpub)
|
const appDomain = getDomain(url)
|
||||||
return (
|
const shortAppNpub = getShortenNpub(appNpub)
|
||||||
<StyledItemAppContainer
|
const appName = name || appDomain || shortAppNpub
|
||||||
direction={'row'}
|
const appIcon = icon || `https://${appDomain}/favicon.ico`
|
||||||
alignItems={'center'}
|
const appAvatarTitle = getAppIconTitle(name || appDomain, appNpub)
|
||||||
gap={'0.5rem'}
|
const isAppNameExists = !!name
|
||||||
padding={'0.5rem 0'}
|
|
||||||
component={Link}
|
return (
|
||||||
to={`/key/${npub}/app/${appNpub}`}
|
<StyledItemAppContainer
|
||||||
>
|
direction={'row'}
|
||||||
<Avatar
|
alignItems={'center'}
|
||||||
variant='square'
|
gap={'0.5rem'}
|
||||||
sx={{ width: 56, height: 56 }}
|
padding={'0.5rem 0'}
|
||||||
src={icon}
|
component={Link}
|
||||||
alt={name}
|
to={`/key/${npub}/app/${appNpub}`}
|
||||||
/>
|
>
|
||||||
<Stack>
|
<Avatar variant="rounded" sx={{ width: 56, height: 56 }} src={appIcon} alt={appName}>
|
||||||
<Typography noWrap display={'block'} variant='body2'>
|
{appAvatarTitle}
|
||||||
{appName}
|
</Avatar>
|
||||||
</Typography>
|
<Stack>
|
||||||
<Typography
|
<Typography noWrap display={'block'} variant="body1">
|
||||||
noWrap
|
{appName}
|
||||||
display={'block'}
|
</Typography>
|
||||||
variant='caption'
|
{isAppNameExists && (
|
||||||
color={'GrayText'}
|
<Typography noWrap display={'block'} variant="body2" color={'GrayText'}>
|
||||||
>
|
{shortAppNpub}
|
||||||
Basic actions
|
</Typography>
|
||||||
</Typography>
|
)}
|
||||||
</Stack>
|
<Typography noWrap display={'block'} variant="caption" color={'GrayText'}>
|
||||||
</StyledItemAppContainer>
|
Basic actions
|
||||||
)
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</StyledItemAppContainer>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||