Compare commits
109 Commits
feature/ap
...
main
Author | SHA1 | Date | |
---|---|---|---|
|
9b10952749 | ||
|
6a6b18bcad | ||
|
67787c182b | ||
|
8c0b5f379e | ||
|
905dc7ac1b | ||
|
5259e17447 | ||
|
ca25712d20 | ||
|
6e334c5078 | ||
|
4d7b2c5a69 | ||
|
051eaf001f | ||
|
4a2362f6b9 | ||
|
da6f68e00a | ||
|
c08e26629a | ||
|
3de39c35b0 | ||
|
7aba51b103 | ||
|
c61869b2a2 | ||
|
47dc8e20fe | ||
|
f55ba7e6f2 | ||
|
98345037aa | ||
|
05bd08e86d | ||
|
4fa5c57a69 | ||
|
3e86ad37b9 | ||
|
3de4a508be | ||
|
febc91632a | ||
|
ecf27d8d23 | ||
|
59c03d16eb | ||
|
adbc7d455d | ||
|
7379d75002 | ||
|
71accbf983 | ||
|
13f9bb13fd | ||
|
b1dd6a5424 | ||
|
dddc90308a | ||
|
e3feb8b5a2 | ||
|
06fa8ffbd7 | ||
|
648567cac8 | ||
|
22753a8d89 | ||
|
6d4a8b4f64 | ||
|
425f7277fc | ||
|
ba3775e6c6 | ||
|
8c77b10b60 | ||
|
b98339e177 | ||
|
4ad66c8711 | ||
|
f0a7f5c58a | ||
|
a60fcd65b5 | ||
|
6a04c3ec4b | ||
|
64082de238 | ||
|
93f6135baf | ||
|
6d72cf1f82 | ||
|
3813cef605 | ||
|
170daa9ee7 | ||
|
2e522b79ad | ||
|
453a16690f | ||
|
46336d817f | ||
|
8ef8157c38 | ||
|
4f00a014d0 | ||
|
a500a2e2a5 | ||
|
1e6bf8679c | ||
|
04373e7991 | ||
|
6acd00ca3b | ||
|
6186f3dd3d | ||
|
87ec23c737 | ||
|
d199dcf9f7 | ||
|
04c425c32c | ||
|
0f28c80a15 | ||
|
34b516a1e3 | ||
|
aac537c7a2 | ||
|
40f4a9922a | ||
|
2058b900ac | ||
|
4b1f7564e7 | ||
|
32c097c1ee | ||
|
43e375efe9 | ||
|
8b349c0350 | ||
|
83d5c013cf | ||
|
0be2159efb | ||
|
e96edf90fe | ||
|
1a9dc0da82 | ||
|
56e71219a5 | ||
|
676eaf6191 | ||
|
97c3bcc16d | ||
|
67b6a3bfcf | ||
|
a5f7bf2a58 | ||
|
0b56813ece | ||
|
8d205d9d93 | ||
|
9a18e79862 | ||
|
ab2df05d50 | ||
|
163de16a84 | ||
|
e9b290db30 | ||
|
544ac18b59 | ||
|
2551022d5e | ||
|
45c39ca904 | ||
|
041b84eb0b | ||
|
69166ff501 | ||
|
043e159e53 | ||
|
13d0a62fec | ||
|
d11cccec35 | ||
|
81b8624bd1 | ||
|
f45300583c | ||
|
0cf042e5d9 | ||
|
977a4b5c93 | ||
|
8ccdc06f49 | ||
|
6589a98d52 | ||
|
fed1ece2d4 | ||
|
e7e3b871e4 | ||
|
063213cb89 | ||
|
0bf6fafb3e | ||
|
14a83ec721 | ||
|
dfb8889b9d | ||
|
b24e3d31b0 | ||
|
b27fb5ec07 |
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?
|
@ -3,37 +3,37 @@ const path = require('path')
|
||||
const ModuleScopePlugin = require('react-dev-utils/ModuleScopePlugin')
|
||||
|
||||
module.exports = function override(config) {
|
||||
const fallback = config.resolve.fallback || {}
|
||||
Object.assign(fallback, {
|
||||
crypto: require.resolve('crypto-browserify'),
|
||||
stream: require.resolve('stream-browserify'),
|
||||
assert: require.resolve('assert'),
|
||||
http: require.resolve('stream-http'),
|
||||
https: require.resolve('https-browserify'),
|
||||
os: require.resolve('os-browserify'),
|
||||
url: require.resolve('url'),
|
||||
})
|
||||
config.resolve.fallback = fallback
|
||||
config.plugins = (config.plugins || []).concat([
|
||||
new webpack.ProvidePlugin({
|
||||
process: 'process/browser',
|
||||
Buffer: ['buffer', 'Buffer'],
|
||||
}),
|
||||
])
|
||||
config.module.rules.unshift({
|
||||
test: /\.m?js$/,
|
||||
resolve: {
|
||||
fullySpecified: false, // disable the behavior
|
||||
},
|
||||
})
|
||||
// turns off the plugin that forbids importing from node_modules for the above-mentioned stuff
|
||||
config.resolve.plugins = config.resolve.plugins.filter((plugin) => {
|
||||
return !(plugin instanceof ModuleScopePlugin)
|
||||
})
|
||||
const fallback = config.resolve.fallback || {}
|
||||
Object.assign(fallback, {
|
||||
crypto: require.resolve('crypto-browserify'),
|
||||
stream: require.resolve('stream-browserify'),
|
||||
assert: require.resolve('assert'),
|
||||
http: require.resolve('stream-http'),
|
||||
https: require.resolve('https-browserify'),
|
||||
os: require.resolve('os-browserify'),
|
||||
url: require.resolve('url'),
|
||||
})
|
||||
config.resolve.fallback = fallback
|
||||
config.plugins = (config.plugins || []).concat([
|
||||
new webpack.ProvidePlugin({
|
||||
process: 'process/browser',
|
||||
Buffer: ['buffer', 'Buffer'],
|
||||
}),
|
||||
])
|
||||
config.module.rules.unshift({
|
||||
test: /\.m?js$/,
|
||||
resolve: {
|
||||
fullySpecified: false, // disable the behavior
|
||||
},
|
||||
})
|
||||
// turns off the plugin that forbids importing from node_modules for the above-mentioned stuff
|
||||
config.resolve.plugins = config.resolve.plugins.filter((plugin) => {
|
||||
return !(plugin instanceof ModuleScopePlugin)
|
||||
})
|
||||
|
||||
config.resolve.alias = {
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
}
|
||||
config.resolve.alias = {
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
}
|
||||
|
||||
return config
|
||||
return config
|
||||
}
|
||||
|
23
package-lock.json
generated
@ -41,6 +41,7 @@
|
||||
"redux-persist": "^6.0.0",
|
||||
"typescript": "^5.3.2",
|
||||
"use-debounce": "^10.0.0",
|
||||
"usehooks-ts": "^2.14.0",
|
||||
"web-vitals": "^2.1.4",
|
||||
"workbox-background-sync": "^6.6.0",
|
||||
"workbox-broadcast-update": "^6.6.0",
|
||||
@ -17214,6 +17215,20 @@
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/usehooks-ts": {
|
||||
"version": "2.14.0",
|
||||
"resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-2.14.0.tgz",
|
||||
"integrity": "sha512-jnhrjTRJoJS7cFxz63tRYc5mzTKf/h+Ii8P0PDHymT9qDe4ZA2/gzDRmDR4WGausg5X8wMIdghwi3BBCN9JKow==",
|
||||
"dependencies": {
|
||||
"lodash.debounce": "^4.0.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.15.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17 || ^18"
|
||||
}
|
||||
},
|
||||
"node_modules/utf-8-validate": {
|
||||
"version": "5.0.10",
|
||||
"resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz",
|
||||
@ -30591,6 +30606,14 @@
|
||||
"integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==",
|
||||
"requires": {}
|
||||
},
|
||||
"usehooks-ts": {
|
||||
"version": "2.14.0",
|
||||
"resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-2.14.0.tgz",
|
||||
"integrity": "sha512-jnhrjTRJoJS7cFxz63tRYc5mzTKf/h+Ii8P0PDHymT9qDe4ZA2/gzDRmDR4WGausg5X8wMIdghwi3BBCN9JKow==",
|
||||
"requires": {
|
||||
"lodash.debounce": "^4.0.8"
|
||||
}
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"version": "5.0.10",
|
||||
"resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz",
|
||||
|
@ -36,6 +36,7 @@
|
||||
"redux-persist": "^6.0.0",
|
||||
"typescript": "^5.3.2",
|
||||
"use-debounce": "^10.0.0",
|
||||
"usehooks-ts": "^2.14.0",
|
||||
"web-vitals": "^2.1.4",
|
||||
"workbox-background-sync": "^6.6.0",
|
||||
"workbox-broadcast-update": "^6.6.0",
|
||||
|
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,16 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Web site created using create-react-app"
|
||||
/>
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="180x180"
|
||||
href="%PUBLIC_URL%/apple-touch-icon.png"
|
||||
/>
|
||||
<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 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"
|
||||
/>
|
||||
<!--
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta name="description" content="Web site created using create-react-app" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="%PUBLIC_URL%/apple-touch-icon.png" />
|
||||
<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 rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
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.
|
||||
@ -42,12 +19,12 @@
|
||||
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`.
|
||||
-->
|
||||
<title>Nsec.app</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
<title>Nsec.app</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
@ -56,5 +33,6 @@
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
--></body>
|
||||
-->
|
||||
</body>
|
||||
</html>
|
||||
|
@ -1,20 +1,20 @@
|
||||
{
|
||||
"name": "Nsec.app",
|
||||
"short_name": "Nsec.app - Nostr key management tool",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
"name": "Nsec.app - Nostr key management tool",
|
||||
"short_name": "Nsec.app",
|
||||
"start_url": ".",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
}
|
||||
|
32
src/App.tsx
@ -1,27 +1,28 @@
|
||||
import { DbKey, dbi } from './modules/db'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { swicOnRender } from './modules/swic'
|
||||
import { swicOnReload, swicOnRender } from './modules/swic'
|
||||
import { useAppDispatch } from './store/hooks/redux'
|
||||
import { setApps, setKeys, setPending, setPerms } from './store/reducers/content.slice'
|
||||
import AppRoutes from './routes/AppRoutes'
|
||||
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 { ModalImportKeys } from './components/Modal/ModalImportKeys/ModalImportKeys'
|
||||
import { ModalSignUp } from './components/Modal/ModalSignUp/ModalSignUp'
|
||||
import { ModalLogin } from './components/Modal/ModalLogin/ModalLogin'
|
||||
import { useSessionStorage } from 'usehooks-ts'
|
||||
import { RELOAD_STORAGE_KEY } from './utils/consts'
|
||||
|
||||
function App() {
|
||||
const [render, setRender] = useState(0)
|
||||
const { handleOpen } = useModalSearchParams()
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
// eslint-disable-next-line
|
||||
const [_, setNeedReload] = useSessionStorage(RELOAD_STORAGE_KEY, false)
|
||||
|
||||
const [isConnected, setIsConnected] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const keys: DbKey[] = await dbi.listKeys()
|
||||
console.log(keys, 'keys')
|
||||
|
||||
dispatch(setKeys({ keys }))
|
||||
const loadProfiles = async () => {
|
||||
@ -58,7 +59,6 @@ function App() {
|
||||
// rerender
|
||||
// setRender((r) => r + 1)
|
||||
|
||||
if (!keys.length) handleOpen(MODAL_PARAMS_KEYS.INITIAL)
|
||||
// eslint-disable-next-line
|
||||
}, [dispatch])
|
||||
|
||||
@ -68,7 +68,7 @@ function App() {
|
||||
|
||||
useEffect(() => {
|
||||
ndk.connect().then(() => {
|
||||
console.log('NDK connected', { ndk })
|
||||
console.log('NDK connected')
|
||||
setIsConnected(true)
|
||||
})
|
||||
// eslint-disable-next-line
|
||||
@ -80,6 +80,24 @@ function App() {
|
||||
setRender((r) => r + 1)
|
||||
})
|
||||
|
||||
// subscribe to service worker updates
|
||||
swicOnReload(() => {
|
||||
console.log('reload')
|
||||
setNeedReload(true)
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = () => {
|
||||
setNeedReload(false)
|
||||
}
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
}
|
||||
// eslint-disable-next-line
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppRoutes />
|
||||
|
BIN
src/assets/fonts/Inter/Inter-Bold.ttf
Normal file
BIN
src/assets/fonts/Inter/Inter-ExtraLight.ttf
Normal file
BIN
src/assets/fonts/Inter/Inter-Light.ttf
Normal file
BIN
src/assets/fonts/Inter/Inter-Medium.ttf
Normal file
BIN
src/assets/fonts/Inter/Inter-Regular.ttf
Normal file
BIN
src/assets/fonts/Inter/Inter-SemiBold.ttf
Normal file
@ -1,3 +1,3 @@
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" 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"/>
|
||||
<svg width="32" height="36" viewBox="0 0 32 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 2.0 KiB |
@ -3,7 +3,7 @@ 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, CircularProgress, Stack, Typography } from '@mui/material'
|
||||
import { Autocomplete, Stack, Typography } from '@mui/material'
|
||||
import { StyledInput } from './styled'
|
||||
import { FormEvent, useEffect, useState } from 'react'
|
||||
import { isEmptyString } from '@/utils/helpers/helpers'
|
||||
@ -13,6 +13,7 @@ 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()
|
||||
@ -118,7 +119,7 @@ export const ModalAppDetails = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const isFormValid = !isEmptyString(url) && !isEmptyString(name) && !isEmptyString(icon)
|
||||
const isFormValid = !isEmptyString(name)
|
||||
|
||||
return (
|
||||
<Modal open={isModalOpened} onClose={handleCloseModal}>
|
||||
@ -129,6 +130,13 @@ export const ModalAppDetails = () => {
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<Input
|
||||
label="Name"
|
||||
fullWidth
|
||||
placeholder="Enter app name"
|
||||
onChange={handleInputChange('name')}
|
||||
value={details.name}
|
||||
/>
|
||||
<Autocomplete
|
||||
options={[]}
|
||||
freeSolo
|
||||
@ -149,13 +157,6 @@ export const ModalAppDetails = () => {
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
label="Name"
|
||||
fullWidth
|
||||
placeholder="Enter app name"
|
||||
onChange={handleInputChange('name')}
|
||||
value={details.name}
|
||||
/>
|
||||
<Input
|
||||
label="Icon"
|
||||
fullWidth
|
||||
@ -165,7 +166,7 @@ export const ModalAppDetails = () => {
|
||||
/>
|
||||
|
||||
<Button varianttype="secondary" type="submit" fullWidth disabled={!isFormValid || isLoading}>
|
||||
Save changes {isLoading && <CircularProgress sx={{ marginLeft: '0.5rem' }} size={'1rem'} />}
|
||||
Save changes {isLoading && <LoadingSpinner />}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { AppInputProps, Input } from '@/shared/Input/Input'
|
||||
import { Input } from '@/shared/Input/Input'
|
||||
import { AppInputProps } from '@/shared/Input/types'
|
||||
import { styled } from '@mui/material'
|
||||
import { forwardRef } from 'react'
|
||||
|
||||
|
@ -1,15 +1,22 @@
|
||||
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
|
||||
import { Modal } from '@/shared/Modal/Modal'
|
||||
import { MODAL_PARAMS_KEYS } from '@/types/modal'
|
||||
import { call, getAppIconTitle, getDomain, 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 { useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||
import { useAppSelector } from '@/store/hooks/redux'
|
||||
import { selectAppsByNpub, selectKeys, selectPendingsByNpub } from '@/store'
|
||||
import { StyledButton, StyledToggleButtonsGroup } from './styled'
|
||||
import { ActionToggleButton } from './сomponents/ActionToggleButton'
|
||||
import { useState } from 'react'
|
||||
import { swicCall } from '@/modules/swic'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { swicCall, swicWaitStarted } from '@/modules/swic'
|
||||
import { ACTION_TYPE } from '@/utils/consts'
|
||||
import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar'
|
||||
|
||||
@ -28,6 +35,7 @@ export const ModalConfirmConnect = () => {
|
||||
const pending = useAppSelector((state) => selectPendingsByNpub(state, npub))
|
||||
|
||||
const [selectedActionType, setSelectedActionType] = useState<ACTION_TYPE>(ACTION_TYPE.BASIC)
|
||||
const [isLoaded, setIsLoaded] = useState(false)
|
||||
|
||||
const appNpub = searchParams.get('appNpub') || ''
|
||||
const pendingReqId = searchParams.get('reqId') || ''
|
||||
@ -37,15 +45,7 @@ export const ModalConfirmConnect = () => {
|
||||
const triggerApp = apps.find((app) => app.appNpub === appNpub)
|
||||
const { name, url = '', icon = '' } = triggerApp || {}
|
||||
|
||||
let appUrl = url || searchParams.get('appUrl') || ''
|
||||
// console.log('referrer', window.document.referrer, appUrl)
|
||||
if (!appUrl && window.document.referrer) {
|
||||
try {
|
||||
const u = new URL(window.document.referrer)
|
||||
appUrl = u.origin
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const appUrl = url || searchParams.get('appUrl') || getReferrerAppUrl()
|
||||
const appDomain = getDomain(appUrl)
|
||||
const appName = name || appDomain || getShortenNpub(appNpub)
|
||||
const appAvatarTitle = getAppIconTitle(name || appDomain, appNpub)
|
||||
@ -61,12 +61,45 @@ export const ModalConfirmConnect = () => {
|
||||
},
|
||||
})
|
||||
|
||||
const isNpubExists = npub.trim().length && keys.some((key) => key.npub === npub)
|
||||
const isAppNpubExists = appNpub.trim().length && apps.some((app) => app.appNpub === appNpub)
|
||||
const isPendingReqIdExists = pendingReqId.trim().length && pending.some((p) => p.id === pendingReqId)
|
||||
if (isModalOpened && (!isNpubExists || !isAppNpubExists || !isPendingReqIdExists)) {
|
||||
closeModalAfterRequest()
|
||||
return null
|
||||
// NOTE: when opened directly to this modal using authUrl,
|
||||
// we might not have pending requests visible yet bcs we haven't
|
||||
// loaded them yet, which means this modal will be closed with
|
||||
// the logic below. So now if it's popup then we wait for SW
|
||||
// and then wait a little more to give it time to fetch
|
||||
// pending reqs from db. Same logic implemented in confirm-event.
|
||||
|
||||
// FIXME move to a separate hook and reuse?
|
||||
|
||||
useEffect(() => {
|
||||
if (isModalOpened) {
|
||||
if (isPopup) {
|
||||
console.log('waiting for sw')
|
||||
// wait for SW to start
|
||||
swicWaitStarted().then(() => {
|
||||
// give it some time to load the pending reqs etc
|
||||
console.log('waiting for sw done')
|
||||
setTimeout(() => setIsLoaded(true), 500)
|
||||
})
|
||||
} else {
|
||||
setIsLoaded(true)
|
||||
}
|
||||
} else {
|
||||
setIsLoaded(false)
|
||||
}
|
||||
}, [isModalOpened, isPopup])
|
||||
|
||||
if (isLoaded) {
|
||||
const isNpubExists = npub.trim().length && keys.some((key) => key.npub === npub)
|
||||
// NOTE: app doesn't exist yet!
|
||||
// const isAppNpubExists = appNpub.trim().length && apps.some((app) => app.appNpub === appNpub)
|
||||
const isPendingReqIdExists = pendingReqId.trim().length && pending.some((p) => p.id === pendingReqId)
|
||||
// console.log("pending", {isModalOpened, isPendingReqIdExists, isNpubExists, /*isAppNpubExists,*/ pendingReqId, pending});
|
||||
if (isModalOpened && (!isNpubExists /*|| !isAppNpubExists*/ || (pendingReqId && !isPendingReqIdExists))) {
|
||||
// if (isPopup) window.close()
|
||||
// else closeModalAfterRequest()
|
||||
if (!isPopup) closeModalAfterRequest()
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const handleActionTypeChange = (_: any, value: ACTION_TYPE | null) => {
|
||||
@ -88,16 +121,18 @@ export const ModalConfirmConnect = () => {
|
||||
if (selectedActionType === ACTION_TYPE.BASIC) perms = [ACTION_TYPE.BASIC]
|
||||
|
||||
if (pendingReqId) {
|
||||
const options = { perms }
|
||||
const options = { perms, appUrl }
|
||||
await confirmPending(pendingReqId, true, true, options)
|
||||
} else {
|
||||
try {
|
||||
await swicCall('enablePush')
|
||||
await askNotificationPermission()
|
||||
const result = await swicCall('enablePush')
|
||||
if (!result) throw new Error('Failed to activate the push subscription')
|
||||
console.log('enablePush done')
|
||||
} catch (e: any) {
|
||||
console.log('error', e)
|
||||
notify('Please enable Notifications in website settings!', 'error')
|
||||
return
|
||||
// keep going
|
||||
}
|
||||
|
||||
try {
|
||||
@ -135,7 +170,9 @@ export const ModalConfirmConnect = () => {
|
||||
if (isPopup) {
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
disallow()
|
||||
// FIXME it should be 'ignore once',
|
||||
// not 'disallow & remember' - this is too strict
|
||||
// disallow()
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -160,11 +197,11 @@ export const ModalConfirmConnect = () => {
|
||||
>
|
||||
{appAvatarTitle}
|
||||
</Avatar>
|
||||
<Box>
|
||||
<Typography variant="h5" fontWeight={600}>
|
||||
<Box overflow={'auto'}>
|
||||
<Typography variant="h5" fontWeight={600} noWrap>
|
||||
{appName}
|
||||
</Typography>
|
||||
<Typography variant="body2" color={'GrayText'}>
|
||||
<Typography variant="body2" color={'GrayText'} noWrap>
|
||||
New app would like to connect
|
||||
</Typography>
|
||||
</Box>
|
||||
@ -183,7 +220,7 @@ export const ModalConfirmConnect = () => {
|
||||
</StyledToggleButtonsGroup>
|
||||
<Stack direction={'row'} gap={'1rem'}>
|
||||
<StyledButton onClick={disallow} varianttype="secondary">
|
||||
Disallow
|
||||
Ignore
|
||||
</StyledButton>
|
||||
<StyledButton fullWidth onClick={allow}>
|
||||
Connect
|
||||
|
@ -21,5 +21,8 @@ export const StyledToggleButtonsGroup = styled((props: ToggleButtonGroupProps) =
|
||||
border: 'initial',
|
||||
borderRadius: '1rem',
|
||||
},
|
||||
'@media screen and (max-width: 320px)': {
|
||||
marginBottom: '0.25rem',
|
||||
},
|
||||
})
|
||||
)
|
||||
|
@ -4,22 +4,18 @@ import { StyledToggleButton } from './styled'
|
||||
|
||||
type ActionToggleButtonProps = ToggleButtonProps & {
|
||||
description?: string
|
||||
hasinfo?: boolean
|
||||
}
|
||||
|
||||
export const ActionToggleButton: FC<ActionToggleButtonProps> = ({ hasinfo = false, ...props }) => {
|
||||
export const ActionToggleButton: FC<ActionToggleButtonProps> = (props) => {
|
||||
const { title, description = '' } = props
|
||||
return (
|
||||
<StyledToggleButton {...props}>
|
||||
<Typography variant="body2">{title}</Typography>
|
||||
<Typography variant="body2" noWrap className="title">
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography className="description" variant="caption" color={'GrayText'}>
|
||||
{description}
|
||||
</Typography>
|
||||
{hasinfo && (
|
||||
<Typography className="info" color={'GrayText'}>
|
||||
Info
|
||||
</Typography>
|
||||
)}
|
||||
</StyledToggleButton>
|
||||
)
|
||||
}
|
||||
|
@ -29,4 +29,17 @@ export const StyledToggleButton = styled((props: ToggleButtonProps) => (
|
||||
fontSize: '10px',
|
||||
fontWeight: 500,
|
||||
},
|
||||
'@media screen and (max-width: 320px)': {
|
||||
'& .title': {
|
||||
fontSize: '14px',
|
||||
},
|
||||
'& .description': {
|
||||
margin: '0.25rem 0',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 3,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
@ -10,7 +10,7 @@ import { ActionToggleButton } from './сomponents/ActionToggleButton'
|
||||
import { FC, useEffect, useMemo, useState } from 'react'
|
||||
import { StyledActionsListContainer, StyledButton, StyledToggleButtonsGroup } from './styled'
|
||||
import { SectionTitle } from '@/shared/SectionTitle/SectionTitle'
|
||||
import { swicCall } from '@/modules/swic'
|
||||
import { swicCall, swicWaitStarted } from '@/modules/swic'
|
||||
import { Checkbox } from '@/shared/Checkbox/Checkbox'
|
||||
import { DbPending } from '@/modules/db'
|
||||
import { IPendingsByAppNpub } from '@/pages/KeyPage/hooks/useTriggerConfirmModal'
|
||||
@ -47,6 +47,7 @@ export const ModalConfirmEvent: FC<ModalConfirmEventProps> = ({ confirmEventReqs
|
||||
|
||||
const [selectedActionType, setSelectedActionType] = useState<ACTION_TYPE>(ACTION_TYPE.ALWAYS)
|
||||
const [pendingRequests, setPendingRequests] = useState<PendingRequest[]>([])
|
||||
const [isLoaded, setIsLoaded] = useState(false)
|
||||
|
||||
const currentAppPendingReqs = useMemo(() => confirmEventReqs[appNpub]?.pending || [], [confirmEventReqs, appNpub])
|
||||
|
||||
@ -61,12 +62,33 @@ export const ModalConfirmEvent: FC<ModalConfirmEventProps> = ({ confirmEventReqs
|
||||
},
|
||||
})
|
||||
|
||||
const isNpubExists = npub.trim().length && keys.some((key) => key.npub === npub)
|
||||
const isAppNpubExists = appNpub.trim().length && apps.some((app) => app.appNpub === appNpub)
|
||||
useEffect(() => {
|
||||
if (isModalOpened) {
|
||||
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])
|
||||
|
||||
if (isModalOpened && (!isNpubExists || !isAppNpubExists)) {
|
||||
closeModalAfterRequest()
|
||||
return null
|
||||
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()
|
||||
if (!isPopup)
|
||||
closeModalAfterRequest()
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const triggerApp = apps.find((app) => app.appNpub === appNpub)
|
||||
|
174
src/components/Modal/ModalEditName/ModalEditName.tsx
Normal file
@ -0,0 +1,174 @@
|
||||
import { CheckmarkIcon } from '@/assets'
|
||||
import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar'
|
||||
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
|
||||
import { swicCall } from '@/modules/swic'
|
||||
import { Button } from '@/shared/Button/Button'
|
||||
import { Input } from '@/shared/Input/Input'
|
||||
import { LoadingSpinner } from '@/shared/LoadingSpinner/LoadingSpinner'
|
||||
import { Modal } from '@/shared/Modal/Modal'
|
||||
import { selectKeys } from '@/store'
|
||||
import { useAppSelector } from '@/store/hooks/redux'
|
||||
import { MODAL_PARAMS_KEYS } from '@/types/modal'
|
||||
import { DOMAIN } from '@/utils/consts'
|
||||
import { fetchNip05 } from '@/utils/helpers/helpers'
|
||||
import { Stack, Typography, useTheme } from '@mui/material'
|
||||
import { ChangeEvent, Fragment, useCallback, useEffect, useState } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { useDebounce } from 'use-debounce'
|
||||
import { StyledSettingContainer } from './styled'
|
||||
import { SectionTitle } from '@/shared/SectionTitle/SectionTitle'
|
||||
|
||||
export const ModalEditName = () => {
|
||||
const keys = useAppSelector(selectKeys)
|
||||
const notify = useEnqueueSnackbar()
|
||||
const { npub = '' } = useParams<{ npub: string }>()
|
||||
|
||||
const key = keys.find((k) => k.npub === npub)
|
||||
const name = key?.name || ''
|
||||
|
||||
const { palette } = useTheme()
|
||||
|
||||
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
|
||||
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.EDIT_NAME)
|
||||
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.EDIT_NAME)
|
||||
|
||||
const [enteredName, setEnteredName] = useState('')
|
||||
const [debouncedName] = useDebounce(enteredName, 300)
|
||||
const isNameEqual = debouncedName === name
|
||||
|
||||
const [receiverNpub, setReceiverNpub] = useState('')
|
||||
|
||||
const [isAvailable, setIsAvailable] = useState(true)
|
||||
const [isChecking, setIsChecking] = useState(false)
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isTransferLoading, setIsTransferLoading] = useState(false)
|
||||
|
||||
const checkIsUsernameAvailable = useCallback(async () => {
|
||||
if (!debouncedName.trim().length) return undefined
|
||||
try {
|
||||
setIsChecking(true)
|
||||
const npubNip05 = await fetchNip05(`${debouncedName}@${DOMAIN}`)
|
||||
setIsAvailable(!npubNip05 || npubNip05 === npub)
|
||||
setIsChecking(false)
|
||||
} catch (error) {
|
||||
setIsAvailable(true)
|
||||
setIsChecking(false)
|
||||
}
|
||||
}, [debouncedName, npub])
|
||||
|
||||
useEffect(() => {
|
||||
checkIsUsernameAvailable()
|
||||
}, [checkIsUsernameAvailable])
|
||||
|
||||
useEffect(() => {
|
||||
setEnteredName(name)
|
||||
return () => {
|
||||
if (isModalOpened) {
|
||||
setEnteredName('')
|
||||
setReceiverNpub('')
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line
|
||||
}, [isModalOpened])
|
||||
|
||||
const handleNameChange = (e: ChangeEvent<HTMLInputElement>) => setEnteredName(e.target.value)
|
||||
|
||||
const handleReceiverNpubChange = (e: ChangeEvent<HTMLInputElement>) => setReceiverNpub(e.target.value)
|
||||
|
||||
const getInputHelperText = () => {
|
||||
if (!debouncedName.trim().length || isNameEqual) return ''
|
||||
if (isChecking) return 'Loading...'
|
||||
if (!isAvailable) return 'Already taken'
|
||||
return (
|
||||
<Fragment>
|
||||
<CheckmarkIcon /> Available
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
const inputHelperText = getInputHelperText()
|
||||
|
||||
const getHelperTextColor = useCallback(() => {
|
||||
if (!debouncedName || isChecking || isNameEqual) return palette.textSecondaryDecorate.main
|
||||
return isAvailable ? palette.success.main : palette.error.main
|
||||
// deps
|
||||
}, [debouncedName, isAvailable, isChecking, isNameEqual, palette])
|
||||
|
||||
const isNpubExists = npub.trim().length && keys.some((key) => key.npub === npub)
|
||||
if (isModalOpened && !isNpubExists) {
|
||||
handleCloseModal()
|
||||
return null
|
||||
}
|
||||
|
||||
const isEditButtonDisabled = isNameEqual || !isAvailable || isChecking || isLoading || !enteredName.trim().length
|
||||
const isTransferButtonDisabled = !name.length || !receiverNpub.trim().length || isTransferLoading
|
||||
|
||||
const handleEditName = async () => {
|
||||
if (isEditButtonDisabled) return
|
||||
try {
|
||||
setIsLoading(true)
|
||||
await swicCall('editName', npub, enteredName)
|
||||
notify('Username updated!', 'success')
|
||||
setIsLoading(false)
|
||||
} catch (error: any) {
|
||||
setIsLoading(false)
|
||||
notify(error?.message || 'Failed to edit username!', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const handleTransferName = async () => {
|
||||
if (isTransferButtonDisabled) return
|
||||
try {
|
||||
setIsTransferLoading(true)
|
||||
await swicCall('transferName', npub, enteredName, receiverNpub)
|
||||
notify('Username transferred!', 'success')
|
||||
setIsTransferLoading(false)
|
||||
setEnteredName('')
|
||||
} catch (error: any) {
|
||||
setIsTransferLoading(false)
|
||||
notify(error?.message || 'Failed to transfer username!', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={isModalOpened} title="Username Settings" onClose={handleCloseModal}>
|
||||
<Stack gap={'1rem'}>
|
||||
<StyledSettingContainer>
|
||||
<SectionTitle>Change name</SectionTitle>
|
||||
<Input
|
||||
label="User name"
|
||||
fullWidth
|
||||
placeholder="Enter a Username"
|
||||
endAdornment={<Typography color={'#FFFFFFA8'}>@{DOMAIN}</Typography>}
|
||||
helperText={inputHelperText}
|
||||
onChange={handleNameChange}
|
||||
value={enteredName}
|
||||
helperTextProps={{
|
||||
sx: {
|
||||
'&.helper_text': {
|
||||
color: getHelperTextColor(),
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Button fullWidth disabled={isEditButtonDisabled} onClick={handleEditName}>
|
||||
Save name {isLoading && <LoadingSpinner />}
|
||||
</Button>
|
||||
</StyledSettingContainer>
|
||||
<StyledSettingContainer>
|
||||
<SectionTitle>Transfer name</SectionTitle>
|
||||
<Input
|
||||
label="Receiver npub"
|
||||
fullWidth
|
||||
placeholder="npub1..."
|
||||
onChange={handleReceiverNpubChange}
|
||||
value={receiverNpub}
|
||||
/>
|
||||
<Button fullWidth onClick={handleTransferName} disabled={isTransferButtonDisabled}>
|
||||
Transfer name
|
||||
</Button>
|
||||
</StyledSettingContainer>
|
||||
</Stack>
|
||||
</Modal>
|
||||
)
|
||||
}
|
10
src/components/Modal/ModalEditName/styled.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { Stack, StackProps, styled } from '@mui/material'
|
||||
|
||||
export const StyledSettingContainer = styled((props: StackProps) => (
|
||||
<Stack gap={'0.75rem'} component={'form'} {...props} />
|
||||
))(({ theme }) => ({
|
||||
padding: '1rem',
|
||||
borderRadius: '1rem',
|
||||
background: theme.palette.background.default,
|
||||
color: theme.palette.text.primary,
|
||||
}))
|
@ -5,8 +5,7 @@ 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 { CircularProgress, Stack, Typography, useTheme } from '@mui/material'
|
||||
import { StyledAppLogo } from './styled'
|
||||
import { Stack, Typography, useTheme } from '@mui/material'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { FormInputType, schema } from './const'
|
||||
@ -17,6 +16,9 @@ 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'
|
||||
import { HeadingContainer } from './styled'
|
||||
|
||||
const FORM_DEFAULT_VALUES = {
|
||||
username: '',
|
||||
@ -42,35 +44,73 @@ export const ModalImportKeys = () => {
|
||||
mode: 'onSubmit',
|
||||
})
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isAvailable, setIsAvailable] = 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 checkIsUsernameAvailable = useCallback(async () => {
|
||||
if (!debouncedUsername.trim().length) return undefined
|
||||
const npubNip05 = await fetchNip05(`${debouncedUsername}@${DOMAIN}`)
|
||||
|
||||
setIsAvailable(!npubNip05)
|
||||
setNameNpub(npubNip05 || '')
|
||||
}, [debouncedUsername])
|
||||
|
||||
useEffect(() => {
|
||||
checkIsUsernameAvailable()
|
||||
}, [checkIsUsernameAvailable])
|
||||
|
||||
const checkNsecUsername = useCallback(async () => {
|
||||
if (!debouncedNsec.trim().length) {
|
||||
setIsTakenByNsec(false)
|
||||
setIsBadNsec(false)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const { type, data } = nip19.decode(debouncedNsec)
|
||||
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])
|
||||
|
||||
useEffect(() => {
|
||||
checkNsecUsername()
|
||||
}, [checkNsecUsername])
|
||||
|
||||
const cleanUpStates = useCallback(() => {
|
||||
hidePassword()
|
||||
reset()
|
||||
setIsLoading(false)
|
||||
setIsAvailable(false)
|
||||
setNameNpub('')
|
||||
setIsTakenByNsec(false)
|
||||
setIsBadNsec(false)
|
||||
}, [reset, hidePassword])
|
||||
|
||||
const notify = useEnqueueSnackbar()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const submitHandler = async (values: FormInputType) => {
|
||||
if (isLoading || !isAvailable) return undefined
|
||||
if (isLoading) return undefined
|
||||
try {
|
||||
const { nsec, username } = values
|
||||
if (!nsec || !username) throw new Error('Enter username and nsec')
|
||||
if (nameNpub && !isTakenByNsec) throw new Error('Name taken')
|
||||
setIsLoading(true)
|
||||
const k: any = await swicCall('importKey', username, nsec)
|
||||
notify('Key imported!', 'success')
|
||||
@ -88,9 +128,11 @@ export const ModalImportKeys = () => {
|
||||
}
|
||||
}, [isModalOpened, cleanUpStates])
|
||||
|
||||
const getInputHelperText = () => {
|
||||
const getNameHelperText = () => {
|
||||
if (!enteredUsername) return "Don't worry, username can be changed later."
|
||||
if (!isAvailable) return 'Already taken'
|
||||
if (isTakenByNsec) return 'Name matches your key'
|
||||
if (isBadNsec) return 'Invalid nsec'
|
||||
if (nameNpub) return 'Already taken'
|
||||
return (
|
||||
<>
|
||||
<CheckmarkIcon /> Available
|
||||
@ -98,17 +140,25 @@ export const ModalImportKeys = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const inputHelperText = getInputHelperText()
|
||||
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 />
|
||||
<Modal open={isModalOpened} onClose={handleCloseModal} withCloseButton={false}>
|
||||
<Stack paddingTop={'1rem'} gap={'1rem'} component={'form'} onSubmit={handleSubmit(submitHandler)}>
|
||||
<HeadingContainer>
|
||||
<Typography fontWeight={600} variant="h5">
|
||||
Import key
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography noWrap variant="body2" color={'GrayText'}>
|
||||
Bring your existing Nostr keys to Nsec.app
|
||||
</Typography>
|
||||
</HeadingContainer>
|
||||
<Input
|
||||
label="Choose a username"
|
||||
fullWidth
|
||||
@ -116,14 +166,14 @@ export const ModalImportKeys = () => {
|
||||
endAdornment={<Typography color={'#FFFFFFA8'}>@{DOMAIN}</Typography>}
|
||||
{...register('username')}
|
||||
error={!!errors.username}
|
||||
helperText={inputHelperText}
|
||||
helperText={nameHelperText}
|
||||
helperTextProps={{
|
||||
sx: {
|
||||
'&.helper_text': {
|
||||
color:
|
||||
enteredUsername && isAvailable
|
||||
enteredUsername && (isTakenByNsec || !nameNpub)
|
||||
? theme.palette.success.main
|
||||
: enteredUsername && !isAvailable
|
||||
: enteredUsername && nameNpub
|
||||
? theme.palette.error.main
|
||||
: theme.palette.textSecondaryDecorate.main,
|
||||
},
|
||||
@ -137,18 +187,18 @@ export const ModalImportKeys = () => {
|
||||
{...register('nsec')}
|
||||
error={!!errors.nsec}
|
||||
{...inputProps}
|
||||
helperText="Keys stay on your device."
|
||||
helperText={nsecHelperText}
|
||||
helperTextProps={{
|
||||
sx: {
|
||||
'&.helper_text': {
|
||||
color: theme.palette.textSecondaryDecorate.main,
|
||||
color: isBadNsec ? theme.palette.error.main : theme.palette.textSecondaryDecorate.main,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
Import key {isLoading && <CircularProgress sx={{ marginLeft: '0.5rem' }} size={'1rem'} />}
|
||||
Import key {isLoading && <LoadingSpinner />}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
@ -1,14 +1,25 @@
|
||||
import { AppLogo } from '@/assets'
|
||||
import { Box, styled } from '@mui/material'
|
||||
import { Box, Stack, StackProps, styled } from '@mui/material'
|
||||
|
||||
export const StyledAppLogo = styled((props) => (
|
||||
<Box {...props}>
|
||||
<AppLogo />
|
||||
</Box>
|
||||
))({
|
||||
background: '#00000054',
|
||||
))(() => ({
|
||||
background: '#0d0d0d',
|
||||
padding: '0.75rem',
|
||||
borderRadius: '16px',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
})
|
||||
}))
|
||||
|
||||
export const HeadingContainer = styled((props: StackProps) => <Stack {...props} />)(() => ({
|
||||
gap: '0.2rem',
|
||||
padding: '0 1rem',
|
||||
alignSelf: 'flex-start',
|
||||
overflow: 'auto',
|
||||
width: '100%',
|
||||
'@media screen and (max-width: 320px)': {
|
||||
padding: '0 0.75rem',
|
||||
},
|
||||
}))
|
||||
|
@ -1,43 +1,36 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
// import { useEffect } from 'react'
|
||||
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
|
||||
import { Button } from '@/shared/Button/Button'
|
||||
import { Modal } from '@/shared/Modal/Modal'
|
||||
import { MODAL_PARAMS_KEYS } from '@/types/modal'
|
||||
import { Fade, Stack } from '@mui/material'
|
||||
import { AppLink } from '@/shared/AppLink/AppLink'
|
||||
import { Stack } from '@mui/material'
|
||||
// import { AppLink } from '@/shared/AppLink/AppLink'
|
||||
|
||||
export const ModalInitial = () => {
|
||||
const { getModalOpened, createHandleCloseReplace, handleOpen } = useModalSearchParams()
|
||||
const isModalOpened = getModalOpened(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 = () => {
|
||||
// setShowAdvancedContent(true)
|
||||
// }
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (isModalOpened) {
|
||||
setShowAdvancedContent(false)
|
||||
}
|
||||
}
|
||||
}, [isModalOpened])
|
||||
// useEffect(() => {
|
||||
// return () => {
|
||||
// if (isModalOpened) {
|
||||
// setShowAdvancedContent(false)
|
||||
// }
|
||||
// }
|
||||
// }, [isModalOpened])
|
||||
|
||||
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 key</Button>
|
||||
</Fade>
|
||||
)}
|
||||
<Button onClick={() => handleOpen(MODAL_PARAMS_KEYS.IMPORT_KEYS)}>Import key</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
)
|
||||
|
@ -4,18 +4,18 @@ import { useModalSearchParams } from '@/hooks/useModalSearchParams'
|
||||
import { swicCall } from '@/modules/swic'
|
||||
import { Modal } from '@/shared/Modal/Modal'
|
||||
import { MODAL_PARAMS_KEYS } from '@/types/modal'
|
||||
import { CircularProgress, Stack, Typography } from '@mui/material'
|
||||
import { StyledAppLogo } from './styled'
|
||||
import { Stack, Typography } from '@mui/material'
|
||||
import { Input } from '@/shared/Input/Input'
|
||||
import { Button } from '@/shared/Button/Button'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { FormInputType, schema } from './const'
|
||||
import { yupResolver } from '@hookform/resolvers/yup'
|
||||
import { DOMAIN } from '@/utils/consts'
|
||||
import { fetchNip05 } from '@/utils/helpers/helpers'
|
||||
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: '',
|
||||
@ -31,11 +31,14 @@ export const ModalLogin = () => {
|
||||
const navigate = useNavigate()
|
||||
const { hidePassword, inputProps } = usePassword()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [searchParams] = useSearchParams()
|
||||
const isPopup = searchParams.get('popup') === 'true'
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
reset,
|
||||
register,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = useForm<FormInputType>({
|
||||
defaultValues: FORM_DEFAULT_VALUES,
|
||||
@ -78,7 +81,10 @@ export const ModalLogin = () => {
|
||||
notify(`Fetched ${k.npub}`, 'success')
|
||||
dbi.addSynced(k.npub)
|
||||
cleanUpStates()
|
||||
navigate(`/key/${k.npub}`)
|
||||
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')
|
||||
@ -86,6 +92,22 @@ export const ModalLogin = () => {
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isModalOpened) {
|
||||
const npub = searchParams.get('npub') || ''
|
||||
const appNpub = searchParams.get('appNpub') || ''
|
||||
if (isPopup && isModalOpened) {
|
||||
swicCall('fetchPendingRequests', npub, appNpub)
|
||||
|
||||
fetchNpubNames(npub).then((names) => {
|
||||
if (names.length) {
|
||||
setValue('username', `${names[0]}@${DOMAIN}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [searchParams, isModalOpened, isPopup, setValue])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (isModalOpened) {
|
||||
@ -96,13 +118,15 @@ export const ModalLogin = () => {
|
||||
}, [isModalOpened, cleanUpStates])
|
||||
|
||||
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 />
|
||||
<Modal open={isModalOpened} onClose={handleCloseModal} withCloseButton={false}>
|
||||
<Stack paddingTop={'1rem'} gap={'1rem'} component={'form'} onSubmit={handleSubmit(submitHandler)}>
|
||||
<Stack gap={'0.2rem'} padding={'0 1rem'} alignSelf={'flex-start'}>
|
||||
<Typography fontWeight={600} variant="h5">
|
||||
Login
|
||||
</Typography>
|
||||
<Typography noWrap variant="body2" color={'GrayText'}>
|
||||
Sync keys from the cloud to this device
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Input
|
||||
label="Username or nip05 or npub"
|
||||
@ -118,10 +142,14 @@ export const ModalLogin = () => {
|
||||
{...register('password')}
|
||||
{...inputProps}
|
||||
error={!!errors.password}
|
||||
helperText={'Password you set in Cloud Sync settings'}
|
||||
/>
|
||||
<Button type="submit" fullWidth disabled={isLoading}>
|
||||
Add account {isLoading && <CircularProgress sx={{ marginLeft: '0.5rem' }} size={'1rem'} />}
|
||||
</Button>
|
||||
|
||||
<Stack gap={'0.5rem'}>
|
||||
<Button type="submit" fullWidth disabled={isLoading}>
|
||||
Login {isLoading && <LoadingSpinner />}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Modal>
|
||||
)
|
||||
|
@ -6,7 +6,7 @@ export const StyledAppLogo = styled((props) => (
|
||||
<AppLogo />
|
||||
</Box>
|
||||
))({
|
||||
background: '#00000054',
|
||||
background: '#0d0d0d',
|
||||
padding: '0.75rem',
|
||||
borderRadius: '16px',
|
||||
display: 'grid',
|
||||
|
@ -1,8 +1,7 @@
|
||||
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
|
||||
import { Button } from '@/shared/Button/Button'
|
||||
import { Modal } from '@/shared/Modal/Modal'
|
||||
import { MODAL_PARAMS_KEYS } from '@/types/modal'
|
||||
import { Box, CircularProgress, Stack, Typography } from '@mui/material'
|
||||
import { Box, Stack, Typography } from '@mui/material'
|
||||
import { StyledButton, StyledSettingContainer, StyledSynchedText } from './styled'
|
||||
import { SectionTitle } from '@/shared/SectionTitle/SectionTitle'
|
||||
import { CheckmarkIcon } from '@/assets'
|
||||
@ -16,6 +15,8 @@ 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 = {
|
||||
isSynced: boolean
|
||||
@ -58,8 +59,9 @@ export const ModalSettings: FC<ModalSettingsProps> = ({ isSynced }) => {
|
||||
}
|
||||
|
||||
const handlePasswordChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setIsPasswordInvalid(false)
|
||||
setEnteredPassword(e.target.value)
|
||||
const password = e.target.value
|
||||
setIsPasswordInvalid(!!password && !isValidPassphase(password))
|
||||
setEnteredPassword(password)
|
||||
}
|
||||
|
||||
const onClose = () => {
|
||||
@ -76,7 +78,7 @@ export const ModalSettings: FC<ModalSettingsProps> = ({ isSynced }) => {
|
||||
e.preventDefault()
|
||||
setIsPasswordInvalid(false)
|
||||
|
||||
if (enteredPassword.trim().length < 6) {
|
||||
if (!isValidPassphase(enteredPassword)) {
|
||||
return setIsPasswordInvalid(true)
|
||||
}
|
||||
try {
|
||||
@ -114,18 +116,22 @@ export const ModalSettings: FC<ModalSettingsProps> = ({ isSynced }) => {
|
||||
{...inputProps}
|
||||
onChange={handlePasswordChange}
|
||||
value={enteredPassword}
|
||||
helperText={isPasswordInvalid ? 'Invalid password' : ''}
|
||||
placeholder="Enter a password"
|
||||
helperTextProps={{
|
||||
sx: {
|
||||
'&.helper_text': {
|
||||
color: 'red',
|
||||
},
|
||||
},
|
||||
}}
|
||||
disabled={!isChecked}
|
||||
/>
|
||||
{isSynced ? (
|
||||
{isPasswordInvalid ? (
|
||||
<Typography variant="body2" color={'red'}>
|
||||
Password must include 6+ English letters, numbers or punctuation marks.
|
||||
</Typography>
|
||||
) : !!enteredPassword && isWeakPassphase(enteredPassword) ? (
|
||||
<Typography variant="body2" color={'orange'}>
|
||||
Weak password
|
||||
</Typography>
|
||||
) : !!enteredPassword && !isPasswordInvalid ? (
|
||||
<Typography variant="body2" color={'green'}>
|
||||
Good password
|
||||
</Typography>
|
||||
) : isSynced ? (
|
||||
<Typography variant="body2" color={'GrayText'}>
|
||||
To change your password, type a new one and sync.
|
||||
</Typography>
|
||||
@ -136,10 +142,9 @@ export const ModalSettings: FC<ModalSettingsProps> = ({ isSynced }) => {
|
||||
</Typography>
|
||||
)}
|
||||
<StyledButton type="submit" fullWidth disabled={!isChecked}>
|
||||
Sync {isLoading && <CircularProgress sx={{ marginLeft: '0.5rem' }} size={'1rem'} />}
|
||||
Sync {isLoading && <LoadingSpinner mode="secondary" />}
|
||||
</StyledButton>
|
||||
</StyledSettingContainer>
|
||||
<Button onClick={onClose}>Done</Button>
|
||||
</Stack>
|
||||
</Modal>
|
||||
)
|
||||
|
@ -2,9 +2,8 @@ import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar'
|
||||
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
|
||||
import { Modal } from '@/shared/Modal/Modal'
|
||||
import { MODAL_PARAMS_KEYS } from '@/types/modal'
|
||||
import { CircularProgress, Stack, Typography, useTheme } from '@mui/material'
|
||||
import { Stack, Typography, useTheme } from '@mui/material'
|
||||
import React, { ChangeEvent, useEffect, useState } from 'react'
|
||||
import { StyledAppLogo } from './styled'
|
||||
import { Input } from '@/shared/Input/Input'
|
||||
import { Button } from '@/shared/Button/Button'
|
||||
import { CheckmarkIcon } from '@/assets'
|
||||
@ -12,6 +11,7 @@ import { swicCall } from '@/modules/swic'
|
||||
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 = () => {
|
||||
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
|
||||
@ -60,9 +60,13 @@ export const ModalSignUp = () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
const k: any = await swicCall('generateKey', name)
|
||||
notify(`Account created for "${name}"`, 'success')
|
||||
navigate(`/key/${k.npub}`)
|
||||
if (k.name) notify(`Account created for "${k.name}"`, 'success')
|
||||
else notify(`Failed to assign name "${name}", try again`, 'error')
|
||||
setIsLoading(false)
|
||||
setTimeout(() => {
|
||||
// give frontend time to read the new key first
|
||||
navigate(`/key/${k.npub}`)
|
||||
}, 300)
|
||||
} catch (error: any) {
|
||||
notify(error?.message || 'Something went wrong!', 'error')
|
||||
setIsLoading(false)
|
||||
@ -80,13 +84,15 @@ export const ModalSignUp = () => {
|
||||
}, [isModalOpened])
|
||||
|
||||
return (
|
||||
<Modal open={isModalOpened} onClose={handleCloseModal}>
|
||||
<Modal open={isModalOpened} onClose={handleCloseModal} withCloseButton={false}>
|
||||
<Stack paddingTop={'1rem'} gap={'1rem'} component={'form'} onSubmit={handleSubmit}>
|
||||
<Stack direction={'row'} gap={'1rem'} alignItems={'center'} alignSelf={'flex-start'}>
|
||||
<StyledAppLogo />
|
||||
<Stack gap={'0.2rem'} padding={'0 1rem'} alignSelf={'flex-start'}>
|
||||
<Typography fontWeight={600} variant="h5">
|
||||
Sign up
|
||||
</Typography>
|
||||
<Typography noWrap variant="body2" color={'GrayText'}>
|
||||
Generate new Nostr keys
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Input
|
||||
label="Username"
|
||||
@ -109,9 +115,11 @@ export const ModalSignUp = () => {
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Button fullWidth type="submit" disabled={isLoading}>
|
||||
Create account {isLoading && <CircularProgress sx={{ marginLeft: '0.5rem' }} size={'1rem'} />}
|
||||
</Button>
|
||||
<Stack gap={'0.5rem'}>
|
||||
<Button fullWidth type="submit" disabled={isLoading}>
|
||||
Create account {isLoading && <LoadingSpinner />}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Modal>
|
||||
)
|
||||
|
@ -6,7 +6,7 @@ export const StyledAppLogo = styled((props) => (
|
||||
<AppLogo />
|
||||
</Box>
|
||||
))({
|
||||
background: '#00000054',
|
||||
background: '#0d0d0d',
|
||||
padding: '0.75rem',
|
||||
borderRadius: '16px',
|
||||
display: 'grid',
|
||||
|
33
src/components/ReloadBadge/ReloadBadge.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import { FC, memo, useCallback } from 'react'
|
||||
import { Stack, Typography } from '@mui/material'
|
||||
import { StyledAlert, StyledReloadButton } from './styled'
|
||||
import { useSessionStorage } from 'usehooks-ts'
|
||||
import { RELOAD_STORAGE_KEY } from '@/utils/consts'
|
||||
|
||||
type ReloadBadgeContentProps = {
|
||||
onReload: () => void
|
||||
}
|
||||
|
||||
const ReloadBadgeContent: FC<ReloadBadgeContentProps> = memo(({ onReload }) => {
|
||||
return (
|
||||
<StyledAlert>
|
||||
<Stack direction={'row'} className="content">
|
||||
<Typography flex={1} className="title">
|
||||
New version available!
|
||||
</Typography>
|
||||
<StyledReloadButton onClick={onReload}>Reload</StyledReloadButton>
|
||||
</Stack>
|
||||
</StyledAlert>
|
||||
)
|
||||
})
|
||||
|
||||
export const ReloadBadge = () => {
|
||||
const [needReload, setNeedReload] = useSessionStorage(RELOAD_STORAGE_KEY, false)
|
||||
|
||||
const handleReload = useCallback(() => {
|
||||
setNeedReload(false)
|
||||
window.location.reload()
|
||||
}, [setNeedReload])
|
||||
|
||||
return <>{needReload && <ReloadBadgeContent onReload={handleReload} />}</>
|
||||
}
|
50
src/components/ReloadBadge/styled.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import { AppButtonProps, Button } from '@/shared/Button/Button'
|
||||
import { Alert, AlertProps, styled } from '@mui/material'
|
||||
import RefreshIcon from '@mui/icons-material/Refresh'
|
||||
|
||||
export const StyledAlert = styled((props: AlertProps) => (
|
||||
<Alert {...props} variant="outlined" severity="info" classes={{ message: 'message' }} />
|
||||
))(() => {
|
||||
return {
|
||||
height: 'auto',
|
||||
marginTop: '0.5rem',
|
||||
alignItems: 'center',
|
||||
'& .message': {
|
||||
flex: 1,
|
||||
overflow: 'initial',
|
||||
},
|
||||
'& .content': {
|
||||
width: '100%',
|
||||
alignItems: 'center',
|
||||
gap: '1rem',
|
||||
},
|
||||
'& .title': {
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 3,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
},
|
||||
'@media screen and (max-width: 320px)': {
|
||||
'& .title': {
|
||||
fontSize: '14px',
|
||||
WebkitLineClamp: 2,
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
export const StyledReloadButton = styled((props: AppButtonProps) => <Button startIcon={<RefreshIcon />} {...props} />)(
|
||||
({ theme }) => {
|
||||
const isDarkTheme = theme.palette.mode === 'dark'
|
||||
return {
|
||||
padding: '0.25rem 1rem',
|
||||
'&.button:is(:hover, :active, &)': {
|
||||
background: isDarkTheme ? '#b8e7fb' : '#014361',
|
||||
},
|
||||
'@media screen and (max-width: 320px)': {
|
||||
padding: '0.25rem 0.5rem',
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
@ -1,27 +1,27 @@
|
||||
import { FC, ReactNode } from 'react'
|
||||
import { IconContainer, StyledContainer } from './styled'
|
||||
import { BoxProps, Stack, Typography } from '@mui/material'
|
||||
import { BoxProps, Typography } from '@mui/material'
|
||||
|
||||
type WarningProps = {
|
||||
message?: string | ReactNode
|
||||
hint?: string | ReactNode
|
||||
icon?: ReactNode
|
||||
} & BoxProps
|
||||
|
||||
export const Warning: FC<WarningProps> = ({ hint, message, icon, ...restProps }) => {
|
||||
export const Warning: FC<WarningProps> = ({ message, icon, ...restProps }) => {
|
||||
const renderMessage = () => {
|
||||
if (typeof message === 'string') {
|
||||
return (
|
||||
<Typography noWrap width={'100%'}>
|
||||
{message}
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
return message
|
||||
}
|
||||
return (
|
||||
<StyledContainer {...restProps}>
|
||||
{icon && <IconContainer>{icon}</IconContainer>}
|
||||
<Stack flex={1} direction={'column'} gap={'0.2rem'}>
|
||||
<Typography noWrap>
|
||||
{message}
|
||||
</Typography>
|
||||
{hint && (
|
||||
<Typography>
|
||||
{hint}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
{renderMessage()}
|
||||
</StyledContainer>
|
||||
)
|
||||
}
|
||||
|
@ -7,13 +7,14 @@ export const StyledContainer = styled((props: BoxProps) => <Box {...props} />)((
|
||||
padding: '0.5rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '1rem',
|
||||
gap: '0.5rem',
|
||||
cursor: 'pointer',
|
||||
}
|
||||
})
|
||||
|
||||
export const IconContainer = styled((props: BoxProps) => <Box {...props} />)(() => ({
|
||||
width: '40px',
|
||||
minWidth: '40px',
|
||||
height: '40px',
|
||||
borderRadius: '50%',
|
||||
background: 'grey',
|
||||
|
@ -31,7 +31,7 @@ export const useModalSearchParams = () => {
|
||||
const enumKey = getEnumParam(modal)
|
||||
searchParams.delete(enumKey)
|
||||
extraOptions?.onClose && extraOptions?.onClose(searchParams)
|
||||
console.log({ searchParams })
|
||||
// console.log({ searchParams })
|
||||
setSearchParams(searchParams, { replace: !!extraOptions?.replace })
|
||||
}
|
||||
|
||||
|
@ -14,7 +14,7 @@ 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 userName = currentKey?.name || getProfileUsername(profile)
|
||||
const userAvatar = profile?.info?.picture || ''
|
||||
const avatarTitle = getFirstLetter(userName)
|
||||
|
||||
|
@ -1,17 +1,63 @@
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
src:
|
||||
local('Inter ExtraLight'),
|
||||
local('Inter-ExtraLight'),
|
||||
url('./assets/fonts/Inter/Inter-ExtraLight.ttf') format('truetype');
|
||||
font-weight: 200;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
src:
|
||||
local('Inter Light'),
|
||||
local('Inter-Light'),
|
||||
url('./assets/fonts/Inter/Inter-Light.ttf') format('truetype');
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
src:
|
||||
local('Inter Regular'),
|
||||
local('Inter-Regular'),
|
||||
url('./assets/fonts/Inter/Inter-Regular.ttf') format('truetype');
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
src:
|
||||
local('Inter Medium'),
|
||||
local('Inter-Medium'),
|
||||
url('./assets/fonts/Inter/Inter-Medium.ttf') format('truetype');
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
src:
|
||||
local('Inter SemiBold'),
|
||||
local('Inter-SemiBold'),
|
||||
url('./assets/fonts/Inter/Inter-SemiBold.ttf') format('truetype');
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
src:
|
||||
local('Inter Bold'),
|
||||
local('Inter-Bold'),
|
||||
url('./assets/fonts/Inter/Inter-Bold.ttf') format('truetype');
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans',
|
||||
'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
html,
|
||||
|
@ -1,13 +1,13 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import './index.css'
|
||||
import ThemeProvider from './modules/theme/ThemeProvider'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
import reportWebVitals from './reportWebVitals'
|
||||
import { swicRegister } from './modules/swic'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { Provider } from 'react-redux'
|
||||
import { persistor, store } from './store'
|
||||
import ThemeProvider from './modules/theme/ThemeProvider'
|
||||
import { PersistGate } from 'redux-persist/integration/react'
|
||||
import { SnackbarProvider } from 'notistack'
|
||||
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { Avatar, Stack, Toolbar, Typography } from '@mui/material'
|
||||
import { AppLogo } from '../../assets'
|
||||
import { StyledAppBar, StyledAppName, StyledProfileContainer, StyledThemeButton } from './styled'
|
||||
import { Avatar, Stack, Toolbar, Typography, Divider, DividerProps, styled } from '@mui/material'
|
||||
import { StyledAppBar, StyledAppLogo, StyledAppName, StyledProfileContainer, StyledThemeButton } from './styled'
|
||||
import { Menu } from './components/Menu'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { ProfileMenu } from './components/ProfileMenu'
|
||||
@ -9,11 +8,14 @@ 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'
|
||||
import { useSessionStorage } from 'usehooks-ts'
|
||||
import { RELOAD_STORAGE_KEY } from '@/utils/consts'
|
||||
|
||||
export const Header = () => {
|
||||
const themeMode = useAppSelector((state) => state.ui.themeMode)
|
||||
const navigate = useNavigate()
|
||||
const dispatch = useAppDispatch()
|
||||
const [needReload] = useSessionStorage(RELOAD_STORAGE_KEY, false)
|
||||
|
||||
const { npub = '' } = useParams<{ npub: string }>()
|
||||
const { userName, userAvatar, avatarTitle } = useProfile(npub)
|
||||
@ -24,14 +26,14 @@ export const Header = () => {
|
||||
}
|
||||
|
||||
const isDarkMode = themeMode === 'dark'
|
||||
const themeIcon = isDarkMode ? <DarkModeIcon htmlColor="#fff" /> : <LightModeIcon htmlColor="#000" />
|
||||
const themeIcon = isDarkMode ? <LightModeIcon htmlColor="#fff" /> : <DarkModeIcon htmlColor="#000" />
|
||||
|
||||
const handleChangeMode = () => {
|
||||
dispatch(setThemeMode({ mode: isDarkMode ? 'light' : 'dark' }))
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledAppBar position="fixed">
|
||||
<StyledAppBar position={needReload ? 'relative' : 'fixed'}>
|
||||
<Toolbar sx={{ padding: '12px' }}>
|
||||
<Stack direction={'row'} justifyContent={'space-between'} alignItems={'center'} width={'100%'}>
|
||||
{showProfile && (
|
||||
@ -47,7 +49,7 @@ export const Header = () => {
|
||||
|
||||
{!showProfile && (
|
||||
<StyledAppName>
|
||||
<AppLogo />
|
||||
<StyledAppLogo />
|
||||
<span>Nsec.app</span>
|
||||
</StyledAppName>
|
||||
)}
|
||||
@ -57,6 +59,15 @@ export const Header = () => {
|
||||
{showProfile ? <ProfileMenu /> : <Menu />}
|
||||
</Stack>
|
||||
</Toolbar>
|
||||
<StyledDivider />
|
||||
</StyledAppBar>
|
||||
)
|
||||
}
|
||||
|
||||
const StyledDivider = styled((props: DividerProps) => <Divider {...props} />)({
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
width: '100%',
|
||||
left: 0,
|
||||
height: '2px',
|
||||
})
|
||||
|
@ -4,28 +4,21 @@ import { Avatar, ListItemIcon, MenuItem, Typography } from '@mui/material'
|
||||
import React, { FC } from 'react'
|
||||
|
||||
type ListItemProfileProps = {
|
||||
onClickItem: () => void
|
||||
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>
|
||||
)
|
||||
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,3 +1,4 @@
|
||||
import { AppLogo } from '@/assets'
|
||||
import { AppBar, IconButton, Stack, StackProps, Typography, TypographyProps, styled } from '@mui/material'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
@ -47,3 +48,9 @@ export const StyledProfileContainer = styled((props: StackProps) => <Stack {...p
|
||||
export const StyledThemeButton = styled(IconButton)({
|
||||
margin: '0 0.5rem',
|
||||
})
|
||||
|
||||
export const StyledAppLogo = styled(AppLogo)(({ theme }) => ({
|
||||
'& path': {
|
||||
fill: theme.palette.text.primary,
|
||||
},
|
||||
}))
|
||||
|
@ -1,13 +1,19 @@
|
||||
import { FC } from 'react'
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import { Header } from './Header/Header'
|
||||
import { Container, ContainerProps, Divider, DividerProps, styled } from '@mui/material'
|
||||
import { Container, ContainerProps, styled } from '@mui/material'
|
||||
import { ReloadBadge } from '@/components/ReloadBadge/ReloadBadge'
|
||||
import { useSessionStorage } from 'usehooks-ts'
|
||||
import { RELOAD_STORAGE_KEY } from '@/utils/consts'
|
||||
|
||||
export const Layout: FC = () => {
|
||||
const [needReload] = useSessionStorage(RELOAD_STORAGE_KEY, false)
|
||||
const containerClassName = needReload ? 'reload' : ''
|
||||
|
||||
return (
|
||||
<StyledContainer maxWidth="md">
|
||||
<StyledContainer maxWidth="md" className={containerClassName}>
|
||||
<ReloadBadge />
|
||||
<Header />
|
||||
<StyledDivider />
|
||||
<main>
|
||||
<Outlet />
|
||||
</main>
|
||||
@ -24,14 +30,14 @@ const StyledContainer = styled((props: ContainerProps) => <Container maxWidth="s
|
||||
'& > main': {
|
||||
flex: 1,
|
||||
maxHeight: '100%',
|
||||
},
|
||||
'&:not(.reload) > main': {
|
||||
paddingTop: 'calc(66px + 1rem)',
|
||||
},
|
||||
})
|
||||
|
||||
const StyledDivider = styled((props: DividerProps) => <Divider {...props} />)({
|
||||
position: 'absolute',
|
||||
top: '66px',
|
||||
width: '100%',
|
||||
left: 0,
|
||||
height: '2px',
|
||||
'@media screen and (max-width: 320px)': {
|
||||
marginBottom: '0.25rem',
|
||||
paddingLeft: '0.75rem',
|
||||
paddingBottom: '0.75rem',
|
||||
paddingRight: '0.75rem',
|
||||
},
|
||||
})
|
||||
|
@ -1,24 +1,34 @@
|
||||
import { generatePrivateKey, getPublicKey, nip19 } from 'nostr-tools'
|
||||
import { Event, generatePrivateKey, getPublicKey, nip19, verifySignature } from 'nostr-tools'
|
||||
import { DbApp, dbi, DbKey, DbPending, DbPerm } from './db'
|
||||
import { Keys } from './keys'
|
||||
import NDK, {
|
||||
IEventHandlingStrategy,
|
||||
NDKEvent,
|
||||
NDKNip46Backend,
|
||||
NDKPrivateKeySigner,
|
||||
NDKSigner,
|
||||
NDKSubscription,
|
||||
NDKSubscriptionCacheUsage,
|
||||
NDKUser,
|
||||
} from '@nostr-dev-kit/ndk'
|
||||
import { NOAUTHD_URL, WEB_PUSH_PUBKEY, NIP46_RELAYS, MIN_POW, MAX_POW, KIND_RPC, DOMAIN } from '../utils/consts'
|
||||
import { Nip04 } from './nip04'
|
||||
import { NOAUTHD_URL, WEB_PUSH_PUBKEY, NIP46_RELAYS, MIN_POW, MAX_POW, KIND_RPC, DOMAIN, REQ_TTL } from '../utils/consts'
|
||||
// import { Nip04 } from './nip04'
|
||||
import { fetchNip05, getReqPerm, getShortenNpub, isPackagePerm } from '@/utils/helpers/helpers'
|
||||
import { NostrPowEvent, minePow } from './pow'
|
||||
//import { PrivateKeySigner } from './signer'
|
||||
|
||||
//const PERF_TEST = false
|
||||
|
||||
enum DECISION {
|
||||
ASK = '',
|
||||
ALLOW = 'allow',
|
||||
DISALLOW = 'disallow',
|
||||
IGNORE = 'ignore',
|
||||
}
|
||||
|
||||
export interface KeyInfo {
|
||||
npub: string
|
||||
nip05?: string
|
||||
name?: string
|
||||
locked: boolean
|
||||
}
|
||||
|
||||
@ -28,16 +38,17 @@ interface Key {
|
||||
backoff: number
|
||||
signer: NDKSigner
|
||||
backend: NDKNip46Backend
|
||||
watcher: Watcher
|
||||
}
|
||||
|
||||
interface Pending {
|
||||
req: DbPending
|
||||
cb: (allow: boolean, remember: boolean, options?: any) => void
|
||||
cb: (allow: DECISION, remember: boolean, options?: any) => void
|
||||
notified?: boolean
|
||||
}
|
||||
|
||||
interface IAllowCallbackParams {
|
||||
backend: NDKNip46Backend
|
||||
backend: Nip46Backend
|
||||
npub: string
|
||||
id: string
|
||||
method: string
|
||||
@ -46,86 +57,171 @@ interface IAllowCallbackParams {
|
||||
params?: any
|
||||
}
|
||||
|
||||
class Nip04KeyHandlingStrategy implements IEventHandlingStrategy {
|
||||
private privkey: string
|
||||
private nip04 = new Nip04()
|
||||
class Watcher {
|
||||
private ndk: NDK
|
||||
private signer: NDKSigner
|
||||
private onReply: (id: string) => void
|
||||
private sub?: NDKSubscription
|
||||
|
||||
constructor(privkey: string) {
|
||||
this.privkey = privkey
|
||||
constructor(ndk: NDK, signer: NDKSigner, onReply: (id: string) => void) {
|
||||
this.ndk = ndk
|
||||
this.signer = signer
|
||||
this.onReply = onReply
|
||||
}
|
||||
|
||||
private async getKey(backend: NDKNip46Backend, id: string, remotePubkey: string, recipientPubkey: string) {
|
||||
if (
|
||||
!(await backend.pubkeyAllowed({
|
||||
id,
|
||||
pubkey: remotePubkey,
|
||||
// @ts-ignore
|
||||
method: 'get_nip04_key',
|
||||
params: recipientPubkey,
|
||||
}))
|
||||
) {
|
||||
backend.debug(`get_nip04_key request from ${remotePubkey} rejected`)
|
||||
return undefined
|
||||
async start() {
|
||||
this.sub = this.ndk.subscribe(
|
||||
{
|
||||
kinds: [KIND_RPC],
|
||||
authors: [(await this.signer.user()).pubkey],
|
||||
since: Math.floor(Date.now() / 1000 - 10),
|
||||
},
|
||||
{
|
||||
closeOnEose: false,
|
||||
cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY,
|
||||
}
|
||||
)
|
||||
this.sub.on('event', async (e: NDKEvent) => {
|
||||
const peer = e.tags.find((t) => t.length >= 2 && t[0] === 'p')
|
||||
console.log('watcher got event', { e, peer })
|
||||
if (!peer) return
|
||||
const decryptedContent = await this.signer.decrypt(new NDKUser({ pubkey: peer[1] }), e.content)
|
||||
const parsedContent = JSON.parse(decryptedContent)
|
||||
const { id, method, params, result, error } = parsedContent
|
||||
console.log('watcher got', { peer, id, method, params, result, error })
|
||||
if (method || result === 'auth_url') return
|
||||
this.onReply(id)
|
||||
})
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.sub!.stop()
|
||||
}
|
||||
}
|
||||
|
||||
class Nip46Backend extends NDKNip46Backend {
|
||||
private allowCb: (params: IAllowCallbackParams) => Promise<DECISION>
|
||||
private npub: string = ''
|
||||
|
||||
public constructor(ndk: NDK, signer: NDKSigner, allowCb: (params: IAllowCallbackParams) => Promise<DECISION>) {
|
||||
super(ndk, signer, () => Promise.resolve(true))
|
||||
this.allowCb = allowCb
|
||||
signer.user().then((u) => (this.npub = nip19.npubEncode(u.pubkey)))
|
||||
}
|
||||
|
||||
public async processEvent(event: NDKEvent) {
|
||||
this.handleIncomingEvent(event)
|
||||
}
|
||||
|
||||
protected async handleIncomingEvent(event: NDKEvent) {
|
||||
const { id, method, params } = (await this.rpc.parseEvent(event)) as any
|
||||
const remotePubkey = event.pubkey
|
||||
let response: string | undefined
|
||||
|
||||
this.debug('incoming event', { id, method, params })
|
||||
|
||||
// validate signature explicitly
|
||||
if (!verifySignature(event.rawEvent() as Event)) {
|
||||
this.debug('invalid signature', event.rawEvent())
|
||||
return
|
||||
}
|
||||
|
||||
return Buffer.from(this.nip04.createKey(this.privkey, recipientPubkey)).toString('hex')
|
||||
}
|
||||
|
||||
async handle(backend: NDKNip46Backend, id: string, remotePubkey: string, params: string[]) {
|
||||
const [recipientPubkey] = params
|
||||
return await this.getKey(backend, id, remotePubkey, recipientPubkey)
|
||||
}
|
||||
}
|
||||
|
||||
class EventHandlingStrategyWrapper implements IEventHandlingStrategy {
|
||||
readonly backend: NDKNip46Backend
|
||||
readonly npub: string
|
||||
readonly method: string
|
||||
private body: IEventHandlingStrategy
|
||||
private allowCb: (params: IAllowCallbackParams) => Promise<boolean>
|
||||
|
||||
constructor(
|
||||
backend: NDKNip46Backend,
|
||||
npub: string,
|
||||
method: string,
|
||||
body: IEventHandlingStrategy,
|
||||
allowCb: (params: IAllowCallbackParams) => Promise<boolean>
|
||||
) {
|
||||
this.backend = backend
|
||||
this.npub = npub
|
||||
this.method = method
|
||||
this.body = body
|
||||
this.allowCb = allowCb
|
||||
}
|
||||
|
||||
async handle(
|
||||
backend: NDKNip46Backend,
|
||||
id: string,
|
||||
remotePubkey: string,
|
||||
params: string[]
|
||||
): Promise<string | undefined> {
|
||||
console.log(Date.now(), 'handle', {
|
||||
method: this.method,
|
||||
id,
|
||||
remotePubkey,
|
||||
params,
|
||||
})
|
||||
const allow = await this.allowCb({
|
||||
backend: this.backend,
|
||||
const decision = await this.allowCb({
|
||||
backend: this,
|
||||
npub: this.npub,
|
||||
id,
|
||||
method: this.method,
|
||||
method,
|
||||
remotePubkey,
|
||||
params,
|
||||
})
|
||||
if (!allow) return undefined
|
||||
return this.body.handle(backend, id, remotePubkey, params).then((r) => {
|
||||
console.log(Date.now(), 'req', id, 'method', this.method, 'result', r)
|
||||
return r
|
||||
})
|
||||
console.log(Date.now(), 'handle', { method, id, decision, remotePubkey, params })
|
||||
if (decision === DECISION.IGNORE) return
|
||||
|
||||
const allow = decision === DECISION.ALLOW
|
||||
const strategy = this.handlers[method]
|
||||
if (allow) {
|
||||
if (strategy) {
|
||||
try {
|
||||
response = await strategy.handle(this, id, remotePubkey, params)
|
||||
console.log(Date.now(), 'req', id, 'method', method, 'result', response)
|
||||
} catch (e: any) {
|
||||
this.debug('error handling event', e, { id, method, params })
|
||||
this.rpc.sendResponse(id, remotePubkey, 'error', undefined, e.message)
|
||||
}
|
||||
} else {
|
||||
this.debug('unsupported method', { method, params })
|
||||
}
|
||||
}
|
||||
|
||||
if (response) {
|
||||
this.debug(`sending response to ${remotePubkey}`, response)
|
||||
this.rpc.sendResponse(id, remotePubkey, response)
|
||||
} else {
|
||||
this.rpc.sendResponse(id, remotePubkey, 'error', undefined, 'Not authorized')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// class Nip04KeyHandlingStrategy implements IEventHandlingStrategy {
|
||||
// private privkey: string
|
||||
// private nip04 = new Nip04()
|
||||
|
||||
// constructor(privkey: string) {
|
||||
// this.privkey = privkey
|
||||
// }
|
||||
|
||||
// private async getKey(backend: NDKNip46Backend, id: string, remotePubkey: string, recipientPubkey: string) {
|
||||
// if (
|
||||
// !(await backend.pubkeyAllowed({
|
||||
// id,
|
||||
// pubkey: remotePubkey,
|
||||
// // @ts-ignore
|
||||
// method: 'get_nip04_key',
|
||||
// params: recipientPubkey,
|
||||
// }))
|
||||
// ) {
|
||||
// backend.debug(`get_nip04_key request from ${remotePubkey} rejected`)
|
||||
// return undefined
|
||||
// }
|
||||
|
||||
// return Buffer.from(this.nip04.createKey(this.privkey, recipientPubkey)).toString('hex')
|
||||
// }
|
||||
|
||||
// async handle(backend: NDKNip46Backend, id: string, remotePubkey: string, params: string[]) {
|
||||
// const [recipientPubkey] = params
|
||||
// return await this.getKey(backend, id, remotePubkey, recipientPubkey)
|
||||
// }
|
||||
// }
|
||||
|
||||
// FIXME why do we need it? Just to print
|
||||
// class EventHandlingStrategyWrapper implements IEventHandlingStrategy {
|
||||
// readonly backend: NDKNip46Backend
|
||||
// readonly method: string
|
||||
// private body: IEventHandlingStrategy
|
||||
|
||||
// constructor(
|
||||
// backend: NDKNip46Backend,
|
||||
// method: string,
|
||||
// body: IEventHandlingStrategy
|
||||
// ) {
|
||||
// this.backend = backend
|
||||
// this.method = method
|
||||
// this.body = body
|
||||
// }
|
||||
|
||||
// async handle(
|
||||
// backend: NDKNip46Backend,
|
||||
// id: string,
|
||||
// remotePubkey: string,
|
||||
// params: string[]
|
||||
// ): Promise<string | undefined> {
|
||||
// return this.body.handle(backend, id, remotePubkey, params).then((r) => {
|
||||
// console.log(Date.now(), 'req', id, 'method', this.method, 'result', r)
|
||||
// return r
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
|
||||
export class NoauthBackend {
|
||||
readonly swg: ServiceWorkerGlobalScope
|
||||
private keysModule: Keys
|
||||
@ -137,20 +233,21 @@ export class NoauthBackend {
|
||||
private confirmBuffer: Pending[] = []
|
||||
private accessBuffer: DbPending[] = []
|
||||
private notifCallback: (() => void) | null = null
|
||||
private pendingNpubEvents = new Map<string, NDKEvent[]>()
|
||||
private ndk = new NDK({
|
||||
explicitRelayUrls: NIP46_RELAYS,
|
||||
enableOutboxModel: false,
|
||||
})
|
||||
|
||||
public constructor(swg: ServiceWorkerGlobalScope) {
|
||||
this.swg = swg
|
||||
this.keysModule = new Keys(swg.crypto.subtle)
|
||||
this.ndk.connect()
|
||||
|
||||
const self = this
|
||||
swg.addEventListener('activate', (event) => {
|
||||
console.log('activate')
|
||||
// swg.addEventListener('activate', event => event.waitUntil(swg.clients.claim()));
|
||||
})
|
||||
|
||||
swg.addEventListener('install', (event) => {
|
||||
console.log('install')
|
||||
// swg.addEventListener('install', event => event.waitUntil(swg.skipWaiting()));
|
||||
console.log('activate new sw worker')
|
||||
this.reloadUI()
|
||||
})
|
||||
|
||||
swg.addEventListener('push', (event) => {
|
||||
@ -213,6 +310,13 @@ export class NoauthBackend {
|
||||
this.apps = await dbi.listApps()
|
||||
console.log('started apps', this.apps)
|
||||
|
||||
// drop old pending reqs
|
||||
const pending = await dbi.listPending()
|
||||
for (const p of pending) {
|
||||
if (p.timestamp < Date.now() - REQ_TTL)
|
||||
await dbi.removePending(p.id)
|
||||
}
|
||||
|
||||
const sub = await this.swg.registration.pushManager.getSubscription()
|
||||
|
||||
for (const k of this.enckeys) {
|
||||
@ -265,7 +369,7 @@ export class NoauthBackend {
|
||||
if (r.status !== 200 && r.status !== 201) {
|
||||
console.log('Fetch error', url, method, r.status)
|
||||
const body = await r.json()
|
||||
throw new Error('Failed to fetch ' + url, { cause: body })
|
||||
throw new Error('Failed to fetch ' + url, { cause: { body, status: r.status } })
|
||||
}
|
||||
|
||||
return await r.json()
|
||||
@ -400,13 +504,48 @@ export class NoauthBackend {
|
||||
})
|
||||
} catch (e: any) {
|
||||
console.log('error', e.cause)
|
||||
if (e.cause && e.cause.minPow > pow) pow = e.cause.minPow
|
||||
if (e.cause && e.cause.body && e.cause.body.minPow > pow) pow = e.cause.body.minPow
|
||||
else throw e
|
||||
}
|
||||
}
|
||||
throw new Error('Too many requests, retry later')
|
||||
}
|
||||
|
||||
private async sendDeleteNameToServer(npub: string, name: string) {
|
||||
const body = JSON.stringify({
|
||||
npub,
|
||||
name,
|
||||
})
|
||||
|
||||
const method = 'DELETE'
|
||||
const url = `${NOAUTHD_URL}/name`
|
||||
|
||||
return this.sendPostAuthd({
|
||||
npub,
|
||||
url,
|
||||
method,
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
private async sendTransferNameToServer(npub: string, name: string, newNpub: string) {
|
||||
const body = JSON.stringify({
|
||||
npub,
|
||||
name,
|
||||
newNpub,
|
||||
})
|
||||
|
||||
const method = 'PUT'
|
||||
const url = `${NOAUTHD_URL}/name`
|
||||
|
||||
return this.sendPostAuthd({
|
||||
npub,
|
||||
url,
|
||||
method,
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
private async sendTokenToServer(npub: string, token: string) {
|
||||
const body = JSON.stringify({
|
||||
npub,
|
||||
@ -501,6 +640,7 @@ export class NoauthBackend {
|
||||
return {
|
||||
npub: k.npub,
|
||||
nip05: k.nip05,
|
||||
name: k.name,
|
||||
locked: this.isLocked(k.npub),
|
||||
}
|
||||
}
|
||||
@ -541,11 +681,16 @@ export class NoauthBackend {
|
||||
await this.startKey({ npub, sk })
|
||||
|
||||
// assign nip05 before adding the key
|
||||
// FIXME set name to db and if this call to 'send' fails
|
||||
// then retry later
|
||||
if (!existingName && name && !name.includes('@')) {
|
||||
console.log('adding key', npub, name)
|
||||
await this.sendNameToServer(npub, name)
|
||||
try {
|
||||
await this.sendNameToServer(npub, name)
|
||||
} catch (e) {
|
||||
console.log('create name failed', e)
|
||||
// clear it
|
||||
await dbi.editName(npub, '')
|
||||
dbKey.name = ''
|
||||
}
|
||||
}
|
||||
|
||||
const sub = await this.swg.registration.pushManager.getSubscription()
|
||||
@ -554,7 +699,9 @@ export class NoauthBackend {
|
||||
return this.keyInfo(dbKey)
|
||||
}
|
||||
|
||||
private getPerm(req: DbPending): string {
|
||||
private getDecision(backend: Nip46Backend, req: DbPending): DECISION {
|
||||
if (!(req.method in backend.handlers)) return DECISION.IGNORE
|
||||
|
||||
const reqPerm = getReqPerm(req)
|
||||
const appPerms = this.perms.filter((p) => p.npub === req.npub && p.appNpub === req.appNpub)
|
||||
|
||||
@ -563,27 +710,36 @@ export class NoauthBackend {
|
||||
// non-exact next
|
||||
if (!perm) perm = appPerms.find((p) => isPackagePerm(p.perm, reqPerm))
|
||||
|
||||
console.log('req', req, 'perm', reqPerm, 'value', perm, appPerms)
|
||||
return perm?.value || ''
|
||||
if (perm) {
|
||||
console.log('req', req, 'perm', reqPerm, 'value', perm, appPerms)
|
||||
return perm.value === '1' ? DECISION.ALLOW : DECISION.DISALLOW
|
||||
}
|
||||
|
||||
const conn = appPerms.find((p) => p.perm === 'connect')
|
||||
if (conn && conn.value === '0') {
|
||||
console.log('req', req, 'perm', reqPerm, 'ignore by connect disallow')
|
||||
return DECISION.IGNORE
|
||||
}
|
||||
|
||||
return DECISION.ASK
|
||||
}
|
||||
|
||||
private async connectApp({
|
||||
npub,
|
||||
appNpub,
|
||||
appUrl,
|
||||
perms,
|
||||
appName = '',
|
||||
appIcon = ''
|
||||
}: {
|
||||
npub: string,
|
||||
appNpub: string,
|
||||
appUrl: string,
|
||||
appName?: string,
|
||||
appIcon?: string,
|
||||
perms: string[]
|
||||
}) {
|
||||
|
||||
await dbi.addApp({
|
||||
npub,
|
||||
appNpub,
|
||||
appUrl,
|
||||
perms,
|
||||
appName = '',
|
||||
appIcon = '',
|
||||
}: {
|
||||
npub: string
|
||||
appNpub: string
|
||||
appUrl: string
|
||||
appName?: string
|
||||
appIcon?: string
|
||||
perms: string[]
|
||||
}) {
|
||||
await dbi.addApp({
|
||||
appNpub: appNpub,
|
||||
npub: npub,
|
||||
timestamp: Date.now(),
|
||||
@ -618,19 +774,19 @@ export class NoauthBackend {
|
||||
method,
|
||||
remotePubkey,
|
||||
params,
|
||||
}: IAllowCallbackParams): Promise<boolean> {
|
||||
}: IAllowCallbackParams): Promise<DECISION> {
|
||||
// same reqs usually come on reconnects
|
||||
if (this.doneReqIds.includes(id)) {
|
||||
console.log('request already done', id)
|
||||
// FIXME maybe repeat the reply, but without the Notification?
|
||||
return false
|
||||
return DECISION.IGNORE
|
||||
}
|
||||
|
||||
const appNpub = nip19.npubEncode(remotePubkey)
|
||||
const connected = !!this.apps.find((a) => a.appNpub === appNpub)
|
||||
if (!connected && method !== 'connect') {
|
||||
console.log('ignoring request before connect', method, id, appNpub, npub)
|
||||
return false
|
||||
return DECISION.IGNORE
|
||||
}
|
||||
|
||||
const req: DbPending = {
|
||||
@ -645,9 +801,21 @@ export class NoauthBackend {
|
||||
const self = this
|
||||
return new Promise(async (ok) => {
|
||||
// called when it's decided whether to allow this or not
|
||||
const onAllow = async (manual: boolean, allow: boolean, remember: boolean, options?: any) => {
|
||||
const onAllow = async (manual: boolean, decision: DECISION, remember: boolean, options?: any) => {
|
||||
// confirm
|
||||
console.log(Date.now(), allow ? 'allowed' : 'disallowed', npub, method, options, params)
|
||||
console.log(Date.now(), decision, npub, method, options, params)
|
||||
|
||||
switch (decision) {
|
||||
case DECISION.ASK:
|
||||
throw new Error('Make a decision!')
|
||||
case DECISION.IGNORE:
|
||||
return // noop
|
||||
case DECISION.ALLOW:
|
||||
case DECISION.DISALLOW:
|
||||
// fall through
|
||||
}
|
||||
|
||||
const allow = decision === DECISION.ALLOW
|
||||
|
||||
if (manual) {
|
||||
await dbi.confirmPending(id, allow)
|
||||
@ -661,7 +829,7 @@ export class NoauthBackend {
|
||||
timestamp: Date.now(),
|
||||
name: '',
|
||||
icon: '',
|
||||
url: '',
|
||||
url: options.appUrl || '',
|
||||
})
|
||||
|
||||
// reload
|
||||
@ -700,35 +868,40 @@ export class NoauthBackend {
|
||||
|
||||
// reload
|
||||
this.perms = await dbi.listPerms()
|
||||
|
||||
// confirm pending requests that might now have
|
||||
// the proper perms
|
||||
const otherReqs = self.confirmBuffer.filter((r) => r.req.appNpub === req.appNpub)
|
||||
console.log('updated perms', this.perms, 'otherReqs', otherReqs, 'connected', connected)
|
||||
for (const r of otherReqs) {
|
||||
let perm = this.getPerm(r.req)
|
||||
if (perm) {
|
||||
r.cb(perm === '1', false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// release this promise to send reply
|
||||
// to this req
|
||||
ok(decision)
|
||||
|
||||
// notify UI that it was confirmed
|
||||
// if (!PERF_TEST)
|
||||
this.updateUI()
|
||||
|
||||
// return to let nip46 flow proceed
|
||||
ok(allow)
|
||||
// after replying to this req check pending
|
||||
// reqs maybe they can be replied right away
|
||||
if (remember) {
|
||||
// confirm pending requests that might now have
|
||||
// the proper perms
|
||||
const otherReqs = self.confirmBuffer.filter((r) => r.req.appNpub === req.appNpub)
|
||||
console.log('updated perms', this.perms, 'otherReqs', otherReqs, 'connected', connected)
|
||||
for (const r of otherReqs) {
|
||||
const dec = this.getDecision(backend, r.req)
|
||||
if (dec !== DECISION.ASK) {
|
||||
r.cb(dec, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// check perms
|
||||
const perm = this.getPerm(req)
|
||||
console.log(Date.now(), 'perm', req.id, perm)
|
||||
const dec = this.getDecision(backend, req)
|
||||
console.log(Date.now(), 'decision', req.id, dec)
|
||||
|
||||
// have perm?
|
||||
if (perm) {
|
||||
if (dec !== DECISION.ASK) {
|
||||
// reply immediately
|
||||
onAllow(false, perm === '1', false)
|
||||
onAllow(false, dec, false)
|
||||
} else {
|
||||
// put pending req to db
|
||||
await dbi.addPending(req)
|
||||
@ -739,19 +912,30 @@ export class NoauthBackend {
|
||||
// put to a list of pending requests
|
||||
this.confirmBuffer.push({
|
||||
req,
|
||||
cb: (allow, remember, options) => onAllow(true, allow, remember, options),
|
||||
cb: (decision, remember, options) => onAllow(true, decision, remember, options),
|
||||
})
|
||||
|
||||
// OAuth flow
|
||||
const confirmMethod = method === 'connect' ? 'confirm-connect' : 'confirm-event'
|
||||
const isConnect = method === 'connect'
|
||||
const confirmMethod = isConnect ? 'confirm-connect' : 'confirm-event'
|
||||
const authUrl = `${self.swg.location.origin}/key/${npub}?${confirmMethod}=true&appNpub=${appNpub}&reqId=${id}&popup=true`
|
||||
console.log('sending authUrl', authUrl, 'for', req)
|
||||
// NOTE: if you set 'Update on reload' in the Chrome SW console
|
||||
// then this message will cause a new tab opened by the peer,
|
||||
// which will cause SW (this code) to reload, to fetch
|
||||
// the pending requests and to re-send this event,
|
||||
// looping for 10 seconds (our request age threshold)
|
||||
backend.rpc.sendResponse(id, remotePubkey, 'auth_url', KIND_RPC, authUrl)
|
||||
|
||||
// NOTE: don't send auth_url immediately, wait some time
|
||||
// to make sure other bunkers aren't replying
|
||||
setTimeout(() => {
|
||||
// request still there? (not dropped by the watcher)
|
||||
if (self.confirmBuffer.find((r) => r.req.id === id)) {
|
||||
// NOTE: if you set 'Update on reload' in the Chrome SW console
|
||||
// then this message will cause a new tab opened by the peer,
|
||||
// which will cause SW (this code) to reload, to fetch
|
||||
// the pending requests and to re-send this event,
|
||||
// looping for 10 seconds (our request age threshold)
|
||||
backend.rpc.sendResponse(id, remotePubkey, 'auth_url', KIND_RPC, authUrl)
|
||||
} else {
|
||||
console.log("skip sending auth_url")
|
||||
}
|
||||
}, 500)
|
||||
|
||||
// show notifs
|
||||
// this.notify()
|
||||
@ -771,25 +955,30 @@ export class NoauthBackend {
|
||||
ndk.connect()
|
||||
|
||||
const signer = new NDKPrivateKeySigner(sk) // PrivateKeySigner
|
||||
const backend = new NDKNip46Backend(ndk, signer, () => Promise.resolve(true))
|
||||
this.keys.push({ npub, backend, signer, ndk, backoff })
|
||||
const backend = new Nip46Backend(ndk, signer, this.allowPermitCallback.bind(this)) // , () => Promise.resolve(true)
|
||||
const watcher = new Watcher(ndk, signer, (id) => {
|
||||
// drop pending request
|
||||
const index = self.confirmBuffer.findIndex((r) => r.req.id === id)
|
||||
if (index >= 0) self.confirmBuffer.splice(index, 1)
|
||||
dbi.removePending(id).then(() => this.updateUI())
|
||||
})
|
||||
this.keys.push({ npub, backend, signer, ndk, backoff, watcher })
|
||||
|
||||
// new method
|
||||
backend.handlers['get_nip04_key'] = new Nip04KeyHandlingStrategy(sk)
|
||||
// backend.handlers['get_nip04_key'] = new Nip04KeyHandlingStrategy(sk)
|
||||
|
||||
// assign our own permission callback
|
||||
for (const method in backend.handlers) {
|
||||
backend.handlers[method] = new EventHandlingStrategyWrapper(
|
||||
backend,
|
||||
npub,
|
||||
method,
|
||||
backend.handlers[method],
|
||||
this.allowPermitCallback.bind(this)
|
||||
)
|
||||
}
|
||||
// // assign our own permission callback
|
||||
// for (const method in backend.handlers) {
|
||||
// backend.handlers[method] = new EventHandlingStrategyWrapper(
|
||||
// backend,
|
||||
// method,
|
||||
// backend.handlers[method]
|
||||
// )
|
||||
// }
|
||||
|
||||
// start
|
||||
backend.start()
|
||||
watcher.start()
|
||||
console.log('started', npub)
|
||||
|
||||
// backoff reset on successfull connection
|
||||
@ -813,11 +1002,13 @@ export class NoauthBackend {
|
||||
const bo = self.keys.find((k) => k.npub === npub)?.backoff || 1000
|
||||
setTimeout(() => {
|
||||
console.log(new Date(), 'reconnect relays for key', npub, 'backoff', bo)
|
||||
// @ts-ignore
|
||||
for (const r of ndk.pool.relays.values()) r.disconnect()
|
||||
// make sure it no longer activates
|
||||
backend.handlers = {}
|
||||
|
||||
// stop watching
|
||||
watcher.stop()
|
||||
|
||||
self.keys = self.keys.filter((k) => k.npub !== npub)
|
||||
self.startKey({ npub, sk, backoff: Math.min(bo * 2, 60000) })
|
||||
}, bo)
|
||||
@ -828,6 +1019,27 @@ export class NoauthBackend {
|
||||
r.on('connect', onConnect)
|
||||
r.on('disconnect', onDisconnect)
|
||||
}
|
||||
|
||||
const pendingEvents = this.pendingNpubEvents.get(npub)
|
||||
if (pendingEvents) {
|
||||
this.pendingNpubEvents.delete(npub)
|
||||
for (const e of pendingEvents) {
|
||||
backend.processEvent(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchPendingRequests(npub: string, appNpub: string) {
|
||||
const { data: pubkey } = nip19.decode(npub)
|
||||
const { data: appPubkey } = nip19.decode(appNpub)
|
||||
|
||||
const events = await this.ndk.fetchEvents({
|
||||
kinds: [KIND_RPC],
|
||||
'#p': [pubkey as string],
|
||||
authors: [appPubkey as string],
|
||||
})
|
||||
console.log('fetched pending for', npub, events.size)
|
||||
this.pendingNpubEvents.set(npub, [...events.values()])
|
||||
}
|
||||
|
||||
public async unlock(npub: string) {
|
||||
@ -944,7 +1156,7 @@ export class NoauthBackend {
|
||||
this.updateUI()
|
||||
} else {
|
||||
console.log('confirming req', id, allow, remember, options)
|
||||
req.cb(allow, remember, options)
|
||||
req.cb(allow ? DECISION.ALLOW : DECISION.DISALLOW, remember, options)
|
||||
}
|
||||
}
|
||||
|
||||
@ -962,6 +1174,36 @@ export class NoauthBackend {
|
||||
this.updateUI()
|
||||
}
|
||||
|
||||
private async editName(npub: string, name: string) {
|
||||
const key = this.enckeys.find((k) => k.npub === npub)
|
||||
if (!key) throw new Error('Npub not found')
|
||||
if (key.name) {
|
||||
try {
|
||||
await this.sendDeleteNameToServer(npub, key.name)
|
||||
} catch (e: any) {
|
||||
if (e.cause && e.cause.status !== 404) throw e
|
||||
console.log("Deleted name didn't exist")
|
||||
}
|
||||
}
|
||||
if (name) {
|
||||
await this.sendNameToServer(npub, name)
|
||||
}
|
||||
await dbi.editName(npub, name)
|
||||
key.name = name
|
||||
this.updateUI()
|
||||
}
|
||||
|
||||
private async transferName(npub: string, name: string, newNpub: string) {
|
||||
const key = this.enckeys.find((k) => k.npub === npub)
|
||||
if (!key) throw new Error('Npub not found')
|
||||
if (!name) throw new Error('Empty name')
|
||||
if (key.name !== name) throw new Error('Name changed, please reload')
|
||||
await this.sendTransferNameToServer(npub, key.name, newNpub)
|
||||
await dbi.editName(npub, '')
|
||||
key.name = ''
|
||||
this.updateUI()
|
||||
}
|
||||
|
||||
private async enablePush(): Promise<boolean> {
|
||||
const options = {
|
||||
userVisibleOnly: true,
|
||||
@ -1008,8 +1250,14 @@ export class NoauthBackend {
|
||||
result = await this.deleteApp(args[0])
|
||||
} else if (method === 'deletePerm') {
|
||||
result = await this.deletePerm(args[0])
|
||||
} else if (method === 'editName') {
|
||||
result = await this.editName(args[0], args[1])
|
||||
} else if (method === 'transferName') {
|
||||
result = await this.transferName(args[0], args[1], args[2])
|
||||
} else if (method === 'enablePush') {
|
||||
result = await this.enablePush()
|
||||
} else if (method === 'fetchPendingRequests') {
|
||||
result = await this.fetchPendingRequests(args[0], args[1])
|
||||
} else {
|
||||
console.log('unknown method from UI ', method)
|
||||
}
|
||||
@ -1036,10 +1284,20 @@ export class NoauthBackend {
|
||||
}
|
||||
}
|
||||
|
||||
private async reloadUI() {
|
||||
const clients = await this.swg.clients.matchAll({
|
||||
includeUncontrolled: true,
|
||||
})
|
||||
console.log('reloadUI clients', clients.length)
|
||||
for (const client of clients) {
|
||||
client.postMessage({ result: 'reload' })
|
||||
}
|
||||
}
|
||||
|
||||
public async onPush(event: any) {
|
||||
console.log('push', { data: event.data })
|
||||
// noop - we just need browser to launch this worker
|
||||
// FIXME use event.waitUntil and and unblock after we
|
||||
// show a notification
|
||||
// show a notification to avoid annoying the browser
|
||||
}
|
||||
}
|
||||
|
@ -89,6 +89,16 @@ export const dbi = {
|
||||
return []
|
||||
}
|
||||
},
|
||||
editName: async (npub: string, name: string): Promise<void> => {
|
||||
try {
|
||||
await db.keys.where({ npub }).modify({
|
||||
name,
|
||||
})
|
||||
} catch (error) {
|
||||
console.log(`db editName error: ${error}`)
|
||||
return
|
||||
}
|
||||
},
|
||||
getApp: async (appNpub: string) => {
|
||||
try {
|
||||
return await db.apps.get(appNpub)
|
||||
|
@ -21,11 +21,31 @@ const ALGO = 'aes-256-cbc'
|
||||
const IV_SIZE = 16
|
||||
|
||||
// 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 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 {
|
||||
subtle: any
|
||||
|
||||
@ -33,10 +53,6 @@ export class Keys {
|
||||
this.subtle = cryptoSubtle
|
||||
}
|
||||
|
||||
public isValidPassphase(passphrase: string): boolean {
|
||||
return ASCII_REGEX.test(passphrase)
|
||||
}
|
||||
|
||||
public async generatePassKey(pubkey: string, passphrase: string): Promise<{ passkey: Buffer; pwh: string }> {
|
||||
const salt = Buffer.from(pubkey, 'hex')
|
||||
|
||||
@ -45,7 +61,7 @@ export class Keys {
|
||||
// We could use string.normalize() to make sure all JS implementations
|
||||
// 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
|
||||
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) => {
|
||||
// NOTE: we should use Argon2 or scrypt later, for now
|
||||
|
@ -1,10 +1,13 @@
|
||||
// service-worker client interface
|
||||
// service-worker client interface,
|
||||
// works on the frontend, not sw
|
||||
import * as serviceWorkerRegistration from '../serviceWorkerRegistration'
|
||||
|
||||
export let swr: ServiceWorkerRegistration | null = null
|
||||
const reqs = new Map<number, { ok: (r: any) => void; rej: (r: any) => void }>()
|
||||
let nextReqId = 1
|
||||
let onRender: (() => void) | null = null
|
||||
let onReload: (() => void) | null = null
|
||||
const queue: (() => Promise<void> | void)[] = []
|
||||
|
||||
export async function swicRegister() {
|
||||
serviceWorkerRegistration.register({
|
||||
@ -13,18 +16,24 @@ export async function swicRegister() {
|
||||
swr = registration
|
||||
},
|
||||
onError(e) {
|
||||
console.log(`error ${e}`)
|
||||
console.log('sw error', e)
|
||||
},
|
||||
onUpdate() {
|
||||
// tell new SW that it should activate immediately
|
||||
swr?.waiting?.postMessage({ type: 'SKIP_WAITING' })
|
||||
},
|
||||
})
|
||||
|
||||
navigator.serviceWorker.ready.then((r) => {
|
||||
console.log('sw ready')
|
||||
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.')
|
||||
}
|
||||
|
||||
while (queue.length) await queue.shift()!()
|
||||
})
|
||||
|
||||
navigator.serviceWorker.addEventListener('message', (event) => {
|
||||
@ -32,12 +41,23 @@ export async function swicRegister() {
|
||||
})
|
||||
}
|
||||
|
||||
export function swicWaitStarted() {
|
||||
return new Promise<void>((ok) => {
|
||||
if (swr && swr.active) ok()
|
||||
else queue.push(ok)
|
||||
})
|
||||
}
|
||||
|
||||
function onMessage(data: any) {
|
||||
const { id, result, error } = data
|
||||
console.log('SW message', id, result, error)
|
||||
|
||||
if (!id) {
|
||||
if (onRender) onRender()
|
||||
if (result === 'reload') {
|
||||
if (onReload) onReload()
|
||||
} else {
|
||||
if (onRender) onRender()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@ -57,22 +77,31 @@ export async function swicCall(method: string, ...args: any[]) {
|
||||
nextReqId++
|
||||
|
||||
return new Promise((ok, rej) => {
|
||||
if (!swr || !swr.active) {
|
||||
rej(new Error('No active service worker'))
|
||||
return
|
||||
const call = async () => {
|
||||
if (!swr || !swr.active) {
|
||||
rej(new Error('No active service worker'))
|
||||
return
|
||||
}
|
||||
|
||||
reqs.set(id, { ok, rej })
|
||||
const msg = {
|
||||
id,
|
||||
method,
|
||||
args: [...args],
|
||||
}
|
||||
console.log('sending to SW', msg)
|
||||
swr.active.postMessage(msg)
|
||||
}
|
||||
|
||||
reqs.set(id, { ok, rej })
|
||||
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) {
|
||||
onRender = cb
|
||||
}
|
||||
|
||||
export function swicOnReload(cb: () => void) {
|
||||
onReload = cb
|
||||
}
|
||||
|
@ -21,6 +21,7 @@ const commonTheme: Theme = createTheme({
|
||||
styleOverrides: {
|
||||
root: {
|
||||
textTransform: 'initial',
|
||||
color: 'red',
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -96,4 +97,6 @@ const darkTheme: Theme = createTheme({
|
||||
},
|
||||
})
|
||||
|
||||
console.log(darkTheme)
|
||||
|
||||
export { lightTheme, darkTheme }
|
||||
|
@ -9,7 +9,6 @@ import { getAppIconTitle, getDomain, getShortenNpub } from '@/utils/helpers/help
|
||||
import { Button } from '@/shared/Button/Button'
|
||||
import { ACTION_TYPE } from '@/utils/consts'
|
||||
import { Permissions } from './components/Permissions/Permissions'
|
||||
import { StyledAppIcon } from './styled'
|
||||
import { useToggleConfirm } from '@/hooks/useToggleConfirm'
|
||||
import { ConfirmModal } from '@/shared/ConfirmModal/ConfirmModal'
|
||||
import { swicCall } from '@/modules/swic'
|
||||
@ -20,6 +19,8 @@ import { useModalSearchParams } from '@/hooks/useModalSearchParams'
|
||||
import { MODAL_PARAMS_KEYS } from '@/types/modal'
|
||||
import MoreIcon from '@mui/icons-material/MoreVertRounded'
|
||||
import { ModalAppDetails } from '@/components/Modal/ModalAppDetails/ModalAppDetails'
|
||||
import { IconApp } from '@/shared/IconApp/IconApp'
|
||||
import { HeadingContainer, AppInfoContainer, AppNameContainer } from './styled'
|
||||
|
||||
const AppPage = () => {
|
||||
const keys = useAppSelector(selectKeys)
|
||||
@ -43,8 +44,10 @@ const AppPage = () => {
|
||||
|
||||
const { icon = '', name = '', url = '' } = currentApp || {}
|
||||
const appDomain = getDomain(url)
|
||||
const appName = name || appDomain || getShortenNpub(appNpub)
|
||||
const shortAppNpub = getShortenNpub(appNpub)
|
||||
const appName = name || appDomain || shortAppNpub
|
||||
const appAvatarTitle = getAppIconTitle(name || appDomain, appNpub)
|
||||
const isAppNameExists = !!name || !!appDomain
|
||||
|
||||
const { timestamp } = connectPerm || {}
|
||||
const connectedOn = connectPerm && timestamp ? `Connected at ${formatTimestampDate(timestamp)}` : 'Not connected'
|
||||
@ -65,22 +68,33 @@ const AppPage = () => {
|
||||
<>
|
||||
<Stack maxHeight={'100%'} overflow={'auto'} alignItems={'flex-start'} height={'100%'}>
|
||||
<IOSBackButton onNavigate={() => navigate(`key/${npub}`)} />
|
||||
<Stack marginBottom={'1rem'} direction={'row'} gap={'1rem'} width={'100%'}>
|
||||
<StyledAppIcon src={icon}>{appAvatarTitle}</StyledAppIcon>
|
||||
<Box flex={'1'} overflow={'hidden'}>
|
||||
<Stack direction={'row'} alignItems={'center'} gap={'0.5rem'}>
|
||||
<Typography variant="h4" noWrap flex={1}>
|
||||
{appName}
|
||||
</Typography>
|
||||
|
||||
<HeadingContainer>
|
||||
<IconApp size="big" picture={icon} alt={appAvatarTitle} />
|
||||
<Box flex={'1'} overflow={'auto'} alignSelf={'flex-start'} width={'100%'}>
|
||||
<AppInfoContainer>
|
||||
<AppNameContainer>
|
||||
<Typography className="app_name" variant="h4" noWrap>
|
||||
{appName}
|
||||
</Typography>
|
||||
{isAppNameExists && (
|
||||
<Typography noWrap display={'block'} variant="body1" color={'GrayText'}>
|
||||
{shortAppNpub}
|
||||
</Typography>
|
||||
)}
|
||||
</AppNameContainer>
|
||||
|
||||
<IconButton onClick={handleShowAppDetailsModal}>
|
||||
<MoreIcon />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
</AppInfoContainer>
|
||||
|
||||
<Typography variant="body2" noWrap>
|
||||
{connectedOn}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</HeadingContainer>
|
||||
|
||||
<Box marginBottom={'1rem'}>
|
||||
<SectionTitle marginBottom={'0.5rem'}>Disconnect</SectionTitle>
|
||||
<Button fullWidth onClick={handleShow}>
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { FC } from 'react'
|
||||
import { FC } from 'react'
|
||||
import { DbHistory } from '@/modules/db'
|
||||
import { Box, IconButton, Typography } from '@mui/material'
|
||||
import { StyledActivityItem } from './styled'
|
||||
@ -6,16 +6,17 @@ import { formatTimestampDate } from '@/utils/helpers/date'
|
||||
import ClearRoundedIcon from '@mui/icons-material/ClearRounded'
|
||||
import DoneRoundedIcon from '@mui/icons-material/DoneRounded'
|
||||
import MoreVertRoundedIcon from '@mui/icons-material/MoreVertRounded'
|
||||
import { ACTIONS } from '@/utils/consts'
|
||||
import { getReqActionName } from '@/utils/helpers/helpers'
|
||||
|
||||
type ItemActivityProps = DbHistory
|
||||
|
||||
export const ItemActivity: FC<ItemActivityProps> = ({ allowed, method, timestamp }) => {
|
||||
export const ItemActivity: FC<ItemActivityProps> = (req) => {
|
||||
const { allowed, timestamp } = req
|
||||
return (
|
||||
<StyledActivityItem>
|
||||
<Box display={'flex'} flexDirection={'column'} gap={'0.5rem'} flex={1}>
|
||||
<Typography flex={1} fontWeight={700}>
|
||||
{ACTIONS[method] || method}
|
||||
{getReqActionName(req)}
|
||||
</Typography>
|
||||
<Typography variant="body2">{formatTimestampDate(timestamp)}</Typography>
|
||||
</Box>
|
||||
|
@ -1,6 +1,32 @@
|
||||
import { Avatar, AvatarProps, styled } from '@mui/material'
|
||||
import { Box, BoxProps, Stack, StackProps, styled } from '@mui/material'
|
||||
|
||||
export const StyledAppIcon = styled((props: AvatarProps) => <Avatar {...props} variant="rounded" />)(() => ({
|
||||
width: 70,
|
||||
height: 70,
|
||||
export const HeadingContainer = styled((props: StackProps) => <Stack {...props} />)(() => ({
|
||||
width: '100%',
|
||||
marginBottom: '1rem',
|
||||
flexDirection: 'row',
|
||||
gap: '1rem',
|
||||
alignItems: 'center',
|
||||
'@media screen and (max-width: 320px)': {
|
||||
flexDirection: 'column',
|
||||
gap: '0.5rem',
|
||||
},
|
||||
}))
|
||||
|
||||
export const AppInfoContainer = styled((props: StackProps) => <Stack {...props} direction={'row'} />)(() => ({
|
||||
width: '100%',
|
||||
flex: 1,
|
||||
alignItems: 'flex-start',
|
||||
gap: '0.5rem',
|
||||
marginBottom: '0.5rem',
|
||||
overflow: 'hidden',
|
||||
'@media screen and (max-width: 320px)': {
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
}))
|
||||
|
||||
export const AppNameContainer = styled((props: BoxProps) => <Box {...props} />)(() => ({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
}))
|
||||
|
@ -24,7 +24,7 @@ export const StyledAppLogo = styled((props) => (
|
||||
<AppLogo />
|
||||
</Box>
|
||||
))({
|
||||
background: '#00000054',
|
||||
background: '#0d0d0d',
|
||||
padding: '0.75rem',
|
||||
borderRadius: '16px',
|
||||
display: 'grid',
|
||||
|
@ -8,6 +8,8 @@ import { ModalConfirmConnect } from '@/components/Modal/ModalConfirmConnect/Moda
|
||||
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()
|
||||
@ -16,6 +18,8 @@ const CreatePage = () => {
|
||||
|
||||
const [searchParams] = useSearchParams()
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const name = searchParams.get('name') || ''
|
||||
const token = searchParams.get('token') || ''
|
||||
const appNpub = searchParams.get('appNpub') || ''
|
||||
@ -30,18 +34,14 @@ const CreatePage = () => {
|
||||
|
||||
const handleClickAddAccount = async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
const key: any = await swicCall('generateKey', name)
|
||||
|
||||
let appUrl = ''
|
||||
if (window.document.referrer) {
|
||||
try {
|
||||
const u = new URL(window.document.referrer)
|
||||
appUrl = u.origin
|
||||
} catch {}
|
||||
}
|
||||
const appUrl = getReferrerAppUrl()
|
||||
|
||||
console.log('Created', key.npub, 'app', appUrl)
|
||||
setCreated(true)
|
||||
setIsLoading(false)
|
||||
|
||||
handleOpen(MODAL_PARAMS_KEYS.CONFIRM_CONNECT, {
|
||||
search: {
|
||||
@ -58,6 +58,7 @@ const CreatePage = () => {
|
||||
})
|
||||
} catch (error: any) {
|
||||
notify(error.message || error.toString(), 'error')
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
@ -93,7 +94,9 @@ const CreatePage = () => {
|
||||
<Typography textAlign={'left'} variant="h6" paddingTop="0.5em">
|
||||
Chosen name: <b>{nip05}</b>
|
||||
</Typography>
|
||||
<GetStartedButton onClick={handleClickAddAccount}>Create account</GetStartedButton>
|
||||
<GetStartedButton onClick={handleClickAddAccount}>
|
||||
Create account {isLoading && <LoadingSpinner />}
|
||||
</GetStartedButton>
|
||||
|
||||
<Typography textAlign={'left'} variant="h5" paddingTop="1em">
|
||||
What you need to know:
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useAppSelector } from '../../store/hooks/redux'
|
||||
import { Navigate, useParams } from 'react-router-dom'
|
||||
import { Stack } from '@mui/material'
|
||||
import { Navigate, useParams, useSearchParams } from 'react-router-dom'
|
||||
import { Box, IconButton, Stack } from '@mui/material'
|
||||
import { StyledIconButton } from './styled'
|
||||
import { SettingsIcon, ShareIcon } from '@/assets'
|
||||
import { Apps } from './components/Apps'
|
||||
@ -18,13 +19,20 @@ import { useTriggerConfirmModal } from './hooks/useTriggerConfirmModal'
|
||||
import { useLiveQuery } from 'dexie-react-hooks'
|
||||
import { checkNpubSyncQuerier } from './utils'
|
||||
import { DOMAIN } from '@/utils/consts'
|
||||
import { useCallback } from 'react'
|
||||
import { InputCopyButton } from '@/shared/InputCopyButton/InputCopyButton'
|
||||
import MoreHorizRoundedIcon from '@mui/icons-material/MoreHorizRounded'
|
||||
import { ModalEditName } from '@/components/Modal/ModalEditName/ModalEditName'
|
||||
|
||||
const KeyPage = () => {
|
||||
const { npub = '' } = useParams<{ npub: string }>()
|
||||
const { keys, apps, pending, perms } = useAppSelector((state) => state.content)
|
||||
const [searchParams] = useSearchParams()
|
||||
|
||||
const [isCheckingSync, setIsChecking] = useState(true)
|
||||
const handleStopChecking = () => setIsChecking(false)
|
||||
|
||||
const isSynced = useLiveQuery(checkNpubSyncQuerier(npub, handleStopChecking), [npub], false)
|
||||
|
||||
const isSynced = useLiveQuery(checkNpubSyncQuerier(npub), [npub], false)
|
||||
const { handleOpen } = useModalSearchParams()
|
||||
const { handleEnableBackground, showWarning, isEnabling } = useBackgroundSigning()
|
||||
|
||||
@ -41,10 +49,21 @@ const KeyPage = () => {
|
||||
const { prepareEventPendings } = useTriggerConfirmModal(npub, pending, perms)
|
||||
|
||||
const isKeyExists = npub.trim().length && key
|
||||
const isPopup = searchParams.get('popup') === 'true'
|
||||
// console.log({ isKeyExists, isPopup })
|
||||
|
||||
if (isPopup && !isKeyExists) {
|
||||
searchParams.set('login', 'true')
|
||||
searchParams.set('npub', npub)
|
||||
const url = `/home?${searchParams.toString()}`
|
||||
return <Navigate to={url} />
|
||||
}
|
||||
|
||||
if (!isKeyExists) return <Navigate to={`/home`} />
|
||||
|
||||
const handleOpenConnectAppModal = () => handleOpen(MODAL_PARAMS_KEYS.CONNECT_APP)
|
||||
const handleOpenSettingsModal = () => handleOpen(MODAL_PARAMS_KEYS.SETTINGS)
|
||||
const handleOpenEditNameModal = () => handleOpen(MODAL_PARAMS_KEYS.EDIT_NAME)
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -55,13 +74,20 @@ const KeyPage = () => {
|
||||
<UserValueSection
|
||||
title="Your login"
|
||||
value={username}
|
||||
copyValue={username}
|
||||
endAdornment={
|
||||
<Box display={'flex'} alignItems={'center'} gap={'0.25rem'}>
|
||||
<IconButton onClick={handleOpenEditNameModal} color={username ? 'default' : 'error'}>
|
||||
<MoreHorizRoundedIcon />
|
||||
</IconButton>
|
||||
<InputCopyButton value={username} />
|
||||
</Box>
|
||||
}
|
||||
explanationType={EXPLANATION_MODAL_KEYS.LOGIN}
|
||||
/>
|
||||
<UserValueSection
|
||||
title="Your NPUB"
|
||||
value={npub}
|
||||
copyValue={npub}
|
||||
endAdornment={<InputCopyButton value={npub} />}
|
||||
explanationType={EXPLANATION_MODAL_KEYS.NPUB}
|
||||
/>
|
||||
|
||||
@ -71,7 +97,11 @@ const KeyPage = () => {
|
||||
Connect app
|
||||
</StyledIconButton>
|
||||
|
||||
<StyledIconButton bgcolor_variant="secondary" onClick={handleOpenSettingsModal} withBadge={!isSynced}>
|
||||
<StyledIconButton
|
||||
bgcolor_variant="secondary"
|
||||
onClick={handleOpenSettingsModal}
|
||||
withBadge={!isCheckingSync && !isSynced}
|
||||
>
|
||||
<SettingsIcon />
|
||||
Settings
|
||||
</StyledIconButton>
|
||||
@ -79,11 +109,13 @@ const KeyPage = () => {
|
||||
|
||||
<Apps apps={filteredApps} npub={npub} />
|
||||
</Stack>
|
||||
|
||||
<ModalConnectApp />
|
||||
<ModalSettings isSynced={isSynced} />
|
||||
<ModalExplanation />
|
||||
<ModalConfirmConnect />
|
||||
<ModalConfirmEvent confirmEventReqs={prepareEventPendings} />
|
||||
<ModalEditName />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -5,9 +5,7 @@ import { Box, Stack, Typography } from '@mui/material'
|
||||
import { FC } from 'react'
|
||||
import { StyledEmptyAppsBox } from '../styled'
|
||||
import { Button } from '@/shared/Button/Button'
|
||||
import { call } from '@/utils/helpers/helpers'
|
||||
import { swicCall } from '@/modules/swic'
|
||||
import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar'
|
||||
|
||||
import { ItemApp } from './ItemApp'
|
||||
|
||||
type AppsProps = {
|
||||
@ -16,22 +14,12 @@ type AppsProps = {
|
||||
}
|
||||
|
||||
export const Apps: FC<AppsProps> = ({ apps = [] }) => {
|
||||
const notify = useEnqueueSnackbar()
|
||||
|
||||
// eslint-disable-next-line
|
||||
async function deletePerm(id: string) {
|
||||
call(async () => {
|
||||
await swicCall('deletePerm', id)
|
||||
notify('Perm deleted!', 'success')
|
||||
})
|
||||
}
|
||||
|
||||
const openAppStore = () => {
|
||||
window.open('https://nostrapp.link', '_blank')
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flex={1} marginBottom={'1rem'} display={'flex'} flexDirection={'column'} overflow={'auto'}>
|
||||
<Box marginBottom={'1rem'} display={'flex'} flexDirection={'column'}>
|
||||
<Stack direction={'row'} alignItems={'center'} justifyContent={'space-between'} marginBottom={'0.5rem'}>
|
||||
<SectionTitle>Connected apps</SectionTitle>
|
||||
<AppLink title="Discover Apps" onClick={openAppStore} />
|
||||
@ -45,7 +33,7 @@ export const Apps: FC<AppsProps> = ({ apps = [] }) => {
|
||||
</StyledEmptyAppsBox>
|
||||
)}
|
||||
|
||||
<Stack gap={'0.5rem'} overflow={'auto'} flex={1}>
|
||||
<Stack gap={'0.5rem'} overflow={'auto'} flex={1} paddingBottom={'0.75rem'}>
|
||||
{apps.map((a) => (
|
||||
<ItemApp {...a} key={a.appNpub} />
|
||||
))}
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { FC } from 'react'
|
||||
import { Warning } from '@/components/Warning/Warning'
|
||||
import { CircularProgress, Stack, Typography } from '@mui/material'
|
||||
import { CircularProgress, Stack, Typography, TypographyProps, styled } from '@mui/material'
|
||||
import AutoModeOutlinedIcon from '@mui/icons-material/AutoModeOutlined'
|
||||
|
||||
type BackgroundSigningWarningProps = {
|
||||
@ -12,18 +12,29 @@ export const BackgroundSigningWarning: FC<BackgroundSigningWarningProps> = ({ is
|
||||
return (
|
||||
<Warning
|
||||
message={
|
||||
<Stack direction={'row'} alignItems={'center'} gap={'1rem'}>
|
||||
Enable background service {isEnabling ? <CircularProgress size={'1.5rem'} /> : null}
|
||||
<Stack gap={'0.25rem'} overflow={'auto'} width={'100%'}>
|
||||
<Typography variant="body1" noWrap fontWeight={'500'}>
|
||||
Enable background service
|
||||
</Typography>
|
||||
<StyledHint>Please allow notifications for background operation.</StyledHint>
|
||||
</Stack>
|
||||
}
|
||||
hint={
|
||||
<Typography variant='body2'>
|
||||
Please allow notifications
|
||||
for background operation.
|
||||
</Typography>
|
||||
icon={
|
||||
isEnabling ? (
|
||||
<CircularProgress size={'1.5rem'} sx={{ color: '#fff' }} />
|
||||
) : (
|
||||
<AutoModeOutlinedIcon htmlColor="white" />
|
||||
)
|
||||
}
|
||||
icon={<AutoModeOutlinedIcon htmlColor="white" />}
|
||||
onClick={isEnabling ? undefined : onEnableBackSigning}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const StyledHint = styled((props: TypographyProps) => <Typography variant="body2" {...props} />)(() => ({
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}))
|
||||
|
@ -9,9 +9,12 @@ type ItemAppProps = DbApp
|
||||
|
||||
export const ItemApp: FC<ItemAppProps> = ({ npub, appNpub, icon, name, url }) => {
|
||||
const appDomain = getDomain(url)
|
||||
const appName = name || appDomain || getShortenNpub(appNpub)
|
||||
const shortAppNpub = getShortenNpub(appNpub)
|
||||
const appName = name || appDomain || shortAppNpub
|
||||
const appIcon = icon || `https://${appDomain}/favicon.ico`
|
||||
const appAvatarTitle = getAppIconTitle(name || appDomain, appNpub)
|
||||
const appAvatarTitle = getAppIconTitle(name || appDomain, appNpub)
|
||||
const isAppNameExists = !!name || !!appDomain
|
||||
|
||||
return (
|
||||
<StyledItemAppContainer
|
||||
direction={'row'}
|
||||
@ -21,18 +24,18 @@ export const ItemApp: FC<ItemAppProps> = ({ npub, appNpub, icon, name, url }) =>
|
||||
component={Link}
|
||||
to={`/key/${npub}/app/${appNpub}`}
|
||||
>
|
||||
<Avatar
|
||||
variant="rounded"
|
||||
sx={{ width: 56, height: 56 }}
|
||||
src={appIcon}
|
||||
alt={appName}
|
||||
>
|
||||
<Avatar variant="rounded" sx={{ width: 56, height: 56 }} src={appIcon} alt={appName}>
|
||||
{appAvatarTitle}
|
||||
</Avatar>
|
||||
<Stack>
|
||||
<Typography noWrap display={'block'} variant="body2">
|
||||
<Typography noWrap display={'block'} variant="body1">
|
||||
{appName}
|
||||
</Typography>
|
||||
{isAppNameExists && (
|
||||
<Typography noWrap display={'block'} variant="body2" color={'GrayText'}>
|
||||
{shortAppNpub}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography noWrap display={'block'} variant="caption" color={'GrayText'}>
|
||||
Basic actions
|
||||
</Typography>
|
||||
|
@ -3,7 +3,6 @@ import { Box, Stack } from '@mui/material'
|
||||
import { EXPLANATION_MODAL_KEYS, MODAL_PARAMS_KEYS } from '@/types/modal'
|
||||
import { SectionTitle } from '@/shared/SectionTitle/SectionTitle'
|
||||
import { AppLink } from '@/shared/AppLink/AppLink'
|
||||
import { InputCopyButton } from '@/shared/InputCopyButton/InputCopyButton'
|
||||
import { StyledInput } from '../styled'
|
||||
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
|
||||
|
||||
@ -11,10 +10,10 @@ type UserValueSectionProps = {
|
||||
title: string
|
||||
value: string
|
||||
explanationType: EXPLANATION_MODAL_KEYS
|
||||
copyValue: string
|
||||
endAdornment?: React.ReactNode
|
||||
}
|
||||
|
||||
const UserValueSection: FC<UserValueSectionProps> = ({ title, value, explanationType, copyValue }) => {
|
||||
const UserValueSection: FC<UserValueSectionProps> = ({ title, value, explanationType, endAdornment }) => {
|
||||
const { handleOpen } = useModalSearchParams()
|
||||
|
||||
const handleOpenExplanationModal = (type: EXPLANATION_MODAL_KEYS) => {
|
||||
@ -30,7 +29,7 @@ const UserValueSection: FC<UserValueSectionProps> = ({ title, value, explanation
|
||||
<SectionTitle>{title}</SectionTitle>
|
||||
<AppLink title="What is this?" onClick={() => handleOpenExplanationModal(explanationType)} />
|
||||
</Stack>
|
||||
<StyledInput value={value} readOnly endAdornment={<InputCopyButton value={copyValue} />} />
|
||||
<StyledInput value={value} readOnly endAdornment={endAdornment} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { Input, AppInputProps } from '@/shared/Input/Input'
|
||||
import { Input } from '@/shared/Input/Input'
|
||||
import { AppInputProps } from '@/shared/Input/types'
|
||||
import { Stack, StackProps, styled } from '@mui/material'
|
||||
import { forwardRef } from 'react'
|
||||
|
||||
|
@ -1,8 +1,9 @@
|
||||
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
|
||||
import { DbPending, DbPerm } from '@/modules/db'
|
||||
import { MODAL_PARAMS_KEYS } from '@/types/modal'
|
||||
import { ACTION_TYPE } from '@/utils/consts'
|
||||
import { ACTION_TYPE, REQ_TTL } from '@/utils/consts'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
|
||||
export type IPendingsByAppNpub = {
|
||||
[appNpub: string]: {
|
||||
@ -18,10 +19,13 @@ type IShownConfirmModals = {
|
||||
export const useTriggerConfirmModal = (npub: string, pending: DbPending[], perms: DbPerm[]) => {
|
||||
const { handleOpen, getModalOpened } = useModalSearchParams()
|
||||
|
||||
const [searchParams] = useSearchParams()
|
||||
const isPopup = searchParams.get('popup') === 'true'
|
||||
|
||||
const isConfirmConnectModalOpened = getModalOpened(MODAL_PARAMS_KEYS.CONFIRM_CONNECT)
|
||||
const isConfirmEventModalOpened = getModalOpened(MODAL_PARAMS_KEYS.CONFIRM_EVENT)
|
||||
|
||||
const filteredPendingReqs = pending.filter((p) => p.npub === npub)
|
||||
const filteredPendingReqs = pending.filter((p) => p.npub === npub && p.timestamp > Date.now() - REQ_TTL)
|
||||
const filteredPerms = perms.filter((p) => p.npub === npub)
|
||||
|
||||
const npubConnectPerms = filteredPerms.filter((perm) => perm.perm === 'connect' || perm.perm === ACTION_TYPE.BASIC)
|
||||
@ -66,11 +70,19 @@ export const useTriggerConfirmModal = (npub: string, pending: DbPending[], perms
|
||||
search: {
|
||||
appNpub: req.appNpub,
|
||||
reqId: req.id,
|
||||
popup: isPopup ? 'true' : '',
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
}, [connectPendings, filteredPendingReqs.length, handleOpen, isConfirmEventModalOpened, isConfirmConnectModalOpened])
|
||||
}, [
|
||||
connectPendings,
|
||||
filteredPendingReqs.length,
|
||||
handleOpen,
|
||||
isConfirmEventModalOpened,
|
||||
isConfirmConnectModalOpened,
|
||||
isPopup,
|
||||
])
|
||||
|
||||
const handleOpenConfirmEventModal = useCallback(() => {
|
||||
if (!filteredPendingReqs.length || connectPendings.length) return undefined
|
||||
@ -86,11 +98,12 @@ export const useTriggerConfirmModal = (npub: string, pending: DbPending[], perms
|
||||
handleOpen(MODAL_PARAMS_KEYS.CONFIRM_EVENT, {
|
||||
search: {
|
||||
appNpub,
|
||||
popup: isPopup ? 'true' : '',
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
}, [connectPendings.length, filteredPendingReqs.length, handleOpen, prepareEventPendings])
|
||||
}, [connectPendings.length, filteredPendingReqs.length, handleOpen, prepareEventPendings, isPopup])
|
||||
|
||||
useEffect(() => {
|
||||
handleOpenConfirmEventModal()
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { Input, AppInputProps } from '@/shared/Input/Input'
|
||||
import { Input } from '@/shared/Input/Input'
|
||||
import { AppInputProps } from '@/shared/Input/types'
|
||||
import { Box, Button, ButtonProps, styled, Badge } from '@mui/material'
|
||||
import { forwardRef } from 'react'
|
||||
|
||||
@ -48,6 +49,7 @@ export const StyledEmptyAppsBox = styled(Box)(({ theme }) => {
|
||||
placeItems: 'center',
|
||||
color: theme.palette.text.primary,
|
||||
opacity: '0.6',
|
||||
maxHeight: '100%',
|
||||
},
|
||||
}
|
||||
})
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { db } from '@/modules/db'
|
||||
|
||||
export const checkNpubSyncQuerier = (npub: string) => async () => {
|
||||
export const checkNpubSyncQuerier = (npub: string, onResolve: () => void) => async () => {
|
||||
const count = await db.syncHistory.where('npub').equals(npub).count()
|
||||
if (!count) onResolve()
|
||||
return count > 0
|
||||
}
|
||||
|
@ -1,13 +1,15 @@
|
||||
import { Suspense, lazy } from 'react'
|
||||
import { Route, Routes, Navigate } from 'react-router-dom'
|
||||
import HomePage from '../pages/HomePage/Home.Page'
|
||||
import { Layout } from '../layout/Layout'
|
||||
import { CircularProgress, Stack } from '@mui/material'
|
||||
import CreatePage from '@/pages/CreatePage/Create.Page'
|
||||
|
||||
const KeyPage = lazy(() => import('../pages/KeyPage/Key.Page'))
|
||||
const ConfirmPage = lazy(() => import('../pages/Confirm.Page'))
|
||||
const AppPage = lazy(() => import('../pages/AppPage/App.Page'))
|
||||
// Pages
|
||||
import CreatePage from '@/pages/CreatePage/Create.Page'
|
||||
import HomePage from '../pages/HomePage/Home.Page'
|
||||
import KeyPage from '../pages/KeyPage/Key.Page'
|
||||
|
||||
const ConfirmPage = lazy(() => import('@/pages/Confirm.Page'))
|
||||
const AppPage = lazy(() => import('@/pages/AppPage/App.Page'))
|
||||
|
||||
const LoadingSpinner = () => (
|
||||
<Stack height={'100%'} justifyContent={'center'} alignItems={'center'}>
|
||||
@ -21,7 +23,6 @@ const AppRoutes = () => {
|
||||
<Routes>
|
||||
<Route path="/" element={<Layout />}>
|
||||
<Route path="/" element={<Navigate to={'/home'} />} />
|
||||
{/* <Route path='/welcome' element={<WelcomePage />} /> */}
|
||||
<Route path="/home" element={<HomePage />} />
|
||||
<Route path="/key/:npub" element={<KeyPage />} />
|
||||
<Route path="/key/:npub/app/:appNpub" element={<AppPage />} />
|
||||
|
@ -19,6 +19,9 @@ const StyledButton = styled(
|
||||
const commonStyles = {
|
||||
fontWeight: 500,
|
||||
borderRadius: '1rem',
|
||||
'@media screen and (max-width: 320px)': {
|
||||
padding: '0.25rem 0.75rem',
|
||||
},
|
||||
}
|
||||
if (varianttype === 'secondary') {
|
||||
return {
|
||||
@ -28,20 +31,20 @@ const StyledButton = styled(
|
||||
},
|
||||
color: theme.palette.text.primary,
|
||||
'&.disabled': {
|
||||
opacity: 0.5,
|
||||
background: `${theme.palette.backgroundSecondary.default}50`,
|
||||
cursor: 'not-allowed',
|
||||
},
|
||||
}
|
||||
}
|
||||
return {
|
||||
...commonStyles,
|
||||
'&.button:is(:hover, :active, &, .disabled)': {
|
||||
'&.button:is(:hover, :active, &)': {
|
||||
background: theme.palette.primary.main,
|
||||
},
|
||||
color: theme.palette.text.secondary,
|
||||
'&.disabled': {
|
||||
'&.button.disabled': {
|
||||
color: theme.palette.text.secondary,
|
||||
opacity: 0.5,
|
||||
background: `${theme.palette.primary.main}75`,
|
||||
cursor: 'not-allowed',
|
||||
},
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { useRef } from 'react'
|
||||
import { Input, AppInputProps } from '../Input/Input'
|
||||
import { Input } from '../Input/Input'
|
||||
import { AppInputProps } from '../Input/types'
|
||||
|
||||
export type DebounceProps = {
|
||||
handleDebounce: (value: string) => void
|
||||
|
@ -9,7 +9,8 @@ export const StyledButton = styled((props: ButtonProps) => (
|
||||
startIcon: 'icon',
|
||||
}}
|
||||
/>
|
||||
))(() => ({
|
||||
))(({ theme }) => ({
|
||||
color: theme.palette.primary.main,
|
||||
marginBottom: '0.5rem',
|
||||
borderRadius: '8px',
|
||||
'&:is(:hover,:active)': {
|
||||
@ -18,4 +19,7 @@ export const StyledButton = styled((props: ButtonProps) => (
|
||||
'& .icon': {
|
||||
marginRight: '5px',
|
||||
},
|
||||
'@media screen and (max-width: 320px)': {
|
||||
marginBottom: '0.25rem',
|
||||
},
|
||||
}))
|
||||
|
@ -1,8 +1,11 @@
|
||||
import React, { FC, useEffect, useState } from 'react'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
import { StyledAppIcon, StyledAppImg } from './styled'
|
||||
import ImageOutlinedIcon from '@mui/icons-material/ImageOutlined'
|
||||
import { IIconApp } from './types'
|
||||
|
||||
const failedCache = new Map<string, boolean>()
|
||||
|
||||
export const IconApp: FC<{ picture: string }> = ({ picture }) => {
|
||||
export const IconApp: FC<IIconApp> = ({ picture = '', alt, isRounded, isSmall, onClick, size, ...rest }) => {
|
||||
const c = failedCache.get(picture)
|
||||
const [isFailed, setIsFailed] = useState(c !== undefined ? c : true)
|
||||
|
||||
@ -26,5 +29,21 @@ export const IconApp: FC<{ picture: string }> = ({ picture }) => {
|
||||
}
|
||||
}, [picture])
|
||||
|
||||
return <div>IconApp</div>
|
||||
return (
|
||||
<StyledAppIcon isNotLoaded={isFailed} size={size} onClick={onClick} {...rest}>
|
||||
{alt ? (
|
||||
<StyledAppImg size={size} alt={alt} isSmall={isSmall} src={isFailed ? '' : picture}>
|
||||
{isFailed && (
|
||||
<div className="MuiAvatar-root MuiAvatar-square MuiAvatar-colorDefault">
|
||||
{alt.substring(0, 1).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
</StyledAppImg>
|
||||
) : (
|
||||
<StyledAppImg size={size} alt={alt} isSmall={isSmall} src={isFailed ? '/' : picture}>
|
||||
<ImageOutlinedIcon fontSize="inherit" />
|
||||
</StyledAppImg>
|
||||
)}
|
||||
</StyledAppIcon>
|
||||
)
|
||||
}
|
||||
|
53
src/shared/IconApp/const.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { APP_NSEC_SIZE } from '@/utils/consts'
|
||||
|
||||
const SIZE_VALUE = {
|
||||
[APP_NSEC_SIZE.BIG]: 70,
|
||||
[APP_NSEC_SIZE.LARGE]: 56,
|
||||
[APP_NSEC_SIZE.MEDIUM]: 40,
|
||||
[APP_NSEC_SIZE.SMALL]: 36,
|
||||
[APP_NSEC_SIZE.EXTRA_SMALL]: 30,
|
||||
}
|
||||
|
||||
export const APP_SIZE_VALUE = {
|
||||
[APP_NSEC_SIZE.BIG]: {
|
||||
height: SIZE_VALUE[APP_NSEC_SIZE.BIG],
|
||||
minWidth: SIZE_VALUE[APP_NSEC_SIZE.BIG],
|
||||
maxWidth: SIZE_VALUE[APP_NSEC_SIZE.BIG],
|
||||
},
|
||||
[APP_NSEC_SIZE.LARGE]: {
|
||||
height: SIZE_VALUE[APP_NSEC_SIZE.LARGE],
|
||||
minWidth: SIZE_VALUE[APP_NSEC_SIZE.LARGE],
|
||||
maxWidth: SIZE_VALUE[APP_NSEC_SIZE.LARGE],
|
||||
},
|
||||
[APP_NSEC_SIZE.MEDIUM]: {
|
||||
height: SIZE_VALUE[APP_NSEC_SIZE.MEDIUM],
|
||||
minWidth: SIZE_VALUE[APP_NSEC_SIZE.MEDIUM],
|
||||
maxWidth: SIZE_VALUE[APP_NSEC_SIZE.MEDIUM],
|
||||
},
|
||||
[APP_NSEC_SIZE.SMALL]: {
|
||||
height: SIZE_VALUE[APP_NSEC_SIZE.SMALL],
|
||||
minWidth: SIZE_VALUE[APP_NSEC_SIZE.SMALL],
|
||||
maxWidth: SIZE_VALUE[APP_NSEC_SIZE.SMALL],
|
||||
},
|
||||
[APP_NSEC_SIZE.EXTRA_SMALL]: {
|
||||
height: SIZE_VALUE[APP_NSEC_SIZE.EXTRA_SMALL],
|
||||
minWidth: SIZE_VALUE[APP_NSEC_SIZE.EXTRA_SMALL],
|
||||
maxWidth: SIZE_VALUE[APP_NSEC_SIZE.EXTRA_SMALL],
|
||||
},
|
||||
}
|
||||
|
||||
const FONT_SIZE_VALUE = {
|
||||
[APP_NSEC_SIZE.BIG]: 24,
|
||||
[APP_NSEC_SIZE.LARGE]: 20,
|
||||
[APP_NSEC_SIZE.MEDIUM]: 16,
|
||||
[APP_NSEC_SIZE.SMALL]: 12,
|
||||
[APP_NSEC_SIZE.EXTRA_SMALL]: 10,
|
||||
}
|
||||
|
||||
export const APP_NAME_FONT_SIZE_VALUE = {
|
||||
[APP_NSEC_SIZE.LARGE]: FONT_SIZE_VALUE[APP_NSEC_SIZE.LARGE],
|
||||
[APP_NSEC_SIZE.BIG]: FONT_SIZE_VALUE[APP_NSEC_SIZE.BIG],
|
||||
[APP_NSEC_SIZE.MEDIUM]: FONT_SIZE_VALUE[APP_NSEC_SIZE.MEDIUM],
|
||||
[APP_NSEC_SIZE.SMALL]: FONT_SIZE_VALUE[APP_NSEC_SIZE.SMALL],
|
||||
[APP_NSEC_SIZE.EXTRA_SMALL]: FONT_SIZE_VALUE[APP_NSEC_SIZE.EXTRA_SMALL],
|
||||
}
|
54
src/shared/IconApp/styled.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
import { Avatar, Box, styled } from '@mui/material'
|
||||
import { forwardRef } from 'react'
|
||||
import { IAvatarStyled, IBoxStyled } from './types'
|
||||
import { grey } from '@mui/material/colors'
|
||||
import { AppNostrSize } from '@/types/app-nsec'
|
||||
import { APP_NAME_FONT_SIZE_VALUE, APP_SIZE_VALUE } from './const'
|
||||
import { APP_NSEC_SIZE } from '@/utils/consts'
|
||||
|
||||
const color = grey[500]
|
||||
|
||||
const getVariantApp = (isRounded: boolean, size: AppNostrSize) => {
|
||||
if (isRounded) {
|
||||
return {
|
||||
height: 34,
|
||||
minWidth: 34,
|
||||
maxWidth: 34,
|
||||
borderRadius: '7px',
|
||||
}
|
||||
}
|
||||
|
||||
return APP_SIZE_VALUE[size]
|
||||
}
|
||||
|
||||
export const StyledAppIcon = styled(
|
||||
forwardRef<HTMLAnchorElement, IBoxStyled>(function BoxDisplayName(props, ref) {
|
||||
return <Box ref={ref} {...props} />
|
||||
})
|
||||
)(({ theme, isNotLoaded, isRounded = false, size = APP_NSEC_SIZE.MEDIUM }) => ({
|
||||
position: 'relative',
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
overflow: 'hidden',
|
||||
...getVariantApp(isRounded, size),
|
||||
transition: theme.transitions.create(['border-color', 'transition']),
|
||||
backgroundColor: isNotLoaded ? color : theme.palette.background.default,
|
||||
boxSizing: 'border-box',
|
||||
':active': {
|
||||
borderColor: 'rgba(255, 255, 255, 0.3)',
|
||||
},
|
||||
}))
|
||||
|
||||
export const StyledAppImg = styled(function BoxDisplayName(props: IAvatarStyled) {
|
||||
return <Avatar variant="square" {...props} />
|
||||
})(({ isSmall = false, size = APP_NSEC_SIZE.MEDIUM }) => ({
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
fontWeight: '500',
|
||||
fontSize: APP_NAME_FONT_SIZE_VALUE[size],
|
||||
'.MuiAvatar-img': {
|
||||
objectFit: isSmall ? 'scale-down' : 'cover',
|
||||
},
|
||||
}))
|
24
src/shared/IconApp/types.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { AppNostrSize } from '@/types/app-nsec'
|
||||
import { AvatarProps, BoxProps } from '@mui/material'
|
||||
|
||||
export type IconAppProps = {
|
||||
size?: AppNostrSize
|
||||
isRounded?: boolean
|
||||
isSmall?: boolean
|
||||
onClick?: () => void
|
||||
isNotLoaded?: boolean
|
||||
}
|
||||
|
||||
export type IconAppBase = {
|
||||
picture: string
|
||||
alt?: string
|
||||
}
|
||||
|
||||
export type IIconApp = Omit<IconAppProps, 'isNotLoaded'> & IconAppBase
|
||||
|
||||
export type IBoxStyled = IconAppProps & BoxProps
|
||||
|
||||
export type IAvatarStyled = {
|
||||
size?: AppNostrSize
|
||||
isSmall?: boolean
|
||||
} & AvatarProps
|
@ -1,69 +1,34 @@
|
||||
import { ReactNode, forwardRef } from 'react'
|
||||
import {
|
||||
Box,
|
||||
BoxProps,
|
||||
FormHelperText,
|
||||
FormHelperTextProps,
|
||||
FormLabel,
|
||||
InputBase,
|
||||
InputBaseProps,
|
||||
styled,
|
||||
} from '@mui/material'
|
||||
import { FormHelperText, FormLabel, InputBase } from '@mui/material'
|
||||
import { StyledInputContainer } from './styled'
|
||||
import { AppInputProps } from './types'
|
||||
|
||||
export type AppInputProps = InputBaseProps & {
|
||||
helperText?: string | ReactNode
|
||||
helperTextProps?: FormHelperTextProps
|
||||
containerProps?: BoxProps
|
||||
label?: string
|
||||
}
|
||||
const renderItem = <T,>(item: T, value: ReactNode) => (item ? value : null)
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, AppInputProps>(
|
||||
({ helperText, containerProps, helperTextProps, label, ...props }, ref) => {
|
||||
return (
|
||||
<StyledInputContainer {...containerProps}>
|
||||
{label ? (
|
||||
{renderItem(
|
||||
label,
|
||||
<FormLabel className="label" htmlFor={props.id}>
|
||||
{label}
|
||||
</FormLabel>
|
||||
) : null}
|
||||
<InputBase autoComplete="off" className="input" {...props} classes={{ error: 'error' }} ref={ref} />
|
||||
{helperText ? (
|
||||
)}
|
||||
|
||||
<InputBase
|
||||
autoComplete="off"
|
||||
{...props}
|
||||
classes={{ error: 'error', root: 'input_root', input: 'input', disabled: 'disabled' }}
|
||||
ref={ref}
|
||||
/>
|
||||
{renderItem(
|
||||
helperText,
|
||||
<FormHelperText {...helperTextProps} className="helper_text">
|
||||
{helperText}
|
||||
</FormHelperText>
|
||||
) : null}
|
||||
)}
|
||||
</StyledInputContainer>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
const StyledInputContainer = styled((props: BoxProps) => <Box {...props} />)(({ theme }) => {
|
||||
const isDark = theme.palette.mode === 'dark'
|
||||
return {
|
||||
width: '100%',
|
||||
'& > .input': {
|
||||
background: isDark ? '#000000A8' : '#000',
|
||||
color: theme.palette.common.white,
|
||||
padding: '0.75rem 1rem',
|
||||
borderRadius: '1rem',
|
||||
border: '0.3px solid #FFFFFF54',
|
||||
fontSize: '0.875rem',
|
||||
'& input::placeholder': {
|
||||
color: '#fff',
|
||||
},
|
||||
'&.error': {
|
||||
border: '0.3px solid ' + theme.palette.error.main,
|
||||
},
|
||||
},
|
||||
'& > .helper_text': {
|
||||
margin: '0.5rem 1rem 0',
|
||||
color: theme.palette.text.primary,
|
||||
},
|
||||
'& > .label': {
|
||||
margin: '0 1rem 0.5rem',
|
||||
display: 'block',
|
||||
color: theme.palette.primary.main,
|
||||
fontSize: '0.875rem',
|
||||
},
|
||||
}
|
||||
})
|
||||
|
45
src/shared/Input/styled.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { Box, BoxProps, styled } from '@mui/material'
|
||||
|
||||
export const StyledInputContainer = styled((props: BoxProps) => <Box {...props} />)(({ theme }) => {
|
||||
const isDark = theme.palette.mode === 'dark'
|
||||
return {
|
||||
width: '100%',
|
||||
'& > .input_root': {
|
||||
background: isDark ? '#000000A8' : '#000',
|
||||
color: theme.palette.common.white,
|
||||
padding: '0.75rem 1rem',
|
||||
borderRadius: '1rem',
|
||||
border: '0.3px solid #FFFFFF54',
|
||||
fontSize: '0.875rem',
|
||||
'&.error': {
|
||||
border: '0.3px solid ' + theme.palette.error.main,
|
||||
},
|
||||
},
|
||||
'& .input:is(.disabled, &)': {
|
||||
WebkitTextFillColor: '#ffffff80',
|
||||
},
|
||||
'& > .helper_text': {
|
||||
margin: '0.5rem 0.5rem 0',
|
||||
color: theme.palette.text.primary,
|
||||
},
|
||||
'& > .label': {
|
||||
margin: '0 1rem 0.5rem',
|
||||
display: 'block',
|
||||
color: theme.palette.primary.main,
|
||||
fontSize: '0.875rem',
|
||||
},
|
||||
'@media screen and (max-width: 320px)': {
|
||||
'& > .input_root': {
|
||||
padding: '0.5rem 0.75rem',
|
||||
borderRadius: '0.75rem',
|
||||
},
|
||||
'& > .label': {
|
||||
margin: '0 0.25rem 0.25rem',
|
||||
fontSize: '0.75rem',
|
||||
},
|
||||
'& > .helper_text': {
|
||||
fontSize: '0.75rem',
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
9
src/shared/Input/types.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { BoxProps, FormHelperTextProps, InputBaseProps } from '@mui/material'
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
export type AppInputProps = InputBaseProps & {
|
||||
helperText?: string | ReactNode
|
||||
helperTextProps?: FormHelperTextProps
|
||||
containerProps?: BoxProps
|
||||
label?: string
|
||||
}
|
@ -1,8 +1,7 @@
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
import { Fade, IconButton, Typography } from '@mui/material'
|
||||
import { Fade, Typography } from '@mui/material'
|
||||
import CopyToClipboard from 'react-copy-to-clipboard'
|
||||
import { CopyIcon } from '@/assets'
|
||||
import { StyledContainer } from './styled'
|
||||
import { StyledContainer, StyledCopyButton } from './styled'
|
||||
|
||||
type InputCopyButtonProps = {
|
||||
value: string
|
||||
@ -40,9 +39,7 @@ export const InputCopyButton: FC<InputCopyButtonProps> = ({ value, onCopy = () =
|
||||
</Fade>
|
||||
)}
|
||||
<CopyToClipboard text={value} onCopy={handleCopy}>
|
||||
<IconButton color="inherit">
|
||||
<CopyIcon />
|
||||
</IconButton>
|
||||
<StyledCopyButton />
|
||||
</CopyToClipboard>
|
||||
</StyledContainer>
|
||||
)
|
||||
|
@ -1,7 +1,17 @@
|
||||
import { Stack, StackProps, styled } from '@mui/material'
|
||||
import { IconButton, Stack, StackProps, styled } from '@mui/material'
|
||||
import { CopyIcon } from '@/assets'
|
||||
|
||||
export const StyledContainer = styled((props: StackProps & { copied: number }) => (
|
||||
<Stack {...props} direction={'row'} alignItems={'center'} />
|
||||
))(({ theme, copied }) => ({
|
||||
color: copied ? theme.palette.success.main : theme.palette.textSecondaryDecorate.main,
|
||||
}))
|
||||
|
||||
export const StyledCopyButton = styled((props) => (
|
||||
<IconButton color="inherit" {...props}>
|
||||
<CopyIcon />
|
||||
</IconButton>
|
||||
))(() => ({
|
||||
width: 40,
|
||||
height: 40,
|
||||
}))
|
||||
|
17
src/shared/LoadingSpinner/LoadingSpinner.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import { CircularProgress, CircularProgressProps, styled } from '@mui/material'
|
||||
import { FC } from 'react'
|
||||
|
||||
type LoadingSpinnerProps = CircularProgressProps & {
|
||||
mode?: 'default' | 'secondary'
|
||||
}
|
||||
|
||||
export const LoadingSpinner: FC<LoadingSpinnerProps> = (props) => {
|
||||
return <StyledCircularProgress {...props} />
|
||||
}
|
||||
|
||||
export const StyledCircularProgress = styled((props: LoadingSpinnerProps) => (
|
||||
<CircularProgress size={'1rem'} {...props} />
|
||||
))(({ theme, mode = 'default' }) => ({
|
||||
marginLeft: '0.5rem',
|
||||
color: mode === 'default' ? theme.palette.text.secondary : theme.palette.text.primary,
|
||||
}))
|
@ -2,16 +2,7 @@ import { combineReducers, configureStore } from '@reduxjs/toolkit'
|
||||
import { contentSlice } from './reducers/content.slice'
|
||||
import { uiSlice } from './reducers/ui.slice'
|
||||
|
||||
import {
|
||||
persistStore,
|
||||
persistReducer,
|
||||
// FLUSH,
|
||||
// REGISTER,
|
||||
// REHYDRATE,
|
||||
// PAUSE,
|
||||
// PERSIST,
|
||||
// PURGE,
|
||||
} from 'redux-persist'
|
||||
import { persistStore, persistReducer } from 'redux-persist'
|
||||
import storage from 'redux-persist/lib/storage' // defaults to localStorage for web
|
||||
import memoizeOne from 'memoize-one'
|
||||
import isDeepEqual from 'lodash.isequal'
|
||||
|
6
src/types/app-nsec.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { APP_NSEC_SIZE } from '@/utils/consts'
|
||||
import { OverridableStringUnion } from './utils'
|
||||
|
||||
export type AppNostrSizeUnion = (typeof APP_NSEC_SIZE)[keyof typeof APP_NSEC_SIZE]
|
||||
|
||||
export type AppNostrSize = OverridableStringUnion<AppNostrSizeUnion>
|
@ -10,6 +10,7 @@ export enum MODAL_PARAMS_KEYS {
|
||||
CONFIRM_EVENT = 'confirm-event',
|
||||
ACTIVITY = 'activity',
|
||||
APP_DETAILS = 'app-details',
|
||||
EDIT_NAME = 'edit-name',
|
||||
}
|
||||
|
||||
export enum EXPLANATION_MODAL_KEYS {
|
||||
|
10
src/types/utils.ts
Normal file
@ -0,0 +1,10 @@
|
||||
export type OverridableStringUnion<T extends string> = GenerateStringUnion<DistributiveOmit<Record<T, true>>>
|
||||
|
||||
type GenerateStringUnion<T> = Extract<
|
||||
{
|
||||
[Key in keyof T]: true extends T[Key] ? Key : never
|
||||
}[keyof T],
|
||||
string
|
||||
>
|
||||
|
||||
type DistributiveOmit<T> = T extends T ? T : never
|
@ -9,6 +9,10 @@ export const MAX_POW = 19
|
||||
|
||||
export const KIND_RPC = 24133
|
||||
|
||||
export const RELOAD_STORAGE_KEY = 'reload'
|
||||
|
||||
export const REQ_TTL = 60000 // 1 min
|
||||
|
||||
export enum ACTION_TYPE {
|
||||
BASIC = 'basic',
|
||||
ADVANCED = 'advanced',
|
||||
@ -16,10 +20,18 @@ export enum ACTION_TYPE {
|
||||
}
|
||||
|
||||
export const ACTIONS: { [type: string]: string } = {
|
||||
basic: 'Basic permissions',
|
||||
basic: 'Basic permissions',
|
||||
get_public_key: 'Get public key',
|
||||
sign_event: 'Sign event',
|
||||
connect: 'Connect',
|
||||
nip04_encrypt: 'Encrypt message',
|
||||
nip04_decrypt: 'Decrypt message',
|
||||
}
|
||||
|
||||
export const APP_NSEC_SIZE = {
|
||||
BIG: 'big',
|
||||
LARGE: 'large',
|
||||
MEDIUM: 'medium',
|
||||
SMALL: 'small',
|
||||
EXTRA_SMALL: 'extra-small',
|
||||
} as const
|
||||
|
@ -3,7 +3,7 @@ import { format } from 'date-fns'
|
||||
export const formatTimestampDate = (timestamp: number) => {
|
||||
try {
|
||||
const date = new Date(timestamp)
|
||||
const formattedDate = format(date, "dd-MM-yyyy HH:mm")
|
||||
const formattedDate = format(date, 'dd-MM-yyyy HH:mm')
|
||||
return formattedDate
|
||||
} catch (error) {
|
||||
return ''
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { ACTIONS, ACTION_TYPE, NIP46_RELAYS } from '../consts'
|
||||
import { DbPending, DbPerm } from '@/modules/db'
|
||||
import { ACTIONS, ACTION_TYPE, DOMAIN, NIP46_RELAYS, NOAUTHD_URL } from '../consts'
|
||||
import { DbHistory, DbPending, DbPerm } from '@/modules/db'
|
||||
import { MetaEvent } from '@/types/meta-event'
|
||||
|
||||
export async function call(cb: () => any) {
|
||||
@ -16,7 +16,7 @@ export const getShortenNpub = (npub = '') => {
|
||||
}
|
||||
|
||||
export const getProfileUsername = (profile: MetaEvent | null) => {
|
||||
if (!profile) return null
|
||||
if (!profile) return undefined
|
||||
return profile?.info?.name || profile?.info?.display_name
|
||||
}
|
||||
|
||||
@ -72,6 +72,7 @@ export function isPackagePerm(perm: string, reqPerm: string) {
|
||||
case 'sign_event:10002':
|
||||
case 'sign_event:30023':
|
||||
case 'sign_event:10000':
|
||||
case 'sign_event:27235':
|
||||
return true
|
||||
}
|
||||
}
|
||||
@ -97,6 +98,21 @@ export async function fetchNip05(value: string, origin?: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchNpubNames(npub: string) {
|
||||
try {
|
||||
const url = `${NOAUTHD_URL}/name?npub=${npub}`
|
||||
const response = await fetch(url)
|
||||
const names: {
|
||||
names: string[]
|
||||
} = await response.json()
|
||||
|
||||
return names.names
|
||||
} catch (e) {
|
||||
console.log('Failed to fetch names for', npub, 'error: ' + e)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export const getDomain = (url: string) => {
|
||||
try {
|
||||
return new URL(url).hostname
|
||||
@ -105,11 +121,21 @@ export const getDomain = (url: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
export const getReferrerAppUrl = () => {
|
||||
// console.log('referrer', window.document.referrer)
|
||||
if (!window.document.referrer) return ''
|
||||
try {
|
||||
const u = new URL(window.document.referrer.toLocaleLowerCase())
|
||||
if (u.hostname !== DOMAIN && !u.hostname.endsWith('.' + DOMAIN)) return u.origin
|
||||
} catch {}
|
||||
return ''
|
||||
}
|
||||
|
||||
export const getAppIconTitle = (name: string | undefined, appNpub: string) => {
|
||||
return name ? name[0].toLocaleUpperCase() : appNpub.substring(4, 7)
|
||||
}
|
||||
|
||||
export function getReqActionName(req: DbPending) {
|
||||
export function getReqActionName(req: DbPending | DbHistory) {
|
||||
const action = ACTIONS[req.method]
|
||||
if (req.method === 'sign_event') {
|
||||
const kind = getSignReqKind(req)
|
||||
|
@ -13,6 +13,7 @@
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"downlevelIteration": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"baseUrl": ".",
|
||||
|