Compare commits

..

No commits in common. "main" and "refactor/sync-npub" have entirely different histories.

158 changed files with 36643 additions and 38282 deletions

3
.env
View File

@ -2,6 +2,5 @@
# change if you're using a different noauthd server
REACT_APP_WEB_PUSH_PUBKEY=BNW_39YcKbV4KunFxFhvMW5JUs8AljfFnGUeZpaerO-gwCoWyQat5ol0xOGB8MLaqqCbz0iptd2Qv3SToSGynMk
#REACT_APP_NOAUTHD_URL=http://localhost:8000
REACT_APP_NOAUTHD_URL=https://noauthd.nsec.app
REACT_APP_NOAUTHD_URL=https://noauthd.login.nostrapps.org
REACT_APP_DOMAIN=nsec.app
REACT_APP_RELAY=wss://relay.nsec.app

View File

@ -1 +0,0 @@
node_modules/

View File

@ -1,9 +0,0 @@
{
"trailingComma": "es5",
"tabWidth": 2,
"semi": false,
"singleQuote": true,
"printWidth": 120,
"bracketSpacing": true,
"endOfLine": "lf"
}

23
README Normal file
View File

@ -0,0 +1,23 @@
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.

View File

@ -1,95 +0,0 @@
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?

59
package-lock.json generated
View File

@ -13,7 +13,7 @@
"@hookform/resolvers": "^3.3.4",
"@mui/icons-material": "^5.14.19",
"@mui/material": "^5.14.20",
"@nostr-dev-kit/ndk": "^2.4.0",
"@nostr-dev-kit/ndk": "^2.0.5",
"@reduxjs/toolkit": "^2.0.1",
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
@ -41,7 +41,6 @@
"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",
@ -65,7 +64,6 @@
"customize-cra": "^1.0.0",
"https-browserify": "^1.0.0",
"os-browserify": "^0.3.0",
"prettier": "^3.2.5",
"process": "^0.11.10",
"react-app-rewired": "^2.2.1",
"serve": "^14.2.1",
@ -3554,9 +3552,9 @@
}
},
"node_modules/@nostr-dev-kit/ndk": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@nostr-dev-kit/ndk/-/ndk-2.4.0.tgz",
"integrity": "sha512-IdL6F/o0p1oVmII6om5ErJ+LarO2hjbRoqYfz8DlSBWVNT/uAx/GdtlJG0qlAdg5PPzDyf3Unrk0Pi/WermVjw==",
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@nostr-dev-kit/ndk/-/ndk-2.1.1.tgz",
"integrity": "sha512-sMD4re3QVpSVMzoyvJAFiftPxJBXkhjRInrK5DcjhSkkPTlJhI+oiVXCpcvCJ3PHT5PilhDgKkg3LyOvv135UQ==",
"dependencies": {
"@noble/hashes": "^1.3.1",
"@noble/secp256k1": "^2.0.0",
@ -14206,21 +14204,6 @@
"node": ">= 0.8.0"
}
},
"node_modules/prettier": {
"version": "3.2.5",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz",
"integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==",
"dev": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/pretty-bytes": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
@ -17215,20 +17198,6 @@
"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",
@ -20720,9 +20689,9 @@
}
},
"@nostr-dev-kit/ndk": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@nostr-dev-kit/ndk/-/ndk-2.4.0.tgz",
"integrity": "sha512-IdL6F/o0p1oVmII6om5ErJ+LarO2hjbRoqYfz8DlSBWVNT/uAx/GdtlJG0qlAdg5PPzDyf3Unrk0Pi/WermVjw==",
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@nostr-dev-kit/ndk/-/ndk-2.1.1.tgz",
"integrity": "sha512-sMD4re3QVpSVMzoyvJAFiftPxJBXkhjRInrK5DcjhSkkPTlJhI+oiVXCpcvCJ3PHT5PilhDgKkg3LyOvv135UQ==",
"requires": {
"@noble/hashes": "^1.3.1",
"@noble/secp256k1": "^2.0.0",
@ -28355,12 +28324,6 @@
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
"integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="
},
"prettier": {
"version": "3.2.5",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz",
"integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==",
"dev": true
},
"pretty-bytes": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
@ -30606,14 +30569,6 @@
"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",

View File

@ -8,7 +8,7 @@
"@hookform/resolvers": "^3.3.4",
"@mui/icons-material": "^5.14.19",
"@mui/material": "^5.14.20",
"@nostr-dev-kit/ndk": "^2.4.0",
"@nostr-dev-kit/ndk": "^2.0.5",
"@reduxjs/toolkit": "^2.0.1",
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
@ -36,7 +36,6 @@
"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",
@ -62,8 +61,7 @@
"build": "react-app-rewired build",
"test": "react-app-rewired test",
"eject": "react-app-rewired eject",
"serve": "npm run build && serve -s build",
"format": "npx prettier --write src"
"serve": "npm run build && serve -s build"
},
"eslintConfig": {
"extends": [
@ -91,7 +89,6 @@
"customize-cra": "^1.0.0",
"https-browserify": "^1.0.0",
"os-browserify": "^0.3.0",
"prettier": "^3.2.5",
"process": "^0.11.10",
"react-app-rewired": "^2.2.1",
"serve": "^14.2.1",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 536 B

After

Width:  |  Height:  |  Size: 502 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -1,15 +1,38 @@
<!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" />
<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"
/>
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
@ -33,6 +56,5 @@
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>

View File

@ -1,7 +1,6 @@
{
"name": "Nsec.app - Nostr key management tool",
"short_name": "Nsec.app",
"start_url": ".",
"name": "Noauth",
"short_name": "Noauth Nostr key manager",
"icons": [
{
"src": "/android-chrome-192x192.png",
@ -14,6 +13,7 @@
"type": "image/png"
}
],
"start_url": ".",
"theme_color": "#000000",
"background_color": "#ffffff",
"display": "standalone"

View File

@ -1,9 +1,9 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import App from './App'
import React from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />)
const linkElement = screen.getByText(/learn react/i)
expect(linkElement).toBeInTheDocument()
})
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});

View File

@ -1,28 +1,32 @@
import { DbKey, dbi } from './modules/db'
import { useCallback, useEffect, useState } from 'react'
import { swicOnReload, swicOnRender } from './modules/swic'
import { swicOnRender } from './modules/swic'
import { useAppDispatch } from './store/hooks/redux'
import { setApps, setKeys, setPending, setPerms } from './store/reducers/content.slice'
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 () => {
@ -46,8 +50,12 @@ function App() {
const apps = await dbi.listApps()
dispatch(
setApps({
apps,
})
apps: apps.map((app) => ({
...app,
// MOCK IMAGE
icon: 'https://nostr.band/android-chrome-192x192.png',
})),
}),
)
const perms = await dbi.listPerms()
@ -59,6 +67,7 @@ function App() {
// rerender
// setRender((r) => r + 1)
if (!keys.length) handleOpen(MODAL_PARAMS_KEYS.INITIAL)
// eslint-disable-next-line
}, [dispatch])
@ -68,7 +77,7 @@ function App() {
useEffect(() => {
ndk.connect().then(() => {
console.log('NDK connected')
console.log('NDK connected', { ndk })
setIsConnected(true)
})
// eslint-disable-next-line
@ -80,24 +89,6 @@ 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 />

View File

@ -1,3 +1,3 @@
<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 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>

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -1,174 +0,0 @@
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { Button } from '@/shared/Button/Button'
import { Input } from '@/shared/Input/Input'
import { Modal } from '@/shared/Modal/Modal'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { Autocomplete, Stack, Typography } from '@mui/material'
import { StyledInput } from './styled'
import { FormEvent, useEffect, useState } from 'react'
import { isEmptyString } from '@/utils/helpers/helpers'
import { useParams } from 'react-router-dom'
import { useAppDispatch, useAppSelector } from '@/store/hooks/redux'
import { selectApps } from '@/store'
import { dbi } from '@/modules/db'
import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar'
import { setApps } from '@/store/reducers/content.slice'
import { LoadingSpinner } from '@/shared/LoadingSpinner/LoadingSpinner'
export const ModalAppDetails = () => {
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.APP_DETAILS)
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.APP_DETAILS)
const { appNpub = '' } = useParams()
const apps = useAppSelector(selectApps)
const dispatch = useAppDispatch()
const notify = useEnqueueSnackbar()
const [details, setDetails] = useState({
url: '',
name: '',
icon: '',
})
const [isLoading, setIsLoading] = useState(false)
useEffect(() => {
const currentApp = apps.find((app) => app.appNpub === appNpub)
if (!currentApp) return
setDetails({
icon: currentApp.icon || '',
name: currentApp.name || '',
url: currentApp.url || '',
})
// eslint-disable-next-line
}, [appNpub, isModalOpened])
useEffect(() => {
return () => {
if (isModalOpened) {
// modal closed
setIsLoading(false)
}
}
}, [isModalOpened])
const isAppNpubExists = appNpub.trim().length && apps.some((app) => app.appNpub === appNpub)
if (isModalOpened && !isAppNpubExists) {
handleCloseModal()
return null
}
const { icon, name, url } = details
const handleInputBlur = () => {
if (isEmptyString(url)) return
try {
const u = new URL(url)
if (isEmptyString(name)) setDetails((prev) => ({ ...prev, name: u.hostname }))
if (isEmptyString(icon)) {
const iconUrl = `https://${u.hostname}/favicon.ico`
setDetails((prev) => ({ ...prev, icon: iconUrl }))
}
} catch {
/* empty */
}
}
const handleInputChange = (key: string) => (e: React.ChangeEvent<HTMLInputElement>) => {
setDetails((prevState) => {
return { ...prevState, [key]: e.target.value }
})
}
const handleAutocompletInputChange = (e: unknown, value: string) => {
setDetails((prevState) => {
return { ...prevState, url: value }
})
}
const submitHandler = async (e: FormEvent) => {
e.preventDefault()
if (isLoading) return undefined
try {
setIsLoading(true)
const updatedApp = {
url,
name,
icon,
appNpub,
}
await dbi.updateApp(updatedApp)
const apps = await dbi.listApps()
dispatch(
setApps({
apps,
})
)
notify(`App successfully updated!`, 'success')
setIsLoading(false)
handleCloseModal()
} catch (error: any) {
setIsLoading(false)
notify(error?.message || 'Something went wrong!', 'error')
}
}
const isFormValid = !isEmptyString(name)
return (
<Modal open={isModalOpened} onClose={handleCloseModal}>
<Stack gap={'1rem'} component={'form'} onSubmit={submitHandler}>
<Stack alignItems={'center'}>
<Typography fontWeight={600} variant="h5">
App details
</Typography>
</Stack>
<Input
label="Name"
fullWidth
placeholder="Enter app name"
onChange={handleInputChange('name')}
value={details.name}
/>
<Autocomplete
options={[]}
freeSolo
onBlur={handleInputBlur}
onInputChange={handleAutocompletInputChange}
inputValue={details.url}
renderInput={({ inputProps, disabled, id, InputProps }) => {
return (
<StyledInput
{...InputProps}
className="input"
inputProps={inputProps}
disabled={disabled}
label="URL"
fullWidth
placeholder="Enter URL"
/>
)
}}
/>
<Input
label="Icon"
fullWidth
placeholder="Enter app icon url"
onChange={handleInputChange('icon')}
value={details.icon}
/>
<Button varianttype="secondary" type="submit" fullWidth disabled={!isFormValid || isLoading}>
Save changes {isLoading && <LoadingSpinner />}
</Button>
</Stack>
</Modal>
)
}

View File

@ -1,12 +0,0 @@
import { Input } from '@/shared/Input/Input'
import { AppInputProps } from '@/shared/Input/types'
import { styled } from '@mui/material'
import { forwardRef } from 'react'
export const StyledInput = styled(
forwardRef<HTMLInputElement, AppInputProps>((props, ref) => <Input {...props} ref={ref} />)
)(() => ({
'& .MuiAutocomplete-endAdornment': {
right: '1rem',
},
}))

View File

@ -1,228 +1,139 @@
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { Modal } from '@/shared/Modal/Modal'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
import {
askNotificationPermission,
call,
getAppIconTitle,
getDomain,
getReferrerAppUrl,
getShortenNpub,
} from '@/utils/helpers/helpers'
import { call, getShortenNpub } from '@/utils/helpers/helpers'
import { Avatar, Box, Stack, Typography } from '@mui/material'
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { useParams, useSearchParams } from 'react-router-dom'
import { useAppSelector } from '@/store/hooks/redux'
import { selectAppsByNpub, selectKeys, selectPendingsByNpub } from '@/store'
import { selectAppsByNpub } from '@/store'
import { StyledButton, StyledToggleButtonsGroup } from './styled'
import { ActionToggleButton } from './сomponents/ActionToggleButton'
import { useEffect, useState } from 'react'
import { swicCall, swicWaitStarted } from '@/modules/swic'
import { useState } from 'react'
import { swicCall } from '@/modules/swic'
import { ACTION_TYPE } from '@/utils/consts'
import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar'
export const ModalConfirmConnect = () => {
const keys = useAppSelector(selectKeys)
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
const notify = useEnqueueSnackbar()
const navigate = useNavigate()
const { getModalOpened, handleClose } = useModalSearchParams()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.CONFIRM_CONNECT)
const [searchParams] = useSearchParams()
const paramNpub = searchParams.get('npub') || ''
const { npub = paramNpub } = useParams<{ npub: string }>()
const { npub = '' } = useParams<{ npub: string }>()
const apps = useAppSelector((state) => selectAppsByNpub(state, npub))
const pending = useAppSelector((state) => selectPendingsByNpub(state, npub))
const [selectedActionType, setSelectedActionType] = useState<ACTION_TYPE>(ACTION_TYPE.BASIC)
const [isLoaded, setIsLoaded] = useState(false)
const [selectedActionType, setSelectedActionType] = useState<ACTION_TYPE>(
ACTION_TYPE.BASIC,
)
const [searchParams] = useSearchParams()
const appNpub = searchParams.get('appNpub') || ''
const pendingReqId = searchParams.get('reqId') || ''
const isPopup = searchParams.get('popup') === 'true'
const token = searchParams.get('token') || ''
const triggerApp = apps.find((app) => app.appNpub === appNpub)
const { name, url = '', icon = '' } = triggerApp || {}
const appUrl = url || searchParams.get('appUrl') || getReferrerAppUrl()
const appDomain = getDomain(appUrl)
const appName = name || appDomain || getShortenNpub(appNpub)
const appAvatarTitle = getAppIconTitle(name || appDomain, appNpub)
const appIcon = icon || (appDomain ? `https://${appDomain}/favicon.ico` : '')
const closeModalAfterRequest = createHandleCloseReplace(MODAL_PARAMS_KEYS.CONFIRM_CONNECT, {
onClose: (sp) => {
sp.delete('appNpub')
sp.delete('reqId')
sp.delete('popup')
sp.delete('npub')
sp.delete('appUrl')
},
})
// 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 { name, icon = '' } = triggerApp || {}
const appName = name || getShortenNpub(appNpub)
const handleActionTypeChange = (_: any, value: ACTION_TYPE | null) => {
if (!value) return undefined
return setSelectedActionType(value)
}
async function confirmPending(id: string, allow: boolean, remember: boolean, options?: any) {
const handleCloseModal = handleClose(
MODAL_PARAMS_KEYS.CONFIRM_CONNECT,
async (sp) => {
sp.delete('appNpub')
sp.delete('reqId')
await swicCall('confirm', pendingReqId, false, false)
},
)
const closeModalAfterRequest = handleClose(
MODAL_PARAMS_KEYS.CONFIRM_CONNECT,
(sp) => {
sp.delete('appNpub')
sp.delete('reqId')
},
)
async function confirmPending(
id: string,
allow: boolean,
remember: boolean,
options?: any
) {
call(async () => {
await swicCall('confirm', id, allow, remember, options)
console.log('confirmed', id, allow, remember, options)
closeModalAfterRequest()
})
if (isPopup) window.close()
}
const allow = async () => {
let perms = ['connect', 'get_public_key']
if (selectedActionType === ACTION_TYPE.BASIC) perms = [ACTION_TYPE.BASIC]
if (pendingReqId) {
const options = { perms, appUrl }
await confirmPending(pendingReqId, true, true, options)
} else {
try {
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')
// keep going
}
try {
await swicCall('connectApp', { npub, appNpub, appUrl, perms })
console.log('connectApp done', npub, appNpub, appUrl, perms)
} catch (e: any) {
notify(e.toString(), 'error')
return
}
if (token) {
try {
await swicCall('redeemToken', npub, token)
console.log('redeemToken done')
} catch (e) {
console.log('error', e)
notify('App did not reply. Please try to log in now.', 'error')
navigate(`/key/${npub}`, { replace: true })
return
}
}
notify('App connected! Closing...', 'success')
if (isPopup) setTimeout(() => window.close(), 3000)
else navigate(`/key/${npub}`, { replace: true })
}
}
const disallow = () => {
if (pendingReqId) confirmPending(pendingReqId, false, true)
else closeModalAfterRequest()
}
if (isPopup) {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
// FIXME it should be 'ignore once',
// not 'disallow & remember' - this is too strict
// disallow()
}
})
const allow = () => {
const options: any = {};
if (selectedActionType === ACTION_TYPE.BASIC)
options.perm = ACTION_TYPE.BASIC;
confirmPending(pendingReqId, true, true, options)
}
return (
<Modal title="Connection request" open={isModalOpened} withCloseButton={false}>
<Modal open={isModalOpened} onClose={handleCloseModal}>
<Stack gap={'1rem'} paddingTop={'1rem'}>
{!pendingReqId && (
<Typography variant="body1" color={'GrayText'}>
You will be asked to <b>enable notifications</b>, this is needed for a reliable communication with Nostr
apps.
</Typography>
)}
<Stack direction={'row'} gap={'1rem'} alignItems={'center'} marginBottom={'1rem'}>
<Stack
direction={'row'}
gap={'1rem'}
alignItems={'center'}
marginBottom={'1rem'}
>
<Avatar
variant="rounded"
variant='square'
sx={{
width: 56,
height: 56,
}}
src={appIcon}
>
{appAvatarTitle}
</Avatar>
<Box overflow={'auto'}>
<Typography variant="h5" fontWeight={600} noWrap>
src={icon}
/>
<Box>
<Typography variant='h5' fontWeight={600}>
{appName}
</Typography>
<Typography variant="body2" color={'GrayText'} noWrap>
New app would like to connect
<Typography variant='body2' color={'GrayText'}>
Would like to connect to your account
</Typography>
</Box>
</Stack>
<StyledToggleButtonsGroup value={selectedActionType} onChange={handleActionTypeChange} exclusive>
<StyledToggleButtonsGroup
value={selectedActionType}
onChange={handleActionTypeChange}
exclusive
>
<ActionToggleButton
value={ACTION_TYPE.BASIC}
title="Basic permissions"
description="Read your public key, sign notes, reactions, zaps, etc"
title='Basic permissions'
description='Read your public key, sign notes and reactions'
// hasinfo
/>
{/* <ActionToggleButton
value={ACTION_TYPE.ADVANCED}
title='Advanced'
description='Use for trusted apps only'
hasinfo
/> */}
<ActionToggleButton
value={ACTION_TYPE.CUSTOM}
title="On demand"
description="Confirm permissions when the app asks for them"
title='On demand'
description='Assign permissions when the app asks for them'
/>
</StyledToggleButtonsGroup>
<Stack direction={'row'} gap={'1rem'}>
<StyledButton onClick={disallow} varianttype="secondary">
Ignore
<StyledButton
onClick={() => confirmPending(pendingReqId, false, true)}
varianttype='secondary'
>
Disallow
</StyledButton>
<StyledButton fullWidth onClick={allow}>
<StyledButton
fullWidth
onClick={allow}
>
{/* Allow {selectedActionType} actions */}
Connect
</StyledButton>
</Stack>

View File

@ -1,19 +1,27 @@
import { AppButtonProps, Button } from '@/shared/Button/Button'
import { ToggleButtonGroup, ToggleButtonGroupProps, styled } from '@mui/material'
import {
ToggleButtonGroup,
ToggleButtonGroupProps,
styled,
} from '@mui/material'
export const StyledButton = styled((props: AppButtonProps) => <Button {...props} />)(() => ({
export const StyledButton = styled((props: AppButtonProps) => (
<Button {...props} />
))(() => ({
borderRadius: '19px',
fontWeight: 600,
padding: '0.75rem 1rem',
maxHeight: '41px',
}))
export const StyledToggleButtonsGroup = styled((props: ToggleButtonGroupProps) => <ToggleButtonGroup {...props} />)(
() => ({
export const StyledToggleButtonsGroup = styled(
(props: ToggleButtonGroupProps) => <ToggleButtonGroup {...props} />,
)(() => ({
gap: '0.75rem',
marginBottom: '1rem',
justifyContent: 'space-between',
'&.MuiToggleButtonGroup-root .MuiToggleButtonGroup-grouped:not(:first-of-type)': {
'&.MuiToggleButtonGroup-root .MuiToggleButtonGroup-grouped:not(:first-of-type)':
{
margin: '0',
border: 'initial',
},
@ -21,8 +29,4 @@ export const StyledToggleButtonsGroup = styled((props: ToggleButtonGroupProps) =
border: 'initial',
borderRadius: '1rem',
},
'@media screen and (max-width: 320px)': {
marginBottom: '0.25rem',
},
})
)
}))

View File

@ -4,18 +4,29 @@ import { StyledToggleButton } from './styled'
type ActionToggleButtonProps = ToggleButtonProps & {
description?: string
hasinfo?: boolean
}
export const ActionToggleButton: FC<ActionToggleButtonProps> = (props) => {
export const ActionToggleButton: FC<ActionToggleButtonProps> = ({
hasinfo = false,
...props
}) => {
const { title, description = '' } = props
return (
<StyledToggleButton {...props}>
<Typography variant="body2" noWrap className="title">
{title}
</Typography>
<Typography className="description" variant="caption" color={'GrayText'}>
<Typography variant='body2'>{title}</Typography>
<Typography
className='description'
variant='caption'
color={'GrayText'}
>
{description}
</Typography>
{hasinfo && (
<Typography className='info' color={'GrayText'}>
Info
</Typography>
)}
</StyledToggleButton>
)
}

View File

@ -29,17 +29,4 @@ 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',
},
},
}))

View File

@ -1,18 +1,32 @@
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { Modal } from '@/shared/Modal/Modal'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { call, getAppIconTitle, getReqActionName, getShortenNpub } from '@/utils/helpers/helpers'
import { Avatar, Box, List, ListItem, ListItemIcon, ListItemText, Stack, Typography } from '@mui/material'
import { call, getShortenNpub, getSignReqKind } from '@/utils/helpers/helpers'
import {
Avatar,
Box,
List,
ListItem,
ListItemIcon,
ListItemText,
Stack,
Typography,
} from '@mui/material'
import { useParams, useSearchParams } from 'react-router-dom'
import { useAppSelector } from '@/store/hooks/redux'
import { selectAppsByNpub, selectKeys } from '@/store'
import { selectAppsByNpub } from '@/store'
import { ActionToggleButton } from './сomponents/ActionToggleButton'
import { FC, useEffect, useMemo, useState } from 'react'
import { StyledActionsListContainer, StyledButton, StyledToggleButtonsGroup } from './styled'
import {
StyledActionsListContainer,
StyledButton,
StyledToggleButtonsGroup,
} from './styled'
import { SectionTitle } from '@/shared/SectionTitle/SectionTitle'
import { swicCall, swicWaitStarted } from '@/modules/swic'
import { swicCall } from '@/modules/swic'
import { Checkbox } from '@/shared/Checkbox/Checkbox'
import { DbPending } from '@/modules/db'
import { ACTIONS } from '@/utils/consts'
import { IPendingsByAppNpub } from '@/pages/KeyPage/hooks/useTriggerConfirmModal'
enum ACTION_TYPE {
@ -33,68 +47,37 @@ type ModalConfirmEventProps = {
type PendingRequest = DbPending & { checked: boolean }
export const ModalConfirmEvent: FC<ModalConfirmEventProps> = ({ confirmEventReqs }) => {
const keys = useAppSelector(selectKeys)
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
export const ModalConfirmEvent: FC<ModalConfirmEventProps> = ({
confirmEventReqs,
}) => {
const { getModalOpened, handleClose } = useModalSearchParams()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.CONFIRM_EVENT)
const [searchParams] = useSearchParams()
const appNpub = searchParams.get('appNpub') || ''
const isPopup = searchParams.get('popup') === 'true'
const { npub = '' } = useParams<{ npub: string }>()
const apps = useAppSelector((state) => selectAppsByNpub(state, npub))
const [selectedActionType, setSelectedActionType] = useState<ACTION_TYPE>(ACTION_TYPE.ALWAYS)
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])
const currentAppPendingReqs = useMemo(
() => confirmEventReqs[appNpub]?.pending || [],
[confirmEventReqs, appNpub],
)
useEffect(() => {
setPendingRequests(currentAppPendingReqs.map((pr) => ({ ...pr, checked: true })))
setPendingRequests(
currentAppPendingReqs.map((pr) => ({ ...pr, checked: true })),
)
}, [currentAppPendingReqs])
const closeModalAfterRequest = createHandleCloseReplace(MODAL_PARAMS_KEYS.CONFIRM_EVENT, {
onClose: (sp) => {
sp.delete('appNpub')
sp.delete('reqId')
},
})
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 (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)
const { name, icon = '' } = triggerApp || {}
const appName = name || getShortenNpub(appNpub)
const appAvatarTitle = getAppIconTitle(name, appNpub)
const handleActionTypeChange = (_: any, value: ACTION_TYPE | null) => {
if (!value) return undefined
@ -103,6 +86,25 @@ export const ModalConfirmEvent: FC<ModalConfirmEventProps> = ({ confirmEventReqs
const selectedPendingRequests = pendingRequests.filter((pr) => pr.checked)
const handleCloseModal = handleClose(
MODAL_PARAMS_KEYS.CONFIRM_EVENT,
(sp) => {
sp.delete('appNpub')
sp.delete('reqId')
selectedPendingRequests.forEach(
async (req) => await swicCall('confirm', req.id, false, false),
)
},
)
const closeModalAfterRequest = handleClose(
MODAL_PARAMS_KEYS.CONFIRM_EVENT,
(sp) => {
sp.delete('appNpub')
sp.delete('reqId')
},
)
async function confirmPending(allow: boolean) {
selectedPendingRequests.forEach((req) => {
call(async () => {
@ -112,7 +114,6 @@ export const ModalConfirmEvent: FC<ModalConfirmEventProps> = ({ confirmEventReqs
})
})
closeModalAfterRequest()
if (isPopup) window.close()
}
const handleChangeCheckbox = (reqId: string) => () => {
@ -123,35 +124,39 @@ export const ModalConfirmEvent: FC<ModalConfirmEventProps> = ({ confirmEventReqs
setPendingRequests(newPendingRequests)
}
if (isPopup) {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
confirmPending(false)
const getAction = (req: PendingRequest) => {
const action = ACTIONS[req.method]
if (req.method === 'sign_event') {
const kind = getSignReqKind(req)
if (kind !== undefined) return `${action} of kind ${kind}`
}
})
return action
}
return (
<Modal title="Permission request" open={isModalOpened} withCloseButton={false}>
<Modal open={isModalOpened} onClose={handleCloseModal}>
<Stack gap={'1rem'} paddingTop={'1rem'}>
<Stack direction={'row'} gap={'1rem'} alignItems={'center'} marginBottom={'1rem'}>
<Stack
direction={'row'}
gap={'1rem'}
alignItems={'center'}
marginBottom={'1rem'}
>
<Avatar
variant="square"
variant='square'
sx={{
width: 56,
height: 56,
borderRadius: '12px',
}}
src={icon}
>
{appAvatarTitle}
</Avatar>
/>
<Box>
<Typography variant="h5" fontWeight={600}>
<Typography variant='h5' fontWeight={600}>
{appName}
</Typography>
<Typography variant="body2" color={'GrayText'}>
App wants to perform these actions
<Typography variant='body2' color={'GrayText'}>
Would like your permission to
</Typography>
</Box>
</Stack>
@ -163,24 +168,51 @@ export const ModalConfirmEvent: FC<ModalConfirmEventProps> = ({ confirmEventReqs
return (
<ListItem key={req.id}>
<ListItemIcon>
<Checkbox checked={req.checked} onChange={handleChangeCheckbox(req.id)} />
<Checkbox
checked={req.checked}
onChange={handleChangeCheckbox(
req.id,
)}
/>
</ListItemIcon>
<ListItemText>{getReqActionName(req)}</ListItemText>
<ListItemText>
{getAction(req)}
</ListItemText>
</ListItem>
)
})}
</List>
</StyledActionsListContainer>
<StyledToggleButtonsGroup value={selectedActionType} onChange={handleActionTypeChange} exclusive>
<ActionToggleButton value={ACTION_TYPE.ALWAYS} title="Always" />
<ActionToggleButton value={ACTION_TYPE.ONCE} title="Just once" />
<StyledToggleButtonsGroup
value={selectedActionType}
onChange={handleActionTypeChange}
exclusive
>
<ActionToggleButton
value={ACTION_TYPE.ALWAYS}
title='Always'
/>
<ActionToggleButton
value={ACTION_TYPE.ONCE}
title='Just once'
/>
{/* <ActionToggleButton
value={ACTION_TYPE.ALLOW_ALL}
title='Allow All Advanced Actions'
hasinfo
/> */}
</StyledToggleButtonsGroup>
<Stack direction={'row'} gap={'1rem'}>
<StyledButton onClick={() => confirmPending(false)} varianttype="secondary">
<StyledButton
onClick={() => confirmPending(false)}
varianttype='secondary'
>
Disallow {ACTION_LABELS[selectedActionType]}
</StyledButton>
<StyledButton onClick={() => confirmPending(true)}>Allow {ACTION_LABELS[selectedActionType]}</StyledButton>
<StyledButton onClick={() => confirmPending(true)}>
Allow {ACTION_LABELS[selectedActionType]}
</StyledButton>
</Stack>
</Stack>
</Modal>

View File

@ -1,19 +1,29 @@
import { AppButtonProps, Button } from '@/shared/Button/Button'
import { Stack, StackProps, ToggleButtonGroup, ToggleButtonGroupProps, styled } from '@mui/material'
import {
Stack,
StackProps,
ToggleButtonGroup,
ToggleButtonGroupProps,
styled,
} from '@mui/material'
export const StyledButton = styled((props: AppButtonProps) => <Button {...props} />)(() => ({
export const StyledButton = styled((props: AppButtonProps) => (
<Button {...props} />
))(() => ({
borderRadius: '19px',
fontWeight: 600,
padding: '0.75rem 1rem',
maxHeight: '41px',
}))
export const StyledToggleButtonsGroup = styled((props: ToggleButtonGroupProps) => <ToggleButtonGroup {...props} />)(
() => ({
export const StyledToggleButtonsGroup = styled(
(props: ToggleButtonGroupProps) => <ToggleButtonGroup {...props} />,
)(() => ({
gap: '0.75rem',
marginBottom: '1rem',
justifyContent: 'space-between',
'&.MuiToggleButtonGroup-root .MuiToggleButtonGroup-grouped:not(:first-of-type)': {
'&.MuiToggleButtonGroup-root .MuiToggleButtonGroup-grouped:not(:first-of-type)':
{
margin: '0',
border: 'initial',
},
@ -21,10 +31,11 @@ export const StyledToggleButtonsGroup = styled((props: ToggleButtonGroupProps) =
border: 'initial',
borderRadius: '1rem',
},
})
)
}))
export const StyledActionsListContainer = styled((props: StackProps) => <Stack {...props} />)(({ theme }) => ({
export const StyledActionsListContainer = styled((props: StackProps) => (
<Stack {...props} />
))(({ theme }) => ({
padding: '0.75rem',
background: theme.palette.backgroundSecondary.default,
borderRadius: '1rem',

View File

@ -6,13 +6,16 @@ type ActionToggleButtonProps = ToggleButtonProps & {
hasinfo?: boolean
}
export const ActionToggleButton: FC<ActionToggleButtonProps> = ({ hasinfo = false, ...props }) => {
export const ActionToggleButton: FC<ActionToggleButtonProps> = ({
hasinfo = false,
...props
}) => {
const { title } = props
return (
<StyledToggleButton {...props}>
<Typography variant="body2">{title}</Typography>
<Typography variant='body2'>{title}</Typography>
{hasinfo && (
<Typography className="info" color={'GrayText'}>
<Typography className='info' color={'GrayText'}>
Info
</Typography>
)}

View File

@ -5,35 +5,26 @@ import { Button } from '@/shared/Button/Button'
import { Input } from '@/shared/Input/Input'
import { InputCopyButton } from '@/shared/InputCopyButton/InputCopyButton'
import { Modal } from '@/shared/Modal/Modal'
import { selectKeys } from '@/store'
import { useAppSelector } from '@/store/hooks/redux'
import { EXPLANATION_MODAL_KEYS, MODAL_PARAMS_KEYS } from '@/types/modal'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { getBunkerLink } from '@/utils/helpers/helpers'
import { Stack, Typography } from '@mui/material'
import { useRef } from 'react'
import { useParams } from 'react-router-dom'
export const ModalConnectApp = () => {
const keys = useAppSelector(selectKeys)
const { getModalOpened, handleClose, handleOpen } = useModalSearchParams()
const timerRef = useRef<NodeJS.Timeout>()
const notify = useEnqueueSnackbar()
const { npub = '' } = useParams<{ npub: string }>()
const bunkerStr = getBunkerLink(npub)
const { getModalOpened, createHandleCloseReplace, handleOpen } = useModalSearchParams()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.CONNECT_APP)
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.CONNECT_APP, {
onClose: () => {
const handleCloseModal = handleClose(MODAL_PARAMS_KEYS.CONNECT_APP, () => {
clearTimeout(timerRef.current)
},
})
const isNpubExists = npub.trim().length && keys.some((key) => key.npub === npub)
if (isModalOpened && !isNpubExists) {
handleCloseModal()
return null
}
const notify = useEnqueueSnackbar()
const { npub = '' } = useParams<{ npub: string }>()
const bunkerStr = getBunkerLink(npub)
const handleShareBunker = async () => {
const shareData = {
@ -58,20 +49,31 @@ export const ModalConnectApp = () => {
}
return (
<Modal open={isModalOpened} title="Share your profile" onClose={handleCloseModal}>
<Modal
open={isModalOpened}
title='Share your profile'
onClose={handleCloseModal}
>
<Stack gap={'1rem'} alignItems={'center'}>
<Typography variant="caption">Please, copy this code and paste it into the app to log in</Typography>
<Typography variant='caption'>
Please, copy this code and paste it into the app to log in
</Typography>
<Input
sx={{
gap: '0.5rem',
}}
fullWidth
value={bunkerStr}
endAdornment={<InputCopyButton value={bunkerStr} onCopy={handleCopy} />}
endAdornment={
<InputCopyButton
value={bunkerStr}
onCopy={handleCopy}
/>
}
/>
<AppLink
title="What is this?"
onClick={() => handleOpen(MODAL_PARAMS_KEYS.EXPLANATION, { search: { type: EXPLANATION_MODAL_KEYS.BUNKER } })}
title='What is this?'
onClick={() => handleOpen(MODAL_PARAMS_KEYS.EXPLANATION)}
/>
<Button fullWidth onClick={handleShareBunker}>
Share it

View File

@ -1,174 +0,0 @@
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>
)
}

View File

@ -1,10 +0,0 @@
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,
}))

View File

@ -1,7 +1,7 @@
import { FC } from 'react'
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { Modal } from '@/shared/Modal/Modal'
import { EXPLANATION_MODAL_KEYS, MODAL_PARAMS_KEYS } from '@/types/modal'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { Stack, Typography } from '@mui/material'
import { Button } from '@/shared/Button/Button'
import { useSearchParams } from 'react-router-dom'
@ -10,7 +10,9 @@ type ModalExplanationProps = {
explanationText?: string
}
export const ModalExplanation: FC<ModalExplanationProps> = () => {
export const ModalExplanation: FC<ModalExplanationProps> = ({
explanationText = '',
}) => {
const { getModalOpened } = useModalSearchParams()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.EXPLANATION)
const [searchParams, setSearchParams] = useSearchParams()
@ -18,76 +20,21 @@ export const ModalExplanation: FC<ModalExplanationProps> = () => {
const handleCloseModal = () => {
searchParams.delete('type')
searchParams.delete(MODAL_PARAMS_KEYS.EXPLANATION)
setSearchParams(searchParams, { replace: true })
setSearchParams(searchParams)
}
const type = searchParams.get('type')
let title = ''
let explanationText
switch (type) {
case EXPLANATION_MODAL_KEYS.NPUB: {
title = 'What is NPUB?'
explanationText = (
<>
NPUB is your Nostr PUBlic key.
<br />
<br />
It is your global unique identifier on the Nostr network, and is derived from your private key.
<br />
<br />
You can share your NPUB with other people so that they could unambiguously find you on the network.
</>
)
break
}
case EXPLANATION_MODAL_KEYS.LOGIN: {
title = 'What is Login?'
explanationText = (
<>
Login (username) is your human-readable name on the Nostr network.
<br />
<br />
Unlike your NPUB, which is a long string of random symbols, your login is a meaningful name tied to a website
address (like name@nsec.app).
<br />
<br />
Use your username to log in to Nostr apps.
<br />
<br />
You can have many usernames all pointing to your NPUB. People also refer to these names as nostr-addresses or
NIP05 names.
</>
)
break
}
case EXPLANATION_MODAL_KEYS.BUNKER: {
title = 'What is Bunker URL?'
explanationText = (
<>
Bunker URL is a string used to connect to Nostr apps.
<br />
<br />
Some apps require bunker URL to connect to your keys. Paste it to the app and then confirm a connection
request.
</>
)
break
}
}
return (
<Modal
title={title}
title='What is this?'
open={isModalOpened}
onClose={handleCloseModal}
withCloseButton={false}
PaperProps={{
sx: {
minHeight: '60%',
},
}}
>
<Stack height={'100%'} gap={2}>
<Stack height={'100%'}>
<Typography flex={1}>{explanationText}</Typography>
<Button fullWidth onClick={handleCloseModal}>
Got it!

View File

@ -5,201 +5,60 @@ 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 { Stack, Typography, useTheme } from '@mui/material'
import { Stack, Typography } from '@mui/material'
import React, { ChangeEvent, FormEvent, useState } from 'react'
import { StyledAppLogo } from './styled'
import { useNavigate } from 'react-router-dom'
import { useForm } from 'react-hook-form'
import { FormInputType, schema } from './const'
import { yupResolver } from '@hookform/resolvers/yup'
import { usePassword } from '@/hooks/usePassword'
import { useCallback, useEffect, useState } from 'react'
import { useDebounce } from 'use-debounce'
import { fetchNip05 } from '@/utils/helpers/helpers'
import { DOMAIN } from '@/utils/consts'
import { CheckmarkIcon } from '@/assets'
import { getPublicKey, nip19 } from 'nostr-tools'
import { LoadingSpinner } from '@/shared/LoadingSpinner/LoadingSpinner'
import { HeadingContainer } from './styled'
const FORM_DEFAULT_VALUES = {
username: '',
nsec: '',
}
export const ModalImportKeys = () => {
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
const { getModalOpened, handleClose } = useModalSearchParams()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.IMPORT_KEYS)
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.IMPORT_KEYS)
const { hidePassword, inputProps } = usePassword()
const theme = useTheme()
const {
handleSubmit,
reset,
register,
formState: { errors },
watch,
} = useForm<FormInputType>({
defaultValues: FORM_DEFAULT_VALUES,
resolver: yupResolver(schema),
mode: 'onSubmit',
})
const [isLoading, setIsLoading] = useState(false)
const [nameNpub, setNameNpub] = useState('')
const [isTakenByNsec, setIsTakenByNsec] = useState(false)
const [isBadNsec, setIsBadNsec] = useState(false)
const enteredUsername = watch('username')
const enteredNsec = watch('nsec')
const [debouncedUsername] = useDebounce(enteredUsername, 100)
const [debouncedNsec] = useDebounce(enteredNsec, 100)
const checkIsUsernameAvailable = useCallback(async () => {
if (!debouncedUsername.trim().length) return undefined
const npubNip05 = await fetchNip05(`${debouncedUsername}@${DOMAIN}`)
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)
setNameNpub('')
setIsTakenByNsec(false)
setIsBadNsec(false)
}, [reset, hidePassword])
const handleCloseModal = handleClose(MODAL_PARAMS_KEYS.IMPORT_KEYS)
const notify = useEnqueueSnackbar()
const navigate = useNavigate()
const submitHandler = async (values: FormInputType) => {
if (isLoading) return undefined
const [enteredNsec, setEnteredNsec] = useState('')
const handleNsecChange = (e: ChangeEvent<HTMLInputElement>) => {
setEnteredNsec(e.target.value)
}
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
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)
if (!enteredNsec.trim().length) return
const k: any = await swicCall('importKey', enteredNsec)
notify('Key imported!', 'success')
navigate(`/key/${k.npub}`)
cleanUpStates()
} catch (error: any) {
notify(error?.message || 'Something went wrong!', 'error')
cleanUpStates()
notify(error.message, 'error')
}
}
useEffect(() => {
return () => {
isModalOpened && cleanUpStates()
}
}, [isModalOpened, cleanUpStates])
const getNameHelperText = () => {
if (!enteredUsername) return "Don't worry, username can be changed later."
if (isTakenByNsec) return 'Name matches your key'
if (isBadNsec) return 'Invalid nsec'
if (nameNpub) return 'Already taken'
return (
<>
<CheckmarkIcon /> Available
</>
)
}
const getNsecHelperText = () => {
if (isBadNsec) return 'Invalid nsec'
return 'Keys stay on your device.'
}
const nameHelperText = getNameHelperText()
const nsecHelperText = getNsecHelperText()
return (
<Modal open={isModalOpened} onClose={handleCloseModal} withCloseButton={false}>
<Stack paddingTop={'1rem'} gap={'1rem'} component={'form'} onSubmit={handleSubmit(submitHandler)}>
<HeadingContainer>
<Typography fontWeight={600} variant="h5">
Import key
<Modal open={isModalOpened} onClose={handleCloseModal}>
<Stack gap={'1rem'} component={'form'} onSubmit={handleSubmit}>
<Stack
direction={'row'}
gap={'1rem'}
alignItems={'center'}
alignSelf={'flex-start'}
>
<StyledAppLogo />
<Typography fontWeight={600} variant='h5'>
Import keys
</Typography>
<Typography noWrap variant="body2" color={'GrayText'}>
Bring your existing Nostr keys to Nsec.app
</Typography>
</HeadingContainer>
</Stack>
<Input
label="Choose a username"
label='Enter a NSEC'
placeholder='Your NSEC'
value={enteredNsec}
onChange={handleNsecChange}
fullWidth
placeholder="Enter a Username"
endAdornment={<Typography color={'#FFFFFFA8'}>@{DOMAIN}</Typography>}
{...register('username')}
error={!!errors.username}
helperText={nameHelperText}
helperTextProps={{
sx: {
'&.helper_text': {
color:
enteredUsername && (isTakenByNsec || !nameNpub)
? theme.palette.success.main
: enteredUsername && nameNpub
? theme.palette.error.main
: theme.palette.textSecondaryDecorate.main,
},
},
}}
type='password'
/>
<Input
label="Paste your private key"
placeholder="nsec1..."
fullWidth
{...register('nsec')}
error={!!errors.nsec}
{...inputProps}
helperText={nsecHelperText}
helperTextProps={{
sx: {
'&.helper_text': {
color: isBadNsec ? theme.palette.error.main : theme.palette.textSecondaryDecorate.main,
},
},
}}
/>
<Button type="submit" disabled={isLoading}>
Import key {isLoading && <LoadingSpinner />}
</Button>
<Button type='submit'>Import nsec</Button>
</Stack>
</Modal>
)

View File

@ -1,8 +0,0 @@
import * as yup from 'yup'
export const schema = yup.object().shape({
username: yup.string().required(),
nsec: yup.string().required(),
})
export type FormInputType = yup.InferType<typeof schema>

View File

@ -1,25 +1,14 @@
import { AppLogo } from '@/assets'
import { Box, Stack, StackProps, styled } from '@mui/material'
import { Box, styled } from '@mui/material'
export const StyledAppLogo = styled((props) => (
<Box {...props}>
<AppLogo />
</Box>
))(() => ({
background: '#0d0d0d',
))({
background: '#00000054',
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',
},
}))
})

View File

@ -1,36 +1,57 @@
// import { useEffect } from 'react'
import React, { useEffect, useState } 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 { Stack } from '@mui/material'
// import { AppLink } from '@/shared/AppLink/AppLink'
import { Fade, Stack } from '@mui/material'
import { AppLink } from '@/shared/AppLink/AppLink'
export const ModalInitial = () => {
const { getModalOpened, createHandleCloseReplace, handleOpen } = useModalSearchParams()
const { getModalOpened, handleClose, handleOpen } = useModalSearchParams()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.INITIAL)
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.INITIAL)
// const [showAdvancedContent, setShowAdvancedContent] = useState(false)
const handleCloseModal = handleClose(MODAL_PARAMS_KEYS.INITIAL)
// const handleShowAdvanced = () => {
// setShowAdvancedContent(true)
// }
const [showAdvancedContent, setShowAdvancedContent] = useState(false)
// useEffect(() => {
// return () => {
// if (isModalOpened) {
// setShowAdvancedContent(false)
// }
// }
// }, [isModalOpened])
const handleShowAdvanced = () => {
setShowAdvancedContent(true)
}
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>
<Button onClick={() => handleOpen(MODAL_PARAMS_KEYS.IMPORT_KEYS)}>Import key</Button>
<Button onClick={() => handleOpen(MODAL_PARAMS_KEYS.SIGN_UP)}>
Sign up
</Button>
<Button onClick={() => handleOpen(MODAL_PARAMS_KEYS.LOGIN)}>
Login
</Button>
<AppLink
title='Advanced'
alignSelf={'center'}
onClick={handleShowAdvanced}
/>
{showAdvancedContent && (
<Fade in>
<Button
onClick={() =>
handleOpen(MODAL_PARAMS_KEYS.IMPORT_KEYS)
}
>
Import keys
</Button>
</Fade>
)}
</Stack>
</Modal>
)

View File

@ -1,113 +1,80 @@
import { useCallback, useEffect, useState } from 'react'
import React, { useCallback, useEffect, useState } from 'react'
import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar'
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { swicCall } from '@/modules/swic'
import { Modal } from '@/shared/Modal/Modal'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { Stack, Typography } from '@mui/material'
import { IconButton, Stack, Typography } from '@mui/material'
import { StyledAppLogo } from './styled'
import { nip19 } from 'nostr-tools'
import { Input } from '@/shared/Input/Input'
import { Button } from '@/shared/Button/Button'
import { useNavigate, useSearchParams } from 'react-router-dom'
import VisibilityOffOutlinedIcon from '@mui/icons-material/VisibilityOffOutlined'
import VisibilityOutlinedIcon from '@mui/icons-material/VisibilityOutlined'
import { useNavigate } 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, fetchNpubNames } from '@/utils/helpers/helpers'
import { usePassword } from '@/hooks/usePassword'
import { dbi } from '@/modules/db'
import { LoadingSpinner } from '@/shared/LoadingSpinner/LoadingSpinner'
const FORM_DEFAULT_VALUES = {
username: '',
password: '',
}
export const ModalLogin = () => {
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
const { getModalOpened, handleClose } = useModalSearchParams()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.LOGIN)
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.LOGIN)
const handleCloseModal = handleClose(MODAL_PARAMS_KEYS.LOGIN)
const notify = useEnqueueSnackbar()
const navigate = useNavigate()
const { hidePassword, inputProps } = usePassword()
const [isLoading, setIsLoading] = useState(false)
const [searchParams] = useSearchParams()
const isPopup = searchParams.get('popup') === 'true'
const {
handleSubmit,
reset,
register,
setValue,
formState: { errors },
} = useForm<FormInputType>({
defaultValues: FORM_DEFAULT_VALUES,
defaultValues: {
username: '',
password: '',
},
resolver: yupResolver(schema),
mode: 'onSubmit',
})
const [isPasswordShown, setIsPasswordShown] = useState(false)
const handlePasswordTypeChange = () =>
setIsPasswordShown((prevState) => !prevState)
const cleanUpStates = useCallback(() => {
hidePassword()
setIsPasswordShown(false)
reset()
setIsLoading(false)
}, [reset, hidePassword])
}, [reset])
const submitHandler = async (values: FormInputType) => {
if (isLoading) return undefined
try {
setIsLoading(true)
let npub = values.username
let name = ''
const [username, domain] = values.username.split('@')
const response = await fetch(
`https://${domain}/.well-known/nostr.json?name=${username}`,
)
const getNpub: {
names: {
[name: string]: string
}
} = await response.json()
if (!npub.startsWith('npub1')) {
name = npub
if (!npub.includes('@')) {
npub += '@' + DOMAIN
} else {
const nameDomain = npub.split('@')
if (nameDomain[1] === DOMAIN) name = nameDomain[0]
}
}
if (npub.includes('@')) {
const npubNip05 = await fetchNip05(npub)
if (!npubNip05) throw new Error(`Username ${npub} not found`)
npub = npubNip05
}
const pubkey = getNpub.names[username]
const npub = nip19.npubEncode(pubkey)
const passphrase = values.password
console.log('fetch', npub, name)
const k: any = await swicCall('fetchKey', npub, passphrase, name)
console.log('fetch', npub, passphrase)
const k: any = await swicCall('fetchKey', npub, passphrase)
notify(`Fetched ${k.npub}`, 'success')
dbi.addSynced(k.npub)
cleanUpStates()
setTimeout(() => {
// give frontend time to read the new key first
navigate(`/key/${k.npub}${isPopup ? '?popup=true' : ''}`)
}, 300)
navigate(`/key/${k.npub}`)
} catch (error: any) {
console.log('error', error)
notify(error?.message || 'Something went wrong!', 'error')
setIsLoading(false)
}
}
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) {
@ -118,39 +85,54 @@ export const ModalLogin = () => {
}, [isModalOpened, cleanUpStates])
return (
<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">
<Modal open={isModalOpened} onClose={handleCloseModal}>
<Stack
gap={'1rem'}
component={'form'}
onSubmit={handleSubmit(submitHandler)}
>
<Stack
direction={'row'}
gap={'1rem'}
alignItems={'center'}
alignSelf={'flex-start'}
>
<StyledAppLogo />
<Typography fontWeight={600} variant='h5'>
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"
label='Enter a Username'
fullWidth
placeholder="name or name@domain.com or npub1..."
placeholder='user@nsec.app'
{...register('username')}
error={!!errors.username}
/>
<Input
label="Password"
label='Password'
fullWidth
placeholder="Your password"
placeholder='Your password'
{...register('password')}
{...inputProps}
endAdornment={
<IconButton
size='small'
onClick={handlePasswordTypeChange}
>
{isPasswordShown ? (
<VisibilityOffOutlinedIcon />
) : (
<VisibilityOutlinedIcon />
)}
</IconButton>
}
type={isPasswordShown ? 'text' : 'password'}
error={!!errors.password}
helperText={'Password you set in Cloud Sync settings'}
/>
<Stack gap={'0.5rem'}>
<Button type="submit" fullWidth disabled={isLoading}>
Login {isLoading && <LoadingSpinner />}
<Button type='submit' fullWidth>
Login
</Button>
</Stack>
</Stack>
</Modal>
)
}

View File

@ -1,7 +1,17 @@
import * as yup from 'yup'
export const schema = yup.object().shape({
username: yup.string().required(),
username: yup
.string()
.test('Domain validation', 'The domain is required!', function (value) {
if (!value || !value.trim().length) return false
const USERNAME_WITH_DOMAIN_REGEXP = new RegExp(
/^[\w-.]+@([\w-]+\.)+[\w-]{2,8}$/g,
)
return USERNAME_WITH_DOMAIN_REGEXP.test(value)
})
.required(),
password: yup.string().required().min(4),
})

View File

@ -6,7 +6,7 @@ export const StyledAppLogo = styled((props) => (
<AppLogo />
</Box>
))({
background: '#0d0d0d',
background: '#00000054',
padding: '0.75rem',
borderRadius: '16px',
display: 'grid',

View File

@ -1,69 +1,63 @@
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, Stack, Typography } from '@mui/material'
import { StyledButton, StyledSettingContainer, StyledSynchedText } from './styled'
import {
Box,
CircularProgress,
IconButton,
Stack,
Typography,
} from '@mui/material'
import {
StyledButton,
StyledSettingContainer,
StyledSynchedText,
} from './styled'
import { SectionTitle } from '@/shared/SectionTitle/SectionTitle'
import { CheckmarkIcon } from '@/assets'
import { Input } from '@/shared/Input/Input'
import VisibilityOffOutlinedIcon from '@mui/icons-material/VisibilityOffOutlined'
import VisibilityOutlinedIcon from '@mui/icons-material/VisibilityOutlined'
import { ChangeEvent, FC, useEffect, useState } from 'react'
import { Checkbox } from '@/shared/Checkbox/Checkbox'
import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar'
import { swicCall } from '@/modules/swic'
import { useParams } from 'react-router-dom'
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
}
export const ModalSettings: FC<ModalSettingsProps> = ({ isSynced }) => {
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
const { getModalOpened, handleClose } = useModalSearchParams()
const { npub = '' } = useParams<{ npub: string }>()
const keys = useAppSelector(selectKeys)
const notify = useEnqueueSnackbar()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.SETTINGS)
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.SETTINGS)
const { hidePassword, inputProps } = usePassword()
const handleCloseModal = handleClose(MODAL_PARAMS_KEYS.SETTINGS)
const [enteredPassword, setEnteredPassword] = useState('')
const [isPasswordShown, setIsPasswordShown] = useState(false)
const [isPasswordInvalid, setIsPasswordInvalid] = useState(false)
const [isChecked, setIsChecked] = useState(false)
const [isLoading, setIsLoading] = useState(false)
useEffect(() => setIsChecked(isSynced), [isModalOpened, isSynced])
useEffect(() => {
return () => {
if (isModalOpened) {
// modal closed
hidePassword()
}
}
}, [hidePassword, isModalOpened])
const isNpubExists = npub.trim().length && keys.some((key) => key.npub === npub)
if (isModalOpened && !isNpubExists) {
handleCloseModal()
return null
}
useEffect(() => setIsChecked(isSynced), [isModalOpened])
const handlePasswordChange = (e: ChangeEvent<HTMLInputElement>) => {
const password = e.target.value
setIsPasswordInvalid(!!password && !isValidPassphase(password))
setEnteredPassword(password)
setIsPasswordInvalid(false)
setEnteredPassword(e.target.value)
}
const handlePasswordTypeChange = () =>
setIsPasswordShown((prevState) => !prevState)
const onClose = () => {
handleCloseModal()
setEnteredPassword('')
@ -78,7 +72,7 @@ export const ModalSettings: FC<ModalSettingsProps> = ({ isSynced }) => {
e.preventDefault()
setIsPasswordInvalid(false)
if (!isValidPassphase(enteredPassword)) {
if (enteredPassword.trim().length < 6) {
return setIsPasswordInvalid(true)
}
try {
@ -96,7 +90,7 @@ export const ModalSettings: FC<ModalSettingsProps> = ({ isSynced }) => {
}
return (
<Modal open={isModalOpened} onClose={onClose} title="Settings">
<Modal open={isModalOpened} onClose={onClose} title='Settings'>
<Stack gap={'1rem'}>
<StyledSettingContainer onSubmit={handleSubmit}>
<Stack direction={'row'} justifyContent={'space-between'}>
@ -108,43 +102,68 @@ export const ModalSettings: FC<ModalSettingsProps> = ({ isSynced }) => {
)}
</Stack>
<Box>
<Checkbox onChange={handleChangeCheckbox} checked={isChecked} />
<Typography variant="caption">Use this key on multiple devices</Typography>
<Checkbox
onChange={handleChangeCheckbox}
checked={isChecked}
/>
<Typography variant='caption'>
Use this key on multiple devices
</Typography>
</Box>
<Input
fullWidth
{...inputProps}
endAdornment={
<IconButton
size='small'
onClick={handlePasswordTypeChange}
>
{isPasswordShown ? (
<VisibilityOffOutlinedIcon htmlColor='#6b6b6b' />
) : (
<VisibilityOutlinedIcon htmlColor='#6b6b6b' />
)}
</IconButton>
}
type={isPasswordShown ? 'text' : 'password'}
onChange={handlePasswordChange}
value={enteredPassword}
placeholder="Enter a password"
helperText={
isPasswordInvalid ? 'Invalid password' : ''
}
placeholder='Enter a password'
helperTextProps={{
sx: {
'&.helper_text': {
color: 'red',
},
},
}}
disabled={!isChecked}
/>
{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'}>
{isSynced ? (
<Typography variant='body2' color={'GrayText'}>
To change your password, type a new one and sync.
</Typography>
) : (
<Typography variant="body2" color={'GrayText'}>
This key will be encrypted and stored on our server. You can use the password to download this key onto
another device.
<Typography variant='body2' color={'GrayText'}>
This key will be encrypted and stored on our server. You can use the password to download this key onto another device.
</Typography>
)}
<StyledButton type="submit" fullWidth disabled={!isChecked}>
Sync {isLoading && <LoadingSpinner mode="secondary" />}
<StyledButton
type='submit'
fullWidth
disabled={!isChecked}
>
Sync{' '}
{isLoading && (
<CircularProgress
sx={{ marginLeft: '0.5rem' }}
size={'1rem'}
/>
)}
</StyledButton>
</StyledSettingContainer>
<Button onClick={onClose}>Done</Button>
</Stack>
</Modal>
)

View File

@ -1,5 +1,11 @@
import { Button } from '@/shared/Button/Button'
import { Stack, StackProps, Typography, TypographyProps, styled } from '@mui/material'
import {
Stack,
StackProps,
Typography,
TypographyProps,
styled,
} from '@mui/material'
export const StyledSettingContainer = styled((props: StackProps) => (
<Stack gap={'0.75rem'} component={'form'} {...props} />
@ -22,9 +28,9 @@ export const StyledButton = styled(Button)(({ theme }) => {
}
})
export const StyledSynchedText = styled((props: TypographyProps) => <Typography variant="caption" {...props} />)(({
theme,
}) => {
export const StyledSynchedText = styled((props: TypographyProps) => (
<Typography variant='caption' {...props} />
))(({ theme }) => {
return {
color: theme.palette.success.main,
}

View File

@ -3,124 +3,94 @@ import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { Modal } from '@/shared/Modal/Modal'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { Stack, Typography, useTheme } from '@mui/material'
import React, { ChangeEvent, useEffect, useState } from 'react'
import React, { ChangeEvent, useState } from 'react'
import { StyledAppLogo } from './styled'
import { Input } from '@/shared/Input/Input'
import { Button } from '@/shared/Button/Button'
import { CheckmarkIcon } from '@/assets'
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()
const { getModalOpened, handleClose } = useModalSearchParams()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.SIGN_UP)
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.SIGN_UP)
const handleCloseModal = handleClose(MODAL_PARAMS_KEYS.SIGN_UP)
const notify = useEnqueueSnackbar()
const theme = useTheme()
const navigate = useNavigate()
const [enteredValue, setEnteredValue] = useState('')
const [isAvailable, setIsAvailable] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const handleInputChange = async (e: ChangeEvent<HTMLInputElement>) => {
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
setEnteredValue(e.target.value)
const name = e.target.value.trim()
if (name) {
const npubNip05 = await fetchNip05(`${name}@${DOMAIN}`)
setIsAvailable(!npubNip05)
} else {
setIsAvailable(false)
}
}
const getInputHelperText = () => {
if (!enteredValue) return "Don't worry, username can be changed later."
if (!isAvailable) return 'Already taken'
return (
const isAvailable = enteredValue.trim().length > 2
const inputHelperText = isAvailable ? (
<>
<CheckmarkIcon /> Available
</>
) : (
"Don't worry, username can be changed later."
)
}
const inputHelperText = getInputHelperText()
const handleSubmit = async (e: React.FormEvent) => {
if (!enteredValue.trim().length) return
e.preventDefault()
if (isLoading || !isAvailable) return undefined
const name = enteredValue.trim()
if (!name.length) return
try {
setIsLoading(true)
const k: any = await swicCall('generateKey', name)
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
const k: any = await swicCall('generateKey')
notify(`New key ${k.npub}`, 'success')
navigate(`/key/${k.npub}`)
}, 300)
} catch (error: any) {
notify(error?.message || 'Something went wrong!', 'error')
setIsLoading(false)
notify(error.message, 'error')
}
}
useEffect(() => {
return () => {
if (isModalOpened) {
// modal closed
setIsLoading(false)
setIsAvailable(false)
}
}
}, [isModalOpened])
return (
<Modal open={isModalOpened} onClose={handleCloseModal} withCloseButton={false}>
<Stack paddingTop={'1rem'} gap={'1rem'} component={'form'} onSubmit={handleSubmit}>
<Stack gap={'0.2rem'} padding={'0 1rem'} alignSelf={'flex-start'}>
<Typography fontWeight={600} variant="h5">
<Modal open={isModalOpened} onClose={handleCloseModal}>
<Stack
paddingTop={'1rem'}
gap={'1rem'}
component={'form'}
onSubmit={handleSubmit}
>
<Stack
direction={'row'}
gap={'1rem'}
alignItems={'center'}
alignSelf={'flex-start'}
>
<StyledAppLogo />
<Typography fontWeight={600} variant='h5'>
Sign up
</Typography>
<Typography noWrap variant="body2" color={'GrayText'}>
Generate new Nostr keys
</Typography>
</Stack>
<Input
label="Username"
label='Enter a Username'
fullWidth
placeholder="Enter a Username"
placeholder='Username'
helperText={inputHelperText}
endAdornment={<Typography color={'#FFFFFFA8'}>@{DOMAIN}</Typography>}
endAdornment={
<Typography color={'#FFFFFFA8'}>@nsec.app</Typography>
}
onChange={handleInputChange}
value={enteredValue}
helperTextProps={{
sx: {
'&.helper_text': {
color:
enteredValue && isAvailable
color: isAvailable
? theme.palette.success.main
: enteredValue && !isAvailable
? theme.palette.error.main
: theme.palette.textSecondaryDecorate.main,
},
},
}}
/>
<Stack gap={'0.5rem'}>
<Button fullWidth type="submit" disabled={isLoading}>
Create account {isLoading && <LoadingSpinner />}
<Button fullWidth type='submit'>
Sign up
</Button>
</Stack>
</Stack>
</Modal>
)
}

View File

@ -6,7 +6,7 @@ export const StyledAppLogo = styled((props) => (
<AppLogo />
</Box>
))({
background: '#0d0d0d',
background: '#00000054',
padding: '0.75rem',
borderRadius: '16px',
display: 'grid',

View File

@ -5,7 +5,8 @@ import CloseIcon from '@mui/icons-material/Close'
import { NotificationProps } from './types'
import { StyledAlert, StyledContainer } from './styled'
export const Notification = forwardRef<HTMLDivElement, NotificationProps>(({ message, alertvariant, id }, ref) => {
export const Notification = forwardRef<HTMLDivElement, NotificationProps>(
({ message, alertvariant, id }, ref) => {
const { closeSnackbar } = useSnackbar()
const closeSnackBarHandler = () => closeSnackbar(id)
@ -13,11 +14,12 @@ export const Notification = forwardRef<HTMLDivElement, NotificationProps>(({ mes
return (
<StyledAlert alertvariant={alertvariant} ref={ref}>
<StyledContainer>
<Typography variant="body1">{message}</Typography>
<IconButton onClick={closeSnackBarHandler} color="inherit">
<CloseIcon color="inherit" />
<Typography variant='body1'>{message}</Typography>
<IconButton onClick={closeSnackBarHandler} color='inherit'>
<CloseIcon color='inherit' />
</IconButton>
</StyledContainer>
</StyledAlert>
)
})
},
)

View File

@ -4,7 +4,9 @@ import { BORDER_STYLES } from './const'
import { forwardRef } from 'react'
export const StyledAlert = styled(
forwardRef<HTMLDivElement, StyledAlertProps>((props, ref) => <Alert {...props} ref={ref} icon={false} />)
forwardRef<HTMLDivElement, StyledAlertProps>((props, ref) => (
<Alert {...props} ref={ref} icon={false} />
)),
)(({ alertvariant }) => ({
width: '100%',
maxHeight: 56,

View File

@ -1,33 +0,0 @@
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} />}</>
}

View File

@ -1,50 +0,0 @@
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',
},
}
}
)

View File

@ -1,27 +1,19 @@
import { FC, ReactNode } from 'react'
import React, { FC, ReactNode } from 'react'
import { IconContainer, StyledContainer } from './styled'
import { BoxProps, Typography } from '@mui/material'
type WarningProps = {
message?: string | ReactNode
icon?: ReactNode
message: string | ReactNode
Icon?: ReactNode
} & BoxProps
export const Warning: FC<WarningProps> = ({ message, icon, ...restProps }) => {
const renderMessage = () => {
if (typeof message === 'string') {
return (
<Typography noWrap width={'100%'}>
{message}
</Typography>
)
}
return message
}
export const Warning: FC<WarningProps> = ({ message, Icon, ...restProps }) => {
return (
<StyledContainer {...restProps}>
{icon && <IconContainer>{icon}</IconContainer>}
{renderMessage()}
{Icon && <IconContainer>{Icon}</IconContainer>}
<Typography flex={1} noWrap>
{message}
</Typography>
</StyledContainer>
)
}

View File

@ -1,23 +1,26 @@
import { Box, BoxProps, styled } from '@mui/material'
export const StyledContainer = styled((props: BoxProps) => <Box {...props} />)(() => {
export const StyledContainer = styled((props: BoxProps) => <Box {...props} />)(
() => {
return {
borderRadius: '4px',
border: '1px solid grey',
padding: '0.5rem',
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
gap: '1rem',
cursor: 'pointer',
}
})
},
)
export const IconContainer = styled((props: BoxProps) => <Box {...props} />)(() => ({
export const IconContainer = styled((props: BoxProps) => <Box {...props} />)(
() => ({
width: '40px',
minWidth: '40px',
height: '40px',
borderRadius: '50%',
background: 'grey',
background: 'blue',
display: 'grid',
placeItems: 'center',
}))
}),
)

View File

@ -1,17 +1,30 @@
import { useSnackbar as useDefaultSnackbar, OptionsObject, VariantType } from 'notistack'
import {
useSnackbar as useDefaultSnackbar,
OptionsObject,
VariantType,
} from 'notistack'
import { Notification } from '../components/Notification/Notification'
export const useEnqueueSnackbar = () => {
const { enqueueSnackbar } = useDefaultSnackbar()
const showSnackbar = (message: string, variant: Exclude<VariantType, 'default' | 'info'> = 'success') => {
const showSnackbar = (
message: string,
variant: Exclude<VariantType, 'default' | 'info'> = 'success',
) => {
enqueueSnackbar(message, {
anchorOrigin: {
vertical: 'top',
horizontal: 'right',
},
content: (id) => {
return <Notification id={id} message={message} alertvariant={variant} />
return (
<Notification
id={id}
message={message}
alertvariant={variant}
/>
)
},
} as OptionsObject)
}

View File

@ -12,7 +12,8 @@ function useIsIOS() {
useEffect(() => {
const isIOSUserAgent =
iOSRegex.test(navigator.userAgent) || (navigator.userAgent.includes('Mac') && 'ontouchend' in document)
iOSRegex.test(navigator.userAgent) ||
(navigator.userAgent.includes('Mac') && 'ontouchend' in document)
setIsIOS(isIOSUserAgent)
}, [])

View File

@ -1,6 +1,11 @@
import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { useCallback } from 'react'
import { createSearchParams, useLocation, useNavigate, useSearchParams } from 'react-router-dom'
import {
createSearchParams,
useLocation,
useNavigate,
useSearchParams,
} from 'react-router-dom'
type SearchParamsType = {
[key: string]: string
@ -12,11 +17,6 @@ export type IExtraOptions = {
append?: boolean
}
export type IExtraCloseOptions = {
replace?: boolean
onClose?: (s: URLSearchParams) => void
}
export const useModalSearchParams = () => {
const [searchParams, setSearchParams] = useSearchParams()
@ -24,19 +24,18 @@ export const useModalSearchParams = () => {
const navigate = useNavigate()
const getEnumParam = useCallback((modal: MODAL_PARAMS_KEYS) => {
return Object.values(MODAL_PARAMS_KEYS)[Object.values(MODAL_PARAMS_KEYS).indexOf(modal)]
return Object.values(MODAL_PARAMS_KEYS)[
Object.values(MODAL_PARAMS_KEYS).indexOf(modal)
]
}, [])
const createHandleClose = (modal: MODAL_PARAMS_KEYS, extraOptions?: IExtraCloseOptions) => () => {
const handleClose =
(modal: MODAL_PARAMS_KEYS, onClose?: (s: URLSearchParams) => void) =>
() => {
const enumKey = getEnumParam(modal)
searchParams.delete(enumKey)
extraOptions?.onClose && extraOptions?.onClose(searchParams)
// console.log({ searchParams })
setSearchParams(searchParams, { replace: !!extraOptions?.replace })
}
const createHandleCloseReplace = (modal: MODAL_PARAMS_KEYS, extraOptions: IExtraCloseOptions = {}) => {
return createHandleClose(modal, { ...extraOptions, replace: true })
onClose && onClose(searchParams)
setSearchParams(searchParams)
}
const handleOpen = useCallback(
@ -53,17 +52,19 @@ export const useModalSearchParams = () => {
const searchString = !extraOptions?.append
? createSearchParams(searchParamsData).toString()
: `${location.search}&${createSearchParams(searchParamsData).toString()}`
: `${location.search}&${createSearchParams(
searchParamsData,
).toString()}`
navigate(
{
pathname: location.pathname,
search: searchString,
},
{ replace: !!extraOptions?.replace }
{ replace: extraOptions?.replace || true },
)
},
[location, navigate, getEnumParam]
[location, navigate, getEnumParam],
)
const getModalOpened = useCallback(
@ -72,13 +73,12 @@ export const useModalSearchParams = () => {
const modalOpened = searchParams.get(enumKey) === 'true'
return modalOpened
},
[getEnumParam, searchParams]
[getEnumParam, searchParams],
)
return {
getModalOpened,
createHandleClose,
createHandleCloseReplace,
handleClose,
handleOpen,
}
}

View File

@ -1,30 +0,0 @@
import { useCallback, useMemo, useState } from 'react'
import { IconButton } from '@mui/material'
import VisibilityOffOutlinedIcon from '@mui/icons-material/VisibilityOffOutlined'
import VisibilityOutlinedIcon from '@mui/icons-material/VisibilityOutlined'
export const usePassword = () => {
const [isPasswordShown, setIsPasswordShown] = useState(false)
const handlePasswordTypeChange = useCallback(() => setIsPasswordShown((prevState) => !prevState), [])
const hidePassword = useCallback(() => setIsPasswordShown(false), [])
const inputProps = useMemo(
() => ({
endAdornment: (
<IconButton size="small" onClick={handlePasswordTypeChange}>
{isPasswordShown ? (
<VisibilityOffOutlinedIcon htmlColor="#6b6b6b" />
) : (
<VisibilityOutlinedIcon htmlColor="#6b6b6b" />
)}
</IconButton>
),
type: isPasswordShown ? 'text' : 'password',
}),
[handlePasswordTypeChange, isPasswordShown]
)
return { inputProps, hidePassword }
}

View File

@ -1,41 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
import { fetchProfile } from '@/modules/nostr'
import { MetaEvent } from '@/types/meta-event'
import { getProfileUsername, getShortenNpub } from '@/utils/helpers/helpers'
import { useAppSelector } from '@/store/hooks/redux'
import { selectKeyByNpub } from '@/store'
const getFirstLetter = (text: string | undefined): string | null => {
if (!text || text.trim().length === 0) return null
return text.substring(0, 1).toUpperCase()
}
export const useProfile = (npub: string) => {
const [profile, setProfile] = useState<MetaEvent | null>(null)
const currentKey = useAppSelector((state) => selectKeyByNpub(state, npub))
const userName = currentKey?.name || getProfileUsername(profile)
const userAvatar = profile?.info?.picture || ''
const avatarTitle = getFirstLetter(userName)
const loadProfile = useCallback(async () => {
if (!npub) return undefined
try {
const response = await fetchProfile(npub)
setProfile(response)
} catch (error) {
console.error('Failed to fetch profile:', error)
}
}, [npub])
useEffect(() => {
loadProfile()
}, [loadProfile])
return {
profile,
userName: userName || getShortenNpub(npub),
userAvatar,
avatarTitle,
}
}

View File

@ -1,63 +1,19 @@
@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;
font-family: 'Inter', sans-serif;
}
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;
}
html,

View File

@ -1,13 +1,13 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import ThemeProvider from './modules/theme/ThemeProvider'
import App from './App'
import './index.css'
import App from './App'
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'
@ -25,7 +25,7 @@ root.render(
</PersistGate>
</Provider>
</BrowserRouter>
</React.StrictMode>
</React.StrictMode>,
)
// If you want your app to work offline and load faster, you can change

View File

@ -1,73 +1,66 @@
import { Avatar, Stack, Toolbar, Typography, Divider, DividerProps, styled } from '@mui/material'
import { StyledAppBar, StyledAppLogo, StyledAppName, StyledProfileContainer, StyledThemeButton } from './styled'
import { Avatar, Stack, Toolbar, Typography } from '@mui/material'
import { AppLogo } from '../../assets'
import { StyledAppBar, StyledAppName } from './styled'
import { Menu } from './components/Menu'
import { useNavigate, useParams } from 'react-router-dom'
import { useParams } from 'react-router-dom'
import { useCallback, useEffect, useState } from 'react'
import { MetaEvent } from '@/types/meta-event'
import { fetchProfile } from '@/modules/nostr'
import { ProfileMenu } from './components/ProfileMenu'
import { useProfile } from '@/hooks/useProfile'
import DarkModeIcon from '@mui/icons-material/DarkMode'
import LightModeIcon from '@mui/icons-material/LightMode'
import { useAppDispatch, useAppSelector } from '@/store/hooks/redux'
import { setThemeMode } from '@/store/reducers/ui.slice'
import { useSessionStorage } from 'usehooks-ts'
import { RELOAD_STORAGE_KEY } from '@/utils/consts'
import { getShortenNpub } from '@/utils/helpers/helpers'
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)
const showProfile = Boolean(npub)
const [profile, setProfile] = useState<MetaEvent | null>(null)
const handleNavigate = () => {
navigate(`/key/${npub}`)
const load = useCallback(async () => {
if (!npub) return setProfile(null)
try {
const response = await fetchProfile(npub)
setProfile(response as any)
} catch (e) {
return setProfile(null)
}
}, [npub])
const isDarkMode = themeMode === 'dark'
const themeIcon = isDarkMode ? <LightModeIcon htmlColor="#fff" /> : <DarkModeIcon htmlColor="#000" />
useEffect(() => {
load()
}, [load])
const handleChangeMode = () => {
dispatch(setThemeMode({ mode: isDarkMode ? 'light' : 'dark' }))
}
const showProfile = Boolean(npub || profile)
const userName = profile?.info?.name || getShortenNpub(npub)
const userAvatar = profile?.info?.picture || ''
return (
<StyledAppBar position={needReload ? 'relative' : 'fixed'}>
<StyledAppBar position='fixed'>
<Toolbar sx={{ padding: '12px' }}>
<Stack direction={'row'} justifyContent={'space-between'} alignItems={'center'} width={'100%'}>
{showProfile && (
<StyledProfileContainer>
<Avatar src={userAvatar} alt={userName} onClick={handleNavigate} className="avatar">
{avatarTitle}
</Avatar>
<Typography fontWeight={600} onClick={handleNavigate} className="username">
{userName}
</Typography>
</StyledProfileContainer>
)}
{!showProfile && (
<Stack
direction={'row'}
justifyContent={'space-between'}
alignItems={'center'}
width={'100%'}
>
{showProfile ? (
<Stack
gap={'1rem'}
direction={'row'}
alignItems={'center'}
flex={1}
>
<Avatar src={userAvatar} alt={userName} />
<Typography fontWeight={600}>{userName}</Typography>
</Stack>
) : (
<StyledAppName>
<StyledAppLogo />
<AppLogo />
<span>Nsec.app</span>
</StyledAppName>
)}
<StyledThemeButton onClick={handleChangeMode}>{themeIcon}</StyledThemeButton>
{showProfile ? <ProfileMenu /> : <Menu />}
</Stack>
</Toolbar>
<StyledDivider />
</StyledAppBar>
)
}
const StyledDivider = styled((props: DividerProps) => <Divider {...props} />)({
position: 'absolute',
bottom: 0,
width: '100%',
left: 0,
height: '2px',
})

View File

@ -1,24 +0,0 @@
import { useProfile } from '@/hooks/useProfile'
import { DbKey } from '@/modules/db'
import { Avatar, ListItemIcon, MenuItem, Typography } from '@mui/material'
import React, { FC } from 'react'
type ListItemProfileProps = {
onClickItem: () => void
} & DbKey
export const ListItemProfile: FC<ListItemProfileProps> = ({ onClickItem, npub }) => {
const { userName, userAvatar, avatarTitle } = useProfile(npub)
return (
<MenuItem sx={{ gap: '0.5rem' }} onClick={onClickItem}>
<ListItemIcon>
<Avatar src={userAvatar} alt={userName} sx={{ width: 36, height: 36 }}>
{avatarTitle}
</Avatar>
</ListItemIcon>
<Typography variant="body2" noWrap>
{userName}
</Typography>
</MenuItem>
)
}

View File

@ -1,18 +1,47 @@
import { DbKey } from '@/modules/db'
import { Stack } from '@mui/material'
import { FC } from 'react'
import { ListItemProfile } from './ListItemProfile'
import { getShortenNpub } from '@/utils/helpers/helpers'
import {
Avatar,
ListItemIcon,
MenuItem,
Stack,
Typography,
} from '@mui/material'
import React, { FC } from 'react'
type ListProfilesProps = {
keys: DbKey[]
onClickItem: (key: DbKey) => void
}
export const ListProfiles: FC<ListProfilesProps> = ({ keys = [], onClickItem }) => {
export const ListProfiles: FC<ListProfilesProps> = ({
keys = [],
onClickItem,
}) => {
return (
<Stack maxHeight={'10rem'} overflow={'auto'}>
{keys.map((key) => {
return <ListItemProfile {...key} key={key.npub} onClickItem={() => onClickItem(key)} />
const userName =
key?.profile?.info?.name || getShortenNpub(key.npub)
const userAvatar = key?.profile?.info?.picture || ''
return (
<MenuItem
sx={{ gap: '0.5rem' }}
onClick={() => onClickItem(key)}
key={key.npub}
>
<ListItemIcon>
<Avatar
src={userAvatar}
alt={userName}
sx={{ width: 36, height: 36 }}
/>
</ListItemIcon>
<Typography variant='body2' noWrap>
{userName}
</Typography>
</MenuItem>
)
})}
</Stack>
)

View File

@ -1,7 +1,10 @@
import { Menu as MuiMenu } from '@mui/material'
import DarkModeIcon from '@mui/icons-material/DarkMode'
import LightModeIcon from '@mui/icons-material/LightMode'
import LoginIcon from '@mui/icons-material/Login'
import PersonAddAltRoundedIcon from '@mui/icons-material/PersonAddAltRounded'
import { useAppSelector } from '@/store/hooks/redux'
import { setThemeMode } from '@/store/reducers/ui.slice'
import { useAppDispatch, useAppSelector } from '@/store/hooks/redux'
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { MenuButton } from './styled'
@ -11,21 +14,39 @@ import MenuRoundedIcon from '@mui/icons-material/MenuRounded'
import { selectKeys } from '@/store'
export const Menu = () => {
const themeMode = useAppSelector((state) => state.ui.themeMode)
const keys = useAppSelector(selectKeys)
const dispatch = useAppDispatch()
const { handleOpen } = useModalSearchParams()
const { anchorEl, handleClose, handleOpen: handleOpenMenu, open } = useOpenMenu()
const isDarkMode = themeMode === 'dark'
const isNoKeys = !keys || keys.length === 0
const {
anchorEl,
handleClose,
handleOpen: handleOpenMenu,
open,
} = useOpenMenu()
const handleChangeMode = () => {
dispatch(setThemeMode({ mode: isDarkMode ? 'light' : 'dark' }))
}
const handleNavigateToAuth = () => {
handleOpen(MODAL_PARAMS_KEYS.INITIAL)
handleClose()
}
const themeIcon = isDarkMode ? (
<DarkModeIcon htmlColor='#fff' />
) : (
<LightModeIcon htmlColor='#feb94a' />
)
return (
<>
<MenuButton onClick={handleOpenMenu}>
<MenuRoundedIcon color="inherit" />
<MenuRoundedIcon color='inherit' />
</MenuButton>
<MuiMenu
anchorEl={anchorEl}
@ -36,10 +57,17 @@ export const Menu = () => {
}}
>
<MenuItem
Icon={isNoKeys ? <LoginIcon /> : <PersonAddAltRoundedIcon />}
Icon={
isNoKeys ? <LoginIcon /> : <PersonAddAltRoundedIcon />
}
onClick={handleNavigateToAuth}
title={isNoKeys ? 'Sign up' : 'Add account'}
/>
<MenuItem
Icon={themeIcon}
onClick={handleChangeMode}
title='Change theme'
/>
</MuiMenu>
</>
)

View File

@ -1,6 +1,10 @@
import React, { FC, ReactNode } from 'react'
import { StyledMenuItem } from './styled'
import { ListItemIcon, MenuItemProps as MuiMenuItemProps, Typography } from '@mui/material'
import {
ListItemIcon,
MenuItemProps as MuiMenuItemProps,
Typography,
} from '@mui/material'
type MenuItemProps = {
onClick: () => void
@ -12,7 +16,7 @@ export const MenuItem: FC<MenuItemProps> = ({ onClick, Icon, title }) => {
return (
<StyledMenuItem onClick={onClick}>
<ListItemIcon>{Icon}</ListItemIcon>
<Typography fontWeight={500} variant="body2" noWrap>
<Typography fontWeight={500} variant='body2' noWrap>
{title}
</Typography>
</StyledMenuItem>

View File

@ -9,19 +9,29 @@ import LoginIcon from '@mui/icons-material/Login'
import KeyboardArrowDownRoundedIcon from '@mui/icons-material/KeyboardArrowDownRounded'
import HomeRoundedIcon from '@mui/icons-material/HomeRounded'
import PersonAddAltRoundedIcon from '@mui/icons-material/PersonAddAltRounded'
import { useAppSelector } from '@/store/hooks/redux'
import { useAppDispatch, useAppSelector } from '@/store/hooks/redux'
import { selectKeys } from '@/store'
import { setThemeMode } from '@/store/reducers/ui.slice'
import DarkModeIcon from '@mui/icons-material/DarkMode'
import LightModeIcon from '@mui/icons-material/LightMode'
import { ListProfiles } from './ListProfiles'
import { DbKey } from '@/modules/db'
export const ProfileMenu = () => {
const { anchorEl, handleOpen: handleOpenMenu, open, handleClose } = useOpenMenu()
const {
anchorEl,
handleOpen: handleOpenMenu,
open,
handleClose,
} = useOpenMenu()
const { handleOpen } = useModalSearchParams()
const keys = useAppSelector(selectKeys)
const isNoKeys = !keys || keys.length === 0
const themeMode = useAppSelector((state) => state.ui.themeMode)
const isDarkMode = themeMode === 'dark'
const dispatch = useAppDispatch()
const navigate = useNavigate()
const handleNavigateToAuth = () => {
@ -34,15 +44,28 @@ export const ProfileMenu = () => {
handleClose()
}
const handleChangeMode = () => {
dispatch(setThemeMode({ mode: isDarkMode ? 'light' : 'dark' }))
}
const handleNavigateToKeyInnerPage = (key: DbKey) => {
navigate('/key/' + key.npub)
handleClose()
}
const themeIcon = isDarkMode ? (
<DarkModeIcon htmlColor='#fff' />
) : (
<LightModeIcon htmlColor='#feb94a' />
)
return (
<>
<MenuButton onClick={handleOpenMenu}>
<KeyboardArrowDownRoundedIcon color="inherit" fontSize="large" />
<KeyboardArrowDownRoundedIcon
color='inherit'
fontSize='large'
/>
</MenuButton>
<Menu
open={open}
@ -52,14 +75,28 @@ export const ProfileMenu = () => {
zIndex: 1302,
}}
>
<ListProfiles keys={keys} onClickItem={handleNavigateToKeyInnerPage} />
<ListProfiles
keys={keys}
onClickItem={handleNavigateToKeyInnerPage}
/>
<Divider />
<MenuItem Icon={<HomeRoundedIcon />} onClick={handleNavigateHome} title="Home" />
<MenuItem
Icon={isNoKeys ? <LoginIcon /> : <PersonAddAltRoundedIcon />}
Icon={<HomeRoundedIcon />}
onClick={handleNavigateHome}
title='Home'
/>
<MenuItem
Icon={
isNoKeys ? <LoginIcon /> : <PersonAddAltRoundedIcon />
}
onClick={handleNavigateToAuth}
title={isNoKeys ? 'Sign up' : 'Add account'}
/>
<MenuItem
Icon={themeIcon}
onClick={handleChangeMode}
title='Change theme'
/>
</Menu>
</>
)

View File

@ -1,6 +1,14 @@
import { IconButton, IconButtonProps, MenuItem, MenuItemProps, styled } from '@mui/material'
import {
IconButton,
IconButtonProps,
MenuItem,
MenuItemProps,
styled,
} from '@mui/material'
export const MenuButton = styled((props: IconButtonProps) => <IconButton {...props} />)(({ theme }) => {
export const MenuButton = styled((props: IconButtonProps) => (
<IconButton {...props} />
))(({ theme }) => {
const isDark = theme.palette.mode === 'dark'
return {
borderRadius: '1rem',
@ -11,6 +19,8 @@ export const MenuButton = styled((props: IconButtonProps) => <IconButton {...pro
}
})
export const StyledMenuItem = styled((props: MenuItemProps) => <MenuItem {...props} />)(() => ({
export const StyledMenuItem = styled((props: MenuItemProps) => (
<MenuItem {...props} />
))(() => ({
padding: '0.5rem 1rem',
}))

View File

@ -1,5 +1,4 @@
import { AppLogo } from '@/assets'
import { AppBar, IconButton, Stack, StackProps, Typography, TypographyProps, styled } from '@mui/material'
import { AppBar, Typography, TypographyProps, styled } from '@mui/material'
import { Link } from 'react-router-dom'
export const StyledAppBar = styled(AppBar)(({ theme }) => {
@ -12,7 +11,6 @@ export const StyledAppBar = styled(AppBar)(({ theme }) => {
maxWidth: 'inherit',
left: '50%',
transform: 'translateX(-50%)',
borderRadius: '8px',
}
})
@ -31,26 +29,3 @@ export const StyledAppName = styled((props: TypographyProps) => (
lineHeight: '22.4px',
marginLeft: '0.5rem',
}))
export const StyledProfileContainer = styled((props: StackProps) => <Stack {...props} />)(() => ({
gap: '1rem',
flexDirection: 'row',
alignItems: 'center',
flex: 1,
'& .avatar': {
cursor: 'pointer',
},
'& .username': {
cursor: 'pointer',
},
}))
export const StyledThemeButton = styled(IconButton)({
margin: '0 0.5rem',
})
export const StyledAppLogo = styled(AppLogo)(({ theme }) => ({
'& path': {
fill: theme.palette.text.primary,
},
}))

View File

@ -1,19 +1,19 @@
import { FC } from 'react'
import { Outlet } from 'react-router-dom'
import { Header } from './Header/Header'
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'
import {
Container,
ContainerProps,
Divider,
DividerProps,
styled,
} from '@mui/material'
export const Layout: FC = () => {
const [needReload] = useSessionStorage(RELOAD_STORAGE_KEY, false)
const containerClassName = needReload ? 'reload' : ''
return (
<StyledContainer maxWidth="md" className={containerClassName}>
<ReloadBadge />
<StyledContainer maxWidth='md'>
<Header />
<StyledDivider />
<main>
<Outlet />
</main>
@ -21,7 +21,9 @@ export const Layout: FC = () => {
)
}
const StyledContainer = styled((props: ContainerProps) => <Container maxWidth="sm" {...props} />)({
const StyledContainer = styled((props: ContainerProps) => (
<Container maxWidth='sm' {...props} />
))({
height: '100%',
display: 'flex',
flexDirection: 'column',
@ -30,14 +32,14 @@ const StyledContainer = styled((props: ContainerProps) => <Container maxWidth="s
'& > main': {
flex: 1,
maxHeight: '100%',
},
'&:not(.reload) > main': {
paddingTop: 'calc(66px + 1rem)',
},
'@media screen and (max-width: 320px)': {
marginBottom: '0.25rem',
paddingLeft: '0.75rem',
paddingBottom: '0.75rem',
paddingRight: '0.75rem',
},
})
const StyledDivider = styled((props: DividerProps) => <Divider {...props} />)({
position: 'absolute',
top: '66px',
width: '100%',
left: 0,
height: '2px',
})

File diff suppressed because it is too large Load Diff

View File

@ -89,16 +89,6 @@ 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)
@ -113,17 +103,6 @@ export const dbi = {
console.log(`db addApp error: ${error}`)
}
},
updateApp: async (app: Omit<DbApp, 'npub' | 'timestamp'>) => {
try {
await db.apps.where({ appNpub: app.appNpub }).modify({
name: app.name,
icon: app.icon,
url: app.url,
})
} catch (error) {
console.log(`db updateApp error: ${error}`)
}
},
listApps: async (): Promise<DbApp[]> => {
try {
return await db.apps.toArray()
@ -172,8 +151,10 @@ export const dbi = {
try {
return db.transaction('rw', db.pending, db.history, async () => {
const exists =
(await db.pending.where('id').equals(r.id).toArray()).length > 0 ||
(await db.history.where('id').equals(r.id).toArray()).length > 0
(await db.pending.where('id').equals(r.id).toArray())
.length > 0 ||
(await db.history.where('id').equals(r.id).toArray())
.length > 0
if (exists) return false
await db.pending.add(r)
@ -202,7 +183,10 @@ export const dbi = {
confirmPending: async (id: string, allowed: boolean) => {
try {
db.transaction('rw', db.pending, db.history, async () => {
const r: DbPending | undefined = await db.pending.where('id').equals(id).first()
const r: DbPending | undefined = await db.pending
.where('id')
.equals(id)
.first()
if (!r) throw new Error('Pending not found ' + id)
const h: DbHistory = {
...r,

View File

@ -4,92 +4,123 @@ ende stands for encryption decryption
import { secp256k1 as secp } from '@noble/curves/secp256k1'
//import * as secp from "./vendor/secp256k1.js";
export async function encrypt(publicKey: string, message: string, privateKey: string): Promise<string> {
const key = secp.getSharedSecret(privateKey, '02' + publicKey)
const normalizedKey = getNormalizedX(key)
const encoder = new TextEncoder()
const iv = Uint8Array.from(randomBytes(16))
const plaintext = encoder.encode(message)
const cryptoKey = await crypto.subtle.importKey('raw', normalizedKey, { name: 'AES-CBC' }, false, ['encrypt'])
const ciphertext = await crypto.subtle.encrypt({ name: 'AES-CBC', iv }, cryptoKey, plaintext)
export async function encrypt(
publicKey: string,
message: string,
privateKey: string,
): Promise<string> {
const key = secp.getSharedSecret(privateKey, "02" + publicKey);
const normalizedKey = getNormalizedX(key);
const encoder = new TextEncoder();
const iv = Uint8Array.from(randomBytes(16));
const plaintext = encoder.encode(message);
const cryptoKey = await crypto.subtle.importKey(
"raw",
normalizedKey,
{ name: "AES-CBC" },
false,
["encrypt"],
);
const ciphertext = await crypto.subtle.encrypt(
{ name: "AES-CBC", iv },
cryptoKey,
plaintext,
);
const ctb64 = toBase64(new Uint8Array(ciphertext))
const ivb64 = toBase64(new Uint8Array(iv.buffer))
return `${ctb64}?iv=${ivb64}`
const ctb64 = toBase64(new Uint8Array(ciphertext));
const ivb64 = toBase64(new Uint8Array(iv.buffer));
return `${ctb64}?iv=${ivb64}`;
}
export async function decrypt(privateKey: string, publicKey: string, data: string): Promise<string | Error> {
const key = secp.getSharedSecret(privateKey, '02' + publicKey) // this line is very slow
return decrypt_with_shared_secret(data, key)
export async function decrypt(
privateKey: string,
publicKey: string,
data: string,
): Promise<string | Error> {
const key = secp.getSharedSecret(privateKey, "02" + publicKey); // this line is very slow
return decrypt_with_shared_secret(data, key);
}
export async function decrypt_with_shared_secret(data: string, sharedSecret: Uint8Array): Promise<string | Error> {
const [ctb64, ivb64] = data.split('?iv=')
const normalizedKey = getNormalizedX(sharedSecret)
export async function decrypt_with_shared_secret(
data: string,
sharedSecret: Uint8Array,
): Promise<string | Error> {
const [ctb64, ivb64] = data.split("?iv=");
const normalizedKey = getNormalizedX(sharedSecret);
const cryptoKey = await crypto.subtle.importKey('raw', normalizedKey, { name: 'AES-CBC' }, false, ['decrypt'])
let ciphertext: BufferSource
let iv: BufferSource
const cryptoKey = await crypto.subtle.importKey(
"raw",
normalizedKey,
{ name: "AES-CBC" },
false,
["decrypt"],
);
let ciphertext: BufferSource;
let iv: BufferSource;
try {
ciphertext = decodeBase64(ctb64)
iv = decodeBase64(ivb64)
ciphertext = decodeBase64(ctb64);
iv = decodeBase64(ivb64);
} catch (e) {
return new Error(`failed to decode, ${e}`)
return new Error(`failed to decode, ${e}`);
}
try {
const plaintext = await crypto.subtle.decrypt({ name: 'AES-CBC', iv }, cryptoKey, ciphertext)
const text = utf8Decode(plaintext)
return text
const plaintext = await crypto.subtle.decrypt(
{ name: "AES-CBC", iv },
cryptoKey,
ciphertext,
);
const text = utf8Decode(plaintext);
return text;
} catch (e) {
return new Error(`failed to decrypt, ${e}`)
return new Error(`failed to decrypt, ${e}`);
}
}
export function utf8Encode(str: string) {
let encoder = new TextEncoder()
return encoder.encode(str)
let encoder = new TextEncoder();
return encoder.encode(str);
}
export function utf8Decode(bin: Uint8Array | ArrayBuffer): string {
let decoder = new TextDecoder()
return decoder.decode(bin)
let decoder = new TextDecoder();
return decoder.decode(bin);
}
function toBase64(uInt8Array: Uint8Array) {
let strChunks = new Array(uInt8Array.length)
let i = 0
let strChunks = new Array(uInt8Array.length);
let i = 0;
// @ts-ignore
for (let byte of uInt8Array) {
strChunks[i] = String.fromCharCode(byte) // bytes to utf16 string
i++
strChunks[i] = String.fromCharCode(byte); // bytes to utf16 string
i++;
}
return btoa(strChunks.join(''))
return btoa(strChunks.join(""));
}
function decodeBase64(base64String: string) {
const binaryString = atob(base64String)
const length = binaryString.length
const bytes = new Uint8Array(length)
const binaryString = atob(base64String);
const length = binaryString.length;
const bytes = new Uint8Array(length);
for (let i = 0; i < length; i++) {
bytes[i] = binaryString.charCodeAt(i)
bytes[i] = binaryString.charCodeAt(i);
}
return bytes
return bytes;
}
function getNormalizedX(key: Uint8Array): Uint8Array {
return key.slice(1, 33)
return key.slice(1, 33);
}
function randomBytes(bytesLength: number = 32) {
return crypto.getRandomValues(new Uint8Array(bytesLength))
return crypto.getRandomValues(new Uint8Array(bytesLength));
}
export function utf16Encode(str: string): number[] {
let array = new Array(str.length)
let array = new Array(str.length);
for (let i = 0; i < str.length; i++) {
array[i] = str.charCodeAt(i)
array[i] = str.charCodeAt(i);
}
return array
return array;
}

View File

@ -1,5 +1,5 @@
import crypto, { pbkdf2 } from 'crypto'
import { getPublicKey, nip19 } from 'nostr-tools'
import crypto, { pbkdf2 } from 'crypto';
import { getPublicKey, nip19 } from 'nostr-tools';
// encrypted keys have a prefix and version
// so that we'd be able to switch to a better
@ -17,34 +17,14 @@ const ITERATIONS_PWH = 100000
const HASH_SIZE = 32
const HASH_ALGO = 'sha256'
// encryption
const ALGO = 'aes-256-cbc'
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!@#$%^&*()\-_]{6,}$/
const ASCII_REGEX = /^[A-Za-z0-9!@#$%^&*()]{4,}$/
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
}
const ALGO_LOCAL = 'AES-CBC';
const KEY_SIZE_LOCAL = 256;
export class Keys {
subtle: any
@ -53,7 +33,13 @@ export class Keys {
this.subtle = cryptoSubtle
}
public async generatePassKey(pubkey: string, passphrase: string): Promise<{ passkey: Buffer; pwh: string }> {
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')
// https://nodejs.org/api/crypto.html#using-strings-as-inputs-to-cryptographic-apis
@ -61,7 +47,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 (!isValidPassphase(passphrase)) throw new Error('Password must be 4+ ASCII chars')
if (!this.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
@ -71,11 +57,7 @@ export class Keys {
else {
pbkdf2(key, passphrase, ITERATIONS_PWH, HASH_SIZE, HASH_ALGO, (err, hash) => {
if (err) fail(err)
else
ok({
passkey: key,
pwh: hash.toString('hex'),
})
else ok({ passkey: key, pwh: hash.toString('hex') })
})
}
})
@ -83,8 +65,8 @@ export class Keys {
}
private isSafari() {
const chrome = navigator.userAgent.indexOf('Chrome') > -1
const safari = navigator.userAgent.indexOf('Safari') > -1
const chrome = navigator.userAgent.indexOf("Chrome") > -1;
const safari = navigator.userAgent.indexOf("Safari") > -1;
return safari && !chrome
}
@ -100,7 +82,7 @@ export class Keys {
// NOTE: important to make sure it's not visible in
// dev console in IndexedDB
/*extractable*/false,
['encrypt', 'decrypt']
["encrypt", "decrypt"]
)
}
@ -112,30 +94,25 @@ export class Keys {
return `${PREFIX_LOCAL}:${VERSION_LOCAL}:${iv.toString('hex')}:${Buffer.from(encrypted).toString('hex')}}`
}
public async decryptKeyLocal({ enckey, localKey }: { enckey: string; localKey: CryptoKey | {} }): Promise<string> {
public async decryptKeyLocal({ enckey, localKey }: { enckey: string, localKey: CryptoKey | {} }): Promise<string> {
if (this.isSafari()) return enckey
const parts = enckey.split(':')
if (parts.length !== 4) throw new Error('Bad encrypted key')
if (parts[0] !== PREFIX_LOCAL) throw new Error('Bad encrypted key prefix')
if (parts[1] !== VERSION_LOCAL) throw new Error('Bad encrypted key version')
if (parts[2].length !== IV_SIZE * 2) throw new Error('Bad encrypted key iv')
if (parts[3].length < 30) throw new Error('Bad encrypted key data')
const iv = Buffer.from(parts[2], 'hex')
const data = Buffer.from(parts[3], 'hex')
if (parts.length !== 4) throw new Error("Bad encrypted key")
if (parts[0] !== PREFIX_LOCAL) throw new Error("Bad encrypted key prefix")
if (parts[1] !== VERSION_LOCAL) throw new Error("Bad encrypted key version")
if (parts[2].length !== IV_SIZE * 2) throw new Error("Bad encrypted key iv")
if (parts[3].length < 30) throw new Error("Bad encrypted key data")
const iv = Buffer.from(parts[2], 'hex');
const data = Buffer.from(parts[3], 'hex');
const decrypted = await this.subtle.decrypt({ name: ALGO_LOCAL, iv }, localKey, data)
const { type, data: value } = nip19.decode(Buffer.from(decrypted).toString())
if (type !== 'nsec') throw new Error('Bad encrypted key payload type')
if ((value as string).length !== 64) throw new Error('Bad encrypted key payload length')
return value as string
if (type !== "nsec") throw new Error("Bad encrypted key payload type")
if ((value as string).length !== 64) throw new Error("Bad encrypted key payload length")
return (value as string)
}
public async encryptKeyPass({
key,
passphrase,
}: {
key: string
passphrase: string
}): Promise<{ enckey: string; pwh: string }> {
public async encryptKeyPass({ key, passphrase }: { key: string, passphrase: string })
: Promise<{ enckey: string, pwh: string }> {
const start = Date.now()
const nsec = nip19.nsecEncode(key)
const pubkey = getPublicKey(key)
@ -143,29 +120,21 @@ export class Keys {
const iv = crypto.randomBytes(IV_SIZE)
const cipher = crypto.createCipheriv(ALGO, passkey, iv)
const encrypted = Buffer.concat([cipher.update(nsec), cipher.final()])
console.log('encrypted key in ', Date.now() - start)
console.log("encrypted key in ", Date.now() - start)
return {
enckey: `${PREFIX}:${VERSION}:${iv.toString('hex')}:${encrypted.toString('hex')}`,
pwh,
enckey: `${PREFIX}:${VERSION}:${iv.toString('hex')}:${encrypted.toString('hex')}}`,
pwh
}
}
public async decryptKeyPass({
pubkey,
enckey,
passphrase,
}: {
pubkey: string
enckey: string
passphrase: string
}): Promise<string> {
public async decryptKeyPass({ pubkey, enckey, passphrase }: { pubkey: string, enckey: string, passphrase: string }): Promise<string> {
const start = Date.now()
const parts = enckey.split(':')
if (parts.length !== 4) throw new Error('Bad encrypted key')
if (parts[0] !== PREFIX) throw new Error('Bad encrypted key prefix')
if (parts[1] !== VERSION) throw new Error('Bad encrypted key version')
if (parts[2].length !== IV_SIZE * 2) throw new Error('Bad encrypted key iv')
if (parts[3].length < 30) throw new Error('Bad encrypted key data')
if (parts.length !== 4) throw new Error("Bad encrypted key")
if (parts[0] !== PREFIX) throw new Error("Bad encrypted key prefix")
if (parts[1] !== VERSION) throw new Error("Bad encrypted key version")
if (parts[2].length !== IV_SIZE * 2) throw new Error("Bad encrypted key iv")
if (parts[3].length < 30) throw new Error("Bad encrypted key data")
const { passkey } = await this.generatePassKey(pubkey, passphrase)
const iv = Buffer.from(parts[2], 'hex')
const data = Buffer.from(parts[3], 'hex')
@ -173,9 +142,9 @@ export class Keys {
const decrypted = Buffer.concat([decipher.update(data), decipher.final()])
const nsec = decrypted.toString()
const { type, data: value } = nip19.decode(nsec)
if (type !== 'nsec') throw new Error('Bad encrypted key payload type')
if (value.length !== 64) throw new Error('Bad encrypted key payload length')
console.log('decrypted key in ', Date.now() - start)
return nsec
if (type !== "nsec") throw new Error("Bad encrypted key payload type")
if (value.length !== 64) throw new Error("Bad encrypted key payload length")
console.log("decrypted key in ", Date.now() - start)
return nsec;
}
}

View File

@ -7,25 +7,25 @@ export const utf8Decoder = new TextDecoder('utf-8')
export const utf8Encoder = new TextEncoder()
function toBase64(uInt8Array: Uint8Array) {
let strChunks = new Array(uInt8Array.length)
let i = 0
let strChunks = new Array(uInt8Array.length);
let i = 0;
// @ts-ignore
for (let byte of uInt8Array) {
strChunks[i] = String.fromCharCode(byte) // bytes to utf16 string
i++
strChunks[i] = String.fromCharCode(byte); // bytes to utf16 string
i++;
}
return btoa(strChunks.join(''))
return btoa(strChunks.join(""));
}
function fromBase64(base64String: string) {
const binaryString = atob(base64String)
const length = binaryString.length
const bytes = new Uint8Array(length)
const binaryString = atob(base64String);
const length = binaryString.length;
const bytes = new Uint8Array(length);
for (let i = 0; i < length; i++) {
bytes[i] = binaryString.charCodeAt(i)
bytes[i] = binaryString.charCodeAt(i);
}
return bytes
return bytes;
}
function getNormalizedX(key: Uint8Array): Uint8Array {
@ -65,7 +65,7 @@ export class Nip04 {
// let ctb64 = toBase64(new Uint8Array(ciphertext))
// let ivb64 = toBase64(new Uint8Array(iv.buffer))
console.log('nip04_encrypt', text, 't1', t2 - t1, 't2', t3 - t2, 't3', Date.now() - t3)
console.log("nip04_encrypt", text, "t1", t2 - t1, "t2", t3 - t2, "t3", Date.now() - t3)
return `${ctb64}?iv=${ivb64}`
}
@ -85,4 +85,7 @@ export class Nip04 {
let text = utf8Decoder.decode(plaintext)
return text
}
}

View File

@ -5,7 +5,12 @@ import NDK, { NDKEvent, NostrEvent } from '@nostr-dev-kit/ndk'
import { nip19 } from 'nostr-tools'
export const ndk = new NDK({
explicitRelayUrls: ['wss://relay.nostr.band/all', 'wss://relay.nostr.band', 'wss://relay.damus.io', 'wss://nos.lol'],
explicitRelayUrls: [
'wss://relay.nostr.band/all',
'wss://relay.nostr.band',
'wss://relay.damus.io',
'wss://nos.lol',
],
})
export function nostrEvent(e: Required<NDKEvent>) {
@ -36,11 +41,17 @@ function parseContentJson(c: string): object {
}
}
export function getTags(e: AugmentedEvent | NDKEvent | MetaEvent, name: string): string[][] {
export function getTags(
e: AugmentedEvent | NDKEvent | MetaEvent,
name: string,
): string[][] {
return e.tags.filter((t: string[]) => t.length > 0 && t[0] === name)
}
export function getTag(e: AugmentedEvent | NDKEvent | MetaEvent, name: string): string[] | null {
export function getTag(
e: AugmentedEvent | NDKEvent | MetaEvent,
name: string,
): string[] | null {
const tags = getTags(e, name)
if (tags.length === 0) return null
return tags[0]
@ -50,10 +61,11 @@ export function getTagValue(
e: AugmentedEvent | NDKEvent | MetaEvent,
name: string,
index: number = 0,
def: string = ''
def: string = '',
): string {
const tag = getTag(e, name)
if (tag === null || !tag.length || (index && index >= tag.length)) return def
if (tag === null || !tag.length || (index && index >= tag.length))
return def
return tag[1 + index]
}

View File

@ -1,51 +0,0 @@
// based on https://git.v0l.io/Kieran/snort/src/branch/main/packages/system/src/pow-util.ts
import { sha256 } from '@noble/hashes/sha256'
import { bytesToHex } from '@noble/hashes/utils'
export interface NostrPowEvent {
id?: string
pubkey: string
created_at: number
kind?: number
tags: Array<Array<string>>
content: string
sig?: string
}
export function minePow(e: NostrPowEvent, target: number) {
let ctr = 0
let nonceTagIdx = e.tags.findIndex((a) => a[0] === 'nonce')
if (nonceTagIdx === -1) {
nonceTagIdx = e.tags.length
e.tags.push(['nonce', ctr.toString(), target.toString()])
}
do {
e.tags[nonceTagIdx][1] = (++ctr).toString()
e.id = createId(e)
} while (countLeadingZeros(e.id) < target)
return e
}
function createId(e: NostrPowEvent) {
const payload = [0, e.pubkey, e.created_at, e.kind, e.tags, e.content]
return bytesToHex(sha256(JSON.stringify(payload)))
}
export function countLeadingZeros(hex: string) {
let count = 0
for (let i = 0; i < hex.length; i++) {
const nibble = parseInt(hex[i], 16)
if (nibble === 0) {
count += 4
} else {
count += Math.clz32(nibble) - 28
break
}
}
return count
}

View File

@ -53,7 +53,11 @@ export class PrivateKeySigner implements NDKSigner {
}
const recipientHexPubKey = recipient.hexpubkey
return await this.nip04.encrypt(this.privateKey, recipientHexPubKey, value)
return await this.nip04.encrypt(
this.privateKey,
recipientHexPubKey,
value,
)
// return await encrypt(recipientHexPubKey, value, this.privateKey);
}

View File

@ -1,13 +1,10 @@
// service-worker client interface,
// works on the frontend, not sw
// service-worker client interface
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({
@ -16,48 +13,23 @@ export async function swicRegister() {
swr = registration
},
onError(e) {
console.log('sw error', e)
},
onUpdate() {
// tell new SW that it should activate immediately
swr?.waiting?.postMessage({ type: 'SKIP_WAITING' })
console.log(`error ${e}`)
},
})
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.ready.then((r) => (swr = r))
navigator.serviceWorker.addEventListener('message', (event) => {
onMessage((event as MessageEvent).data)
})
}
export function swicWaitStarted() {
return new Promise<void>((ok) => {
if (swr && swr.active) ok()
else queue.push(ok)
})
}
function onMessage(data: any) {
const { id, result, error } = data
console.log('SW message', id, result, error)
if (!id) {
if (result === 'reload') {
if (onReload) onReload()
} else {
if (onRender) onRender()
}
return
}
@ -77,7 +49,6 @@ export async function swicCall(method: string, ...args: any[]) {
nextReqId++
return new Promise((ok, rej) => {
const call = async () => {
if (!swr || !swr.active) {
rej(new Error('No active service worker'))
return
@ -91,17 +62,9 @@ export async function swicCall(method: string, ...args: any[]) {
}
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
}

View File

@ -21,7 +21,6 @@ const commonTheme: Theme = createTheme({
styleOverrides: {
root: {
textTransform: 'initial',
color: 'red',
},
},
},
@ -97,6 +96,4 @@ const darkTheme: Theme = createTheme({
},
})
console.log(darkTheme)
export { lightTheme, darkTheme }

View File

@ -1,14 +1,15 @@
import { useParams } from 'react-router'
import { useAppSelector } from '@/store/hooks/redux'
import { selectAppByAppNpub, selectKeys, selectPermsByNpubAndAppNpub } from '@/store'
import { selectAppByAppNpub, selectPermsByNpubAndAppNpub } from '@/store'
import { Navigate, useNavigate } from 'react-router-dom'
import { formatTimestampDate } from '@/utils/helpers/date'
import { Box, IconButton, Stack, Typography } from '@mui/material'
import { Box, Stack, Typography } from '@mui/material'
import { SectionTitle } from '@/shared/SectionTitle/SectionTitle'
import { getAppIconTitle, getDomain, getShortenNpub } from '@/utils/helpers/helpers'
import { getShortenNpub } from '@/utils/helpers/helpers'
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'
@ -17,40 +18,38 @@ import { IOSBackButton } from '@/shared/IOSBackButton/IOSBackButton'
import { ModalActivities } from './components/Activities/ModalActivities'
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)
const { appNpub = '', npub = '' } = useParams()
const currentApp = useAppSelector((state) => selectAppByAppNpub(state, appNpub))
const perms = useAppSelector((state) => selectPermsByNpubAndAppNpub(state, npub, appNpub))
const navigate = useNavigate()
const notify = useEnqueueSnackbar()
const perms = useAppSelector((state) =>
selectPermsByNpubAndAppNpub(state, npub, appNpub),
)
const currentApp = useAppSelector((state) =>
selectAppByAppNpub(state, appNpub),
)
const { open, handleClose, handleShow } = useToggleConfirm()
const { handleOpen: handleOpenModal } = useModalSearchParams()
const connectPerm = perms.find((perm) => perm.perm === 'connect' || perm.perm === ACTION_TYPE.BASIC)
const connectPerm = perms.find(
(perm) => perm.perm === 'connect' || perm.perm === ACTION_TYPE.BASIC,
)
const isNpubExists = npub.trim().length && keys.some((key) => key.npub === npub)
if (!isNpubExists || !currentApp) {
if (!currentApp) {
return <Navigate to={`/key/${npub}`} />
}
const { icon = '', name = '', url = '' } = currentApp || {}
const appDomain = getDomain(url)
const shortAppNpub = getShortenNpub(appNpub)
const appName = name || appDomain || shortAppNpub
const appAvatarTitle = getAppIconTitle(name || appDomain, appNpub)
const isAppNameExists = !!name || !!appDomain
const { icon = '', name = '' } = currentApp || {}
const appName = name || getShortenNpub(appNpub)
const { timestamp } = connectPerm || {}
const connectedOn = connectPerm && timestamp ? `Connected at ${formatTimestampDate(timestamp)}` : 'Not connected'
const connectedOn =
connectPerm && timestamp
? `Connected at ${formatTimestampDate(timestamp)}`
: 'Not connected'
const handleDeleteApp = async () => {
try {
@ -62,62 +61,62 @@ const AppPage = () => {
}
}
const handleShowAppDetailsModal = () => handleOpenModal(MODAL_PARAMS_KEYS.APP_DETAILS)
return (
<>
<Stack maxHeight={'100%'} overflow={'auto'} alignItems={'flex-start'} height={'100%'}>
<Stack
maxHeight={'100%'}
overflow={'auto'}
alignItems={'flex-start'}
height={'100%'}
>
<IOSBackButton onNavigate={() => navigate(`key/${npub}`)} />
<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>
<Stack
marginBottom={'1rem'}
direction={'row'}
gap={'1rem'}
width={'100%'}
>
<StyledAppIcon src={icon} />
<Box flex={'1'} overflow={'hidden'}>
<Typography variant='h4' noWrap>
{appName}
</Typography>
{isAppNameExists && (
<Typography noWrap display={'block'} variant="body1" color={'GrayText'}>
{shortAppNpub}
</Typography>
)}
</AppNameContainer>
<IconButton onClick={handleShowAppDetailsModal}>
<MoreIcon />
</IconButton>
</AppInfoContainer>
<Typography variant="body2" noWrap>
<Typography variant='body2' noWrap>
{connectedOn}
</Typography>
</Box>
</HeadingContainer>
</Stack>
<Box marginBottom={'1rem'}>
<SectionTitle marginBottom={'0.5rem'}>Disconnect</SectionTitle>
<SectionTitle marginBottom={'0.5rem'}>
Disconnect
</SectionTitle>
<Button fullWidth onClick={handleShow}>
Delete app
</Button>
</Box>
<Permissions perms={perms} />
<Button fullWidth onClick={() => handleOpenModal(MODAL_PARAMS_KEYS.ACTIVITY)}>
<Button
fullWidth
onClick={() =>
handleOpenModal(MODAL_PARAMS_KEYS.ACTIVITY, {
replace: true,
})
}
>
Activity
</Button>
</Stack>
<ConfirmModal
open={open}
headingText="Delete app"
description="Are you sure you want to delete this app?"
headingText='Delete app'
description='Are you sure you want to delete this app?'
onCancel={handleClose}
onConfirm={handleDeleteApp}
onClose={handleClose}
/>
<ModalActivities appNpub={appNpub} />
<ModalAppDetails />
</>
)
}

View File

@ -1,4 +1,4 @@
import { FC } from 'react'
import React, { FC } from 'react'
import { DbHistory } from '@/modules/db'
import { Box, IconButton, Typography } from '@mui/material'
import { StyledActivityItem } from './styled'
@ -6,21 +6,37 @@ 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 { getReqActionName } from '@/utils/helpers/helpers'
import { ACTIONS } from '@/utils/consts'
type ItemActivityProps = DbHistory
export const ItemActivity: FC<ItemActivityProps> = (req) => {
const { allowed, timestamp } = req
export const ItemActivity: FC<ItemActivityProps> = ({
allowed,
method,
timestamp,
}) => {
return (
<StyledActivityItem>
<Box display={'flex'} flexDirection={'column'} gap={'0.5rem'} flex={1}>
<Box
display={'flex'}
flexDirection={'column'}
gap={'0.5rem'}
flex={1}
>
<Typography flex={1} fontWeight={700}>
{getReqActionName(req)}
{ACTIONS[method] || method}
</Typography>
<Typography variant='body2'>
{formatTimestampDate(timestamp)}
</Typography>
<Typography variant="body2">{formatTimestampDate(timestamp)}</Typography>
</Box>
<Box>{allowed ? <DoneRoundedIcon htmlColor="green" /> : <ClearRoundedIcon htmlColor="red" />}</Box>
<Box>
{allowed ? (
<DoneRoundedIcon htmlColor='green' />
) : (
<ClearRoundedIcon htmlColor='red' />
)}
</Box>
<IconButton>
<MoreVertRoundedIcon />
</IconButton>

View File

@ -12,14 +12,23 @@ type ModalActivitiesProps = {
}
export const ModalActivities: FC<ModalActivitiesProps> = ({ appNpub }) => {
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
const { getModalOpened, handleClose } = useModalSearchParams()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.ACTIVITY)
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.ACTIVITY)
const handleCloseModal = handleClose(MODAL_PARAMS_KEYS.ACTIVITY)
const history = useLiveQuery(getActivityHistoryQuerier(appNpub), [], HistoryDefaultValue)
const history = useLiveQuery(
getActivityHistoryQuerier(appNpub),
[],
HistoryDefaultValue,
)
return (
<Modal open={isModalOpened} onClose={handleCloseModal} fixedHeight="calc(100% - 5rem)" title="Activity history">
<Modal
open={isModalOpened}
onClose={handleCloseModal}
fixedHeight='calc(100% - 5rem)'
title='Activities history'
>
<Box overflow={'auto'}>
{history.map((item) => {
return <ItemActivity {...item} key={item.id} />

View File

@ -1,7 +1,9 @@
import styled from '@emotion/styled'
import { Box, BoxProps } from '@mui/material'
export const StyledActivityItem = styled((props: BoxProps) => <Box {...props} />)(() => ({
export const StyledActivityItem = styled((props: BoxProps) => (
<Box {...props} />
))(() => ({
display: 'flex',
gap: '0.5rem',
justifyContent: 'space-between',

View File

@ -2,20 +2,20 @@ import { FC } from 'react'
import { Box, IconButton, Typography } from '@mui/material'
import { DbPerm } from '@/modules/db'
import { formatTimestampDate } from '@/utils/helpers/date'
import { ACTIONS } from '@/utils/consts'
import { StyledPermissionItem } from './styled'
import ClearRoundedIcon from '@mui/icons-material/ClearRounded'
import DoneRoundedIcon from '@mui/icons-material/DoneRounded'
import MoreVertRoundedIcon from '@mui/icons-material/MoreVertRounded'
import { ItemPermissionMenu } from './ItemPermissionMenu'
import { useOpenMenu } from '@/hooks/useOpenMenu'
import { getPermActionName } from '@/utils/helpers/helpers'
type ItemPermissionProps = {
permission: DbPerm
}
export const ItemPermission: FC<ItemPermissionProps> = ({ permission }) => {
const { value, timestamp, id } = permission || {}
const { perm, value, timestamp, id } = permission || {}
const { anchorEl, handleClose, handleOpen, open } = useOpenMenu()
@ -24,18 +24,36 @@ export const ItemPermission: FC<ItemPermissionProps> = ({ permission }) => {
return (
<>
<StyledPermissionItem>
<Box display={'flex'} flexDirection={'column'} gap={'0.5rem'} flex={1}>
<Box
display={'flex'}
flexDirection={'column'}
gap={'0.5rem'}
flex={1}
>
<Typography flex={1} fontWeight={700}>
{getPermActionName(permission)}
{ACTIONS[perm] || perm}
</Typography>
<Typography variant='body2'>
{formatTimestampDate(timestamp)}
</Typography>
<Typography variant="body2">{formatTimestampDate(timestamp)}</Typography>
</Box>
<Box>{isAllowed ? <DoneRoundedIcon htmlColor="green" /> : <ClearRoundedIcon htmlColor="red" />}</Box>
<Box>
{isAllowed ? (
<DoneRoundedIcon htmlColor='green' />
) : (
<ClearRoundedIcon htmlColor='red' />
)}
</Box>
<IconButton onClick={handleOpen}>
<MoreVertRoundedIcon />
</IconButton>
</StyledPermissionItem>
<ItemPermissionMenu anchorEl={anchorEl} open={open} handleClose={handleClose} permId={id} />
<ItemPermissionMenu
anchorEl={anchorEl}
open={open}
handleClose={handleClose}
permId={id}
/>
</>
)
}

View File

@ -9,7 +9,12 @@ type ItemPermissionMenuProps = {
handleClose: () => void
} & MenuProps
export const ItemPermissionMenu: FC<ItemPermissionMenuProps> = ({ open, anchorEl, handleClose, permId }) => {
export const ItemPermissionMenu: FC<ItemPermissionMenuProps> = ({
open,
anchorEl,
handleClose,
permId,
}) => {
const [showConfirm, setShowConfirm] = useState(false)
const notify = useEnqueueSnackbar()
@ -40,14 +45,16 @@ export const ItemPermissionMenu: FC<ItemPermissionMenuProps> = ({ open, anchorEl
vertical: 'bottom',
}}
>
<MenuItem onClick={handleShowConfirm}>Delete permission</MenuItem>
<MenuItem onClick={handleShowConfirm}>
Delete permission
</MenuItem>
</Menu>
<ConfirmModal
open={showConfirm}
onClose={handleCloseConfirm}
onCancel={handleCloseConfirm}
headingText="Delete permission"
description="Are you sure you want to delete this permission?"
headingText='Delete permission'
description='Are you sure you want to delete this permission?'
onConfirm={handleDeletePerm}
/>
</>

View File

@ -12,7 +12,13 @@ export const Permissions: FC<PermissionsProps> = ({ perms }) => {
return (
<Box width={'100%'} marginBottom={'1rem'} flex={1} overflow={'auto'}>
<SectionTitle marginBottom={'0.5rem'}>Permissions</SectionTitle>
<Box flex={1} overflow={'auto'} display={'flex'} flexDirection={'column'} gap={'0.5rem'}>
<Box
flex={1}
overflow={'auto'}
display={'flex'}
flexDirection={'column'}
gap={'0.5rem'}
>
{perms.map((perm) => {
return <ItemPermission key={perm.id} permission={perm} />
})}

View File

@ -1,6 +1,8 @@
import { Box, BoxProps, styled } from '@mui/material'
export const StyledPermissionItem = styled((props: BoxProps) => <Box {...props} />)(() => ({
export const StyledPermissionItem = styled((props: BoxProps) => (
<Box {...props} />
))(() => ({
display: 'flex',
gap: '0.5rem',
justifyContent: 'space-between',

View File

@ -1,32 +1,8 @@
import { Box, BoxProps, Stack, StackProps, styled } from '@mui/material'
import { Avatar, AvatarProps, styled } from '@mui/material'
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',
export const StyledAppIcon = styled((props: AvatarProps) => (
<Avatar {...props} variant='rounded' />
))(() => ({
width: 70,
height: 70,
}))

View File

@ -6,11 +6,8 @@ export const getActivityHistoryQuerier = (appNpub: string) => () => {
const result = db.history
.where('appNpub')
.equals(appNpub)
.reverse()
.sortBy('timestamp')
.then((a) => a.slice(0, 30))
// .limit(30)
// .toArray()
.limit(30)
.toArray()
return result
}

View File

@ -4,7 +4,6 @@ import { StyledAppLogo, StyledContent } from './styled'
import { Button } from '@/shared/Button/Button'
import { ChangeEvent, useState } from 'react'
import { CheckmarkIcon } from '@/assets'
import { DOMAIN } from '@/utils/consts'
const AuthPage = () => {
const isMobile = useMediaQuery('(max-width:600px)')
@ -30,17 +29,21 @@ const AuthPage = () => {
const mainContent = (
<>
<Input
label="Enter a Username"
label='Enter a Username'
fullWidth
placeholder="Username"
placeholder='Username'
helperText={inputHelperText}
endAdornment={<Typography color={'#FFFFFFA8'}>@{DOMAIN}</Typography>}
endAdornment={
<Typography color={'#FFFFFFA8'}>@nsec.app</Typography>
}
onChange={handleInputChange}
value={enteredValue}
helperTextProps={{
sx: {
'&.helper_text': {
color: isAvailable ? theme.palette.success.main : theme.palette.textSecondaryDecorate.main,
color: isAvailable
? theme.palette.success.main
: theme.palette.textSecondaryDecorate.main,
},
},
}}
@ -53,9 +56,14 @@ const AuthPage = () => {
<Stack height={'100%'} position={'relative'}>
{isMobile ? (
<StyledContent>
<Stack direction={'row'} gap={'1rem'} alignItems={'center'} alignSelf={'flex-start'}>
<Stack
direction={'row'}
gap={'1rem'}
alignItems={'center'}
alignSelf={'flex-start'}
>
<StyledAppLogo />
<Typography fontWeight={600} variant="h5">
<Typography fontWeight={600} variant='h5'>
Sign up
</Typography>
</Stack>

View File

@ -1,9 +1,9 @@
import { AppLogo } from '@/assets'
import { Stack, styled, StackProps, Box } from '@mui/material'
export const StyledContent = styled((props: StackProps) => <Stack {...props} gap={'1rem'} alignItems={'center'} />)(({
theme,
}) => {
export const StyledContent = styled((props: StackProps) => (
<Stack {...props} gap={'1rem'} alignItems={'center'} />
))(({ theme }) => {
return {
background: theme.palette.secondary.main,
position: 'absolute',
@ -24,7 +24,7 @@ export const StyledAppLogo = styled((props) => (
<AppLogo />
</Box>
))({
background: '#0d0d0d',
background: '#00000054',
padding: '0.75rem',
borderRadius: '16px',
display: 'grid',

View File

@ -1,122 +0,0 @@
import { Stack, Typography } from '@mui/material'
import { GetStartedButton, LearnMoreButton } from './styled'
import { DOMAIN } from '@/utils/consts'
import { useSearchParams } from 'react-router-dom'
import { swicCall } from '@/modules/swic'
import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar'
import { ModalConfirmConnect } from '@/components/Modal/ModalConfirmConnect/ModalConfirmConnect'
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { useState } from 'react'
import { getReferrerAppUrl } from '@/utils/helpers/helpers'
import { LoadingSpinner } from '@/shared/LoadingSpinner/LoadingSpinner'
const CreatePage = () => {
const notify = useEnqueueSnackbar()
const { handleOpen } = useModalSearchParams()
const [created, setCreated] = useState(false)
const [searchParams] = useSearchParams()
const [isLoading, setIsLoading] = useState(false)
const name = searchParams.get('name') || ''
const token = searchParams.get('token') || ''
const appNpub = searchParams.get('appNpub') || ''
const isValid = name && token && appNpub
const nip05 = `${name}@${DOMAIN}`
const handleLearnMore = () => {
// @ts-ignore
window.open(`https://${DOMAIN}`, '_blank').focus()
}
const handleClickAddAccount = async () => {
try {
setIsLoading(true)
const key: any = await swicCall('generateKey', name)
const appUrl = getReferrerAppUrl()
console.log('Created', key.npub, 'app', appUrl)
setCreated(true)
setIsLoading(false)
handleOpen(MODAL_PARAMS_KEYS.CONFIRM_CONNECT, {
search: {
npub: key.npub,
appNpub,
appUrl,
token,
// needed for this screen itself
name,
// will close after all done
popup: 'true',
},
replace: true,
})
} catch (error: any) {
notify(error.message || error.toString(), 'error')
setIsLoading(false)
}
}
if (!isValid) {
return (
<Stack maxHeight={'100%'} overflow={'auto'}>
<Typography textAlign={'center'} variant="h6" paddingTop="1em">
Bad parameters.
</Typography>
</Stack>
)
}
return (
<>
<Stack maxHeight={'100%'} overflow={'auto'}>
{created && (
<>
<Typography textAlign={'center'} variant="h4" paddingTop="0.5em">
Account created!
</Typography>
<Typography textAlign={'center'} variant="body1" paddingTop="0.5em">
User name: <b>{nip05}</b>
</Typography>
</>
)}
{!created && (
<>
<Typography textAlign={'center'} variant="h4" paddingTop="0.5em">
Welcome to Nostr!
</Typography>
<Stack gap={'0.5rem'} overflow={'auto'}>
<Typography textAlign={'left'} variant="h6" paddingTop="0.5em">
Chosen name: <b>{nip05}</b>
</Typography>
<GetStartedButton onClick={handleClickAddAccount}>
Create account {isLoading && <LoadingSpinner />}
</GetStartedButton>
<Typography textAlign={'left'} variant="h5" paddingTop="1em">
What you need to know:
</Typography>
<ol style={{ marginLeft: '1em' }}>
<li>Nostr accounts are based on cryptographic keys.</li>
<li>All your actions on Nostr will be signed by your keys.</li>
<li>Nsec.app is one of many services to manage Nostr keys.</li>
<li>When you create an account, a new key will be created.</li>
<li>This key can later be used with other Nostr websites.</li>
</ol>
<LearnMoreButton onClick={handleLearnMore}>Learn more</LearnMoreButton>
</Stack>
</>
)}
</Stack>
<ModalConfirmConnect />
</>
)
}
export default CreatePage

View File

@ -1,26 +0,0 @@
import { AppButtonProps, Button } from '@/shared/Button/Button'
import { styled } from '@mui/material'
import PersonAddAltRoundedIcon from '@mui/icons-material/PersonAddAltRounded'
import PlayArrowOutlinedIcon from '@mui/icons-material/PlayArrowOutlined'
import HelpOutlineOutlinedIcon from '@mui/icons-material/HelpOutlineOutlined'
export const AddAccountButton = styled((props: AppButtonProps) => (
<Button {...props} startIcon={<PersonAddAltRoundedIcon />} />
))(() => ({
alignSelf: 'center',
padding: '0.35rem 1rem',
}))
export const GetStartedButton = styled((props: AppButtonProps) => (
<Button {...props} startIcon={<PlayArrowOutlinedIcon />} />
))(() => ({
alignSelf: 'left',
padding: '0.35rem 1rem',
}))
export const LearnMoreButton = styled((props: AppButtonProps) => (
<Button {...props} startIcon={<HelpOutlineOutlinedIcon />} />
))(() => ({
alignSelf: 'left',
padding: '0.35rem 1rem',
}))

View File

@ -18,34 +18,48 @@ const HomePage = () => {
const handleLearnMore = () => {
// @ts-ignore
window.open(`https://${DOMAIN}`, '_blank').focus()
window.open(`https://info.${DOMAIN}`, '_blank').focus();
}
return (
<Stack maxHeight={'100%'} overflow={'auto'}>
<SectionTitle marginBottom={'0.5rem'}>{isNoKeys ? 'Welcome' : 'Accounts:'}</SectionTitle>
<SectionTitle marginBottom={'0.5rem'}>
{isNoKeys ? 'Welcome' : 'Keys:'}
</SectionTitle>
<Stack gap={'0.5rem'} overflow={'auto'}>
{isNoKeys && (
<>
<Typography textAlign={'left'} variant="h6" paddingTop="1em">
<Typography textAlign={'left'} variant='h6' paddingTop='1em'>
Nsec.app is a novel key storage app for Nostr.
</Typography>
<GetStartedButton onClick={handleClickAddAccount}>Get started</GetStartedButton>
<Typography textAlign={'left'} variant="h6" paddingTop="2em">
Your keys are stored in your browser and can be used in many Nostr apps without the need for a browser
extension.
<GetStartedButton onClick={handleClickAddAccount}>
Get started
</GetStartedButton>
<Typography textAlign={'left'} variant='h6' paddingTop='2em'>
Your keys are stored in your browser and
can be used in many Nostr apps without the
need for a browser extension.
</Typography>
<LearnMoreButton onClick={handleLearnMore}>Learn more</LearnMoreButton>
<LearnMoreButton onClick={handleLearnMore}>
Learn more
</LearnMoreButton>
</>
)}
{!isNoKeys && (
<Fragment>
<Box flex={1} overflow={'auto'} borderRadius={'8px'} padding={'0.25rem'}>
<Box
flex={1}
overflow={'auto'}
borderRadius={'8px'}
padding={'0.25rem'}
>
{keys.map((key) => (
<ItemKey {...key} key={key.npub} />
))}
</Box>
<AddAccountButton onClick={handleClickAddAccount}>Add account</AddAccountButton>
<AddAccountButton onClick={handleClickAddAccount}>
Add account
</AddAccountButton>
</Fragment>
)}
</Stack>

View File

@ -1,37 +1,47 @@
import { FC } from 'react'
import { DbKey } from '../../../modules/db'
import { Avatar, Stack, StackProps, Typography, TypographyProps, styled } from '@mui/material'
import {
Avatar,
Stack,
StackProps,
Typography,
TypographyProps,
styled,
} from '@mui/material'
import { getShortenNpub } from '../../../utils/helpers/helpers'
import { useNavigate } from 'react-router-dom'
import { useProfile } from '@/hooks/useProfile'
type ItemKeyProps = DbKey
export const ItemKey: FC<ItemKeyProps> = (props) => {
const { npub } = props
const { npub, profile } = props
const navigate = useNavigate()
const { userName, userAvatar, avatarTitle } = useProfile(npub)
const handleNavigate = () => {
navigate('/key/' + npub)
}
const { name = '', picture = '' } = profile?.info || {}
const userName = name || getShortenNpub(npub)
const userAvatar = picture || ''
return (
<StyledKeyContainer onClick={handleNavigate}>
<Stack direction={'row'} alignItems={'center'} gap="1rem">
<Avatar src={userAvatar} alt={userName}>
{avatarTitle}
</Avatar>
<StyledText variant="body1">{userName}</StyledText>
<Stack direction={'row'} alignItems={'center'} gap='1rem'>
<Avatar src={userAvatar} alt={userName} />
<StyledText variant='body1'>{userName}</StyledText>
</Stack>
</StyledKeyContainer>
)
}
const StyledKeyContainer = styled((props: StackProps) => <Stack marginBottom={'0.5rem'} gap={'0.25rem'} {...props} />)(
({ theme }) => {
const StyledKeyContainer = styled((props: StackProps) => (
<Stack marginBottom={'0.5rem'} gap={'0.25rem'} {...props} />
))(({ theme }) => {
return {
boxShadow:
theme.palette.mode === 'dark' ? '0px 1px 6px 0px rgba(92, 92, 92, 0.2)' : '0px 1px 6px 0px rgba(0, 0, 0, 0.2)',
theme.palette.mode === 'dark'
? '0px 1px 6px 0px rgba(92, 92, 92, 0.2)'
: '0px 1px 6px 0px rgba(0, 0, 0, 0.2)',
borderRadius: '12px',
padding: '0.5rem 1rem',
background: theme.palette.background.paper,
@ -40,10 +50,11 @@ const StyledKeyContainer = styled((props: StackProps) => <Stack marginBottom={'0
},
cursor: 'pointer',
}
}
)
})
export const StyledText = styled((props: TypographyProps) => <Typography {...props} />)({
export const StyledText = styled((props: TypographyProps) => (
<Typography {...props} />
))({
fontWeight: 500,
width: '100%',
wordBreak: 'break-all',

View File

@ -1,7 +1,6 @@
import { useCallback, useState } from 'react'
import { useAppSelector } from '../../store/hooks/redux'
import { Navigate, useParams, useSearchParams } from 'react-router-dom'
import { Box, IconButton, Stack } from '@mui/material'
import { useParams } from 'react-router-dom'
import { Stack } from '@mui/material'
import { StyledIconButton } from './styled'
import { SettingsIcon, ShareIcon } from '@/assets'
import { Apps } from './components/Apps'
@ -12,82 +11,56 @@ import { ModalSettings } from '@/components/Modal/ModalSettings/ModalSettings'
import { ModalExplanation } from '@/components/Modal/ModalExplanation/ModalExplanation'
import { ModalConfirmConnect } from '@/components/Modal/ModalConfirmConnect/ModalConfirmConnect'
import { ModalConfirmEvent } from '@/components/Modal/ModalConfirmEvent/ModalConfirmEvent'
import { useProfile } from './hooks/useProfile'
import { useBackgroundSigning } from './hooks/useBackgroundSigning'
import { BackgroundSigningWarning } from './components/BackgroundSigningWarning'
import UserValueSection from './components/UserValueSection'
import { useTriggerConfirmModal } from './hooks/useTriggerConfirmModal'
import { useLiveQuery } from 'dexie-react-hooks'
import { checkNpubSyncQuerier } from './utils'
import { DOMAIN } from '@/utils/consts'
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 { apps, pending, perms } = useAppSelector((state) => state.content)
const isSynced = useLiveQuery(checkNpubSyncQuerier(npub), [npub], false)
const { handleOpen } = useModalSearchParams()
const { handleEnableBackground, showWarning, isEnabling } = useBackgroundSigning()
const key = keys.find((k) => k.npub === npub)
const getUsername = useCallback(() => {
if (!key || !key?.name) return ''
if (key.name.includes('@')) return key.name
return `${key?.name}@${DOMAIN}`
}, [key])
const username = getUsername()
const { userNameWithPrefix } = useProfile(npub)
const { handleEnableBackground, showWarning, isEnabling } =
useBackgroundSigning()
const filteredApps = apps.filter((a) => a.npub === npub)
const { prepareEventPendings } = useTriggerConfirmModal(npub, pending, perms)
const { prepareEventPendings } = useTriggerConfirmModal(
npub,
pending,
perms,
)
const isKeyExists = npub.trim().length && key
const isPopup = searchParams.get('popup') === 'true'
// console.log({ isKeyExists, isPopup })
const handleOpenConnectAppModal = () =>
handleOpen(MODAL_PARAMS_KEYS.CONNECT_APP)
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 (
<>
<Stack gap={'1rem'} height={'100%'}>
{showWarning && (
<BackgroundSigningWarning isEnabling={isEnabling} onEnableBackSigning={handleEnableBackground} />
<BackgroundSigningWarning
isEnabling={isEnabling}
onEnableBackSigning={handleEnableBackground}
/>
)}
<UserValueSection
title="Your login"
value={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}
title='Your login'
value={userNameWithPrefix}
copyValue={npub + '@nsec.app'}
explanationType={EXPLANATION_MODAL_KEYS.NPUB}
/>
<UserValueSection
title="Your NPUB"
title='Your NPUB'
value={npub}
endAdornment={<InputCopyButton value={npub} />}
copyValue={npub}
explanationType={EXPLANATION_MODAL_KEYS.NPUB}
/>
@ -98,9 +71,9 @@ const KeyPage = () => {
</StyledIconButton>
<StyledIconButton
bgcolor_variant="secondary"
bgcolor_variant='secondary'
onClick={handleOpenSettingsModal}
withBadge={!isCheckingSync && !isSynced}
withBadge={!isSynced}
>
<SettingsIcon />
Settings
@ -109,13 +82,11 @@ const KeyPage = () => {
<Apps apps={filteredApps} npub={npub} />
</Stack>
<ModalConnectApp />
<ModalSettings isSynced={isSynced} />
<ModalExplanation />
<ModalConfirmConnect />
<ModalConfirmEvent confirmEventReqs={prepareEventPendings} />
<ModalEditName />
</>
)
}

View File

@ -5,7 +5,9 @@ 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 = {
@ -13,27 +15,49 @@ type AppsProps = {
npub: string
}
export const Apps: FC<AppsProps> = ({ apps = [] }) => {
const openAppStore = () => {
window.open('https://nostrapp.link', '_blank')
export const Apps: FC<AppsProps> = ({ apps = [], npub = '' }) => {
const notify = useEnqueueSnackbar()
// eslint-disable-next-line
async function deletePerm(id: string) {
call(async () => {
await swicCall('deletePerm', id)
notify('Perm deleted!', 'success')
})
}
return (
<Box marginBottom={'1rem'} display={'flex'} flexDirection={'column'}>
<Stack direction={'row'} alignItems={'center'} justifyContent={'space-between'} marginBottom={'0.5rem'}>
<Box
flex={1}
marginBottom={'1rem'}
display={'flex'}
flexDirection={'column'}
overflow={'auto'}
>
<Stack
direction={'row'}
alignItems={'center'}
justifyContent={'space-between'}
marginBottom={'0.5rem'}
>
<SectionTitle>Connected apps</SectionTitle>
<AppLink title="Discover Apps" onClick={openAppStore} />
<AppLink title='Discover Apps' />
</Stack>
{!apps.length && (
<StyledEmptyAppsBox>
<Typography className="message" variant="h5" fontWeight={600} textAlign={'center'}>
<Typography
className='message'
variant='h5'
fontWeight={600}
textAlign={'center'}
>
No connected apps
</Typography>
<Button onClick={openAppStore}>Discover Nostr Apps</Button>
<Button>Discover Nostr Apps</Button>
</StyledEmptyAppsBox>
)}
<Stack gap={'0.5rem'} overflow={'auto'} flex={1} paddingBottom={'0.75rem'}>
<Stack gap={'0.5rem'} overflow={'auto'} flex={1}>
{apps.map((a) => (
<ItemApp {...a} key={a.appNpub} />
))}

View File

@ -1,40 +1,27 @@
import { FC } from 'react'
import React, { FC } from 'react'
import { Warning } from '@/components/Warning/Warning'
import { CircularProgress, Stack, Typography, TypographyProps, styled } from '@mui/material'
import AutoModeOutlinedIcon from '@mui/icons-material/AutoModeOutlined'
import { CircularProgress, Stack } from '@mui/material'
import GppMaybeIcon from '@mui/icons-material/GppMaybe'
type BackgroundSigningWarningProps = {
isEnabling: boolean
onEnableBackSigning: () => void
}
export const BackgroundSigningWarning: FC<BackgroundSigningWarningProps> = ({ isEnabling, onEnableBackSigning }) => {
export const BackgroundSigningWarning: FC<BackgroundSigningWarningProps> = ({
isEnabling,
onEnableBackSigning,
}) => {
return (
<Warning
message={
<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 direction={'row'} alignItems={'center'} gap={'1rem'}>
Please enable push notifications{' '}
{isEnabling ? <CircularProgress size={'1.5rem'} /> : null}
</Stack>
}
icon={
isEnabling ? (
<CircularProgress size={'1.5rem'} sx={{ color: '#fff' }} />
) : (
<AutoModeOutlinedIcon htmlColor="white" />
)
}
Icon={<GppMaybeIcon 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',
}))

View File

@ -2,19 +2,14 @@ import { DbApp } from '@/modules/db'
import { Avatar, Stack, Typography } from '@mui/material'
import { FC } from 'react'
import { Link } from 'react-router-dom'
import { getAppIconTitle, getDomain, getShortenNpub } from '@/utils/helpers/helpers'
// import ImageOutlinedIcon from '@mui/icons-material/ImageOutlined'
import { getShortenNpub } from '@/utils/helpers/helpers'
import { StyledItemAppContainer } from './styled'
type ItemAppProps = DbApp
export const ItemApp: FC<ItemAppProps> = ({ npub, appNpub, icon, name, url }) => {
const appDomain = getDomain(url)
const shortAppNpub = getShortenNpub(appNpub)
const appName = name || appDomain || shortAppNpub
const appIcon = icon || `https://${appDomain}/favicon.ico`
const appAvatarTitle = getAppIconTitle(name || appDomain, appNpub)
const isAppNameExists = !!name || !!appDomain
export const ItemApp: FC<ItemAppProps> = ({ npub, appNpub, icon, name }) => {
const appName = name || getShortenNpub(appNpub)
return (
<StyledItemAppContainer
direction={'row'}
@ -24,19 +19,22 @@ 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}>
{appAvatarTitle}
</Avatar>
<Avatar
variant='square'
sx={{ width: 56, height: 56 }}
src={icon}
alt={name}
/>
<Stack>
<Typography noWrap display={'block'} variant="body1">
<Typography noWrap display={'block'} variant='body2'>
{appName}
</Typography>
{isAppNameExists && (
<Typography noWrap display={'block'} variant="body2" color={'GrayText'}>
{shortAppNpub}
</Typography>
)}
<Typography noWrap display={'block'} variant="caption" color={'GrayText'}>
<Typography
noWrap
display={'block'}
variant='caption'
color={'GrayText'}
>
Basic actions
</Typography>
</Stack>

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