Compare commits

..

76 Commits

Author SHA1 Message Date
artur
0b56813ece Add hyphen and underscore as valid password symbols, increase valid password to 6 chars, add password validity and strength indicator 2024-02-14 09:55:11 +03:00
Nostr.Band
8d205d9d93 Merge pull request #53 from nostrband/develop
Allow import w/ existing name
2024-02-13 11:48:24 +03:00
artur
9a18e79862 Allow importing nsec w/ existing name, improve import form 2024-02-13 11:47:35 +03:00
Nostr.Band
ab2df05d50 Merge pull request #33 from nostrband/main
Merge w/ main
2024-02-13 08:21:17 +03:00
Nostr.Band
163de16a84 Merge pull request #32 from nostrband/fix/referrer
Don't use referrer if it's our domain
2024-02-13 08:20:39 +03:00
artur
e9b290db30 Don't use referrer if it's our domain 2024-02-13 08:19:42 +03:00
Nostr.Band
544ac18b59 Merge pull request #31 from nostrband/develop
New logo
2024-02-12 14:56:23 +03:00
Nostr.Band
2551022d5e Merge pull request #30 from nostrband/feature/app-logo
change app logo
2024-02-12 14:33:13 +03:00
Bekbolsun
45c39ca904 change app logo 2024-02-12 17:26:30 +06:00
Nostr.Band
041b84eb0b Merge pull request #29 from nostrband/develop
Remove redirect to initial=true
2024-02-12 14:14:28 +03:00
artur
69166ff501 Remove redirect to initial=true 2024-02-12 14:13:50 +03:00
Nostr.Band
043e159e53 Merge pull request #28 from nostrband/develop
Fix bad validity checks on confirm modal
2024-02-12 12:43:07 +03:00
artur
13d0a62fec Fix bad validity checks on confirm modal 2024-02-12 12:42:32 +03:00
Nostr.Band
d11cccec35 Merge pull request #27 from nostrband/develop
Fix connect modal without pending request id
2024-02-12 10:58:28 +03:00
artur
81b8624bd1 Fix connect modal without pending request id 2024-02-12 10:57:55 +03:00
Nostr.Band
f45300583c Merge pull request #26 from nostrband/develop
Lots of minor fixes
2024-02-12 10:29:57 +03:00
Nostr.Band
0cf042e5d9 Merge pull request #23 from nostrband/feature/app-details
Feature/app details
2024-02-12 10:28:23 +03:00
artur
ec544a0592 Add explanations, make login name lowercase, add nostrapp link 2024-02-12 10:26:21 +03:00
Bekbolsun
72d561f8c9 Merge branch 'feature/app-details' of https://github.com/nostrband/noauth into feature/app-details 2024-02-09 19:34:19 +06:00
Bekbolsun
f408fd1b38 fix reload on submit, button disabled styles, profile name styles in header 2024-02-09 19:33:32 +06:00
Nostr.Band
977a4b5c93 Merge pull request #25 from nostrband/develop
Fix enablePush at connectModal
2024-02-09 15:58:52 +03:00
artur
8ccdc06f49 Fix enablePush at connectModal 2024-02-09 15:57:03 +03:00
Nostr.Band
6589a98d52 Merge pull request #24 from nostrband/develop
Save app url from referrer on connect request by bunker url
2024-02-09 15:23:14 +03:00
artur
fed1ece2d4 Save app url from referrer on connect request by bunker url 2024-02-09 15:09:17 +03:00
artur
2b6a1e1e5d Fix check of pending req id on connect modal 2024-02-09 15:07:16 +03:00
Bekbolsun
104404b04c Merge branch 'develop' of https://github.com/nostrband/noauth into feature/app-details 2024-02-09 03:43:40 +06:00
Bekbolsun
e4fdb7794a add app details modal, refactor showing username logic, handle modals&pages in case of errors from input params, replace change theme button and etc.. 2024-02-09 03:42:07 +06:00
Nostr.Band
e7e3b871e4 Merge pull request #22 from nostrband/develop
Add proper app name to app page
2024-02-08 21:18:17 +03:00
artur
1566592683 Add proper app name to app page 2024-02-08 21:17:06 +03:00
Nostr.Band
063213cb89 Merge pull request #21 from nostrband/develop
Add referrer log
2024-02-08 21:01:47 +03:00
artur
52b119b424 Add referrer log 2024-02-08 20:56:47 +03:00
Nostr.Band
0bf6fafb3e Merge pull request #20 from nostrband/develop
Add referrer parsing to connect modal
2024-02-08 20:38:05 +03:00
artur
12afbaa76b Add referrer parsing to connect modal 2024-02-08 20:37:21 +03:00
Nostr.Band
14a83ec721 Merge pull request #19 from nostrband/develop
Add text to enable notifications, add account created message
2024-02-08 19:52:46 +03:00
artur
4aa4f7f175 Add text to enable notifications, add account created message 2024-02-08 19:51:47 +03:00
Bekbolsun
7aaea89f21 Merge branch 'develop' of https://github.com/nostrband/noauth into feature/app-details 2024-02-08 19:26:12 +06:00
Nostr.Band
dfb8889b9d Merge pull request #18 from nostrband/develop
Implement connectApp logic, add app url and icon
2024-02-08 14:53:10 +03:00
artur
89fc5b0ae0 Fix create account bug - failure to show connect confirm modal 2024-02-08 14:52:34 +03:00
artur
48c07ad1c0 Implement connectApp logic, add app url and icon 2024-02-08 14:15:45 +03:00
Nostr.Band
b24e3d31b0 Merge pull request #17 from nostrband/develop
Fix app avatars, fix perm names in App page, fix time format
2024-02-08 08:52:25 +03:00
artur
caf8f9a82b Fix app avatars, fix perm names in App page, fix time format 2024-02-08 08:50:37 +03:00
Nostr.Band
b27fb5ec07 Merge pull request #16 from nostrband/develop
Develop
2024-02-07 10:46:04 +03:00
Nostr.Band
449bdb79ce Merge pull request #15 from nostrband/main
Merge w/ main
2024-02-07 10:44:16 +03:00
Nostr.Band
d16c3cd9b0 Merge pull request #14 from nostrband/better-confirms
Better confirms
2024-02-07 10:42:54 +03:00
artur
d00e16139e Assign name on login, change confirm modals, change push warning, reject reqs before connect 2024-02-07 10:41:00 +03:00
Nostr.Band
fe4705afc8 Merge pull request #11 from nostrband/feature/prettier-config
add prettier
2024-02-06 20:02:32 +03:00
Bekbolsun
326d824451 Merge branch 'develop' of https://github.com/nostrband/noauth into feature/prettier-config 2024-02-06 22:51:50 +06:00
Bekbolsun
9d565ddbde save 2024-02-06 22:47:40 +06:00
Nostr.Band
c5c5843cb8 Merge pull request #13 from nostrband/develop
Add disallow on window close in popup mode
2024-02-06 19:28:40 +03:00
artur
cc9840760b Add disallow on window close in popup mode 2024-02-06 19:03:56 +03:00
Nostr.Band
34bf3f7c12 Merge pull request #12 from nostrband/develop
Add popup confirm mode, make on-demand mean connect+get_public_key
2024-02-06 15:43:50 +03:00
Bekbolsun
be8cfcb3a5 add prettier 2024-02-06 15:49:05 +06:00
artur
14940a4345 Add popup confirm mode, make on-demand mean connect+get_public_key 2024-02-06 11:41:51 +03:00
Nostr.Band
d3ab9174e1 Merge pull request #10 from nostrband/develop
Start OAuth-flow support by sending authUrl replies
2024-02-06 09:55:49 +03:00
artur
fa4c5d3532 Start OAuth-flow support by sending authUrl replies 2024-02-06 09:53:01 +03:00
Nostr.Band
8faccc383b Merge pull request #9 from nostrband/develop
Change relay to .env variable
2024-02-05 19:12:36 +03:00
artur
e80a41bfa0 Change relay to .env variable 2024-02-05 19:08:33 +03:00
Nostr.Band
1305af6896 Merge pull request #8 from nostrband/develop
Add name saving to login flow, fix updateUI
2024-02-05 16:20:18 +03:00
artur
6c2a12c924 Fix updateUI to ensure all action windows are notified 2024-02-05 16:18:20 +03:00
artur
8aabb45917 Add name saving to login flow 2024-02-05 16:01:26 +03:00
Nostr.Band
593fafd9f8 Merge pull request #7 from nostrband/develop
Add name processing for signup, add pow to nip98 and to sendName, min…
2024-02-05 14:34:51 +03:00
artur
5b57b42111 Add name processing for signup, add pow to nip98 and to sendName, minor UI changes 2024-02-05 14:29:25 +03:00
Nostr.Band
2ba1eaef65 Merge pull request #6 from nostrband/develop
Develop
2024-02-05 09:14:58 +03:00
Nostr.Band
9c18310fd9 Merge pull request #4 from nostrband/refactor/sync-npub
Refactor/login
2024-02-05 09:12:22 +03:00
Bekbolsun
c5af7d377d fix error on login 2024-02-02 18:48:08 +06:00
artur
f2e70a998d Merge branch 'main' into develop 2024-02-02 13:40:27 +03:00
artur
b2e1a43f1b Fix createHandleCloseReplace implementation 2024-02-02 13:39:56 +03:00
Nostr.Band
878bae6c2f Merge pull request #5 from nostrband/fix/modal-replace-notifs
Fix/modal replace notifs
2024-02-02 12:59:31 +03:00
Bekbolsun
1c6947d549 Merge branch 'refactor/sync-npub' of https://github.com/nostrband/noauth into refactor/sync-npub 2024-02-02 14:31:27 +06:00
Bekbolsun
fabc920563 fix navigating to key page & handle empty input values on submit 2024-02-02 14:30:49 +06:00
Nostr.Band
020ab18e56 Merge pull request #3 from nostrband/refactor/sync-npub
add sync npub logic & change perms and activity history design & add …
2024-02-02 09:51:20 +03:00
artur
41de75ff6e Fix typo in encryptKeyPass 2024-01-30 15:42:53 +03:00
Nostr.Band
8ae416047d Merge pull request #2 from nostrband/develop
App page
2024-01-30 15:39:33 +03:00
artur
cddf0b7805 Merge branch 'develop' of https://github.com/nostrband/noauth into develop 2024-01-30 11:19:35 +03:00
artur
c28ef815ac Fix notifCallback reset after notif 2024-01-30 11:19:32 +03:00
Nostr.Band
50e31ceb1c Merge pull request #1 from nostrband/develop
init develop branch
2024-01-29 11:34:02 +03:00
136 changed files with 36981 additions and 36446 deletions

5
.env
View File

@@ -2,5 +2,6 @@
# 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.login.nostrapps.org
REACT_APP_DOMAIN=nsec.app
REACT_APP_NOAUTHD_URL=https://noauthd.nsec.app
REACT_APP_DOMAIN=nsec.app
REACT_APP_RELAY=wss://relay.nsec.app

1
.prettierignore Normal file
View File

@@ -0,0 +1 @@
node_modules/

9
.prettierrc Normal file
View File

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

62886
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,96 +1,101 @@
{
"name": "noauth",
"version": "0.1.0",
"private": true,
"dependencies": {
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.14.19",
"@mui/material": "^5.14.20",
"@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",
"@testing-library/user-event": "^13.5.0",
"@types/jest": "^27.5.2",
"@types/node": "^17.0.45",
"@types/react": "^18.2.38",
"@types/react-copy-to-clipboard": "^5.0.7",
"@types/react-dom": "^18.2.17",
"crypto": "^1.0.1",
"date-fns": "^3.3.1",
"dexie": "^3.2.4",
"dexie-react-hooks": "^1.1.7",
"lodash.isequal": "^4.5.0",
"memoize-one": "^6.0.0",
"nostr-tools": "^1.17.0",
"notistack": "^3.0.1",
"react": "^18.2.0",
"react-copy-to-clipboard": "^5.1.0",
"react-dom": "^18.2.0",
"react-redux": "^9.0.3",
"react-router-dom": "^6.20.1",
"react-scripts": "5.0.1",
"redux-persist": "^6.0.0",
"typescript": "^5.3.2",
"use-debounce": "^10.0.0",
"web-vitals": "^2.1.4",
"workbox-background-sync": "^6.6.0",
"workbox-broadcast-update": "^6.6.0",
"workbox-cacheable-response": "^6.5.4",
"workbox-core": "^6.6.0",
"workbox-expiration": "^6.6.0",
"workbox-google-analytics": "^6.6.0",
"workbox-navigation-preload": "^6.6.0",
"workbox-precaching": "^6.6.0",
"workbox-range-requests": "^6.6.0",
"workbox-routing": "^6.6.0",
"workbox-strategies": "^6.6.0",
"workbox-streams": "^6.6.0"
},
"overrides": {
"react-scripts": {
"typescript": "^5.3.2"
}
},
"scripts": {
"start": "react-app-rewired start",
"build": "react-app-rewired build",
"test": "react-app-rewired test",
"eject": "react-app-rewired eject",
"serve": "npm run build && serve -s build"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@types/lodash.isequal": "^4.5.8",
"assert": "^2.1.0",
"buffer": "^6.0.3",
"crypto-browserify": "^3.12.0",
"customize-cra": "^1.0.0",
"https-browserify": "^1.0.0",
"os-browserify": "^0.3.0",
"process": "^0.11.10",
"react-app-rewired": "^2.2.1",
"serve": "^14.2.1",
"stream-browserify": "^3.0.0",
"stream-http": "^3.2.0",
"url": "^0.11.3"
}
"name": "noauth",
"version": "0.1.0",
"private": true,
"dependencies": {
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@hookform/resolvers": "^3.3.4",
"@mui/icons-material": "^5.14.19",
"@mui/material": "^5.14.20",
"@nostr-dev-kit/ndk": "^2.4.0",
"@reduxjs/toolkit": "^2.0.1",
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"@types/jest": "^27.5.2",
"@types/node": "^17.0.45",
"@types/react": "^18.2.38",
"@types/react-copy-to-clipboard": "^5.0.7",
"@types/react-dom": "^18.2.17",
"crypto": "^1.0.1",
"date-fns": "^3.3.1",
"dexie": "^3.2.4",
"dexie-react-hooks": "^1.1.7",
"lodash.isequal": "^4.5.0",
"memoize-one": "^6.0.0",
"nostr-tools": "^1.17.0",
"notistack": "^3.0.1",
"react": "^18.2.0",
"react-copy-to-clipboard": "^5.1.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.50.0",
"react-redux": "^9.0.3",
"react-router-dom": "^6.20.1",
"react-scripts": "5.0.1",
"redux-persist": "^6.0.0",
"typescript": "^5.3.2",
"use-debounce": "^10.0.0",
"web-vitals": "^2.1.4",
"workbox-background-sync": "^6.6.0",
"workbox-broadcast-update": "^6.6.0",
"workbox-cacheable-response": "^6.5.4",
"workbox-core": "^6.6.0",
"workbox-expiration": "^6.6.0",
"workbox-google-analytics": "^6.6.0",
"workbox-navigation-preload": "^6.6.0",
"workbox-precaching": "^6.6.0",
"workbox-range-requests": "^6.6.0",
"workbox-routing": "^6.6.0",
"workbox-strategies": "^6.6.0",
"workbox-streams": "^6.6.0",
"yup": "^1.3.3"
},
"overrides": {
"react-scripts": {
"typescript": "^5.3.2"
}
},
"scripts": {
"start": "react-app-rewired start",
"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"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@types/lodash.isequal": "^4.5.8",
"assert": "^2.1.0",
"buffer": "^6.0.3",
"crypto-browserify": "^3.12.0",
"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",
"stream-browserify": "^3.0.0",
"stream-http": "^3.2.0",
"url": "^0.11.3"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 45 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: 502 B

After

Width:  |  Height:  |  Size: 536 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,39 +1,22 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link
rel="apple-touch-icon"
sizes="180x180"
href="%PUBLIC_URL%/apple-touch-icon.png"
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="%PUBLIC_URL%/favicon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="%PUBLIC_URL%/favicon-16x16.png"
/>
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@200;300;400;500;600;700&display=swap"
rel="stylesheet"
/>
<!--
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Web site created using create-react-app" />
<link rel="apple-touch-icon" sizes="180x180" href="%PUBLIC_URL%/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="%PUBLIC_URL%/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="%PUBLIC_URL%/favicon-16x16.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<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.
Only files inside the `public` folder can be referenced from the HTML.
@@ -42,12 +25,12 @@
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Nsec.app</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
<title>Nsec.app</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
@@ -56,5 +39,6 @@
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
--></body>
-->
</body>
</html>

View File

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

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

@@ -2,12 +2,7 @@ import { DbKey, dbi } from './modules/db'
import { useCallback, useEffect, useState } from 'react'
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'
@@ -18,86 +13,82 @@ import { ModalSignUp } from './components/Modal/ModalSignUp/ModalSignUp'
import { ModalLogin } from './components/Modal/ModalLogin/ModalLogin'
function App() {
const [render, setRender] = useState(0)
const { handleOpen } = useModalSearchParams()
const dispatch = useAppDispatch()
const [render, setRender] = useState(0)
const { handleOpen } = useModalSearchParams()
const dispatch = useAppDispatch()
const [isConnected, setIsConnected] = useState(false)
const [isConnected, setIsConnected] = useState(false)
const load = useCallback(async () => {
const keys: DbKey[] = await dbi.listKeys()
console.log(keys, 'keys')
const load = useCallback(async () => {
const keys: DbKey[] = await dbi.listKeys()
console.log(keys, 'keys')
dispatch(setKeys({ keys }))
const loadProfiles = async () => {
const newKeys = []
dispatch(setKeys({ keys }))
const loadProfiles = async () => {
const newKeys = []
for (const key of keys) {
// make it async
const response = await fetchProfile(key.npub)
if (!response) {
newKeys.push(key)
} else {
newKeys.push({ ...key, profile: response })
}
}
for (const key of keys) {
// make it async
const response = await fetchProfile(key.npub)
if (!response) {
newKeys.push(key)
} else {
newKeys.push({ ...key, profile: response })
}
}
dispatch(setKeys({ keys: newKeys }))
}
// async load to avoid blocking main code below
loadProfiles()
dispatch(setKeys({ keys: newKeys }))
}
// async load to avoid blocking main code below
loadProfiles()
const apps = await dbi.listApps()
dispatch(
setApps({
apps: apps.map((app) => ({
...app,
// MOCK IMAGE
icon: 'https://nostr.band/android-chrome-192x192.png',
})),
}),
)
const apps = await dbi.listApps()
dispatch(
setApps({
apps,
})
)
const perms = await dbi.listPerms()
dispatch(setPerms({ perms }))
const perms = await dbi.listPerms()
dispatch(setPerms({ perms }))
const pending = await dbi.listPending()
dispatch(setPending({ pending }))
const pending = await dbi.listPending()
dispatch(setPending({ pending }))
// rerender
// setRender((r) => r + 1)
// rerender
// setRender((r) => r + 1)
if (!keys.length) handleOpen(MODAL_PARAMS_KEYS.INITIAL)
// eslint-disable-next-line
}, [dispatch])
// if (!keys.length) handleOpen(MODAL_PARAMS_KEYS.INITIAL)
// eslint-disable-next-line
}, [dispatch])
useEffect(() => {
if (isConnected) load()
}, [render, isConnected, load])
useEffect(() => {
if (isConnected) load()
}, [render, isConnected, load])
useEffect(() => {
ndk.connect().then(() => {
console.log('NDK connected', { ndk })
setIsConnected(true)
})
// eslint-disable-next-line
}, [])
useEffect(() => {
ndk.connect().then(() => {
console.log('NDK connected', { ndk })
setIsConnected(true)
})
// eslint-disable-next-line
}, [])
// subscribe to updates from the service worker
swicOnRender(() => {
console.log('render')
setRender((r) => r + 1)
})
// subscribe to updates from the service worker
swicOnRender(() => {
console.log('render')
setRender((r) => r + 1)
})
return (
<>
<AppRoutes />
<ModalInitial />
<ModalImportKeys />
<ModalSignUp />
<ModalLogin />
</>
)
return (
<>
<AppRoutes />
<ModalInitial />
<ModalImportKeys />
<ModalSignUp />
<ModalLogin />
</>
)
}
export default App

View File

@@ -1,3 +1,3 @@
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M25.5479 20.2413C24.4588 19.0649 22.6437 18.9473 21.3126 19.7708L18.8925 17.8885C19.8606 16.2416 19.9816 14.2417 19.0135 12.5948L21.4337 10.8302C23.0067 12.1242 25.3059 12.0066 26.7579 10.5949C28.331 9.06555 28.452 6.59511 26.8789 5.06579C25.3059 3.53647 22.7647 3.41883 21.1916 4.94815C19.9816 6.12455 19.6186 7.88915 20.3446 9.41847L18.0455 11.1831C16.1094 9.41847 13.0842 9.18319 11.0271 10.5949L8.72796 8.35971C10.059 6.59511 9.93803 4.00703 8.24394 2.36007C6.42884 0.477835 3.28267 0.477835 1.46757 2.24243C-0.468534 4.00703 -0.468534 7.06567 1.34656 8.83027C3.04066 10.4772 5.58179 10.7125 7.5179 9.53611L9.81702 11.7713C8.36494 13.7712 8.36495 16.4769 9.93803 18.3591L7.27589 21.1825C6.06582 20.5943 4.61374 20.8295 3.64569 21.7707C2.43562 22.9471 2.31462 24.9469 3.52468 26.1233C4.73475 27.2997 6.79186 27.4174 8.00192 26.241C9.09098 25.1822 9.21199 23.6529 8.48595 22.4765L11.0271 19.6531C13.0842 20.9472 15.8674 20.8295 17.6824 19.1826L20.1026 21.0648C19.6186 22.2412 19.7396 23.6529 20.7076 24.594C21.9177 25.8881 24.0958 25.8881 25.3059 24.7117C26.7579 23.5353 26.7579 21.5354 25.5479 20.2413Z" fill="currentColor"/>
<svg width="32" height="36" viewBox="0 0 32 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M28.2711 21.2958C27.1084 21.2958 26.0749 21.8372 25.4037 22.6797L21.8702 20.6405C22.2577 19.8106 22.4755 18.8846 22.4755 17.908C22.4755 16.9314 22.2577 16.0053 21.8702 15.1755L25.3404 13.1742C26.0091 14.0648 27.0704 14.6442 28.2711 14.6442C30.2949 14.6442 31.9363 13.0047 31.9363 10.9831C31.9363 8.96158 30.2949 7.32208 28.2711 7.32208C26.2472 7.32208 24.6058 8.96158 24.6058 10.9831C24.6058 11.4006 24.6793 11.8003 24.8084 12.1748L21.3028 14.1963C20.2338 12.6732 18.5241 11.6333 16.5635 11.4638V7.274C18.3189 7.00076 19.6639 5.49029 19.6639 3.66104C19.6639 1.6395 18.0225 0 15.9987 0C13.9748 0 12.3334 1.6395 12.3334 3.66104C12.3334 5.49029 13.6784 7.00329 15.4338 7.274V11.4638C13.4733 11.6333 11.7635 12.6732 10.6946 14.1963L7.1889 12.1748C7.31808 11.8003 7.39154 11.4006 7.39154 10.9831C7.39154 8.96158 5.75015 7.32208 3.72629 7.32208C1.70242 7.32208 0.0610352 8.96158 0.0610352 10.9831C0.0610352 13.0047 1.70242 14.6442 3.72629 14.6442C4.92693 14.6442 5.98825 14.0648 6.65697 13.1742L10.1272 15.1755C9.73963 16.0053 9.52179 16.9314 9.52179 17.908C9.52179 18.8846 9.73963 19.8106 10.1272 20.643L6.59364 22.6822C5.9224 21.8397 4.88893 21.2983 3.72629 21.2983C1.70242 21.2983 0.0610352 22.9378 0.0610352 24.9593C0.0610352 26.9809 1.70242 28.6204 3.72629 28.6204C5.75015 28.6204 7.39154 26.9809 7.39154 24.9593C7.39154 24.5039 7.30542 24.0687 7.1509 23.6639L10.6946 21.6196C11.3329 22.5279 12.1992 23.2667 13.2098 23.7499V32.3497C13.2098 32.9721 13.5569 33.8551 13.9799 34.3131L15.2286 35.6565C15.6516 36.1145 16.3457 36.1145 16.7712 35.6565L18.02 34.3131C18.443 33.8551 18.79 32.9721 18.79 32.3497V23.7499C19.8007 23.2667 20.667 22.5304 21.3053 21.6196L24.849 23.6639C24.697 24.0662 24.6083 24.5014 24.6083 24.9593C24.6083 26.9809 26.2497 28.6204 28.2736 28.6204C30.2975 28.6204 31.9388 26.9809 31.9388 24.9593C31.9388 22.9378 30.2975 21.2983 28.2736 21.2983L28.2711 21.2958Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -10,14 +10,14 @@ import { ReactComponent as UnchekedLightIcon } from './icons/unchecked-light.svg
import { default as AddImageIcon } from './icons/add-image.svg'
export {
AppLogo,
ShareIcon,
SettingsIcon,
CopyIcon,
CheckmarkIcon,
CheckedIcon,
CheckedLightIcon,
UnchekedIcon,
UnchekedLightIcon,
AddImageIcon,
AppLogo,
ShareIcon,
SettingsIcon,
CopyIcon,
CheckmarkIcon,
CheckedIcon,
CheckedLightIcon,
UnchekedIcon,
UnchekedLightIcon,
AddImageIcon,
}

View File

@@ -0,0 +1,173 @@
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, CircularProgress, 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'
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(url) && !isEmptyString(name) && !isEmptyString(icon)
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>
<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="Name"
fullWidth
placeholder="Enter app name"
onChange={handleInputChange('name')}
value={details.name}
/>
<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 && <CircularProgress sx={{ marginLeft: '0.5rem' }} size={'1rem'} />}
</Button>
</Stack>
</Modal>
)
}

View File

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

View File

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

View File

@@ -1,32 +1,25 @@
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} />
))(() => ({
borderRadius: '19px',
fontWeight: 600,
padding: '0.75rem 1rem',
maxHeight: '41px',
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} />,
)(() => ({
gap: '0.75rem',
marginBottom: '1rem',
justifyContent: 'space-between',
'&.MuiToggleButtonGroup-root .MuiToggleButtonGroup-grouped:not(:first-of-type)':
{
margin: '0',
border: 'initial',
},
'&.MuiToggleButtonGroup-root .MuiToggleButtonGroup-grouped': {
border: 'initial',
borderRadius: '1rem',
},
}))
export const StyledToggleButtonsGroup = styled((props: ToggleButtonGroupProps) => <ToggleButtonGroup {...props} />)(
() => ({
gap: '0.75rem',
marginBottom: '1rem',
justifyContent: 'space-between',
'&.MuiToggleButtonGroup-root .MuiToggleButtonGroup-grouped:not(:first-of-type)': {
margin: '0',
border: 'initial',
},
'&.MuiToggleButtonGroup-root .MuiToggleButtonGroup-grouped': {
border: 'initial',
borderRadius: '1rem',
},
})
)

View File

@@ -3,30 +3,23 @@ import { ToggleButtonProps, Typography } from '@mui/material'
import { StyledToggleButton } from './styled'
type ActionToggleButtonProps = ToggleButtonProps & {
description?: string
hasinfo?: boolean
description?: string
hasinfo?: boolean
}
export const ActionToggleButton: FC<ActionToggleButtonProps> = ({
hasinfo = false,
...props
}) => {
const { title, description = '' } = props
return (
<StyledToggleButton {...props}>
<Typography variant='body2'>{title}</Typography>
<Typography
className='description'
variant='caption'
color={'GrayText'}
>
{description}
</Typography>
{hasinfo && (
<Typography className='info' color={'GrayText'}>
Info
</Typography>
)}
</StyledToggleButton>
)
export const ActionToggleButton: FC<ActionToggleButtonProps> = ({ hasinfo = false, ...props }) => {
const { title, description = '' } = props
return (
<StyledToggleButton {...props}>
<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

@@ -1,32 +1,32 @@
import { ToggleButton, ToggleButtonProps, styled } from '@mui/material'
export const StyledToggleButton = styled((props: ToggleButtonProps) => (
<ToggleButton classes={{ selected: 'selected' }} {...props} />
<ToggleButton classes={{ selected: 'selected' }} {...props} />
))(({ theme }) => ({
'&:is(&, :hover, :active)': {
background: theme.palette.backgroundSecondary.default,
},
color: theme.palette.text.primary,
flex: '1 0 6.25rem',
height: '100px',
borderRadius: '1rem',
border: `2px solid transparent !important`,
'&.selected': {
border: `2px solid ${theme.palette.text.primary} !important`,
},
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
justifyContent: 'flex-start',
textTransform: 'initial',
'& .description': {
display: 'inline-block',
textAlign: 'left',
lineHeight: '15px',
margin: '0.5rem 0 0.25rem',
},
'& .info': {
fontSize: '10px',
fontWeight: 500,
},
'&:is(&, :hover, :active)': {
background: theme.palette.backgroundSecondary.default,
},
color: theme.palette.text.primary,
flex: '1 0 6.25rem',
height: '100px',
borderRadius: '1rem',
border: `2px solid transparent !important`,
'&.selected': {
border: `2px solid ${theme.palette.text.primary} !important`,
},
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
justifyContent: 'flex-start',
textTransform: 'initial',
'& .description': {
display: 'inline-block',
textAlign: 'left',
lineHeight: '15px',
margin: '0.5rem 0 0.25rem',
},
'& .info': {
fontSize: '10px',
fontWeight: 500,
},
}))

View File

@@ -1,224 +1,166 @@
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { Modal } from '@/shared/Modal/Modal'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { call, getShortenNpub, getSignReqKind } from '@/utils/helpers/helpers'
import {
Avatar,
Box,
List,
ListItem,
ListItemIcon,
ListItemText,
Stack,
Typography,
} from '@mui/material'
import { call, getAppIconTitle, getReqActionName, getShortenNpub } 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 } from '@/store'
import { selectAppsByNpub, selectKeys } 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 } 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 {
ALWAYS = 'ALWAYS',
ONCE = 'ONCE',
ALLOW_ALL = 'ALLOW_ALL',
ALWAYS = 'ALWAYS',
ONCE = 'ONCE',
ALLOW_ALL = 'ALLOW_ALL',
}
const ACTION_LABELS = {
[ACTION_TYPE.ALWAYS]: 'Always',
[ACTION_TYPE.ONCE]: 'Just Once',
[ACTION_TYPE.ALLOW_ALL]: 'All Advanced Actions',
[ACTION_TYPE.ALWAYS]: 'Always',
[ACTION_TYPE.ONCE]: 'Just Once',
[ACTION_TYPE.ALLOW_ALL]: 'All Advanced Actions',
}
type ModalConfirmEventProps = {
confirmEventReqs: IPendingsByAppNpub
confirmEventReqs: IPendingsByAppNpub
}
type PendingRequest = DbPending & { checked: boolean }
export const ModalConfirmEvent: FC<ModalConfirmEventProps> = ({
confirmEventReqs,
}) => {
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.CONFIRM_EVENT)
const [searchParams] = useSearchParams()
export const ModalConfirmEvent: FC<ModalConfirmEventProps> = ({ confirmEventReqs }) => {
const keys = useAppSelector(selectKeys)
const appNpub = searchParams.get('appNpub') || ''
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.CONFIRM_EVENT)
const [searchParams] = useSearchParams()
const { npub = '' } = useParams<{ npub: string }>()
const apps = useAppSelector((state) => selectAppsByNpub(state, npub))
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 [pendingRequests, setPendingRequests] = useState<PendingRequest[]>([])
const [selectedActionType, setSelectedActionType] = useState<ACTION_TYPE>(ACTION_TYPE.ALWAYS)
const [pendingRequests, setPendingRequests] = useState<PendingRequest[]>([])
const currentAppPendingReqs = useMemo(
() => confirmEventReqs[appNpub]?.pending || [],
[confirmEventReqs, appNpub],
)
const currentAppPendingReqs = useMemo(() => confirmEventReqs[appNpub]?.pending || [], [confirmEventReqs, appNpub])
useEffect(() => {
setPendingRequests(
currentAppPendingReqs.map((pr) => ({ ...pr, checked: true })),
)
}, [currentAppPendingReqs])
useEffect(() => {
setPendingRequests(currentAppPendingReqs.map((pr) => ({ ...pr, checked: true })))
}, [currentAppPendingReqs])
const triggerApp = apps.find((app) => app.appNpub === appNpub)
const { name, icon = '' } = triggerApp || {}
const appName = name || getShortenNpub(appNpub)
const closeModalAfterRequest = createHandleCloseReplace(MODAL_PARAMS_KEYS.CONFIRM_EVENT, {
onClose: (sp) => {
sp.delete('appNpub')
sp.delete('reqId')
},
})
const handleActionTypeChange = (_: any, value: ACTION_TYPE | null) => {
if (!value) return undefined
return setSelectedActionType(value)
}
const isNpubExists = npub.trim().length && keys.some((key) => key.npub === npub)
const isAppNpubExists = appNpub.trim().length && apps.some((app) => app.appNpub === appNpub)
const selectedPendingRequests = pendingRequests.filter((pr) => pr.checked)
if (isModalOpened && (!isNpubExists || !isAppNpubExists)) {
closeModalAfterRequest()
return null
}
const handleCloseModal = createHandleCloseReplace(
MODAL_PARAMS_KEYS.CONFIRM_EVENT,
{
onClose: (sp) => {
sp.delete('appNpub')
sp.delete('reqId')
selectedPendingRequests.forEach(
async (req) => await swicCall('confirm', req.id, false, false),
)
}
}
)
const triggerApp = apps.find((app) => app.appNpub === appNpub)
const { name, icon = '' } = triggerApp || {}
const appName = name || getShortenNpub(appNpub)
const appAvatarTitle = getAppIconTitle(name, appNpub)
const closeModalAfterRequest = createHandleCloseReplace(
MODAL_PARAMS_KEYS.CONFIRM_EVENT,
{
onClose: (sp) => {
sp.delete('appNpub')
sp.delete('reqId')
}
}
)
const handleActionTypeChange = (_: any, value: ACTION_TYPE | null) => {
if (!value) return undefined
return setSelectedActionType(value)
}
async function confirmPending(allow: boolean) {
selectedPendingRequests.forEach((req) => {
call(async () => {
const remember = selectedActionType !== ACTION_TYPE.ONCE
await swicCall('confirm', req.id, allow, remember)
console.log('confirmed', req.id, selectedActionType, allow)
})
})
closeModalAfterRequest()
}
const selectedPendingRequests = pendingRequests.filter((pr) => pr.checked)
const handleChangeCheckbox = (reqId: string) => () => {
const newPendingRequests = pendingRequests.map((req) => {
if (req.id === reqId) return { ...req, checked: !req.checked }
return req
})
setPendingRequests(newPendingRequests)
}
async function confirmPending(allow: boolean) {
selectedPendingRequests.forEach((req) => {
call(async () => {
const remember = selectedActionType !== ACTION_TYPE.ONCE
await swicCall('confirm', req.id, allow, remember)
console.log('confirmed', req.id, selectedActionType, allow)
})
})
closeModalAfterRequest()
if (isPopup) window.close()
}
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
}
const handleChangeCheckbox = (reqId: string) => () => {
const newPendingRequests = pendingRequests.map((req) => {
if (req.id === reqId) return { ...req, checked: !req.checked }
return req
})
setPendingRequests(newPendingRequests)
}
return (
<Modal open={isModalOpened} onClose={handleCloseModal}>
<Stack gap={'1rem'} paddingTop={'1rem'}>
<Stack
direction={'row'}
gap={'1rem'}
alignItems={'center'}
marginBottom={'1rem'}
>
<Avatar
variant='square'
sx={{
width: 56,
height: 56,
borderRadius: '12px',
}}
src={icon}
/>
<Box>
<Typography variant='h5' fontWeight={600}>
{appName}
</Typography>
<Typography variant='body2' color={'GrayText'}>
Would like your permission to
</Typography>
</Box>
</Stack>
if (isPopup) {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
confirmPending(false)
}
})
}
<StyledActionsListContainer marginBottom={'1rem'}>
<SectionTitle>Actions</SectionTitle>
<List>
{pendingRequests.map((req) => {
return (
<ListItem key={req.id}>
<ListItemIcon>
<Checkbox
checked={req.checked}
onChange={handleChangeCheckbox(
req.id,
)}
/>
</ListItemIcon>
<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'
/>
{/* <ActionToggleButton
value={ACTION_TYPE.ALLOW_ALL}
title='Allow All Advanced Actions'
hasinfo
/> */}
</StyledToggleButtonsGroup>
return (
<Modal title="Permission request" open={isModalOpened} withCloseButton={false}>
<Stack gap={'1rem'} paddingTop={'1rem'}>
<Stack direction={'row'} gap={'1rem'} alignItems={'center'} marginBottom={'1rem'}>
<Avatar
variant="square"
sx={{
width: 56,
height: 56,
borderRadius: '12px',
}}
src={icon}
>
{appAvatarTitle}
</Avatar>
<Box>
<Typography variant="h5" fontWeight={600}>
{appName}
</Typography>
<Typography variant="body2" color={'GrayText'}>
App wants to perform these actions
</Typography>
</Box>
</Stack>
<Stack direction={'row'} gap={'1rem'}>
<StyledButton
onClick={() => confirmPending(false)}
varianttype='secondary'
>
Disallow {ACTION_LABELS[selectedActionType]}
</StyledButton>
<StyledButton onClick={() => confirmPending(true)}>
Allow {ACTION_LABELS[selectedActionType]}
</StyledButton>
</Stack>
</Stack>
</Modal>
)
<StyledActionsListContainer marginBottom={'1rem'}>
<SectionTitle>Actions</SectionTitle>
<List>
{pendingRequests.map((req) => {
return (
<ListItem key={req.id}>
<ListItemIcon>
<Checkbox checked={req.checked} onChange={handleChangeCheckbox(req.id)} />
</ListItemIcon>
<ListItemText>{getReqActionName(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>
<Stack direction={'row'} gap={'1rem'}>
<StyledButton onClick={() => confirmPending(false)} varianttype="secondary">
Disallow {ACTION_LABELS[selectedActionType]}
</StyledButton>
<StyledButton onClick={() => confirmPending(true)}>Allow {ACTION_LABELS[selectedActionType]}</StyledButton>
</Stack>
</Stack>
</Modal>
)
}

View File

@@ -1,42 +1,31 @@
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} />
))(() => ({
borderRadius: '19px',
fontWeight: 600,
padding: '0.75rem 1rem',
maxHeight: '41px',
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} />,
)(() => ({
gap: '0.75rem',
marginBottom: '1rem',
justifyContent: 'space-between',
'&.MuiToggleButtonGroup-root .MuiToggleButtonGroup-grouped:not(:first-of-type)':
{
margin: '0',
border: 'initial',
},
'&.MuiToggleButtonGroup-root .MuiToggleButtonGroup-grouped': {
border: 'initial',
borderRadius: '1rem',
},
}))
export const StyledToggleButtonsGroup = styled((props: ToggleButtonGroupProps) => <ToggleButtonGroup {...props} />)(
() => ({
gap: '0.75rem',
marginBottom: '1rem',
justifyContent: 'space-between',
'&.MuiToggleButtonGroup-root .MuiToggleButtonGroup-grouped:not(:first-of-type)': {
margin: '0',
border: 'initial',
},
'&.MuiToggleButtonGroup-root .MuiToggleButtonGroup-grouped': {
border: 'initial',
borderRadius: '1rem',
},
})
)
export const StyledActionsListContainer = styled((props: StackProps) => (
<Stack {...props} />
))(({ theme }) => ({
padding: '0.75rem',
background: theme.palette.backgroundSecondary.default,
borderRadius: '1rem',
export const StyledActionsListContainer = styled((props: StackProps) => <Stack {...props} />)(({ theme }) => ({
padding: '0.75rem',
background: theme.palette.backgroundSecondary.default,
borderRadius: '1rem',
}))

View File

@@ -3,22 +3,19 @@ import { ToggleButtonProps, Typography } from '@mui/material'
import { StyledToggleButton } from './styled'
type ActionToggleButtonProps = ToggleButtonProps & {
hasinfo?: boolean
hasinfo?: boolean
}
export const ActionToggleButton: FC<ActionToggleButtonProps> = ({
hasinfo = false,
...props
}) => {
const { title } = props
return (
<StyledToggleButton {...props}>
<Typography variant='body2'>{title}</Typography>
{hasinfo && (
<Typography className='info' color={'GrayText'}>
Info
</Typography>
)}
</StyledToggleButton>
)
export const ActionToggleButton: FC<ActionToggleButtonProps> = ({ hasinfo = false, ...props }) => {
const { title } = props
return (
<StyledToggleButton {...props}>
<Typography variant="body2">{title}</Typography>
{hasinfo && (
<Typography className="info" color={'GrayText'}>
Info
</Typography>
)}
</StyledToggleButton>
)
}

View File

@@ -1,33 +1,33 @@
import { ToggleButton, ToggleButtonProps, styled } from '@mui/material'
export const StyledToggleButton = styled((props: ToggleButtonProps) => (
<ToggleButton classes={{ selected: 'selected' }} {...props} />
<ToggleButton classes={{ selected: 'selected' }} {...props} />
))(({ theme }) => ({
'&:is(&, :hover, :active)': {
background: theme.palette.backgroundSecondary.default,
},
color: theme.palette.text.primary,
flex: '1 0 6.25rem',
height: '100px',
borderRadius: '1rem',
border: `2px solid transparent !important`,
'&.selected': {
border: `2px solid ${theme.palette.text.primary} !important`,
},
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
justifyContent: 'flex-start',
textTransform: 'initial',
textAlign: 'left',
'& .description': {
display: 'inline-block',
textAlign: 'left',
lineHeight: '15px',
margin: '0.5rem 0 0.25rem',
},
'& .info': {
fontSize: '10px',
fontWeight: 500,
},
'&:is(&, :hover, :active)': {
background: theme.palette.backgroundSecondary.default,
},
color: theme.palette.text.primary,
flex: '1 0 6.25rem',
height: '100px',
borderRadius: '1rem',
border: `2px solid transparent !important`,
'&.selected': {
border: `2px solid ${theme.palette.text.primary} !important`,
},
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
justifyContent: 'flex-start',
textTransform: 'initial',
textAlign: 'left',
'& .description': {
display: 'inline-block',
textAlign: 'left',
lineHeight: '15px',
margin: '0.5rem 0 0.25rem',
},
'& .info': {
fontSize: '10px',
fontWeight: 500,
},
}))

View File

@@ -5,88 +5,81 @@ 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 { MODAL_PARAMS_KEYS } from '@/types/modal'
import { selectKeys } from '@/store'
import { useAppSelector } from '@/store/hooks/redux'
import { EXPLANATION_MODAL_KEYS, MODAL_PARAMS_KEYS } from '@/types/modal'
import { getBunkerLink } from '@/utils/helpers/helpers'
import { Stack, Typography } from '@mui/material'
import { useRef } from 'react'
import { useParams } from 'react-router-dom'
export const ModalConnectApp = () => {
const { getModalOpened, createHandleCloseReplace, handleOpen } = useModalSearchParams()
const timerRef = useRef<NodeJS.Timeout>()
const keys = useAppSelector(selectKeys)
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.CONNECT_APP)
const handleCloseModal = createHandleCloseReplace(
MODAL_PARAMS_KEYS.CONNECT_APP,
{
onClose: () => {
clearTimeout(timerRef.current)
}
}
)
const timerRef = useRef<NodeJS.Timeout>()
const notify = useEnqueueSnackbar()
const { npub = '' } = useParams<{ npub: string }>()
const bunkerStr = getBunkerLink(npub)
const notify = useEnqueueSnackbar()
const { getModalOpened, createHandleCloseReplace, handleOpen } = useModalSearchParams()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.CONNECT_APP)
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.CONNECT_APP, {
onClose: () => {
clearTimeout(timerRef.current)
},
})
const { npub = '' } = useParams<{ npub: string }>()
const isNpubExists = npub.trim().length && keys.some((key) => key.npub === npub)
if (isModalOpened && !isNpubExists) {
handleCloseModal()
return null
}
const bunkerStr = getBunkerLink(npub)
const handleShareBunker = async () => {
const shareData = {
text: bunkerStr,
}
try {
if (navigator.share && navigator.canShare(shareData)) {
await navigator.share(shareData)
} else {
navigator.clipboard.writeText(bunkerStr)
}
} catch (err) {
console.log(err)
notify('Your browser does not support sharing data', 'warning')
}
}
const handleShareBunker = async () => {
const shareData = {
text: bunkerStr,
}
try {
if (navigator.share && navigator.canShare(shareData)) {
await navigator.share(shareData)
} else {
navigator.clipboard.writeText(bunkerStr)
}
} catch (err) {
console.log(err)
notify('Your browser does not support sharing data', 'warning')
}
}
const handleCopy = () => {
timerRef.current = setTimeout(() => {
handleCloseModal()
}, 3000)
}
const handleCopy = () => {
timerRef.current = setTimeout(() => {
handleCloseModal()
}, 3000)
}
return (
<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>
<Input
sx={{
gap: '0.5rem',
}}
fullWidth
value={bunkerStr}
endAdornment={
<InputCopyButton
value={bunkerStr}
onCopy={handleCopy}
/>
}
/>
<AppLink
title='What is this?'
onClick={() => handleOpen(MODAL_PARAMS_KEYS.EXPLANATION)}
/>
<Button fullWidth onClick={handleShareBunker}>
Share it
</Button>
<Button fullWidth onClick={handleCloseModal}>
Done
</Button>
</Stack>
</Modal>
)
return (
<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>
<Input
sx={{
gap: '0.5rem',
}}
fullWidth
value={bunkerStr}
endAdornment={<InputCopyButton value={bunkerStr} onCopy={handleCopy} />}
/>
<AppLink
title="What is this?"
onClick={() => handleOpen(MODAL_PARAMS_KEYS.EXPLANATION, { search: { type: EXPLANATION_MODAL_KEYS.BUNKER } })}
/>
<Button fullWidth onClick={handleShareBunker}>
Share it
</Button>
<Button fullWidth onClick={handleCloseModal}>
Done
</Button>
</Stack>
</Modal>
)
}

View File

@@ -1,45 +1,98 @@
import { FC } from 'react'
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { Modal } from '@/shared/Modal/Modal'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { EXPLANATION_MODAL_KEYS, MODAL_PARAMS_KEYS } from '@/types/modal'
import { Stack, Typography } from '@mui/material'
import { Button } from '@/shared/Button/Button'
import { useSearchParams } from 'react-router-dom'
type ModalExplanationProps = {
explanationText?: string
explanationText?: string
}
export const ModalExplanation: FC<ModalExplanationProps> = ({
explanationText = '',
}) => {
const { getModalOpened } = useModalSearchParams()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.EXPLANATION)
const [searchParams, setSearchParams] = useSearchParams()
export const ModalExplanation: FC<ModalExplanationProps> = () => {
const { getModalOpened } = useModalSearchParams()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.EXPLANATION)
const [searchParams, setSearchParams] = useSearchParams()
const handleCloseModal = () => {
searchParams.delete('type')
searchParams.delete(MODAL_PARAMS_KEYS.EXPLANATION)
setSearchParams(searchParams)
}
const handleCloseModal = () => {
searchParams.delete('type')
searchParams.delete(MODAL_PARAMS_KEYS.EXPLANATION)
setSearchParams(searchParams, { replace: true })
}
return (
<Modal
title='What is this?'
open={isModalOpened}
onClose={handleCloseModal}
PaperProps={{
sx: {
minHeight: '60%',
},
}}
>
<Stack height={'100%'}>
<Typography flex={1}>{explanationText}</Typography>
<Button fullWidth onClick={handleCloseModal}>
Got it!
</Button>
</Stack>
</Modal>
)
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}
open={isModalOpened}
onClose={handleCloseModal}
withCloseButton={false}
PaperProps={{
sx: {
minHeight: '60%',
},
}}
>
<Stack height={'100%'} gap={2}>
<Typography flex={1}>{explanationText}</Typography>
<Button fullWidth onClick={handleCloseModal}>
Got it!
</Button>
</Stack>
</Modal>
)
}

View File

@@ -5,61 +5,199 @@ 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 } from '@mui/material'
import React, { ChangeEvent, FormEvent, useState } from 'react'
import { CircularProgress, Stack, Typography, useTheme } from '@mui/material'
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'
const FORM_DEFAULT_VALUES = {
username: '',
nsec: '',
}
export const ModalImportKeys = () => {
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.IMPORT_KEYS)
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.IMPORT_KEYS)
const { getModalOpened, createHandleCloseReplace } = 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 notify = useEnqueueSnackbar()
const navigate = useNavigate()
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 [enteredNsec, setEnteredNsec] = useState('')
const checkIsUsernameAvailable = useCallback(async () => {
if (!debouncedUsername.trim().length) return undefined
const npubNip05 = await fetchNip05(`${debouncedUsername}@${DOMAIN}`)
setNameNpub(npubNip05 || '')
}, [debouncedUsername])
const handleNsecChange = (e: ChangeEvent<HTMLInputElement>) => {
setEnteredNsec(e.target.value)
}
useEffect(() => {
checkIsUsernameAvailable()
}, [checkIsUsernameAvailable])
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
try {
if (!enteredNsec.trim().length) return
const k: any = await swicCall('importKey', enteredNsec)
notify('Key imported!', 'success')
navigate(`/key/${k.npub}`)
} catch (error: any) {
notify(error.message, 'error')
}
}
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
}
}, [debouncedNsec])
return (
<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>
</Stack>
<Input
label='Enter a NSEC'
placeholder='Your NSEC'
value={enteredNsec}
onChange={handleNsecChange}
fullWidth
type='password'
/>
<Button type='submit'>Import nsec</Button>
</Stack>
</Modal>
)
useEffect(() => {
checkNsecUsername()
}, [checkNsecUsername])
const cleanUpStates = useCallback(() => {
hidePassword()
reset()
setIsLoading(false)
setNameNpub('')
setIsTakenByNsec(false)
setIsBadNsec(false)
}, [reset, hidePassword])
const notify = useEnqueueSnackbar()
const navigate = useNavigate()
const submitHandler = async (values: FormInputType) => {
if (isLoading) return undefined
try {
const { nsec, username } = values
if (!nsec || !username) throw new Error("Enter username and nsec")
if (nameNpub && !isTakenByNsec) throw new Error("Name taken")
setIsLoading(true)
const k: any = await swicCall('importKey', username, nsec)
notify('Key imported!', 'success')
navigate(`/key/${k.npub}`)
cleanUpStates()
} catch (error: any) {
notify(error?.message || 'Something went wrong!', 'error')
cleanUpStates()
}
}
useEffect(() => {
return () => {
isModalOpened && cleanUpStates()
}
}, [isModalOpened, cleanUpStates])
const getNameHelperText = () => {
if (!enteredUsername) return "Don't worry, username can be changed later."
if (isTakenByNsec) return 'Name matches your key'
if (isBadNsec) return 'Invalid nsec'
if (nameNpub) return 'Already taken'
return (
<>
<CheckmarkIcon /> Available
</>
)
}
const getNsecHelperText = () => {
if (isBadNsec) return 'Invalid nsec'
return 'Keys stay on your device.'
}
const nameHelperText = getNameHelperText()
const nsecHelperText = getNsecHelperText()
return (
<Modal open={isModalOpened} onClose={handleCloseModal}>
<Stack gap={'1rem'} component={'form'} onSubmit={handleSubmit(submitHandler)}>
<Stack direction={'row'} gap={'1rem'} alignItems={'center'} alignSelf={'flex-start'}>
<StyledAppLogo />
<Typography fontWeight={600} variant="h5">
Import key
</Typography>
</Stack>
<Input
label="Choose a username"
fullWidth
placeholder="Enter a Username"
endAdornment={<Typography color={'#FFFFFFA8'}>@{DOMAIN}</Typography>}
{...register('username')}
error={!!errors.username}
helperText={nameHelperText}
helperTextProps={{
sx: {
'&.helper_text': {
color:
enteredUsername && (isTakenByNsec || !nameNpub)
? theme.palette.success.main
: enteredUsername && nameNpub
? theme.palette.error.main
: theme.palette.textSecondaryDecorate.main,
},
},
}}
/>
<Input
label="Paste your private key"
placeholder="nsec1..."
fullWidth
{...register('nsec')}
error={!!errors.nsec}
{...inputProps}
helperText={nsecHelperText}
helperTextProps={{
sx: {
'&.helper_text': {
color: isBadNsec
? theme.palette.error.main
: theme.palette.textSecondaryDecorate.main,
},
},
}}
/>
<Button type="submit" disabled={isLoading}>
Import key {isLoading && <CircularProgress sx={{ marginLeft: '0.5rem' }} size={'1rem'} />}
</Button>
</Stack>
</Modal>
)
}

View File

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

View File

@@ -2,13 +2,13 @@ import { AppLogo } from '@/assets'
import { Box, styled } from '@mui/material'
export const StyledAppLogo = styled((props) => (
<Box {...props}>
<AppLogo />
</Box>
))({
background: '#00000054',
padding: '0.75rem',
borderRadius: '16px',
display: 'grid',
placeItems: 'center',
})
<Box {...props}>
<AppLogo />
</Box>
))(() => ({
background: '#0d0d0d',
padding: '0.75rem',
borderRadius: '16px',
display: 'grid',
placeItems: 'center',
}))

View File

@@ -7,52 +7,38 @@ import { Fade, Stack } from '@mui/material'
import { AppLink } from '@/shared/AppLink/AppLink'
export const ModalInitial = () => {
const { getModalOpened, createHandleCloseReplace, handleOpen } = useModalSearchParams()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.INITIAL)
const { getModalOpened, createHandleCloseReplace, handleOpen } = useModalSearchParams()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.INITIAL)
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.INITIAL)
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.INITIAL)
const [showAdvancedContent, setShowAdvancedContent] = useState(false)
const [showAdvancedContent, setShowAdvancedContent] = useState(false)
const handleShowAdvanced = () => {
setShowAdvancedContent(true)
}
const handleShowAdvanced = () => {
setShowAdvancedContent(true)
}
useEffect(() => {
return () => {
if (isModalOpened) {
setShowAdvancedContent(false)
}
}
}, [isModalOpened])
useEffect(() => {
return () => {
if (isModalOpened) {
setShowAdvancedContent(false)
}
}
}, [isModalOpened])
return (
<Modal open={isModalOpened} onClose={handleCloseModal}>
<Stack paddingTop={'0.5rem'} gap={'1rem'}>
<Button onClick={() => handleOpen(MODAL_PARAMS_KEYS.SIGN_UP)}>
Sign up
</Button>
<Button onClick={() => handleOpen(MODAL_PARAMS_KEYS.LOGIN)}>
Login
</Button>
<AppLink
title='Advanced'
alignSelf={'center'}
onClick={handleShowAdvanced}
/>
return (
<Modal open={isModalOpened} onClose={handleCloseModal}>
<Stack paddingTop={'0.5rem'} gap={'1rem'}>
<Button onClick={() => handleOpen(MODAL_PARAMS_KEYS.SIGN_UP)}>Sign up</Button>
<Button onClick={() => handleOpen(MODAL_PARAMS_KEYS.LOGIN)}>Login</Button>
<AppLink title="Advanced" alignSelf={'center'} onClick={handleShowAdvanced} />
{showAdvancedContent && (
<Fade in>
<Button
onClick={() =>
handleOpen(MODAL_PARAMS_KEYS.IMPORT_KEYS)
}
>
Import keys
</Button>
</Fade>
)}
</Stack>
</Modal>
)
{showAdvancedContent && (
<Fade in>
<Button onClick={() => handleOpen(MODAL_PARAMS_KEYS.IMPORT_KEYS)}>Import key</Button>
</Fade>
)}
</Stack>
</Modal>
)
}

View File

@@ -1,115 +1,128 @@
import { 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 { IconButton, Stack, Typography } from '@mui/material'
import React, { ChangeEvent, useState } from 'react'
import { CircularProgress, 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 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 } from '@/utils/helpers/helpers'
import { usePassword } from '@/hooks/usePassword'
import { dbi } from '@/modules/db'
const FORM_DEFAULT_VALUES = {
username: '',
password: '',
}
export const ModalLogin = () => {
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.LOGIN)
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.LOGIN)
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.LOGIN)
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.LOGIN)
const notify = useEnqueueSnackbar()
const notify = useEnqueueSnackbar()
const navigate = useNavigate()
const { hidePassword, inputProps } = usePassword()
const [isLoading, setIsLoading] = useState(false)
const navigate = useNavigate()
const {
handleSubmit,
reset,
register,
formState: { errors },
} = useForm<FormInputType>({
defaultValues: FORM_DEFAULT_VALUES,
resolver: yupResolver(schema),
mode: 'onSubmit',
})
const [enteredUsername, setEnteredUsername] = useState('')
const [enteredPassword, setEnteredPassword] = useState('')
const [isPasswordShown, setIsPasswordShown] = useState(false)
const cleanUpStates = useCallback(() => {
hidePassword()
reset()
setIsLoading(false)
}, [reset, hidePassword])
const handleUsernameChange = (e: ChangeEvent<HTMLInputElement>) => {
setEnteredUsername(e.target.value)
}
const submitHandler = async (values: FormInputType) => {
if (isLoading) return undefined
const handlePasswordChange = (e: ChangeEvent<HTMLInputElement>) => {
setEnteredPassword(e.target.value)
}
try {
setIsLoading(true)
let npub = values.username
let name = ''
const handlePasswordTypeChange = () =>
setIsPasswordShown((prevState) => !prevState)
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 passphrase = values.password
const isFormValid =
enteredUsername.trim().length > 0 && enteredPassword.trim().length > 0
console.log('fetch', npub, name)
const k: any = await swicCall('fetchKey', npub, passphrase, name)
notify(`Fetched ${k.npub}`, 'success')
dbi.addSynced(k.npub)
cleanUpStates()
navigate(`/key/${k.npub}`)
} catch (error: any) {
console.log('error', error)
notify(error?.message || 'Something went wrong!', 'error')
setIsLoading(false)
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!isFormValid) return undefined
try {
const [username, domain] = enteredUsername.split('@')
const response = await fetch(
`https://${domain}/.well-known/nostr.json?name=${username}`,
)
const getNpub: {
names: {
[name: string]: string
}
} = await response.json()
useEffect(() => {
return () => {
if (isModalOpened) {
// modal closed
cleanUpStates()
}
}
}, [isModalOpened, cleanUpStates])
const pubkey = getNpub.names[username]
const npub = nip19.npubEncode(pubkey)
const passphrase = enteredPassword
console.log('fetch', npub, passphrase)
const k: any = await swicCall('fetchKey', npub, passphrase)
notify(`Fetched ${k.npub}`, 'success')
navigate(`/key/${k.npub}`)
} catch (error: any) {
notify(error.message, 'error')
}
}
return (
<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'>
Login
</Typography>
</Stack>
<Input
label='Enter a Username'
fullWidth
placeholder='user@nsec.app'
onChange={handleUsernameChange}
value={enteredUsername}
/>
<Input
label='Password'
fullWidth
placeholder='Your password'
onChange={handlePasswordChange}
value={enteredPassword}
endAdornment={
<IconButton
size='small'
onClick={handlePasswordTypeChange}
>
{isPasswordShown ? (
<VisibilityOffOutlinedIcon />
) : (
<VisibilityOutlinedIcon />
)}
</IconButton>
}
type={isPasswordShown ? 'text' : 'password'}
/>
<Button type='submit' fullWidth disabled={!isFormValid}>
Login
</Button>
</Stack>
</Modal>
)
return (
<Modal open={isModalOpened} onClose={handleCloseModal}>
<Stack gap={'1rem'} component={'form'} onSubmit={handleSubmit(submitHandler)}>
<Stack direction={'row'} gap={'1rem'} alignItems={'center'} alignSelf={'flex-start'}>
<StyledAppLogo />
<Typography fontWeight={600} variant="h5">
Login
</Typography>
</Stack>
<Input
label="Username or nip05 or npub"
fullWidth
placeholder="name or name@domain.com or npub1..."
{...register('username')}
error={!!errors.username}
/>
<Input
label="Password"
fullWidth
placeholder="Your password"
{...register('password')}
{...inputProps}
error={!!errors.password}
/>
<Button type="submit" fullWidth disabled={isLoading}>
Add account {isLoading && <CircularProgress sx={{ marginLeft: '0.5rem' }} size={'1rem'} />}
</Button>
</Stack>
</Modal>
)
}

View File

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

View File

@@ -2,13 +2,13 @@ import { AppLogo } from '@/assets'
import { Box, styled } from '@mui/material'
export const StyledAppLogo = styled((props) => (
<Box {...props}>
<AppLogo />
</Box>
<Box {...props}>
<AppLogo />
</Box>
))({
background: '#00000054',
padding: '0.75rem',
borderRadius: '16px',
display: 'grid',
placeItems: 'center',
background: '#0d0d0d',
padding: '0.75rem',
borderRadius: '16px',
display: 'grid',
placeItems: 'center',
})

View File

@@ -2,169 +2,159 @@ import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { Button } from '@/shared/Button/Button'
import { Modal } from '@/shared/Modal/Modal'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
import {
Box,
CircularProgress,
IconButton,
Stack,
Typography,
} from '@mui/material'
import {
StyledButton,
StyledSettingContainer,
StyledSynchedText,
} from './styled'
import { Box, CircularProgress, 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'
type ModalSettingsProps = {
isSynced: boolean
isSynced: boolean
}
export const ModalSettings: FC<ModalSettingsProps> = ({ isSynced }) => {
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
const { npub = '' } = useParams<{ npub: string }>()
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
const { npub = '' } = useParams<{ npub: string }>()
const keys = useAppSelector(selectKeys)
const notify = useEnqueueSnackbar()
const notify = useEnqueueSnackbar()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.SETTINGS)
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.SETTINGS)
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.SETTINGS)
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.SETTINGS)
const [enteredPassword, setEnteredPassword] = useState('')
const [isPasswordShown, setIsPasswordShown] = useState(false)
const [isPasswordInvalid, setIsPasswordInvalid] = useState(false)
const { hidePassword, inputProps } = usePassword()
const [isChecked, setIsChecked] = useState(false)
const [enteredPassword, setEnteredPassword] = useState('')
const [isPasswordInvalid, setIsPasswordInvalid] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [isChecked, setIsChecked] = useState(false)
const [isLoading, setIsLoading] = useState(false)
useEffect(() => setIsChecked(isSynced), [isModalOpened, isSynced])
useEffect(() => setIsChecked(isSynced), [isModalOpened, isSynced])
useEffect(() => {
return () => {
if (isModalOpened) {
// modal closed
hidePassword()
}
}
}, [hidePassword, isModalOpened])
const handlePasswordChange = (e: ChangeEvent<HTMLInputElement>) => {
setIsPasswordInvalid(false)
setEnteredPassword(e.target.value)
}
const isNpubExists = npub.trim().length && keys.some((key) => key.npub === npub)
const handlePasswordTypeChange = () =>
setIsPasswordShown((prevState) => !prevState)
if (isModalOpened && !isNpubExists) {
handleCloseModal()
return null
}
const onClose = () => {
handleCloseModal()
setEnteredPassword('')
setIsPasswordInvalid(false)
}
const handlePasswordChange = (e: ChangeEvent<HTMLInputElement>) => {
const password = e.target.value
setIsPasswordInvalid(!!password && !isValidPassphase(password))
setEnteredPassword(password)
}
const handleChangeCheckbox = (e: unknown, checked: boolean) => {
setIsChecked(checked)
}
const onClose = () => {
handleCloseModal()
setEnteredPassword('')
setIsPasswordInvalid(false)
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsPasswordInvalid(false)
const handleChangeCheckbox = (e: unknown, checked: boolean) => {
setIsChecked(checked)
}
if (enteredPassword.trim().length < 6) {
return setIsPasswordInvalid(true)
}
try {
setIsLoading(true)
await swicCall('saveKey', npub, enteredPassword)
notify('Key saved', 'success')
dbi.addSynced(npub) // Sync npub
setEnteredPassword('')
setIsPasswordInvalid(false)
setIsLoading(false)
} catch (error) {
setIsPasswordInvalid(false)
setIsLoading(false)
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsPasswordInvalid(false)
return (
<Modal open={isModalOpened} onClose={onClose} title='Settings'>
<Stack gap={'1rem'}>
<StyledSettingContainer onSubmit={handleSubmit}>
<Stack direction={'row'} justifyContent={'space-between'}>
<SectionTitle>Cloud sync</SectionTitle>
{isSynced && (
<StyledSynchedText>
<CheckmarkIcon /> Synched
</StyledSynchedText>
)}
</Stack>
<Box>
<Checkbox
onChange={handleChangeCheckbox}
checked={isChecked}
/>
<Typography variant='caption'>
Use this key on multiple devices
</Typography>
</Box>
<Input
fullWidth
endAdornment={
<IconButton
size='small'
onClick={handlePasswordTypeChange}
>
{isPasswordShown ? (
<VisibilityOffOutlinedIcon htmlColor='#6b6b6b' />
) : (
<VisibilityOutlinedIcon htmlColor='#6b6b6b' />
)}
</IconButton>
}
type={isPasswordShown ? 'text' : 'password'}
onChange={handlePasswordChange}
value={enteredPassword}
helperText={
isPasswordInvalid ? 'Invalid password' : ''
}
placeholder='Enter a password'
helperTextProps={{
sx: {
'&.helper_text': {
color: 'red',
},
},
}}
disabled={!isChecked}
/>
{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>
)}
<StyledButton
type='submit'
fullWidth
disabled={!isChecked}
>
Sync{' '}
{isLoading && (
<CircularProgress
sx={{ marginLeft: '0.5rem' }}
size={'1rem'}
/>
)}
</StyledButton>
</StyledSettingContainer>
<Button onClick={onClose}>Done</Button>
</Stack>
</Modal>
)
if (!isValidPassphase(enteredPassword)) {
return setIsPasswordInvalid(true)
}
try {
setIsLoading(true)
await swicCall('saveKey', npub, enteredPassword)
notify('Key saved', 'success')
dbi.addSynced(npub) // Sync npub
setEnteredPassword('')
setIsPasswordInvalid(false)
setIsLoading(false)
} catch (error) {
setIsPasswordInvalid(false)
setIsLoading(false)
}
}
return (
<Modal open={isModalOpened} onClose={onClose} title="Settings">
<Stack gap={'1rem'}>
<StyledSettingContainer onSubmit={handleSubmit}>
<Stack direction={'row'} justifyContent={'space-between'}>
<SectionTitle>Cloud sync</SectionTitle>
{isSynced && (
<StyledSynchedText>
<CheckmarkIcon /> Synched
</StyledSynchedText>
)}
</Stack>
<Box>
<Checkbox onChange={handleChangeCheckbox} checked={isChecked} />
<Typography variant="caption">Use this key on multiple devices</Typography>
</Box>
<Input
fullWidth
{...inputProps}
onChange={handlePasswordChange}
value={enteredPassword}
// 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'}>
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>
)}
<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,37 +1,31 @@
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} />
<Stack gap={'0.75rem'} component={'form'} {...props} />
))(({ theme }) => ({
padding: '1rem',
borderRadius: '1rem',
background: theme.palette.background.default,
color: theme.palette.text.primary,
padding: '1rem',
borderRadius: '1rem',
background: theme.palette.background.default,
color: theme.palette.text.primary,
}))
export const StyledButton = styled(Button)(({ theme }) => {
return {
'&.button:is(:hover, :active, &)': {
background: theme.palette.secondary.main,
color: theme.palette.text.primary,
},
':disabled': {
cursor: 'not-allowed',
},
}
return {
'&.button:is(:hover, :active, &)': {
background: theme.palette.secondary.main,
color: theme.palette.text.primary,
},
':disabled': {
cursor: 'not-allowed',
},
}
})
export const StyledSynchedText = styled((props: TypographyProps) => (
<Typography variant='caption' {...props} />
))(({ theme }) => {
return {
color: theme.palette.success.main,
}
export const StyledSynchedText = styled((props: TypographyProps) => <Typography variant="caption" {...props} />)(({
theme,
}) => {
return {
color: theme.palette.success.main,
}
})

View File

@@ -2,94 +2,117 @@ import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar'
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, useState } from 'react'
import { CircularProgress, Stack, Typography, useTheme } from '@mui/material'
import React, { ChangeEvent, useEffect, useState } from 'react'
import { StyledAppLogo } from './styled'
import { Input } from '@/shared/Input/Input'
import { Button } from '@/shared/Button/Button'
import { CheckmarkIcon } from '@/assets'
import { swicCall } from '@/modules/swic'
import { useNavigate } from 'react-router-dom'
import { DOMAIN } from '@/utils/consts'
import { fetchNip05 } from '@/utils/helpers/helpers'
export const ModalSignUp = () => {
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.SIGN_UP)
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.SIGN_UP)
const notify = useEnqueueSnackbar()
const theme = useTheme()
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.SIGN_UP)
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.SIGN_UP)
const notify = useEnqueueSnackbar()
const theme = useTheme()
const navigate = useNavigate()
const navigate = useNavigate()
const [enteredValue, setEnteredValue] = useState('')
const [enteredValue, setEnteredValue] = useState('')
const [isAvailable, setIsAvailable] = useState(false)
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
setEnteredValue(e.target.value)
}
const [isLoading, setIsLoading] = useState(false)
const isAvailable = enteredValue.trim().length > 2
const handleInputChange = async (e: ChangeEvent<HTMLInputElement>) => {
setEnteredValue(e.target.value)
const name = e.target.value.trim()
if (name) {
const npubNip05 = await fetchNip05(`${name}@${DOMAIN}`)
setIsAvailable(!npubNip05)
} else {
setIsAvailable(false)
}
}
const inputHelperText = isAvailable ? (
<>
<CheckmarkIcon /> Available
</>
) : (
"Don't worry, username can be changed later."
)
const getInputHelperText = () => {
if (!enteredValue) return "Don't worry, username can be changed later."
if (!isAvailable) return 'Already taken'
return (
<>
<CheckmarkIcon /> Available
</>
)
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
try {
const k: any = await swicCall('generateKey')
notify(`New key ${k.npub}`, 'success')
navigate(`/key/${k.npub}`)
} catch (error: any) {
notify(error.message, 'error')
}
}
const inputHelperText = getInputHelperText()
return (
<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>
</Stack>
<Input
label='Enter a Username'
fullWidth
placeholder='Username'
helperText={inputHelperText}
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,
},
},
}}
/>
<Button fullWidth type='submit'>
Sign up
</Button>
</Stack>
</Modal>
)
const handleSubmit = async (e: React.FormEvent) => {
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)
notify(`Account created for "${name}"`, 'success')
navigate(`/key/${k.npub}`)
setIsLoading(false)
} catch (error: any) {
notify(error?.message || 'Something went wrong!', 'error')
setIsLoading(false)
}
}
useEffect(() => {
return () => {
if (isModalOpened) {
// modal closed
setIsLoading(false)
setIsAvailable(false)
}
}
}, [isModalOpened])
return (
<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>
</Stack>
<Input
label="Username"
fullWidth
placeholder="Enter a Username"
helperText={inputHelperText}
endAdornment={<Typography color={'#FFFFFFA8'}>@{DOMAIN}</Typography>}
onChange={handleInputChange}
value={enteredValue}
helperTextProps={{
sx: {
'&.helper_text': {
color:
enteredValue && isAvailable
? theme.palette.success.main
: enteredValue && !isAvailable
? theme.palette.error.main
: theme.palette.textSecondaryDecorate.main,
},
},
}}
/>
<Button fullWidth type="submit" disabled={isLoading}>
Create account {isLoading && <CircularProgress sx={{ marginLeft: '0.5rem' }} size={'1rem'} />}
</Button>
</Stack>
</Modal>
)
}

View File

@@ -2,13 +2,13 @@ import { AppLogo } from '@/assets'
import { Box, styled } from '@mui/material'
export const StyledAppLogo = styled((props) => (
<Box {...props}>
<AppLogo />
</Box>
<Box {...props}>
<AppLogo />
</Box>
))({
background: '#00000054',
padding: '0.75rem',
borderRadius: '16px',
display: 'grid',
placeItems: 'center',
background: '#0d0d0d',
padding: '0.75rem',
borderRadius: '16px',
display: 'grid',
placeItems: 'center',
})

View File

@@ -5,21 +5,19 @@ 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) => {
const { closeSnackbar } = useSnackbar()
export const Notification = forwardRef<HTMLDivElement, NotificationProps>(({ message, alertvariant, id }, ref) => {
const { closeSnackbar } = useSnackbar()
const closeSnackBarHandler = () => closeSnackbar(id)
const closeSnackBarHandler = () => closeSnackbar(id)
return (
<StyledAlert alertvariant={alertvariant} ref={ref}>
<StyledContainer>
<Typography variant='body1'>{message}</Typography>
<IconButton onClick={closeSnackBarHandler} color='inherit'>
<CloseIcon color='inherit' />
</IconButton>
</StyledContainer>
</StyledAlert>
)
},
)
return (
<StyledAlert alertvariant={alertvariant} ref={ref}>
<StyledContainer>
<Typography variant="body1">{message}</Typography>
<IconButton onClick={closeSnackBarHandler} color="inherit">
<CloseIcon color="inherit" />
</IconButton>
</StyledContainer>
</StyledAlert>
)
})

View File

@@ -3,7 +3,7 @@ import { VariantType } from 'notistack'
type Variant = Exclude<VariantType, 'default' | 'info'>
export const BORDER_STYLES: Record<Variant, string> = {
error: '#b90e0a',
success: '#32cd32',
warning: '#FF9500',
error: '#b90e0a',
success: '#32cd32',
warning: '#FF9500',
}

View File

@@ -4,43 +4,41 @@ 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,
padding: '0.5rem 1rem',
backgroundColor: '#FFF',
borderRadius: 4,
border: `solid ${BORDER_STYLES[alertvariant]} 1px`,
color: BORDER_STYLES[alertvariant],
fontSize: 12,
fontWeight: '500',
'& .MuiAlert-message': {
display: 'flex',
minWidth: '100%',
justifyContent: 'space-between',
overflow: 'hidden',
padding: 0,
},
width: '100%',
maxHeight: 56,
padding: '0.5rem 1rem',
backgroundColor: '#FFF',
borderRadius: 4,
border: `solid ${BORDER_STYLES[alertvariant]} 1px`,
color: BORDER_STYLES[alertvariant],
fontSize: 12,
fontWeight: '500',
'& .MuiAlert-message': {
display: 'flex',
minWidth: '100%',
justifyContent: 'space-between',
overflow: 'hidden',
padding: 0,
},
}))
export const StyledContainer = styled(Box)(() => ({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: '1rem',
width: '100%',
'& > .MuiTypography-root': {
flex: 1,
width: '100%',
wordBreak: 'break-word',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
textOverflow: 'ellipsis',
fontWeight: 500,
},
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: '1rem',
width: '100%',
'& > .MuiTypography-root': {
flex: 1,
width: '100%',
wordBreak: 'break-word',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
textOverflow: 'ellipsis',
fontWeight: 500,
},
}))

View File

@@ -2,10 +2,10 @@ import { AlertProps } from '@mui/material'
import { SnackbarKey, VariantType } from 'notistack'
export type StyledAlertProps = Omit<AlertProps, 'id'> & {
alertvariant: Exclude<VariantType, 'default' | 'info'>
alertvariant: Exclude<VariantType, 'default' | 'info'>
}
export type NotificationProps = {
message: string
id: SnackbarKey
message: string
id: SnackbarKey
} & StyledAlertProps

View File

@@ -1,19 +1,27 @@
import React, { FC, ReactNode } from 'react'
import { FC, ReactNode } from 'react'
import { IconContainer, StyledContainer } from './styled'
import { BoxProps, Typography } from '@mui/material'
import { BoxProps, Stack, Typography } from '@mui/material'
type WarningProps = {
message: string | ReactNode
Icon?: ReactNode
message?: string | ReactNode
hint?: string | ReactNode
icon?: ReactNode
} & BoxProps
export const Warning: FC<WarningProps> = ({ message, Icon, ...restProps }) => {
return (
<StyledContainer {...restProps}>
{Icon && <IconContainer>{Icon}</IconContainer>}
<Typography flex={1} noWrap>
{message}
</Typography>
</StyledContainer>
)
export const Warning: FC<WarningProps> = ({ hint, message, icon, ...restProps }) => {
return (
<StyledContainer {...restProps}>
{icon && <IconContainer>{icon}</IconContainer>}
<Stack flex={1} direction={'column'} gap={'0.2rem'}>
<Typography noWrap>
{message}
</Typography>
{hint && (
<Typography>
{hint}
</Typography>
)}
</Stack>
</StyledContainer>
)
}

View File

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

View File

@@ -1,33 +1,20 @@
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 { enqueueSnackbar } = useDefaultSnackbar()
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}
/>
)
},
} as OptionsObject)
}
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} />
},
} as OptionsObject)
}
return showSnackbar
return showSnackbar
}

View File

@@ -8,16 +8,15 @@ import { useState, useEffect } from 'react'
const iOSRegex = /iPad|iPhone|iPod/
function useIsIOS() {
const [isIOS, setIsIOS] = useState(false)
const [isIOS, setIsIOS] = useState(false)
useEffect(() => {
const isIOSUserAgent =
iOSRegex.test(navigator.userAgent) ||
(navigator.userAgent.includes('Mac') && 'ontouchend' in document)
setIsIOS(isIOSUserAgent)
}, [])
useEffect(() => {
const isIOSUserAgent =
iOSRegex.test(navigator.userAgent) || (navigator.userAgent.includes('Mac') && 'ontouchend' in document)
setIsIOS(isIOSUserAgent)
}, [])
return isIOS
return isIOS
}
export default useIsIOS

View File

@@ -1,97 +1,84 @@
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
[key: string]: string
}
export type IExtraOptions = {
search?: SearchParamsType
replace?: boolean
append?: boolean
search?: SearchParamsType
replace?: boolean
append?: boolean
}
export type IExtraCloseOptions = {
replace?: boolean
onClose?: (s: URLSearchParams) => void
replace?: boolean
onClose?: (s: URLSearchParams) => void
}
export const useModalSearchParams = () => {
const [searchParams, setSearchParams] = useSearchParams()
const [searchParams, setSearchParams] = useSearchParams()
const location = useLocation()
const navigate = useNavigate()
const location = useLocation()
const navigate = useNavigate()
const getEnumParam = useCallback((modal: MODAL_PARAMS_KEYS) => {
return Object.values(MODAL_PARAMS_KEYS)[
Object.values(MODAL_PARAMS_KEYS).indexOf(modal)
]
}, [])
const getEnumParam = useCallback((modal: MODAL_PARAMS_KEYS) => {
return Object.values(MODAL_PARAMS_KEYS)[Object.values(MODAL_PARAMS_KEYS).indexOf(modal)]
}, [])
const createHandleClose =
(modal: MODAL_PARAMS_KEYS, extraOptions?: IExtraCloseOptions) =>
() => {
const enumKey = getEnumParam(modal)
searchParams.delete(enumKey)
extraOptions?.onClose && extraOptions?.onClose(searchParams)
console.log({ searchParams })
setSearchParams(searchParams, { replace: !!extraOptions?.replace })
}
const createHandleClose = (modal: MODAL_PARAMS_KEYS, extraOptions?: IExtraCloseOptions) => () => {
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 })
}
const createHandleCloseReplace = (modal: MODAL_PARAMS_KEYS, extraOptions: IExtraCloseOptions = {}) => {
return createHandleClose(modal, { ...extraOptions, replace: true })
}
const handleOpen = useCallback(
(modal: MODAL_PARAMS_KEYS, extraOptions?: IExtraOptions) => {
const enumKey = getEnumParam(modal)
const handleOpen = useCallback(
(modal: MODAL_PARAMS_KEYS, extraOptions?: IExtraOptions) => {
const enumKey = getEnumParam(modal)
let searchParamsData: SearchParamsType = { [enumKey]: 'true' }
if (extraOptions?.search) {
searchParamsData = {
...searchParamsData,
...extraOptions.search,
}
}
let searchParamsData: SearchParamsType = { [enumKey]: 'true' }
if (extraOptions?.search) {
searchParamsData = {
...searchParamsData,
...extraOptions.search,
}
}
const searchString = !extraOptions?.append
? createSearchParams(searchParamsData).toString()
: `${location.search}&${createSearchParams(
searchParamsData,
).toString()}`
const searchString = !extraOptions?.append
? createSearchParams(searchParamsData).toString()
: `${location.search}&${createSearchParams(searchParamsData).toString()}`
navigate(
{
pathname: location.pathname,
search: searchString,
},
{ replace: !!extraOptions?.replace },
)
},
[location, navigate, getEnumParam],
)
navigate(
{
pathname: location.pathname,
search: searchString,
},
{ replace: !!extraOptions?.replace }
)
},
[location, navigate, getEnumParam]
)
const getModalOpened = useCallback(
(modal: MODAL_PARAMS_KEYS) => {
const enumKey = getEnumParam(modal)
const modalOpened = searchParams.get(enumKey) === 'true'
return modalOpened
},
[getEnumParam, searchParams],
)
const getModalOpened = useCallback(
(modal: MODAL_PARAMS_KEYS) => {
const enumKey = getEnumParam(modal)
const modalOpened = searchParams.get(enumKey) === 'true'
return modalOpened
},
[getEnumParam, searchParams]
)
return {
getModalOpened,
createHandleClose,
createHandleCloseReplace,
handleOpen,
}
return {
getModalOpened,
createHandleClose,
createHandleCloseReplace,
handleOpen,
}
}

View File

@@ -1,21 +1,21 @@
import React, { useState } from 'react'
export const useOpenMenu = () => {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
const open = Boolean(anchorEl)
const open = Boolean(anchorEl)
const handleOpen = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget)
}
const handleClose = () => {
setAnchorEl(null)
}
const handleOpen = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget)
}
const handleClose = () => {
setAnchorEl(null)
}
return {
open,
handleOpen,
handleClose,
anchorEl,
}
return {
open,
handleOpen,
handleClose,
anchorEl,
}
}

30
src/hooks/usePassword.tsx Normal file
View File

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

41
src/hooks/useProfile.ts Normal file
View File

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

View File

@@ -1,15 +1,15 @@
import { useCallback, useState } from 'react'
export const useToggleConfirm = () => {
const [showConfirm, setShowConfirm] = useState(false)
const [showConfirm, setShowConfirm] = useState(false)
const handleShow = useCallback(() => setShowConfirm(true), [])
const handleShow = useCallback(() => setShowConfirm(true), [])
const handleClose = useCallback(() => setShowConfirm(false), [])
const handleClose = useCallback(() => setShowConfirm(false), [])
return {
open: showConfirm,
handleShow,
handleClose,
}
return {
open: showConfirm,
handleShow,
handleClose,
}
}

View File

@@ -1,23 +1,21 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto',
'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans',
'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans',
'Droid Sans', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
}
html,
body,
#root {
height: 100%;
height: 100%;
}

View File

@@ -13,19 +13,19 @@ import { SnackbarProvider } from 'notistack'
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement)
root.render(
<React.StrictMode>
<BrowserRouter>
<Provider store={store}>
<PersistGate loading={null} persistor={persistor}>
<ThemeProvider>
<SnackbarProvider maxSnack={3} autoHideDuration={3000}>
<App />
</SnackbarProvider>
</ThemeProvider>
</PersistGate>
</Provider>
</BrowserRouter>
</React.StrictMode>,
<React.StrictMode>
<BrowserRouter>
<Provider store={store}>
<PersistGate loading={null} persistor={persistor}>
<ThemeProvider>
<SnackbarProvider maxSnack={3} autoHideDuration={3000}>
<App />
</SnackbarProvider>
</ThemeProvider>
</PersistGate>
</Provider>
</BrowserRouter>
</React.StrictMode>
)
// If you want your app to work offline and load faster, you can change

View File

@@ -1,66 +1,61 @@
import { Avatar, Stack, Toolbar, Typography } from '@mui/material'
import { AppLogo } from '../../assets'
import { StyledAppBar, StyledAppName } from './styled'
import { StyledAppBar, StyledAppLogo, StyledAppName, StyledProfileContainer, StyledThemeButton } from './styled'
import { Menu } from './components/Menu'
import { useParams } from 'react-router-dom'
import { useCallback, useEffect, useState } from 'react'
import { MetaEvent } from '@/types/meta-event'
import { fetchProfile } from '@/modules/nostr'
import { useNavigate, useParams } from 'react-router-dom'
import { ProfileMenu } from './components/ProfileMenu'
import { getShortenNpub } from '@/utils/helpers/helpers'
import { useProfile } from '@/hooks/useProfile'
import DarkModeIcon from '@mui/icons-material/DarkMode'
import LightModeIcon from '@mui/icons-material/LightMode'
import { useAppDispatch, useAppSelector } from '@/store/hooks/redux'
import { setThemeMode } from '@/store/reducers/ui.slice'
export const Header = () => {
const { npub = '' } = useParams<{ npub: string }>()
const [profile, setProfile] = useState<MetaEvent | null>(null)
const themeMode = useAppSelector((state) => state.ui.themeMode)
const navigate = useNavigate()
const dispatch = useAppDispatch()
const load = useCallback(async () => {
if (!npub) return setProfile(null)
const { npub = '' } = useParams<{ npub: string }>()
const { userName, userAvatar, avatarTitle } = useProfile(npub)
const showProfile = Boolean(npub)
try {
const response = await fetchProfile(npub)
setProfile(response as any)
} catch (e) {
return setProfile(null)
}
}, [npub])
const handleNavigate = () => {
navigate(`/key/${npub}`)
}
useEffect(() => {
load()
}, [load])
const isDarkMode = themeMode === 'dark'
const themeIcon = isDarkMode ? <DarkModeIcon htmlColor="#fff" /> : <LightModeIcon htmlColor="#000" />
const showProfile = Boolean(npub || profile)
const userName = profile?.info?.name || getShortenNpub(npub)
const userAvatar = profile?.info?.picture || ''
const handleChangeMode = () => {
dispatch(setThemeMode({ mode: isDarkMode ? 'light' : 'dark' }))
}
return (
<StyledAppBar position='fixed'>
<Toolbar sx={{ padding: '12px' }}>
<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>
<AppLogo />
<span>Nsec.app</span>
</StyledAppName>
)}
return (
<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 ? <ProfileMenu /> : <Menu />}
</Stack>
</Toolbar>
</StyledAppBar>
)
{!showProfile && (
<StyledAppName>
<StyledAppLogo />
<span>Nsec.app</span>
</StyledAppName>
)}
<StyledThemeButton onClick={handleChangeMode}>{themeIcon}</StyledThemeButton>
{showProfile ? <ProfileMenu /> : <Menu />}
</Stack>
</Toolbar>
</StyledAppBar>
)
}

View File

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

View File

@@ -1,48 +1,19 @@
import { DbKey } from '@/modules/db'
import { getShortenNpub } from '@/utils/helpers/helpers'
import {
Avatar,
ListItemIcon,
MenuItem,
Stack,
Typography,
} from '@mui/material'
import React, { FC } from 'react'
import { Stack } from '@mui/material'
import { FC } from 'react'
import { ListItemProfile } from './ListItemProfile'
type ListProfilesProps = {
keys: DbKey[]
onClickItem: (key: DbKey) => void
keys: DbKey[]
onClickItem: (key: DbKey) => void
}
export const ListProfiles: FC<ListProfilesProps> = ({
keys = [],
onClickItem,
}) => {
return (
<Stack maxHeight={'10rem'} overflow={'auto'}>
{keys.map((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>
)
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)} />
})}
</Stack>
)
}

View File

@@ -1,10 +1,7 @@
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 { setThemeMode } from '@/store/reducers/ui.slice'
import { useAppDispatch, useAppSelector } from '@/store/hooks/redux'
import { useAppSelector } from '@/store/hooks/redux'
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { MenuButton } from './styled'
@@ -14,61 +11,36 @@ 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 keys = useAppSelector(selectKeys)
const { handleOpen } = useModalSearchParams()
const { anchorEl, handleClose, handleOpen: handleOpenMenu, open } = useOpenMenu()
const isDarkMode = themeMode === 'dark'
const isNoKeys = !keys || keys.length === 0
const isNoKeys = !keys || keys.length === 0
const {
anchorEl,
handleClose,
handleOpen: handleOpenMenu,
open,
} = useOpenMenu()
const handleNavigateToAuth = () => {
handleOpen(MODAL_PARAMS_KEYS.INITIAL)
handleClose()
}
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' />
</MenuButton>
<MuiMenu
anchorEl={anchorEl}
open={open}
onClose={handleClose}
sx={{
zIndex: 1302,
}}
>
<MenuItem
Icon={
isNoKeys ? <LoginIcon /> : <PersonAddAltRoundedIcon />
}
onClick={handleNavigateToAuth}
title={isNoKeys ? 'Sign up' : 'Add account'}
/>
<MenuItem
Icon={themeIcon}
onClick={handleChangeMode}
title='Change theme'
/>
</MuiMenu>
</>
)
return (
<>
<MenuButton onClick={handleOpenMenu}>
<MenuRoundedIcon color="inherit" />
</MenuButton>
<MuiMenu
anchorEl={anchorEl}
open={open}
onClose={handleClose}
sx={{
zIndex: 1302,
}}
>
<MenuItem
Icon={isNoKeys ? <LoginIcon /> : <PersonAddAltRoundedIcon />}
onClick={handleNavigateToAuth}
title={isNoKeys ? 'Sign up' : 'Add account'}
/>
</MuiMenu>
</>
)
}

View File

@@ -1,24 +1,20 @@
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
title: string
Icon: ReactNode
onClick: () => void
title: string
Icon: ReactNode
} & MuiMenuItemProps
export const MenuItem: FC<MenuItemProps> = ({ onClick, Icon, title }) => {
return (
<StyledMenuItem onClick={onClick}>
<ListItemIcon>{Icon}</ListItemIcon>
<Typography fontWeight={500} variant='body2' noWrap>
{title}
</Typography>
</StyledMenuItem>
)
return (
<StyledMenuItem onClick={onClick}>
<ListItemIcon>{Icon}</ListItemIcon>
<Typography fontWeight={500} variant="body2" noWrap>
{title}
</Typography>
</StyledMenuItem>
)
}

View File

@@ -9,95 +9,58 @@ 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 { useAppDispatch, useAppSelector } from '@/store/hooks/redux'
import { 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 { handleOpen } = useModalSearchParams()
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 keys = useAppSelector(selectKeys)
const isNoKeys = !keys || keys.length === 0
const dispatch = useAppDispatch()
const navigate = useNavigate()
const navigate = useNavigate()
const handleNavigateToAuth = () => {
handleOpen(MODAL_PARAMS_KEYS.INITIAL)
handleClose()
}
const handleNavigateToAuth = () => {
handleOpen(MODAL_PARAMS_KEYS.INITIAL)
handleClose()
}
const handleNavigateHome = () => {
navigate('/home')
handleClose()
}
const handleNavigateHome = () => {
navigate('/home')
handleClose()
}
const handleChangeMode = () => {
dispatch(setThemeMode({ mode: isDarkMode ? 'light' : 'dark' }))
}
const handleNavigateToKeyInnerPage = (key: DbKey) => {
navigate('/key/' + key.npub)
handleClose()
}
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'
/>
</MenuButton>
<Menu
open={open}
anchorEl={anchorEl}
onClose={handleClose}
sx={{
zIndex: 1302,
}}
>
<ListProfiles
keys={keys}
onClickItem={handleNavigateToKeyInnerPage}
/>
<Divider />
<MenuItem
Icon={<HomeRoundedIcon />}
onClick={handleNavigateHome}
title='Home'
/>
<MenuItem
Icon={
isNoKeys ? <LoginIcon /> : <PersonAddAltRoundedIcon />
}
onClick={handleNavigateToAuth}
title={isNoKeys ? 'Sign up' : 'Add account'}
/>
<MenuItem
Icon={themeIcon}
onClick={handleChangeMode}
title='Change theme'
/>
</Menu>
</>
)
return (
<>
<MenuButton onClick={handleOpenMenu}>
<KeyboardArrowDownRoundedIcon color="inherit" fontSize="large" />
</MenuButton>
<Menu
open={open}
anchorEl={anchorEl}
onClose={handleClose}
sx={{
zIndex: 1302,
}}
>
<ListProfiles keys={keys} onClickItem={handleNavigateToKeyInnerPage} />
<Divider />
<MenuItem Icon={<HomeRoundedIcon />} onClick={handleNavigateHome} title="Home" />
<MenuItem
Icon={isNoKeys ? <LoginIcon /> : <PersonAddAltRoundedIcon />}
onClick={handleNavigateToAuth}
title={isNoKeys ? 'Sign up' : 'Add account'}
/>
</Menu>
</>
)
}

View File

@@ -1,26 +1,16 @@
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 }) => {
const isDark = theme.palette.mode === 'dark'
return {
borderRadius: '1rem',
background: isDark ? '#333333A8' : 'transparent',
color: isDark ? '#FFFFFFA8' : 'initial',
width: 42,
height: 42,
}
export const MenuButton = styled((props: IconButtonProps) => <IconButton {...props} />)(({ theme }) => {
const isDark = theme.palette.mode === 'dark'
return {
borderRadius: '1rem',
background: isDark ? '#333333A8' : 'transparent',
color: isDark ? '#FFFFFFA8' : 'initial',
width: 42,
height: 42,
}
})
export const StyledMenuItem = styled((props: MenuItemProps) => (
<MenuItem {...props} />
))(() => ({
padding: '0.5rem 1rem',
export const StyledMenuItem = styled((props: MenuItemProps) => <MenuItem {...props} />)(() => ({
padding: '0.5rem 1rem',
}))

View File

@@ -1,31 +1,56 @@
import { AppBar, Typography, TypographyProps, styled } from '@mui/material'
import { AppLogo } from '@/assets'
import { AppBar, IconButton, Stack, StackProps, Typography, TypographyProps, styled } from '@mui/material'
import { Link } from 'react-router-dom'
export const StyledAppBar = styled(AppBar)(({ theme }) => {
return {
color: theme.palette.primary.main,
boxShadow: 'none',
marginBottom: '1rem',
background: theme.palette.background.default,
zIndex: 1301,
maxWidth: 'inherit',
left: '50%',
transform: 'translateX(-50%)',
}
return {
color: theme.palette.primary.main,
boxShadow: 'none',
marginBottom: '1rem',
background: theme.palette.background.default,
zIndex: 1301,
maxWidth: 'inherit',
left: '50%',
transform: 'translateX(-50%)',
borderRadius: '8px',
}
})
export const StyledAppName = styled((props: TypographyProps) => (
<Typography component={Link} to={'/'} flexGrow={1} {...props} />
<Typography component={Link} to={'/'} flexGrow={1} {...props} />
))(() => ({
'&:not(:hover)': {
textDecoration: 'initial',
},
color: 'inherit',
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
fontWeight: 600,
fontSize: '1rem',
lineHeight: '22.4px',
marginLeft: '0.5rem',
'&:not(:hover)': {
textDecoration: 'initial',
},
color: 'inherit',
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
fontWeight: 600,
fontSize: '1rem',
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,45 +1,37 @@
import { FC } from 'react'
import { Outlet } from 'react-router-dom'
import { Header } from './Header/Header'
import {
Container,
ContainerProps,
Divider,
DividerProps,
styled,
} from '@mui/material'
import { Container, ContainerProps, Divider, DividerProps, styled } from '@mui/material'
export const Layout: FC = () => {
return (
<StyledContainer maxWidth='md'>
<Header />
<StyledDivider />
<main>
<Outlet />
</main>
</StyledContainer>
)
return (
<StyledContainer maxWidth="md">
<Header />
<StyledDivider />
<main>
<Outlet />
</main>
</StyledContainer>
)
}
const StyledContainer = styled((props: ContainerProps) => (
<Container maxWidth='sm' {...props} />
))({
height: '100%',
display: 'flex',
flexDirection: 'column',
paddingBottom: '1rem',
position: 'relative',
'& > main': {
flex: 1,
maxHeight: '100%',
paddingTop: 'calc(66px + 1rem)',
},
const StyledContainer = styled((props: ContainerProps) => <Container maxWidth="sm" {...props} />)({
height: '100%',
display: 'flex',
flexDirection: 'column',
paddingBottom: '1rem',
position: 'relative',
'& > main': {
flex: 1,
maxHeight: '100%',
paddingTop: 'calc(66px + 1rem)',
},
})
const StyledDivider = styled((props: DividerProps) => <Divider {...props} />)({
position: 'absolute',
top: '66px',
width: '100%',
left: 0,
height: '2px',
position: 'absolute',
top: '66px',
width: '100%',
left: 0,
height: '2px',
})

File diff suppressed because it is too large Load Diff

View File

@@ -2,217 +2,223 @@ import { MetaEvent } from '@/types/meta-event'
import Dexie from 'dexie'
export interface DbKey {
npub: string
nip05?: string
name?: string
avatar?: string
relays?: string[]
enckey: string
profile?: MetaEvent | null
npub: string
nip05?: string
name?: string
avatar?: string
relays?: string[]
enckey: string
profile?: MetaEvent | null
}
export interface DbApp {
appNpub: string
npub: string
name: string
icon: string
url: string
timestamp: number
appNpub: string
npub: string
name: string
icon: string
url: string
timestamp: number
}
export interface DbPerm {
id: string
npub: string
appNpub: string
perm: string
value: string
timestamp: number
id: string
npub: string
appNpub: string
perm: string
value: string
timestamp: number
}
export interface DbPending {
id: string
npub: string
appNpub: string
timestamp: number
method: string
params: string
id: string
npub: string
appNpub: string
timestamp: number
method: string
params: string
}
export interface DbHistory {
id: string
npub: string
appNpub: string
timestamp: number
method: string
params: string
allowed: boolean
id: string
npub: string
appNpub: string
timestamp: number
method: string
params: string
allowed: boolean
}
export interface DbSyncHistory {
npub: string
npub: string
}
export interface DbSchema extends Dexie {
keys: Dexie.Table<DbKey, string>
apps: Dexie.Table<DbApp, string>
perms: Dexie.Table<DbPerm, string>
pending: Dexie.Table<DbPending, string>
history: Dexie.Table<DbHistory, string>
syncHistory: Dexie.Table<DbSyncHistory, string>
keys: Dexie.Table<DbKey, string>
apps: Dexie.Table<DbApp, string>
perms: Dexie.Table<DbPerm, string>
pending: Dexie.Table<DbPending, string>
history: Dexie.Table<DbHistory, string>
syncHistory: Dexie.Table<DbSyncHistory, string>
}
export const db = new Dexie('noauthdb') as DbSchema
db.version(8).stores({
keys: 'npub',
apps: 'appNpub,npub,name,timestamp',
perms: 'id,npub,appNpub,perm,value,timestamp',
pending: 'id,npub,appNpub,timestamp,method',
history: 'id,npub,appNpub,timestamp,method,allowed',
requestHistory: 'id',
syncHistory: 'npub',
keys: 'npub',
apps: 'appNpub,npub,name,timestamp',
perms: 'id,npub,appNpub,perm,value,timestamp',
pending: 'id,npub,appNpub,timestamp,method',
history: 'id,npub,appNpub,timestamp,method,allowed',
requestHistory: 'id',
syncHistory: 'npub',
})
export const dbi = {
addKey: async (key: DbKey) => {
try {
await db.keys.add(key)
} catch (error) {
console.log(`db addKey error: ${error}`)
}
},
listKeys: async (): Promise<DbKey[]> => {
try {
return await db.keys.toArray()
} catch (error) {
console.log(`db listKeys error: ${error}`)
return []
}
},
getApp: async (appNpub: string) => {
try {
return await db.apps.get(appNpub)
} catch (error) {
console.log(`db getApp error: ${error}`)
}
},
addApp: async (app: DbApp) => {
try {
await db.apps.add(app)
} catch (error) {
console.log(`db addApp error: ${error}`)
}
},
listApps: async (): Promise<DbApp[]> => {
try {
return await db.apps.toArray()
} catch (error) {
console.log(`db listApps error: ${error}`)
return []
}
},
removeApp: async (appNpub: string) => {
try {
return await db.apps.delete(appNpub)
} catch (error) {
console.log(`db removeApp error: ${error}`)
}
},
addPerm: async (perm: DbPerm) => {
try {
await db.perms.add(perm)
} catch (error) {
console.log(`db addPerm error: ${error}`)
}
},
listPerms: async (): Promise<DbPerm[]> => {
try {
return await db.perms.toArray()
} catch (error) {
console.log(`db listPerms error: ${error}`)
return []
}
},
removePerm: async (id: string) => {
try {
return await db.perms.delete(id)
} catch (error) {
console.log(`db removePerm error: ${error}`)
}
},
removeAppPerms: async (appNpub: string) => {
try {
return await db.perms.where({ appNpub }).delete()
} catch (error) {
console.log(`db removeAppPerms error: ${error}`)
}
},
addPending: async (r: DbPending) => {
try {
return db.transaction('rw', db.pending, db.history, async () => {
const exists =
(await db.pending.where('id').equals(r.id).toArray())
.length > 0 ||
(await db.history.where('id').equals(r.id).toArray())
.length > 0
if (exists) return false
addKey: async (key: DbKey) => {
try {
await db.keys.add(key)
} catch (error) {
console.log(`db addKey error: ${error}`)
}
},
listKeys: async (): Promise<DbKey[]> => {
try {
return await db.keys.toArray()
} catch (error) {
console.log(`db listKeys error: ${error}`)
return []
}
},
getApp: async (appNpub: string) => {
try {
return await db.apps.get(appNpub)
} catch (error) {
console.log(`db getApp error: ${error}`)
}
},
addApp: async (app: DbApp) => {
try {
await db.apps.add(app)
} catch (error) {
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()
} catch (error) {
console.log(`db listApps error: ${error}`)
return []
}
},
removeApp: async (appNpub: string) => {
try {
return await db.apps.delete(appNpub)
} catch (error) {
console.log(`db removeApp error: ${error}`)
}
},
addPerm: async (perm: DbPerm) => {
try {
await db.perms.add(perm)
} catch (error) {
console.log(`db addPerm error: ${error}`)
}
},
listPerms: async (): Promise<DbPerm[]> => {
try {
return await db.perms.toArray()
} catch (error) {
console.log(`db listPerms error: ${error}`)
return []
}
},
removePerm: async (id: string) => {
try {
return await db.perms.delete(id)
} catch (error) {
console.log(`db removePerm error: ${error}`)
}
},
removeAppPerms: async (appNpub: string) => {
try {
return await db.perms.where({ appNpub }).delete()
} catch (error) {
console.log(`db removeAppPerms error: ${error}`)
}
},
addPending: async (r: DbPending) => {
try {
return db.transaction('rw', db.pending, db.history, async () => {
const exists =
(await db.pending.where('id').equals(r.id).toArray()).length > 0 ||
(await db.history.where('id').equals(r.id).toArray()).length > 0
if (exists) return false
await db.pending.add(r)
return true
})
} catch (error) {
console.log(`db addPending error: ${error}`)
return false
}
},
removePending: async (id: string) => {
try {
return await db.pending.delete(id)
} catch (error) {
console.log(`db removePending error: ${error}`)
}
},
listPending: async (): Promise<DbPending[]> => {
try {
return await db.pending.toArray()
} catch (error) {
console.log(`db listPending error: ${error}`)
return []
}
},
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()
if (!r) throw new Error('Pending not found ' + id)
const h: DbHistory = {
...r,
allowed,
}
await db.pending.delete(id)
await db.history.add(h)
})
} catch (error) {
console.log(`db addPending error: ${error}`)
}
},
addConfirmed: async (r: DbHistory) => {
try {
await db.history.add(r)
} catch (error) {
console.log(`db addConfirmed error: ${error}`)
return false
}
},
addSynced: async (npub: string) => {
try {
await db.syncHistory.add({ npub })
} catch (error) {
console.log(`db addSynced error: ${error}`)
return false
}
},
await db.pending.add(r)
return true
})
} catch (error) {
console.log(`db addPending error: ${error}`)
return false
}
},
removePending: async (id: string) => {
try {
return await db.pending.delete(id)
} catch (error) {
console.log(`db removePending error: ${error}`)
}
},
listPending: async (): Promise<DbPending[]> => {
try {
return await db.pending.toArray()
} catch (error) {
console.log(`db listPending error: ${error}`)
return []
}
},
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()
if (!r) throw new Error('Pending not found ' + id)
const h: DbHistory = {
...r,
allowed,
}
await db.pending.delete(id)
await db.history.add(h)
})
} catch (error) {
console.log(`db addPending error: ${error}`)
}
},
addConfirmed: async (r: DbHistory) => {
try {
await db.history.add(r)
} catch (error) {
console.log(`db addConfirmed error: ${error}`)
return false
}
},
addSynced: async (npub: string) => {
try {
await db.syncHistory.add({ npub })
} catch (error) {
console.log(`db addSynced error: ${error}`)
return false
}
},
}

View File

@@ -4,123 +4,92 @@ 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;
try {
ciphertext = decodeBase64(ctb64);
iv = decodeBase64(ivb64);
} catch (e) {
return new Error(`failed to decode, ${e}`);
}
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)
} catch (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;
} catch (e) {
return new Error(`failed to decrypt, ${e}`);
}
try {
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}`)
}
}
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;
// @ts-ignore
for (let byte of uInt8Array) {
strChunks[i] = String.fromCharCode(byte); // bytes to utf16 string
i++;
}
return btoa(strChunks.join(""));
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++
}
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);
}
return bytes;
for (let i = 0; i < length; i++) {
bytes[i] = binaryString.charCodeAt(i)
}
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);
for (let i = 0; i < str.length; i++) {
array[i] = str.charCodeAt(i);
}
return array;
}
let array = new Array(str.length)
for (let i = 0; i < str.length; i++) {
array[i] = str.charCodeAt(i)
}
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,14 +17,34 @@ 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!@#$%^&*()]{4,}$/
// valid passwords are a limited ASCII only, see notes below
const ASCII_REGEX = /^[A-Za-z0-9!@#$%^&*()\-_]{6,}$/
const ALGO_LOCAL = 'AES-CBC';
const KEY_SIZE_LOCAL = 256;
const ALGO_LOCAL = 'AES-CBC'
const KEY_SIZE_LOCAL = 256
export function isValidPassphase(passphrase: string): boolean {
return ASCII_REGEX.test(passphrase)
}
export function isWeakPassphase(passphrase: string): boolean {
const BIG_LETTER_REGEX = /[A-Z]+/
const SMALL_LETTER_REGEX = /[a-z]+/
const NUMBER_REGEX = /[0-9]+/
const PUNCT_REGEX = /[!@#$%^&*()\-_]+/
const big = BIG_LETTER_REGEX.test(passphrase) ? 1 : 0
const small = SMALL_LETTER_REGEX.test(passphrase) ? 1 : 0
const number = NUMBER_REGEX.test(passphrase) ? 1 : 0
const punct = PUNCT_REGEX.test(passphrase) ? 1 : 0
const base = big * 26 + small * 26 + number * 10 + punct * 12
const compl = Math.pow(base, passphrase.length)
const thresh = Math.pow(11, 14)
// console.log({ big, small, number, punct, base, compl, thresh });
return compl < thresh;
}
export class Keys {
subtle: any
@@ -33,13 +53,7 @@ export class Keys {
this.subtle = cryptoSubtle
}
public isValidPassphase(passphrase: string): boolean {
return ASCII_REGEX.test(passphrase)
}
public async generatePassKey(pubkey: string, passphrase: string)
: Promise<{ passkey: Buffer, pwh: string }> {
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
@@ -47,7 +61,7 @@ export class Keys {
// We could use string.normalize() to make sure all JS implementations
// are compatible, but since we're looking to make this thing a standard
// then the simplest way is to exclude unicode and only work with ASCII
if (!this.isValidPassphase(passphrase)) throw new Error("Password must be 4+ ASCII chars")
if (!isValidPassphase(passphrase)) throw new Error('Password must be 4+ ASCII chars')
return new Promise((ok, fail) => {
// NOTE: we should use Argon2 or scrypt later, for now
@@ -57,7 +71,11 @@ 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'),
})
})
}
})
@@ -65,8 +83,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
}
@@ -81,8 +99,8 @@ export class Keys {
{ name: ALGO_LOCAL, length: KEY_SIZE_LOCAL },
// NOTE: important to make sure it's not visible in
// dev console in IndexedDB
/*extractable*/false,
["encrypt", "decrypt"]
/*extractable*/ false,
['encrypt', 'decrypt']
)
}
@@ -94,25 +112,30 @@ 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)
@@ -120,21 +143,29 @@ 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')
@@ -142,9 +173,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,7 +85,4 @@ export class Nip04 {
let text = utf8Decoder.decode(plaintext)
return text
}
}

View File

@@ -5,91 +5,79 @@ 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>) {
return {
id: e.id,
created_at: e.created_at,
pubkey: e.pubkey,
kind: e.kind,
tags: e.tags,
content: e.content,
sig: e.sig,
}
return {
id: e.id,
created_at: e.created_at,
pubkey: e.pubkey,
kind: e.kind,
tags: e.tags,
content: e.content,
sig: e.sig,
}
}
function rawEvent(e: Required<NDKEvent>): AugmentedEvent {
return {
...nostrEvent(e),
identifier: getTagValue(e as NDKEvent, 'd'),
order: e.created_at as number,
}
return {
...nostrEvent(e),
identifier: getTagValue(e as NDKEvent, 'd'),
order: e.created_at as number,
}
}
function parseContentJson(c: string): object {
try {
return JSON.parse(c)
} catch (e) {
console.log('Bad json: ', c, e)
return {}
}
try {
return JSON.parse(c)
} catch (e) {
console.log('Bad json: ', c, e)
return {}
}
}
export function getTags(
e: AugmentedEvent | NDKEvent | MetaEvent,
name: string,
): string[][] {
return e.tags.filter((t: string[]) => t.length > 0 && t[0] === name)
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 {
const tags = getTags(e, name)
if (tags.length === 0) return null
return tags[0]
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]
}
export function getTagValue(
e: AugmentedEvent | NDKEvent | MetaEvent,
name: string,
index: number = 0,
def: string = '',
e: AugmentedEvent | NDKEvent | MetaEvent,
name: string,
index: number = 0,
def: string = ''
): string {
const tag = getTag(e, name)
if (tag === null || !tag.length || (index && index >= tag.length))
return def
return tag[1 + index]
const tag = getTag(e, name)
if (tag === null || !tag.length || (index && index >= tag.length)) return def
return tag[1 + index]
}
export function parseProfileJson(e: NostrEvent): Meta {
// all meta fields are optional so 'as' works fine
const profile = createMeta(parseContentJson(e.content))
profile.pubkey = e.pubkey
profile.npub = nip19.npubEncode(e.pubkey)
return profile
// all meta fields are optional so 'as' works fine
const profile = createMeta(parseContentJson(e.content))
profile.pubkey = e.pubkey
profile.npub = nip19.npubEncode(e.pubkey)
return profile
}
export async function fetchProfile(npub: string): Promise<MetaEvent | null> {
const npubToken = npub.includes('#') ? npub.split('#')[0] : npub
const { type, data: pubkey } = nip19.decode(npubToken)
if (type !== 'npub') return null
const npubToken = npub.includes('#') ? npub.split('#')[0] : npub
const { type, data: pubkey } = nip19.decode(npubToken)
if (type !== 'npub') return null
const event = await ndk.fetchEvent({ kinds: [0], authors: [pubkey] })
const event = await ndk.fetchEvent({ kinds: [0], authors: [pubkey] })
if (event) {
const augmentedEvent = rawEvent(event as Required<NDKEvent>)
const m = createMetaEvent(augmentedEvent)
m.info = parseProfileJson(m)
return m
}
if (event) {
const augmentedEvent = rawEvent(event as Required<NDKEvent>)
const m = createMetaEvent(augmentedEvent)
m.info = parseProfileJson(m)
return m
}
return event
return event
}

51
src/modules/pow.ts Normal file
View File

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

View File

@@ -8,67 +8,63 @@ import { Nip04 } from './nip04'
//import { decrypt, encrypt } from "./ende";
export class PrivateKeySigner implements NDKSigner {
private _user: NDKUser | undefined
privateKey?: string
private nip04: Nip04
private _user: NDKUser | undefined
privateKey?: string
private nip04: Nip04
public constructor(privateKey?: string) {
if (privateKey) {
this.privateKey = privateKey
this._user = new NDKUser({
hexpubkey: getPublicKey(this.privateKey),
})
}
this.nip04 = new Nip04()
}
public constructor(privateKey?: string) {
if (privateKey) {
this.privateKey = privateKey
this._user = new NDKUser({
hexpubkey: getPublicKey(this.privateKey),
})
}
this.nip04 = new Nip04()
}
public static generate() {
const privateKey = generatePrivateKey()
return new PrivateKeySigner(privateKey)
}
public static generate() {
const privateKey = generatePrivateKey()
return new PrivateKeySigner(privateKey)
}
public async blockUntilReady(): Promise<NDKUser> {
if (!this._user) {
throw new Error('NDKUser not initialized')
}
return this._user
}
public async blockUntilReady(): Promise<NDKUser> {
if (!this._user) {
throw new Error('NDKUser not initialized')
}
return this._user
}
public async user(): Promise<NDKUser> {
await this.blockUntilReady()
return this._user as NDKUser
}
public async user(): Promise<NDKUser> {
await this.blockUntilReady()
return this._user as NDKUser
}
public async sign(event: NostrEvent): Promise<string> {
if (!this.privateKey) {
throw Error('Attempted to sign without a private key')
}
public async sign(event: NostrEvent): Promise<string> {
if (!this.privateKey) {
throw Error('Attempted to sign without a private key')
}
return getSignature(event as UnsignedEvent, this.privateKey)
}
return getSignature(event as UnsignedEvent, this.privateKey)
}
public async encrypt(recipient: NDKUser, value: string): Promise<string> {
if (!this.privateKey) {
throw Error('Attempted to encrypt without a private key')
}
public async encrypt(recipient: NDKUser, value: string): Promise<string> {
if (!this.privateKey) {
throw Error('Attempted to encrypt without a private key')
}
const recipientHexPubKey = recipient.hexpubkey
return await this.nip04.encrypt(
this.privateKey,
recipientHexPubKey,
value,
)
// return await encrypt(recipientHexPubKey, value, this.privateKey);
}
const recipientHexPubKey = recipient.hexpubkey
return await this.nip04.encrypt(this.privateKey, recipientHexPubKey, value)
// return await encrypt(recipientHexPubKey, value, this.privateKey);
}
public async decrypt(sender: NDKUser, value: string): Promise<string> {
if (!this.privateKey) {
throw Error('Attempted to decrypt without a private key')
}
public async decrypt(sender: NDKUser, value: string): Promise<string> {
if (!this.privateKey) {
throw Error('Attempted to decrypt without a private key')
}
const senderHexPubKey = sender.hexpubkey
// console.log("nip04_decrypt", value)
return await this.nip04.decrypt(this.privateKey, senderHexPubKey, value)
// return await decrypt(this.privateKey, senderHexPubKey, value) as string;
}
const senderHexPubKey = sender.hexpubkey
// console.log("nip04_decrypt", value)
return await this.nip04.decrypt(this.privateKey, senderHexPubKey, value)
// return await decrypt(this.privateKey, senderHexPubKey, value) as string;
}
}

View File

@@ -7,64 +7,72 @@ let nextReqId = 1
let onRender: (() => void) | null = null
export async function swicRegister() {
serviceWorkerRegistration.register({
onSuccess(registration) {
console.log('sw registered')
swr = registration
},
onError(e) {
console.log(`error ${e}`)
},
})
serviceWorkerRegistration.register({
onSuccess(registration) {
console.log('sw registered')
swr = registration
},
onError(e) {
console.log(`error ${e}`)
},
})
navigator.serviceWorker.ready.then((r) => (swr = r))
navigator.serviceWorker.ready.then((r) => {
console.log('sw ready')
swr = r
if (navigator.serviceWorker.controller) {
console.log(`This page is currently controlled by: ${navigator.serviceWorker.controller}`)
} else {
console.log('This page is not currently controlled by a service worker.')
}
})
navigator.serviceWorker.addEventListener('message', (event) => {
onMessage((event as MessageEvent).data)
})
navigator.serviceWorker.addEventListener('message', (event) => {
onMessage((event as MessageEvent).data)
})
}
function onMessage(data: any) {
const { id, result, error } = data
console.log('SW message', id, result, error)
const { id, result, error } = data
console.log('SW message', id, result, error)
if (!id) {
if (onRender) onRender()
return
}
if (!id) {
if (onRender) onRender()
return
}
const r = reqs.get(id)
if (!r) {
console.log('Unexpected message from service worker', data)
return
}
const r = reqs.get(id)
if (!r) {
console.log('Unexpected message from service worker', data)
return
}
reqs.delete(id)
if (error) r.rej(error)
else r.ok(result)
reqs.delete(id)
if (error) r.rej(error)
else r.ok(result)
}
export async function swicCall(method: string, ...args: any[]) {
const id = nextReqId
nextReqId++
const id = nextReqId
nextReqId++
return new Promise((ok, rej) => {
if (!swr || !swr.active) {
rej(new Error('No active service worker'))
return
}
return new Promise((ok, rej) => {
if (!swr || !swr.active) {
rej(new Error('No active service worker'))
return
}
reqs.set(id, { ok, rej })
const msg = {
id,
method,
args: [...args],
}
console.log('sending to SW', msg)
swr.active.postMessage(msg)
})
reqs.set(id, { ok, rej })
const msg = {
id,
method,
args: [...args],
}
console.log('sending to SW', msg)
swr.active.postMessage(msg)
})
}
export function swicOnRender(cb: () => void) {
onRender = cb
onRender = cb
}

View File

@@ -4,15 +4,15 @@ import { darkTheme, lightTheme } from './theme'
import { useAppSelector } from '../../store/hooks/redux'
const ThemeProvider: FC<PropsWithChildren> = ({ children }) => {
const themeMode = useAppSelector((state) => state.ui.themeMode)
const isDarkMode = themeMode === 'dark'
const themeMode = useAppSelector((state) => state.ui.themeMode)
const isDarkMode = themeMode === 'dark'
return (
<ThemeMuiProvider theme={isDarkMode ? darkTheme : lightTheme}>
<CssBaseline />
{children}
</ThemeMuiProvider>
)
return (
<ThemeMuiProvider theme={isDarkMode ? darkTheme : lightTheme}>
<CssBaseline />
{children}
</ThemeMuiProvider>
)
}
export default ThemeProvider

View File

@@ -1,99 +1,99 @@
import { createTheme, Theme } from '@mui/material'
declare module '@mui/material/styles' {
interface Palette {
textSecondaryDecorate: Palette['primary']
backgroundSecondary: Palette['background']
}
interface Palette {
textSecondaryDecorate: Palette['primary']
backgroundSecondary: Palette['background']
}
interface PaletteOptions {
textSecondaryDecorate?: Palette['primary']
backgroundSecondary?: Palette['background']
}
interface PaletteOptions {
textSecondaryDecorate?: Palette['primary']
backgroundSecondary?: Palette['background']
}
}
const commonTheme: Theme = createTheme({
typography: {
fontFamily: ['Inter', 'sans-serif'].join(','),
},
components: {
MuiButton: {
styleOverrides: {
root: {
textTransform: 'initial',
},
},
},
},
typography: {
fontFamily: ['Inter', 'sans-serif'].join(','),
},
components: {
MuiButton: {
styleOverrides: {
root: {
textTransform: 'initial',
},
},
},
},
})
const lightTheme: Theme = createTheme({
...commonTheme,
palette: {
mode: 'light',
primary: {
main: '#000000',
},
secondary: {
main: '#E8E9EB',
dark: '#ACACAC',
},
error: {
main: '#f44336',
},
background: {
default: '#f7f7f7',
paper: '#f7f7f7',
},
backgroundSecondary: {
default: '#E8E9EB',
paper: '#f7f7f7',
},
text: {
primary: '#000000',
secondary: '#ffffff',
},
textSecondaryDecorate: {
main: '#6b6b6b',
light: '#000',
dark: '#000',
contrastText: '#000',
},
},
...commonTheme,
palette: {
mode: 'light',
primary: {
main: '#000000',
},
secondary: {
main: '#E8E9EB',
dark: '#ACACAC',
},
error: {
main: '#f44336',
},
background: {
default: '#f7f7f7',
paper: '#f7f7f7',
},
backgroundSecondary: {
default: '#E8E9EB',
paper: '#f7f7f7',
},
text: {
primary: '#000000',
secondary: '#ffffff',
},
textSecondaryDecorate: {
main: '#6b6b6b',
light: '#000',
dark: '#000',
contrastText: '#000',
},
},
})
const darkTheme: Theme = createTheme({
...commonTheme,
palette: {
mode: 'dark',
primary: {
main: '#FFFFFF',
},
secondary: {
main: '#222222',
},
error: {
main: '#ef9a9a',
},
background: {
default: '#121212',
paper: '#28282B',
},
backgroundSecondary: {
default: '#0d0d0d',
paper: '#28282B',
},
text: {
primary: '#ffffff',
secondary: '#000000',
},
textSecondaryDecorate: {
main: '#6b6b6b',
light: '#000',
dark: '#000',
contrastText: '#000',
},
},
...commonTheme,
palette: {
mode: 'dark',
primary: {
main: '#FFFFFF',
},
secondary: {
main: '#222222',
},
error: {
main: '#ef9a9a',
},
background: {
default: '#121212',
paper: '#28282B',
},
backgroundSecondary: {
default: '#0d0d0d',
paper: '#28282B',
},
text: {
primary: '#ffffff',
secondary: '#000000',
},
textSecondaryDecorate: {
main: '#6b6b6b',
light: '#000',
dark: '#000',
contrastText: '#000',
},
},
})
export { lightTheme, darkTheme }

View File

@@ -1,11 +1,11 @@
import { useParams } from 'react-router'
import { useAppSelector } from '@/store/hooks/redux'
import { selectAppByAppNpub, selectPermsByNpubAndAppNpub } from '@/store'
import { selectAppByAppNpub, selectKeys, selectPermsByNpubAndAppNpub } from '@/store'
import { Navigate, useNavigate } from 'react-router-dom'
import { formatTimestampDate } from '@/utils/helpers/date'
import { Box, Stack, Typography } from '@mui/material'
import { Box, IconButton, Stack, Typography } from '@mui/material'
import { SectionTitle } from '@/shared/SectionTitle/SectionTitle'
import { getShortenNpub } from '@/utils/helpers/helpers'
import { getAppIconTitle, getDomain, getShortenNpub } from '@/utils/helpers/helpers'
import { Button } from '@/shared/Button/Button'
import { ACTION_TYPE } from '@/utils/consts'
import { Permissions } from './components/Permissions/Permissions'
@@ -18,105 +18,94 @@ 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'
const AppPage = () => {
const { appNpub = '', npub = '' } = useParams()
const navigate = useNavigate()
const notify = useEnqueueSnackbar()
const keys = useAppSelector(selectKeys)
const perms = useAppSelector((state) =>
selectPermsByNpubAndAppNpub(state, npub, appNpub),
)
const currentApp = useAppSelector((state) =>
selectAppByAppNpub(state, appNpub),
)
const { appNpub = '', npub = '' } = useParams()
const currentApp = useAppSelector((state) => selectAppByAppNpub(state, appNpub))
const perms = useAppSelector((state) => selectPermsByNpubAndAppNpub(state, npub, appNpub))
const { open, handleClose, handleShow } = useToggleConfirm()
const { handleOpen: handleOpenModal } = useModalSearchParams()
const navigate = useNavigate()
const notify = useEnqueueSnackbar()
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)
if (!currentApp) {
return <Navigate to={`/key/${npub}`} />
}
const isNpubExists = npub.trim().length && keys.some((key) => key.npub === npub)
const { icon = '', name = '' } = currentApp || {}
const appName = name || getShortenNpub(appNpub)
const { timestamp } = connectPerm || {}
if (!isNpubExists || !currentApp) {
return <Navigate to={`/key/${npub}`} />
}
const connectedOn =
connectPerm && timestamp
? `Connected at ${formatTimestampDate(timestamp)}`
: 'Not connected'
const { icon = '', name = '', url = '' } = currentApp || {}
const appDomain = getDomain(url)
const appName = name || appDomain || getShortenNpub(appNpub)
const appAvatarTitle = getAppIconTitle(name || appDomain, appNpub)
const handleDeleteApp = async () => {
try {
await swicCall('deleteApp', appNpub)
notify(`App: «${appName}» successfully deleted!`, 'success')
navigate(`key/${npub}`)
} catch (error: any) {
notify(error?.message || 'Failed to delete app', 'error')
}
}
const { timestamp } = connectPerm || {}
const connectedOn = connectPerm && timestamp ? `Connected at ${formatTimestampDate(timestamp)}` : 'Not connected'
return (
<>
<Stack
maxHeight={'100%'}
overflow={'auto'}
alignItems={'flex-start'}
height={'100%'}
>
<IOSBackButton onNavigate={() => navigate(`key/${npub}`)} />
<Stack
marginBottom={'1rem'}
direction={'row'}
gap={'1rem'}
width={'100%'}
>
<StyledAppIcon src={icon} />
<Box flex={'1'} overflow={'hidden'}>
<Typography variant='h4' noWrap>
{appName}
</Typography>
<Typography variant='body2' noWrap>
{connectedOn}
</Typography>
</Box>
</Stack>
<Box marginBottom={'1rem'}>
<SectionTitle marginBottom={'0.5rem'}>
Disconnect
</SectionTitle>
<Button fullWidth onClick={handleShow}>
Delete app
</Button>
</Box>
<Permissions perms={perms} />
const handleDeleteApp = async () => {
try {
await swicCall('deleteApp', appNpub)
notify(`App: «${appName}» successfully deleted!`, 'success')
navigate(`/key/${npub}`)
} catch (error: any) {
notify(error?.message || 'Failed to delete app', 'error')
}
}
<Button
fullWidth
onClick={() =>
handleOpenModal(MODAL_PARAMS_KEYS.ACTIVITY)
}
>
Activity
</Button>
</Stack>
const handleShowAppDetailsModal = () => handleOpenModal(MODAL_PARAMS_KEYS.APP_DETAILS)
<ConfirmModal
open={open}
headingText='Delete app'
description='Are you sure you want to delete this app?'
onCancel={handleClose}
onConfirm={handleDeleteApp}
onClose={handleClose}
/>
<ModalActivities appNpub={appNpub} />
</>
)
return (
<>
<Stack maxHeight={'100%'} overflow={'auto'} alignItems={'flex-start'} height={'100%'}>
<IOSBackButton onNavigate={() => navigate(`key/${npub}`)} />
<Stack marginBottom={'1rem'} direction={'row'} gap={'1rem'} width={'100%'}>
<StyledAppIcon src={icon}>{appAvatarTitle}</StyledAppIcon>
<Box flex={'1'} overflow={'hidden'}>
<Stack direction={'row'} alignItems={'center'} gap={'0.5rem'}>
<Typography variant="h4" noWrap flex={1}>
{appName}
</Typography>
<IconButton onClick={handleShowAppDetailsModal}>
<MoreIcon />
</IconButton>
</Stack>
<Typography variant="body2" noWrap>
{connectedOn}
</Typography>
</Box>
</Stack>
<Box marginBottom={'1rem'}>
<SectionTitle marginBottom={'0.5rem'}>Disconnect</SectionTitle>
<Button fullWidth onClick={handleShow}>
Delete app
</Button>
</Box>
<Permissions perms={perms} />
<Button fullWidth onClick={() => handleOpenModal(MODAL_PARAMS_KEYS.ACTIVITY)}>
Activity
</Button>
</Stack>
<ConfirmModal
open={open}
headingText="Delete app"
description="Are you sure you want to delete this app?"
onCancel={handleClose}
onConfirm={handleDeleteApp}
onClose={handleClose}
/>
<ModalActivities appNpub={appNpub} />
<ModalAppDetails />
</>
)
}
export default AppPage

View File

@@ -10,36 +10,19 @@ import { ACTIONS } from '@/utils/consts'
type ItemActivityProps = DbHistory
export const ItemActivity: FC<ItemActivityProps> = ({
allowed,
method,
timestamp,
}) => {
return (
<StyledActivityItem>
<Box
display={'flex'}
flexDirection={'column'}
gap={'0.5rem'}
flex={1}
>
<Typography flex={1} fontWeight={700}>
{ACTIONS[method] || method}
</Typography>
<Typography variant='body2'>
{formatTimestampDate(timestamp)}
</Typography>
</Box>
<Box>
{allowed ? (
<DoneRoundedIcon htmlColor='green' />
) : (
<ClearRoundedIcon htmlColor='red' />
)}
</Box>
<IconButton>
<MoreVertRoundedIcon />
</IconButton>
</StyledActivityItem>
)
export const ItemActivity: FC<ItemActivityProps> = ({ allowed, method, timestamp }) => {
return (
<StyledActivityItem>
<Box display={'flex'} flexDirection={'column'} gap={'0.5rem'} flex={1}>
<Typography flex={1} fontWeight={700}>
{ACTIONS[method] || method}
</Typography>
<Typography variant="body2">{formatTimestampDate(timestamp)}</Typography>
</Box>
<Box>{allowed ? <DoneRoundedIcon htmlColor="green" /> : <ClearRoundedIcon htmlColor="red" />}</Box>
<IconButton>
<MoreVertRoundedIcon />
</IconButton>
</StyledActivityItem>
)
}

View File

@@ -8,32 +8,23 @@ import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
type ModalActivitiesProps = {
appNpub: string
appNpub: string
}
export const ModalActivities: FC<ModalActivitiesProps> = ({ appNpub }) => {
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.ACTIVITY)
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.ACTIVITY)
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.ACTIVITY)
const handleCloseModal = createHandleCloseReplace(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'
>
<Box overflow={'auto'}>
{history.map((item) => {
return <ItemActivity {...item} key={item.id} />
})}
</Box>
</Modal>
)
return (
<Modal open={isModalOpened} onClose={handleCloseModal} fixedHeight="calc(100% - 5rem)" title="Activity history">
<Box overflow={'auto'}>
{history.map((item) => {
return <ItemActivity {...item} key={item.id} />
})}
</Box>
</Modal>
)
}

View File

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

View File

@@ -2,58 +2,40 @@ 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
permission: DbPerm
}
export const ItemPermission: FC<ItemPermissionProps> = ({ permission }) => {
const { perm, value, timestamp, id } = permission || {}
const { value, timestamp, id } = permission || {}
const { anchorEl, handleClose, handleOpen, open } = useOpenMenu()
const { anchorEl, handleClose, handleOpen, open } = useOpenMenu()
const isAllowed = value === '1'
const isAllowed = value === '1'
return (
<>
<StyledPermissionItem>
<Box
display={'flex'}
flexDirection={'column'}
gap={'0.5rem'}
flex={1}
>
<Typography flex={1} fontWeight={700}>
{ACTIONS[perm] || perm}
</Typography>
<Typography variant='body2'>
{formatTimestampDate(timestamp)}
</Typography>
</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}
/>
</>
)
return (
<>
<StyledPermissionItem>
<Box display={'flex'} flexDirection={'column'} gap={'0.5rem'} flex={1}>
<Typography flex={1} fontWeight={700}>
{getPermActionName(permission)}
</Typography>
<Typography variant="body2">{formatTimestampDate(timestamp)}</Typography>
</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} />
</>
)
}

View File

@@ -5,58 +5,51 @@ import { swicCall } from '@/modules/swic'
import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar'
type ItemPermissionMenuProps = {
permId: string
handleClose: () => void
permId: string
handleClose: () => void
} & MenuProps
export const ItemPermissionMenu: FC<ItemPermissionMenuProps> = ({
open,
anchorEl,
handleClose,
permId,
}) => {
const [showConfirm, setShowConfirm] = useState(false)
const notify = useEnqueueSnackbar()
export const ItemPermissionMenu: FC<ItemPermissionMenuProps> = ({ open, anchorEl, handleClose, permId }) => {
const [showConfirm, setShowConfirm] = useState(false)
const notify = useEnqueueSnackbar()
const handleShowConfirm = () => {
setShowConfirm(true)
handleClose()
}
const handleCloseConfirm = () => setShowConfirm(false)
const handleShowConfirm = () => {
setShowConfirm(true)
handleClose()
}
const handleCloseConfirm = () => setShowConfirm(false)
const handleDeletePerm = async () => {
try {
await swicCall('deletePerm', permId)
notify('Permission successfully deleted!', 'success')
handleCloseConfirm()
} catch (error: any) {
notify(error?.message || 'Failed to delete permission', 'error')
}
}
const handleDeletePerm = async () => {
try {
await swicCall('deletePerm', permId)
notify('Permission successfully deleted!', 'success')
handleCloseConfirm()
} catch (error: any) {
notify(error?.message || 'Failed to delete permission', 'error')
}
}
return (
<>
<Menu
open={open}
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{
horizontal: 'left',
vertical: 'bottom',
}}
>
<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?'
onConfirm={handleDeletePerm}
/>
</>
)
return (
<>
<Menu
open={open}
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{
horizontal: 'left',
vertical: 'bottom',
}}
>
<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?"
onConfirm={handleDeletePerm}
/>
</>
)
}

View File

@@ -5,24 +5,18 @@ import { Box } from '@mui/material'
import { ItemPermission } from './ItemPermission'
type PermissionsProps = {
perms: DbPerm[]
perms: DbPerm[]
}
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'}
>
{perms.map((perm) => {
return <ItemPermission key={perm.id} permission={perm} />
})}
</Box>
</Box>
)
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'}>
{perms.map((perm) => {
return <ItemPermission key={perm.id} permission={perm} />
})}
</Box>
</Box>
)
}

View File

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

View File

@@ -2,5 +2,5 @@ import { Button } from '@/shared/Button/Button'
import { styled } from '@mui/material'
export const StyledButton = styled(Button)({
textTransform: 'capitalize',
textTransform: 'capitalize',
})

View File

@@ -1,8 +1,6 @@
import { Avatar, AvatarProps, styled } from '@mui/material'
export const StyledAppIcon = styled((props: AvatarProps) => (
<Avatar {...props} variant='rounded' />
))(() => ({
width: 70,
height: 70,
export const StyledAppIcon = styled((props: AvatarProps) => <Avatar {...props} variant="rounded" />)(() => ({
width: 70,
height: 70,
}))

View File

@@ -1,18 +1,18 @@
import { DbHistory, db } from '@/modules/db'
export const getActivityHistoryQuerier = (appNpub: string) => () => {
if (!appNpub.trim().length) return []
if (!appNpub.trim().length) return []
const result = db.history
.where('appNpub')
.equals(appNpub)
.reverse()
.sortBy('timestamp')
.then(a => a.slice(0, 30))
// .limit(30)
// .toArray()
const result = db.history
.where('appNpub')
.equals(appNpub)
.reverse()
.sortBy('timestamp')
.then((a) => a.slice(0, 30))
// .limit(30)
// .toArray()
return result
return result
}
export const HistoryDefaultValue: DbHistory[] = []

View File

@@ -4,78 +4,70 @@ 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)')
const isMobile = useMediaQuery('(max-width:600px)')
const [enteredValue, setEnteredValue] = useState('')
const [enteredValue, setEnteredValue] = useState('')
const theme = useTheme()
const theme = useTheme()
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
setEnteredValue(e.target.value)
}
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
setEnteredValue(e.target.value)
}
const isAvailable = enteredValue.trim().length > 2
const isAvailable = enteredValue.trim().length > 2
const inputHelperText = isAvailable ? (
<>
<CheckmarkIcon /> Available
</>
) : (
"Don't worry, username can be changed later."
)
const inputHelperText = isAvailable ? (
<>
<CheckmarkIcon /> Available
</>
) : (
"Don't worry, username can be changed later."
)
const mainContent = (
<>
<Input
label='Enter a Username'
fullWidth
placeholder='Username'
helperText={inputHelperText}
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,
},
},
}}
/>
<Button fullWidth>Sign up</Button>
</>
)
const mainContent = (
<>
<Input
label="Enter a Username"
fullWidth
placeholder="Username"
helperText={inputHelperText}
endAdornment={<Typography color={'#FFFFFFA8'}>@{DOMAIN}</Typography>}
onChange={handleInputChange}
value={enteredValue}
helperTextProps={{
sx: {
'&.helper_text': {
color: isAvailable ? theme.palette.success.main : theme.palette.textSecondaryDecorate.main,
},
},
}}
/>
<Button fullWidth>Sign up</Button>
</>
)
return (
<Stack height={'100%'} position={'relative'}>
{isMobile ? (
<StyledContent>
<Stack
direction={'row'}
gap={'1rem'}
alignItems={'center'}
alignSelf={'flex-start'}
>
<StyledAppLogo />
<Typography fontWeight={600} variant='h5'>
Sign up
</Typography>
</Stack>
{mainContent}
</StyledContent>
) : (
<Stack gap={'1rem'} alignItems={'center'}>
{mainContent}
</Stack>
)}
</Stack>
)
return (
<Stack height={'100%'} position={'relative'}>
{isMobile ? (
<StyledContent>
<Stack direction={'row'} gap={'1rem'} alignItems={'center'} alignSelf={'flex-start'}>
<StyledAppLogo />
<Typography fontWeight={600} variant="h5">
Sign up
</Typography>
</Stack>
{mainContent}
</StyledContent>
) : (
<Stack gap={'1rem'} alignItems={'center'}>
{mainContent}
</Stack>
)}
</Stack>
)
}
export default AuthPage

View File

@@ -1,32 +1,32 @@
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 }) => {
return {
background: theme.palette.secondary.main,
position: 'absolute',
bottom: '-1rem',
left: '-1rem',
width: 'calc(100% + 2rem)',
height: '70%',
borderTopLeftRadius: '2rem',
borderTopRightRadius: '2rem',
padding: '1rem',
maxWidth: '50rem',
margin: '0 auto',
}
export const StyledContent = styled((props: StackProps) => <Stack {...props} gap={'1rem'} alignItems={'center'} />)(({
theme,
}) => {
return {
background: theme.palette.secondary.main,
position: 'absolute',
bottom: '-1rem',
left: '-1rem',
width: 'calc(100% + 2rem)',
height: '70%',
borderTopLeftRadius: '2rem',
borderTopRightRadius: '2rem',
padding: '1rem',
maxWidth: '50rem',
margin: '0 auto',
}
})
export const StyledAppLogo = styled((props) => (
<Box {...props}>
<AppLogo />
</Box>
<Box {...props}>
<AppLogo />
</Box>
))({
background: '#00000054',
padding: '0.75rem',
borderRadius: '16px',
display: 'grid',
placeItems: 'center',
background: '#0d0d0d',
padding: '0.75rem',
borderRadius: '16px',
display: 'grid',
placeItems: 'center',
})

View File

@@ -1,7 +1,7 @@
import React from 'react'
const ConfirmPage = () => {
return <div>ConfirmPage</div>
return <div>ConfirmPage</div>
}
export default ConfirmPage

View File

@@ -0,0 +1,114 @@
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'
const CreatePage = () => {
const notify = useEnqueueSnackbar()
const { handleOpen } = useModalSearchParams()
const [created, setCreated] = useState(false)
const [searchParams] = useSearchParams()
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 {
const key: any = await swicCall('generateKey', name)
const appUrl = getReferrerAppUrl();
console.log('Created', key.npub, 'app', appUrl)
setCreated(true)
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')
}
}
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</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

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

View File

@@ -10,61 +10,47 @@ import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { DOMAIN } from '@/utils/consts'
const HomePage = () => {
const keys = useAppSelector(selectKeys)
const isNoKeys = !keys || keys.length === 0
const keys = useAppSelector(selectKeys)
const isNoKeys = !keys || keys.length === 0
const { handleOpen } = useModalSearchParams()
const handleClickAddAccount = () => handleOpen(MODAL_PARAMS_KEYS.INITIAL)
const { handleOpen } = useModalSearchParams()
const handleClickAddAccount = () => handleOpen(MODAL_PARAMS_KEYS.INITIAL)
const handleLearnMore = () => {
// @ts-ignore
window.open(`https://info.${DOMAIN}`, '_blank').focus();
}
const handleLearnMore = () => {
// @ts-ignore
window.open(`https://${DOMAIN}`, '_blank').focus()
}
return (
<Stack maxHeight={'100%'} overflow={'auto'}>
<SectionTitle marginBottom={'0.5rem'}>
{isNoKeys ? 'Welcome' : 'Keys:'}
</SectionTitle>
<Stack gap={'0.5rem'} overflow={'auto'}>
{isNoKeys && (
<>
<Typography textAlign={'left'} variant='h6' paddingTop='1em'>
Nsec.app is a novel key storage app for Nostr.
</Typography>
<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>
</>
)}
{!isNoKeys && (
<Fragment>
<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>
</Fragment>
)}
</Stack>
</Stack>
)
return (
<Stack maxHeight={'100%'} overflow={'auto'}>
<SectionTitle marginBottom={'0.5rem'}>{isNoKeys ? 'Welcome' : 'Accounts:'}</SectionTitle>
<Stack gap={'0.5rem'} overflow={'auto'}>
{isNoKeys && (
<>
<Typography textAlign={'left'} variant="h6" paddingTop="1em">
Nsec.app is a novel key storage app for Nostr.
</Typography>
<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>
</>
)}
{!isNoKeys && (
<Fragment>
<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>
</Fragment>
)}
</Stack>
</Stack>
)
}
export default HomePage

View File

@@ -1,61 +1,50 @@
import { FC } from 'react'
import { DbKey } from '../../../modules/db'
import {
Avatar,
Stack,
StackProps,
Typography,
TypographyProps,
styled,
} from '@mui/material'
import { getShortenNpub } from '../../../utils/helpers/helpers'
import { Avatar, Stack, StackProps, Typography, TypographyProps, styled } from '@mui/material'
import { useNavigate } from 'react-router-dom'
import { useProfile } from '@/hooks/useProfile'
type ItemKeyProps = DbKey
export const ItemKey: FC<ItemKeyProps> = (props) => {
const { npub, profile } = props
const navigate = useNavigate()
const { npub } = 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 || ''
const handleNavigate = () => {
navigate('/key/' + npub)
}
return (
<StyledKeyContainer onClick={handleNavigate}>
<Stack direction={'row'} alignItems={'center'} gap='1rem'>
<Avatar src={userAvatar} alt={userName} />
<StyledText variant='body1'>{userName}</StyledText>
</Stack>
</StyledKeyContainer>
)
return (
<StyledKeyContainer onClick={handleNavigate}>
<Stack direction={'row'} alignItems={'center'} gap="1rem">
<Avatar src={userAvatar} alt={userName}>
{avatarTitle}
</Avatar>
<StyledText variant="body1">{userName}</StyledText>
</Stack>
</StyledKeyContainer>
)
}
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)',
borderRadius: '12px',
padding: '0.5rem 1rem',
background: theme.palette.background.paper,
':hover': {
background: `${theme.palette.background.paper}95`,
},
cursor: 'pointer',
}
})
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)',
borderRadius: '12px',
padding: '0.5rem 1rem',
background: theme.palette.background.paper,
':hover': {
background: `${theme.palette.background.paper}95`,
},
cursor: 'pointer',
}
}
)
export const StyledText = styled((props: TypographyProps) => (
<Typography {...props} />
))({
fontWeight: 500,
width: '100%',
wordBreak: 'break-all',
export const StyledText = styled((props: TypographyProps) => <Typography {...props} />)({
fontWeight: 500,
width: '100%',
wordBreak: 'break-all',
})

View File

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

View File

@@ -1,5 +1,5 @@
import { useAppSelector } from '../../store/hooks/redux'
import { useParams } from 'react-router-dom'
import { Navigate, useParams } from 'react-router-dom'
import { Stack } from '@mui/material'
import { StyledIconButton } from './styled'
import { SettingsIcon, ShareIcon } from '@/assets'
@@ -11,84 +11,81 @@ 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 { useCallback } from 'react'
const KeyPage = () => {
const { npub = '' } = useParams<{ npub: string }>()
const { apps, pending, perms } = useAppSelector((state) => state.content)
const isSynced = useLiveQuery(checkNpubSyncQuerier(npub), [npub], false)
const { npub = '' } = useParams<{ npub: string }>()
const { keys, apps, pending, perms } = useAppSelector((state) => state.content)
const { handleOpen } = useModalSearchParams()
const isSynced = useLiveQuery(checkNpubSyncQuerier(npub), [npub], false)
const { handleOpen } = useModalSearchParams()
const { handleEnableBackground, showWarning, isEnabling } = useBackgroundSigning()
const { userNameWithPrefix } = useProfile(npub)
const { handleEnableBackground, showWarning, isEnabling } =
useBackgroundSigning()
const key = keys.find((k) => k.npub === npub)
const filteredApps = apps.filter((a) => a.npub === npub)
const { prepareEventPendings } = useTriggerConfirmModal(
npub,
pending,
perms,
)
const getUsername = useCallback(() => {
if (!key || !key?.name) return ''
if (key.name.includes('@')) return key.name
return `${key?.name}@${DOMAIN}`
}, [key])
const username = getUsername()
const handleOpenConnectAppModal = () =>
handleOpen(MODAL_PARAMS_KEYS.CONNECT_APP)
const filteredApps = apps.filter((a) => a.npub === npub)
const { prepareEventPendings } = useTriggerConfirmModal(npub, pending, perms)
const handleOpenSettingsModal = () => handleOpen(MODAL_PARAMS_KEYS.SETTINGS)
const isKeyExists = npub.trim().length && key
if (!isKeyExists) return <Navigate to={`/home`} />
return (
<>
<Stack gap={'1rem'} height={'100%'}>
{showWarning && (
<BackgroundSigningWarning
isEnabling={isEnabling}
onEnableBackSigning={handleEnableBackground}
/>
)}
<UserValueSection
title='Your login'
value={userNameWithPrefix}
copyValue={npub + '@nsec.app'}
explanationType={EXPLANATION_MODAL_KEYS.NPUB}
/>
<UserValueSection
title='Your NPUB'
value={npub}
copyValue={npub}
explanationType={EXPLANATION_MODAL_KEYS.NPUB}
/>
const handleOpenConnectAppModal = () => handleOpen(MODAL_PARAMS_KEYS.CONNECT_APP)
const handleOpenSettingsModal = () => handleOpen(MODAL_PARAMS_KEYS.SETTINGS)
<Stack direction={'row'} gap={'0.75rem'}>
<StyledIconButton onClick={handleOpenConnectAppModal}>
<ShareIcon />
Connect app
</StyledIconButton>
return (
<>
<Stack gap={'1rem'} height={'100%'}>
{showWarning && (
<BackgroundSigningWarning isEnabling={isEnabling} onEnableBackSigning={handleEnableBackground} />
)}
<UserValueSection
title="Your login"
value={username}
copyValue={username}
explanationType={EXPLANATION_MODAL_KEYS.LOGIN}
/>
<UserValueSection
title="Your NPUB"
value={npub}
copyValue={npub}
explanationType={EXPLANATION_MODAL_KEYS.NPUB}
/>
<StyledIconButton
bgcolor_variant='secondary'
onClick={handleOpenSettingsModal}
withBadge={!isSynced}
>
<SettingsIcon />
Settings
</StyledIconButton>
</Stack>
<Stack direction={'row'} gap={'0.75rem'}>
<StyledIconButton onClick={handleOpenConnectAppModal}>
<ShareIcon />
Connect app
</StyledIconButton>
<Apps apps={filteredApps} npub={npub} />
</Stack>
<ModalConnectApp />
<ModalSettings isSynced={isSynced} />
<ModalExplanation />
<ModalConfirmConnect />
<ModalConfirmEvent confirmEventReqs={prepareEventPendings} />
</>
)
<StyledIconButton bgcolor_variant="secondary" onClick={handleOpenSettingsModal} withBadge={!isSynced}>
<SettingsIcon />
Settings
</StyledIconButton>
</Stack>
<Apps apps={filteredApps} npub={npub} />
</Stack>
<ModalConnectApp />
<ModalSettings isSynced={isSynced} />
<ModalExplanation />
<ModalConfirmConnect />
<ModalConfirmEvent confirmEventReqs={prepareEventPendings} />
</>
)
}
export default KeyPage

View File

@@ -11,57 +11,45 @@ import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar'
import { ItemApp } from './ItemApp'
type AppsProps = {
apps: DbApp[]
npub: string
apps: DbApp[]
npub: string
}
export const Apps: FC<AppsProps> = ({ apps = [], npub = '' }) => {
const notify = useEnqueueSnackbar()
export const Apps: FC<AppsProps> = ({ apps = [] }) => {
const notify = useEnqueueSnackbar()
// eslint-disable-next-line
async function deletePerm(id: string) {
call(async () => {
await swicCall('deletePerm', id)
notify('Perm deleted!', 'success')
})
}
// eslint-disable-next-line
async function deletePerm(id: string) {
call(async () => {
await swicCall('deletePerm', id)
notify('Perm deleted!', 'success')
})
}
return (
<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' />
</Stack>
{!apps.length && (
<StyledEmptyAppsBox>
<Typography
className='message'
variant='h5'
fontWeight={600}
textAlign={'center'}
>
No connected apps
</Typography>
<Button>Discover Nostr Apps</Button>
</StyledEmptyAppsBox>
)}
const openAppStore = () => {
window.open('https://nostrapp.link', '_blank')
}
<Stack gap={'0.5rem'} overflow={'auto'} flex={1}>
{apps.map((a) => (
<ItemApp {...a} key={a.appNpub} />
))}
</Stack>
</Box>
)
return (
<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} />
</Stack>
{!apps.length && (
<StyledEmptyAppsBox>
<Typography className="message" variant="h5" fontWeight={600} textAlign={'center'}>
No connected apps
</Typography>
<Button onClick={openAppStore}>Discover Nostr Apps</Button>
</StyledEmptyAppsBox>
)}
<Stack gap={'0.5rem'} overflow={'auto'} flex={1}>
{apps.map((a) => (
<ItemApp {...a} key={a.appNpub} />
))}
</Stack>
</Box>
)
}

View File

@@ -1,27 +1,29 @@
import React, { FC } from 'react'
import { FC } from 'react'
import { Warning } from '@/components/Warning/Warning'
import { CircularProgress, Stack } from '@mui/material'
import GppMaybeIcon from '@mui/icons-material/GppMaybe'
import { CircularProgress, Stack, Typography } from '@mui/material'
import AutoModeOutlinedIcon from '@mui/icons-material/AutoModeOutlined'
type BackgroundSigningWarningProps = {
isEnabling: boolean
onEnableBackSigning: () => void
isEnabling: boolean
onEnableBackSigning: () => void
}
export const BackgroundSigningWarning: FC<BackgroundSigningWarningProps> = ({
isEnabling,
onEnableBackSigning,
}) => {
return (
<Warning
message={
<Stack direction={'row'} alignItems={'center'} gap={'1rem'}>
Please enable push notifications{' '}
{isEnabling ? <CircularProgress size={'1.5rem'} /> : null}
</Stack>
}
Icon={<GppMaybeIcon htmlColor='white' />}
onClick={isEnabling ? undefined : onEnableBackSigning}
/>
)
export const BackgroundSigningWarning: FC<BackgroundSigningWarningProps> = ({ isEnabling, onEnableBackSigning }) => {
return (
<Warning
message={
<Stack direction={'row'} alignItems={'center'} gap={'1rem'}>
Enable background service {isEnabling ? <CircularProgress size={'1.5rem'} /> : null}
</Stack>
}
hint={
<Typography variant='body2'>
Please allow notifications
for background operation.
</Typography>
}
icon={<AutoModeOutlinedIcon htmlColor="white" />}
onClick={isEnabling ? undefined : onEnableBackSigning}
/>
)
}

View File

@@ -2,42 +2,41 @@ import { DbApp } from '@/modules/db'
import { Avatar, Stack, Typography } from '@mui/material'
import { FC } from 'react'
import { Link } from 'react-router-dom'
// import ImageOutlinedIcon from '@mui/icons-material/ImageOutlined'
import { getShortenNpub } from '@/utils/helpers/helpers'
import { getAppIconTitle, getDomain, getShortenNpub } from '@/utils/helpers/helpers'
import { StyledItemAppContainer } from './styled'
type ItemAppProps = DbApp
export const ItemApp: FC<ItemAppProps> = ({ npub, appNpub, icon, name }) => {
const appName = name || getShortenNpub(appNpub)
return (
<StyledItemAppContainer
direction={'row'}
alignItems={'center'}
gap={'0.5rem'}
padding={'0.5rem 0'}
component={Link}
to={`/key/${npub}/app/${appNpub}`}
>
<Avatar
variant='square'
sx={{ width: 56, height: 56 }}
src={icon}
alt={name}
/>
<Stack>
<Typography noWrap display={'block'} variant='body2'>
{appName}
</Typography>
<Typography
noWrap
display={'block'}
variant='caption'
color={'GrayText'}
>
Basic actions
</Typography>
</Stack>
</StyledItemAppContainer>
)
export const ItemApp: FC<ItemAppProps> = ({ npub, appNpub, icon, name, url }) => {
const appDomain = getDomain(url)
const appName = name || appDomain || getShortenNpub(appNpub)
const appIcon = icon || `https://${appDomain}/favicon.ico`
const appAvatarTitle = getAppIconTitle(name || appDomain, appNpub)
return (
<StyledItemAppContainer
direction={'row'}
alignItems={'center'}
gap={'0.5rem'}
padding={'0.5rem 0'}
component={Link}
to={`/key/${npub}/app/${appNpub}`}
>
<Avatar
variant="rounded"
sx={{ width: 56, height: 56 }}
src={appIcon}
alt={appName}
>
{appAvatarTitle}
</Avatar>
<Stack>
<Typography noWrap display={'block'} variant="body2">
{appName}
</Typography>
<Typography noWrap display={'block'} variant="caption" color={'GrayText'}>
Basic actions
</Typography>
</Stack>
</StyledItemAppContainer>
)
}

View File

@@ -8,48 +8,31 @@ import { StyledInput } from '../styled'
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
type UserValueSectionProps = {
title: string
value: string
explanationType: EXPLANATION_MODAL_KEYS
copyValue: string
title: string
value: string
explanationType: EXPLANATION_MODAL_KEYS
copyValue: string
}
const UserValueSection: FC<UserValueSectionProps> = ({
title,
value,
explanationType,
copyValue,
}) => {
const { handleOpen } = useModalSearchParams()
const UserValueSection: FC<UserValueSectionProps> = ({ title, value, explanationType, copyValue }) => {
const { handleOpen } = useModalSearchParams()
const handleOpenExplanationModal = (type: EXPLANATION_MODAL_KEYS) => {
handleOpen(MODAL_PARAMS_KEYS.EXPLANATION, {
search: {
type,
},
})
}
return (
<Box>
<Stack
direction={'row'}
alignItems={'center'}
justifyContent={'space-between'}
marginBottom={'0.5rem'}
>
<SectionTitle>{title}</SectionTitle>
<AppLink
title='What is this?'
onClick={() => handleOpenExplanationModal(explanationType)}
/>
</Stack>
<StyledInput
value={value}
readOnly
endAdornment={<InputCopyButton value={copyValue} />}
/>
</Box>
)
const handleOpenExplanationModal = (type: EXPLANATION_MODAL_KEYS) => {
handleOpen(MODAL_PARAMS_KEYS.EXPLANATION, {
search: {
type,
},
})
}
return (
<Box>
<Stack direction={'row'} alignItems={'center'} justifyContent={'space-between'} marginBottom={'0.5rem'}>
<SectionTitle>{title}</SectionTitle>
<AppLink title="What is this?" onClick={() => handleOpenExplanationModal(explanationType)} />
</Stack>
<StyledInput value={value} readOnly endAdornment={<InputCopyButton value={copyValue} />} />
</Box>
)
}
export default UserValueSection

View File

@@ -1,40 +1,42 @@
import { Input, InputProps } from '@/shared/Input/Input'
import { Input, AppInputProps } from '@/shared/Input/Input'
import { Stack, StackProps, styled } from '@mui/material'
import { forwardRef } from 'react'
export const StyledInput = styled(({ className, ...props }: InputProps) => {
return (
<Input
{...props}
className='input'
containerProps={{
className,
}}
fullWidth
/>
)
})(({ theme }) => ({
'& > .input': {
border: 'none',
background: theme.palette.secondary.main,
color: theme.palette.primary.main,
'& .adornment': {
color: theme.palette.primary.main,
},
},
}))
export const StyledItemAppContainer = styled(
<C extends React.ElementType>(props: StackProps<C, { component?: C }>) => (
<Stack {...props} />
),
export const StyledInput = styled(
forwardRef<HTMLInputElement, AppInputProps>(({ className, ...props }, ref) => {
return (
<Input
{...props}
ref={ref}
className="input"
containerProps={{
className,
}}
fullWidth
/>
)
})
)(({ theme }) => ({
textDecoration: 'none',
boxShadow: 'none',
color: theme.palette.text.primary,
background: theme.palette.backgroundSecondary.default,
borderRadius: '12px',
padding: '0.5rem 1rem',
':hover': {
background: `${theme.palette.backgroundSecondary.default}95`,
},
'& > .input': {
border: 'none',
background: theme.palette.secondary.main,
color: theme.palette.primary.main,
'& .adornment': {
color: theme.palette.primary.main,
},
},
}))
export const StyledItemAppContainer = styled(<C extends React.ElementType>(props: StackProps<C, { component?: C }>) => (
<Stack {...props} />
))(({ theme }) => ({
textDecoration: 'none',
boxShadow: 'none',
color: theme.palette.text.primary,
background: theme.palette.backgroundSecondary.default,
borderRadius: '12px',
padding: '0.5rem 1rem',
':hover': {
background: `${theme.palette.backgroundSecondary.default}95`,
},
}))

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