chore: implemented basic layout and login page
This commit is contained in:
parent
4dfb379921
commit
aa991be416
1
.env.example
Normal file
1
.env.example
Normal file
@ -0,0 +1 @@
|
||||
VITE_MOST_POPULAR_RELAYS=wss://relay.damus.io wss://eden.nostr.land wss://nos.lol wss://relay.snort.social wss://relay.current.fyi wss://brb.io wss://nostr.orangepill.dev wss://nostr-pub.wellorder.net wss://nostr.bitcoiner.social wss://nostr.wine wss://nostr.oxtr.dev wss://relay.nostr.bg wss://nostr.mom wss://nostr.fmt.wiz.biz wss://relay.nostr.band wss://nostr-pub.semisol.dev wss://nostr.milou.lol wss://puravida.nostr.land wss://nostr.onsats.org wss://relay.nostr.info wss://offchain.pub wss://relay.orangepill.dev wss://no.str.cr wss://atlas.nostr.land wss://nostr.zebedee.cloud wss://nostr-relay.wlvs.space wss://relay.nostrati.com wss://relay.nostr.com.au wss://nostr.inosta.cc wss://nostr.rocks
|
@ -4,7 +4,7 @@ module.exports = {
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
'plugin:react-hooks/recommended'
|
||||
],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
@ -12,7 +12,8 @@ module.exports = {
|
||||
rules: {
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
{ allowConstantExport: true }
|
||||
],
|
||||
},
|
||||
'@typescript-eslint/no-explicit-any': 'warn'
|
||||
}
|
||||
}
|
||||
|
1478
package-lock.json
generated
1478
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
16
package.json
16
package.json
@ -10,10 +10,24 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "11.11.4",
|
||||
"@emotion/styled": "11.11.0",
|
||||
"@mui/icons-material": "5.15.11",
|
||||
"@mui/material": "5.15.11",
|
||||
"@nostr-dev-kit/ndk": "2.5.0",
|
||||
"@reduxjs/toolkit": "2.2.1",
|
||||
"axios": "1.6.7",
|
||||
"lodash": "4.17.21",
|
||||
"nostr-tools": "2.3.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
"react-dom": "^18.2.0",
|
||||
"react-redux": "9.1.0",
|
||||
"react-router-dom": "6.22.1",
|
||||
"react-toastify": "10.0.4",
|
||||
"redux": "5.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/lodash": "4.14.202",
|
||||
"@types/react": "^18.2.56",
|
||||
"@types/react-dom": "^18.2.19",
|
||||
"@typescript-eslint/eslint-plugin": "^7.0.2",
|
||||
|
95
src/App.tsx
95
src/App.tsx
@ -1,34 +1,73 @@
|
||||
import { useState } from 'react'
|
||||
import reactLogo from './assets/react.svg'
|
||||
import viteLogo from '/vite.svg'
|
||||
import './App.css'
|
||||
import { useEffect } from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { NostrController } from './controllers'
|
||||
import { appPrivateRoutes, privateRoutes, publicRoutes } from './routes'
|
||||
import { State } from './store/rootReducer'
|
||||
import { getNsecBunkerDelegatedKey, saveNsecBunkerDelegatedKey } from './utils'
|
||||
import { MainLayout } from './layouts/Main'
|
||||
import { LandingPage } from './pages/landing/LandingPage'
|
||||
|
||||
function App() {
|
||||
const [count, setCount] = useState(0)
|
||||
const App = () => {
|
||||
const authState = useSelector((state: State) => state.auth)
|
||||
|
||||
useEffect(() => {
|
||||
generateBunkerDelegatedKey()
|
||||
}, [])
|
||||
|
||||
const generateBunkerDelegatedKey = () => {
|
||||
const existingKey = getNsecBunkerDelegatedKey()
|
||||
|
||||
if (!existingKey) {
|
||||
const nostrController = NostrController.getInstance()
|
||||
const newDelegatedKey = nostrController.generateDelegatedKey()
|
||||
|
||||
saveNsecBunkerDelegatedKey(newDelegatedKey)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<a href="https://vitejs.dev" target="_blank">
|
||||
<img src={viteLogo} className="logo" alt="Vite logo" />
|
||||
</a>
|
||||
<a href="https://react.dev" target="_blank">
|
||||
<img src={reactLogo} className="logo react" alt="React logo" />
|
||||
</a>
|
||||
</div>
|
||||
<h1>Vite + React</h1>
|
||||
<div className="card">
|
||||
<button onClick={() => setCount((count) => count + 1)}>
|
||||
count is {count}
|
||||
</button>
|
||||
<p>
|
||||
Edit <code>src/App.tsx</code> and save to test HMR
|
||||
</p>
|
||||
</div>
|
||||
<p className="read-the-docs">
|
||||
Click on the Vite and React logos to learn more
|
||||
</p>
|
||||
</>
|
||||
<Routes>
|
||||
<Route element={<MainLayout />}>
|
||||
{authState?.loggedIn && (
|
||||
<Route
|
||||
path='/'
|
||||
element={<Navigate to={appPrivateRoutes.homePage} />}
|
||||
/>
|
||||
)}
|
||||
{authState?.loggedIn &&
|
||||
privateRoutes.map((route, index) => (
|
||||
<Route
|
||||
key={route.path + index}
|
||||
path={route.path}
|
||||
element={route.element}
|
||||
/>
|
||||
))}
|
||||
{publicRoutes.map((route, index) => {
|
||||
if (authState?.loggedIn) {
|
||||
if (!route.hiddenWhenLoggedIn) {
|
||||
return (
|
||||
<Route
|
||||
key={route.path + index}
|
||||
path={route.path}
|
||||
element={route.element}
|
||||
/>
|
||||
)
|
||||
}
|
||||
} else {
|
||||
return (
|
||||
<Route
|
||||
key={route.path + index}
|
||||
path={route.path}
|
||||
element={route.element}
|
||||
/>
|
||||
)
|
||||
}
|
||||
})}
|
||||
{!authState ||
|
||||
(!authState.loggedIn && <Route path='*' element={<LandingPage />} />)}
|
||||
</Route>
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
|
BIN
src/assets/avenir-font/AvenirLTStd-Black.otf
Normal file
BIN
src/assets/avenir-font/AvenirLTStd-Black.otf
Normal file
Binary file not shown.
BIN
src/assets/avenir-font/AvenirLTStd-Book.otf
Normal file
BIN
src/assets/avenir-font/AvenirLTStd-Book.otf
Normal file
Binary file not shown.
BIN
src/assets/avenir-font/AvenirLTStd-Roman.otf
Normal file
BIN
src/assets/avenir-font/AvenirLTStd-Roman.otf
Normal file
Binary file not shown.
BIN
src/assets/images/avatar.png
Normal file
BIN
src/assets/images/avatar.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 169 KiB |
BIN
src/assets/images/nostr-logo.jpg
Normal file
BIN
src/assets/images/nostr-logo.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 85 KiB |
@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
Before Width: | Height: | Size: 4.0 KiB |
50
src/colors.scss
Normal file
50
src/colors.scss
Normal file
@ -0,0 +1,50 @@
|
||||
$primary-main: var(--mui-palette-primary-main);
|
||||
$primary-light: var(--mui-palette-primary-light);
|
||||
$primary-dark: var(--mui-palette-primary-dark);
|
||||
|
||||
$box-shadow-color: rgba(0, 0, 0, 0.1);
|
||||
$border-color: #27323c;
|
||||
|
||||
$page-background-color: #f4f4fb;
|
||||
$modal-input-background: #f4f4fb;
|
||||
$background-color: #fff;
|
||||
|
||||
$text-color: #3e3e3e;
|
||||
$input-text-color: #717171;
|
||||
$info-text-color: #4169e1;
|
||||
|
||||
$icon-color: #c1c1c1;
|
||||
$zap-icon-color: #ffd700;
|
||||
|
||||
$btn-background-color: $primary-main;
|
||||
$btn-color: #fff;
|
||||
|
||||
$progress-box-background-color: $primary-main;
|
||||
$progress-box-inner-background-color: $primary-dark;
|
||||
$rank-progress-background-color: #445c76;
|
||||
|
||||
$correct-answer-background-color: $primary-light;
|
||||
$incorrect-answer-background-color: #e84d67;
|
||||
$incorrect-answer-label-color: #fff;
|
||||
|
||||
$checkbox-border-color: #ccc;
|
||||
$checkbox-hover-border-color: #888;
|
||||
$checkbox-checked-background-color: $primary-main;
|
||||
$checkbox-checked-color: #fff;
|
||||
|
||||
$comment-notice-background-color: #0d6efd;
|
||||
$comment-notice-color: #fff;
|
||||
$comment-divider-color: #eee;
|
||||
|
||||
$activated-topic-background-color: var(--mui-palette-info-dark);
|
||||
|
||||
$error-msg-color: red;
|
||||
$suggestion-box-heading-color: #dfe0e0;
|
||||
|
||||
$callOut-success-background: $primary-light;
|
||||
$callOut-success-color: $primary-dark;
|
||||
|
||||
$review-feedback-correct: #178b13;
|
||||
$review-feedback-incorrect: #d82222;
|
||||
$review-feedback-neutral: #f39220;
|
||||
$review-feedback-selected-color: #fff;
|
155
src/components/AppBar/AppBar.tsx
Normal file
155
src/components/AppBar/AppBar.tsx
Normal file
@ -0,0 +1,155 @@
|
||||
import {
|
||||
AppBar as AppBarMui,
|
||||
Box,
|
||||
Button,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Toolbar,
|
||||
Typography
|
||||
} from '@mui/material'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import { setAuthState } from '../../store/actions'
|
||||
import { State } from '../../store/rootReducer'
|
||||
import { Dispatch } from '../../store/store'
|
||||
import Username from '../username'
|
||||
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import nostrichAvatar from '../../assets/images/avatar.png'
|
||||
import nostrichLogo from '../../assets/images/nostr-logo.jpg'
|
||||
import { appPublicRoutes } from '../../routes'
|
||||
import { shorten } from '../../utils'
|
||||
import styles from './style.module.scss'
|
||||
|
||||
export const AppBar = () => {
|
||||
const navigate = useNavigate()
|
||||
const dispatch: Dispatch = useDispatch()
|
||||
|
||||
const [username, setUsername] = useState('')
|
||||
const [userAvatar, setUserAvatar] = useState(nostrichAvatar)
|
||||
const [anchorElUser, setAnchorElUser] = useState<null | HTMLElement>(null)
|
||||
|
||||
const authState = useSelector((state: State) => state.auth)
|
||||
const metadataState = useSelector((state: State) => state.metadata)
|
||||
|
||||
useEffect(() => {
|
||||
if (metadataState && metadataState.content) {
|
||||
const { picture, display_name, name } = JSON.parse(metadataState.content)
|
||||
|
||||
if (picture) setUserAvatar(picture)
|
||||
|
||||
setUsername(shorten(display_name || name || '', 7))
|
||||
}
|
||||
}, [metadataState])
|
||||
|
||||
const handleOpenUserMenu = (event: React.MouseEvent<HTMLElement>) => {
|
||||
setAnchorElUser(event.currentTarget)
|
||||
}
|
||||
|
||||
const handleCloseUserMenu = () => {
|
||||
setAnchorElUser(null)
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
dispatch(
|
||||
setAuthState({
|
||||
loggedIn: false,
|
||||
loginMethod: undefined
|
||||
})
|
||||
)
|
||||
|
||||
navigate('/')
|
||||
}
|
||||
const isAuthenticated = authState?.loggedIn === true
|
||||
|
||||
return (
|
||||
<AppBarMui position='fixed' className={styles.AppBar}>
|
||||
<Toolbar>
|
||||
<Box
|
||||
className={
|
||||
isAuthenticated ? styles.AppBarLogoWrapper : styles.AppBarUnAuth
|
||||
}
|
||||
>
|
||||
<div className={styles.logoWrapper}>
|
||||
<img src={nostrichLogo} alt='Logo' onClick={() => navigate('/')} />
|
||||
</div>
|
||||
|
||||
{!isAuthenticated && (
|
||||
<Box>
|
||||
<Button
|
||||
onClick={() => {
|
||||
navigate(appPublicRoutes.login)
|
||||
}}
|
||||
variant='contained'
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{authState?.loggedIn && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end'
|
||||
}}
|
||||
>
|
||||
<Username
|
||||
username={username}
|
||||
avatarContent={userAvatar}
|
||||
handleClick={handleOpenUserMenu}
|
||||
/>
|
||||
<Menu
|
||||
id='menu-appbar'
|
||||
anchorEl={anchorElUser}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'center'
|
||||
}}
|
||||
keepMounted
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'center'
|
||||
}}
|
||||
open={!!anchorElUser}
|
||||
onClose={handleCloseUserMenu}
|
||||
>
|
||||
<MenuItem
|
||||
sx={{
|
||||
justifyContent: 'center',
|
||||
display: { md: 'none', paddingBottom: 0, marginBottom: -10 }
|
||||
}}
|
||||
>
|
||||
<Typography variant='h5'>{username}</Typography>
|
||||
</MenuItem>
|
||||
<Link
|
||||
to={appPublicRoutes.help}
|
||||
target='_blank'
|
||||
style={{ color: 'inherit', textDecoration: 'inherit' }}
|
||||
>
|
||||
<MenuItem
|
||||
sx={{
|
||||
justifyContent: 'center',
|
||||
borderTop: '0.5px solid var(--mui-palette-text-secondary)'
|
||||
}}
|
||||
>
|
||||
Help
|
||||
</MenuItem>
|
||||
</Link>
|
||||
<MenuItem
|
||||
onClick={handleLogout}
|
||||
sx={{
|
||||
justifyContent: 'center',
|
||||
borderTop: '0.5px solid var(--mui-palette-text-secondary)'
|
||||
}}
|
||||
>
|
||||
Logout
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</div>
|
||||
)}
|
||||
</Toolbar>
|
||||
</AppBarMui>
|
||||
)
|
||||
}
|
56
src/components/AppBar/style.module.scss
Normal file
56
src/components/AppBar/style.module.scss
Normal file
@ -0,0 +1,56 @@
|
||||
@import '../../colors.scss';
|
||||
|
||||
.AppBar {
|
||||
background-color: $background-color !important;
|
||||
z-index: 1400 !important;
|
||||
|
||||
.AppBarIcon {
|
||||
background-color: $btn-background-color !important;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.logoWrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
padding: 13px 0 13px 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.AppBarLogoWrapper {
|
||||
height: 60px;
|
||||
width: 100px;
|
||||
cursor: pointer;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
.logoWrapper {
|
||||
margin-left: 20px;
|
||||
|
||||
img {
|
||||
max-height: 100%;
|
||||
max-width: 150px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.AppBarUnAuth {
|
||||
height: 60px;
|
||||
cursor: pointer;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
|
||||
.logoWrapper {
|
||||
img {
|
||||
max-height: 100%;
|
||||
max-width: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
.loginBtn {
|
||||
margin-right: 16px;
|
||||
}
|
||||
}
|
||||
}
|
18
src/components/LoadingSpinner/index.tsx
Normal file
18
src/components/LoadingSpinner/index.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import styles from './style.module.scss'
|
||||
|
||||
interface Props {
|
||||
desc: string
|
||||
}
|
||||
|
||||
export const LoadingSpinner = (props: Props) => {
|
||||
const { desc } = props
|
||||
|
||||
return (
|
||||
<div className={styles.loadingSpinnerOverlay}>
|
||||
<div className={styles.loadingSpinnerContainer}>
|
||||
<div className={styles.loadingSpinner}></div>
|
||||
{desc && <span>{desc}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
37
src/components/LoadingSpinner/style.module.scss
Normal file
37
src/components/LoadingSpinner/style.module.scss
Normal file
@ -0,0 +1,37 @@
|
||||
.loadingSpinnerOverlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: 9999;
|
||||
|
||||
.loadingSpinnerContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.loadingSpinner {
|
||||
border: 4px solid #f3f3f3;
|
||||
border-top: 4px solid #3498db;
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
33
src/components/username.tsx
Normal file
33
src/components/username.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import { Typography, IconButton } from '@mui/material'
|
||||
|
||||
type Props = {
|
||||
username: string
|
||||
avatarContent: string
|
||||
handleClick: (event: React.MouseEvent<HTMLElement>) => void
|
||||
}
|
||||
|
||||
const Username = ({ username, avatarContent, handleClick }: Props) => {
|
||||
return (
|
||||
<IconButton
|
||||
aria-label="account of current user"
|
||||
aria-controls="menu-appbar"
|
||||
aria-haspopup="true"
|
||||
onClick={handleClick}
|
||||
color="inherit"
|
||||
>
|
||||
<img src={avatarContent} alt="user-avatar" className="profile-image" />
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
color: '#3e3e3e',
|
||||
padding: '0 8px',
|
||||
display: { xs: 'none', md: 'flex' }
|
||||
}}
|
||||
>
|
||||
{username}
|
||||
</Typography>
|
||||
</IconButton>
|
||||
)
|
||||
}
|
||||
|
||||
export default Username
|
66
src/controllers/AuthController.ts
Normal file
66
src/controllers/AuthController.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import { EventTemplate } from 'nostr-tools'
|
||||
import { MetadataController, NostrController } from '.'
|
||||
import { setAuthState, setMetadataEvent } from '../store/actions'
|
||||
import store from '../store/store'
|
||||
import { getVisitedLink } from '../utils'
|
||||
import { appPrivateRoutes } from '../routes'
|
||||
|
||||
export class AuthController {
|
||||
private nostrController: NostrController
|
||||
private metadataController: MetadataController
|
||||
|
||||
constructor() {
|
||||
this.nostrController = NostrController.getInstance()
|
||||
this.metadataController = new MetadataController()
|
||||
}
|
||||
|
||||
/**
|
||||
* Function will authenticate user by signing an auth event
|
||||
* which is done by calling the sign() function, where appropriate
|
||||
* method will be chosen (extension, nsecbunker or keys)
|
||||
*
|
||||
* @param pubkey of the user trying to login
|
||||
* @returns url to redirect if authentication successfull
|
||||
* or error if otherwise
|
||||
*/
|
||||
async authenticateAndFindMetadata(pubkey: string) {
|
||||
this.metadataController
|
||||
.findMetadata(pubkey)
|
||||
.then((event) => {
|
||||
store.dispatch(setMetadataEvent(event))
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Error occurred while finding metadata', err)
|
||||
})
|
||||
|
||||
// Nostr uses unix timestamps
|
||||
const timestamp = Math.floor(Date.now() / 1000)
|
||||
const { hostname } = window.location
|
||||
|
||||
const authEvent: EventTemplate = {
|
||||
kind: 1,
|
||||
tags: [],
|
||||
content: `${hostname}-${timestamp}`,
|
||||
created_at: timestamp
|
||||
}
|
||||
|
||||
await this.nostrController.signEvent(authEvent)
|
||||
|
||||
store.dispatch(
|
||||
setAuthState({
|
||||
loggedIn: true
|
||||
})
|
||||
)
|
||||
|
||||
const visitedLink = getVisitedLink()
|
||||
|
||||
if (visitedLink) {
|
||||
const { pathname, search } = visitedLink
|
||||
|
||||
return Promise.resolve(`${pathname}${search}`)
|
||||
} else {
|
||||
// Navigate user in
|
||||
return Promise.resolve(appPrivateRoutes.homePage)
|
||||
}
|
||||
}
|
||||
}
|
53
src/controllers/MetadataController.ts
Normal file
53
src/controllers/MetadataController.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import {
|
||||
Filter,
|
||||
SimplePool,
|
||||
VerifiedEvent,
|
||||
kinds,
|
||||
validateEvent,
|
||||
verifyEvent
|
||||
} from 'nostr-tools'
|
||||
|
||||
export class MetadataController {
|
||||
constructor() {}
|
||||
|
||||
public findMetadata = async (hexKey: string) => {
|
||||
const mostPopularRelays = import.meta.env.VITE_MOST_POPULAR_RELAYS
|
||||
const hardcodedPopularRelays = (mostPopularRelays || '').split(' ')
|
||||
const specialMetadataRelay = 'wss://purplepag.es'
|
||||
|
||||
const relays = [...hardcodedPopularRelays, specialMetadataRelay]
|
||||
|
||||
const eventFilter: Filter = {
|
||||
kinds: [kinds.Metadata],
|
||||
authors: [hexKey]
|
||||
}
|
||||
|
||||
const pool = new SimplePool()
|
||||
|
||||
const events = await pool.querySync(relays, eventFilter).catch((err) => {
|
||||
console.error(err)
|
||||
return null
|
||||
})
|
||||
|
||||
if (events && events.length) {
|
||||
events.sort((a, b) => b.created_at - a.created_at)
|
||||
|
||||
for (const event of events) {
|
||||
if (validateEvent(event) && verifyEvent(event)) {
|
||||
return event
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Mo metadata found.')
|
||||
}
|
||||
|
||||
public extractProfileMetadataContent = (event: VerifiedEvent) => {
|
||||
try {
|
||||
return JSON.parse(event.content)
|
||||
} catch (error) {
|
||||
console.log('error in parsing metadata event content :>> ', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
292
src/controllers/NostrController.ts
Normal file
292
src/controllers/NostrController.ts
Normal file
@ -0,0 +1,292 @@
|
||||
import NDK, {
|
||||
NDKEvent,
|
||||
NDKNip46Signer,
|
||||
NDKPrivateKeySigner,
|
||||
NostrEvent
|
||||
} from '@nostr-dev-kit/ndk'
|
||||
import {
|
||||
Event,
|
||||
EventTemplate,
|
||||
UnsignedEvent,
|
||||
finalizeEvent,
|
||||
nip19
|
||||
} from 'nostr-tools'
|
||||
import { updateNsecbunkerPubkey } from '../store/actions'
|
||||
import { AuthState, LoginMethods } from '../store/auth/types'
|
||||
import store from '../store/store'
|
||||
import { SignedEvent } from '../types'
|
||||
import { getNsecBunkerDelegatedKey, verifySignedEvent } from '../utils'
|
||||
|
||||
export class NostrController {
|
||||
private static instance: NostrController
|
||||
|
||||
private bunkerNDK: NDK | undefined
|
||||
private remoteSigner: NDKNip46Signer | undefined
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public nsecBunkerInit = async (relays: string[]) => {
|
||||
// Don't reinstantiate bunker NDK if exists with same relays
|
||||
if (
|
||||
this.bunkerNDK &&
|
||||
this.bunkerNDK.explicitRelayUrls?.length === relays.length &&
|
||||
this.bunkerNDK.explicitRelayUrls?.every((relay) => relays.includes(relay))
|
||||
)
|
||||
return
|
||||
|
||||
this.bunkerNDK = new NDK({
|
||||
explicitRelayUrls: relays
|
||||
})
|
||||
|
||||
try {
|
||||
await this.bunkerNDK
|
||||
.connect(2000)
|
||||
.then(() => {
|
||||
console.log(
|
||||
`Successfully connected to the nsecBunker relays: ${relays.join(
|
||||
','
|
||||
)}`
|
||||
)
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(
|
||||
`Error connecting to the nsecBunker relays: ${relays.join(
|
||||
','
|
||||
)} ${err}`
|
||||
)
|
||||
})
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates nSecBunker signer instance for the given npub
|
||||
* Or if npub omitted it will return existing signer
|
||||
* If neither, error will be thrown
|
||||
* @param npub nPub / public key in hex format
|
||||
* @returns nsecBunker Signer instance
|
||||
*/
|
||||
public createNsecBunkerSigner = async (
|
||||
npub: string | undefined
|
||||
): Promise<NDKNip46Signer> => {
|
||||
const nsecBunkerDelegatedKey = getNsecBunkerDelegatedKey()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!nsecBunkerDelegatedKey) {
|
||||
reject('nsecBunker delegated key is not found in the browser.')
|
||||
return
|
||||
}
|
||||
const localSigner = new NDKPrivateKeySigner(nsecBunkerDelegatedKey)
|
||||
|
||||
if (!npub) {
|
||||
if (this.remoteSigner) resolve(this.remoteSigner)
|
||||
|
||||
const npubFromStorage = (store.getState().auth as AuthState)
|
||||
.nsecBunkerPubkey
|
||||
|
||||
if (npubFromStorage) {
|
||||
npub = npubFromStorage
|
||||
} else {
|
||||
reject(
|
||||
'No signer instance present, no npub provided by user or found in the browser.'
|
||||
)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
store.dispatch(updateNsecbunkerPubkey(npub))
|
||||
}
|
||||
|
||||
// Pubkey of a key pair stored in nsecbunker that will be used to sign event with
|
||||
const appPubkeyOrToken = npub.includes('npub')
|
||||
? npub
|
||||
: nip19.npubEncode(npub)
|
||||
|
||||
/**
|
||||
* When creating and NDK instance we create new connection to the relay
|
||||
* To prevent too much connections and hitting rate limits, if npub against which we sign
|
||||
* we will reuse existing instance. Otherwise we will create new NDK and signer instance.
|
||||
*/
|
||||
if (!this.remoteSigner || this.remoteSigner?.remotePubkey !== npub) {
|
||||
this.remoteSigner = new NDKNip46Signer(
|
||||
this.bunkerNDK!,
|
||||
appPubkeyOrToken,
|
||||
localSigner
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* when nsecbunker-delegated-key is regenerated we have to reinitialize the remote signer
|
||||
*/
|
||||
if (this.remoteSigner.localSigner !== localSigner) {
|
||||
this.remoteSigner = new NDKNip46Signer(
|
||||
this.bunkerNDK!,
|
||||
appPubkeyOrToken,
|
||||
localSigner
|
||||
)
|
||||
}
|
||||
|
||||
this.remoteSigner.on('authUrl', (url: string) => {
|
||||
window.open(url, '_blank')
|
||||
})
|
||||
|
||||
resolve(this.remoteSigner)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs the nostr event and returns the sig and id or full raw nostr event
|
||||
* @param npub stored in nsecBunker to sign with
|
||||
* @param event to be signed
|
||||
* @param returnFullEvent whether to return full raw nostr event or just SIG and ID values
|
||||
*/
|
||||
public signWithNsecBunker = async (
|
||||
npub: string | undefined,
|
||||
event: NostrEvent,
|
||||
returnFullEvent = true
|
||||
): Promise<{ id: string; sig: string } | NostrEvent> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.createNsecBunkerSigner(npub)
|
||||
.then(async (signer) => {
|
||||
const ndkEvent = new NDKEvent(undefined, event)
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
reject('Timeout occurred while waiting for event signing')
|
||||
}, 60000) // 60000 ms (1 min) = 1000 * 60
|
||||
|
||||
await ndkEvent.sign(signer).catch((err) => {
|
||||
clearTimeout(timeout)
|
||||
reject(err)
|
||||
return
|
||||
})
|
||||
|
||||
clearTimeout(timeout)
|
||||
|
||||
if (returnFullEvent) {
|
||||
resolve(ndkEvent.rawEvent())
|
||||
} else {
|
||||
resolve({
|
||||
id: ndkEvent.id,
|
||||
sig: ndkEvent.sig!
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
public static getInstance(): NostrController {
|
||||
if (!NostrController.instance) {
|
||||
NostrController.instance = new NostrController()
|
||||
}
|
||||
return NostrController.instance
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs an event with private key (if it is present in local storage) or
|
||||
* with browser extension (if it is present) or
|
||||
* with nSecBunker instance.
|
||||
* @param event - unsigned nostr event.
|
||||
* @returns - a promised that is resolved with signed nostr event.
|
||||
*/
|
||||
signEvent = async (
|
||||
event: UnsignedEvent | EventTemplate
|
||||
): Promise<SignedEvent> => {
|
||||
const loginMethod = (store.getState().auth as AuthState).loginMethod
|
||||
|
||||
if (!loginMethod) {
|
||||
return Promise.reject('No login method found in the browser storage')
|
||||
}
|
||||
|
||||
if (loginMethod === LoginMethods.nsecBunker) {
|
||||
// Check if nsecBunker is available
|
||||
if (!this.bunkerNDK) {
|
||||
return Promise.reject(
|
||||
`Login method is ${loginMethod} but bunkerNDK is not created`
|
||||
)
|
||||
}
|
||||
|
||||
if (!this.remoteSigner) {
|
||||
return Promise.reject(
|
||||
`Login method is ${loginMethod} but bunkerNDK is not created`
|
||||
)
|
||||
}
|
||||
|
||||
const signedEvent = await this.signWithNsecBunker(
|
||||
'',
|
||||
event as NostrEvent
|
||||
).catch((err) => {
|
||||
throw err
|
||||
})
|
||||
|
||||
return Promise.resolve(signedEvent as SignedEvent)
|
||||
} else if (loginMethod === LoginMethods.privateKey) {
|
||||
const keys = (store.getState().auth as AuthState).keyPair
|
||||
|
||||
if (!keys) {
|
||||
return Promise.reject(
|
||||
`Login method is ${loginMethod}, but keys are not found`
|
||||
)
|
||||
}
|
||||
|
||||
const { private: secretKey } = keys
|
||||
const nsec = new TextEncoder().encode(secretKey)
|
||||
|
||||
const signedEvent = finalizeEvent(event, nsec)
|
||||
|
||||
verifySignedEvent(signedEvent)
|
||||
|
||||
return Promise.resolve(signedEvent)
|
||||
} else if (loginMethod === LoginMethods.extension) {
|
||||
if (!window.nostr) {
|
||||
return Promise.reject(
|
||||
`Login method is ${loginMethod} but window.nostr is not present. Make sure extension is working properly`
|
||||
)
|
||||
}
|
||||
|
||||
return (await window.nostr
|
||||
.signEvent(event as NostrEvent)
|
||||
.catch((err: any) => {
|
||||
console.log('Error while signing event: ', err)
|
||||
|
||||
throw err
|
||||
})) as Event
|
||||
} else {
|
||||
return Promise.reject(
|
||||
`We could not sign the event, none of the signing methods are available`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Function will capture the public key from the nostr extension or if no extension present
|
||||
* function wil capture the public key from the local storage
|
||||
*/
|
||||
capturePublicKey = async (): Promise<string> => {
|
||||
if (window.nostr) {
|
||||
const pubKey = await window.nostr.getPublicKey().catch((err: any) => {
|
||||
return Promise.reject(err.message)
|
||||
})
|
||||
|
||||
if (!pubKey) {
|
||||
return Promise.reject('Error getting public key, user canceled')
|
||||
}
|
||||
|
||||
return Promise.resolve(pubKey)
|
||||
}
|
||||
|
||||
return Promise.reject(
|
||||
'window.nostr object not present. Make sure you have an nostr extension installed.'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates NDK Private Signer
|
||||
* @returns nSecBunker delegated key
|
||||
*/
|
||||
generateDelegatedKey = (): string => {
|
||||
return NDKPrivateKeySigner.generate().privateKey!
|
||||
}
|
||||
}
|
3
src/controllers/index.ts
Normal file
3
src/controllers/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './AuthController'
|
||||
export * from './MetadataController'
|
||||
export * from './NostrController'
|
120
src/index.css
120
src/index.css
@ -11,6 +11,7 @@
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
a {
|
||||
@ -24,10 +25,12 @@ a:hover {
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
/* display: flex;
|
||||
place-items: center; */
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
background-color: #f4f4fb;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
h1 {
|
||||
@ -46,12 +49,12 @@ button {
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
|
||||
.qrzap {
|
||||
position: fixed;
|
||||
top: 80px;
|
||||
right: 20px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
@ -66,3 +69,104 @@ button:focus-visible {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
|
||||
.main {
|
||||
padding: 64px 0;
|
||||
}
|
||||
|
||||
.hide-mobile {
|
||||
@media (max-width: 639px) {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.hide-desktop {
|
||||
@media (min-width: 639px) {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* when this class is assigned to a component, user will not be able to select and copy the content from that component
|
||||
*/
|
||||
.no-select {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.no-privilege {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
color: red;
|
||||
}
|
||||
|
||||
.quiz-btn {
|
||||
color: #fff !important;
|
||||
border-radius: 8px !important;
|
||||
border: 1px solid transparent !important;
|
||||
padding: 0.6em 1.2em !important;
|
||||
font-size: 1em !important;
|
||||
font-weight: 500 !important;
|
||||
}
|
||||
|
||||
Button {
|
||||
border-radius: 8px !important;
|
||||
|
||||
&:disabled {
|
||||
/* background-color: gray !important; */
|
||||
/* color: black !important; */
|
||||
cursor: not-allowed !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Style <pre> tag of markdown editor affected by prism theme */
|
||||
pre[class*='language-'][class*='w-md-editor-text-pre'] {
|
||||
padding: 0;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
/* Style <code> tag of markdown editor affected by prism theme */
|
||||
code[class*='language-'][class*='code-highlight'] {
|
||||
color: #3e3e3e;
|
||||
}
|
||||
|
||||
.bold-link {
|
||||
color: inherit !important;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.dashed-field.Mui-focused fieldset {
|
||||
border-color: unset !important;
|
||||
}
|
||||
|
||||
.dashed-field fieldset {
|
||||
border-style: dashed !important;
|
||||
}
|
||||
|
||||
.pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.force-pointer {
|
||||
cursor: pointer !important;
|
||||
}
|
||||
|
||||
.inherit-color {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.inherit-color-force {
|
||||
color: inherit !important;
|
||||
}
|
||||
|
||||
.profile-image {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
33
src/layouts/Main.tsx
Normal file
33
src/layouts/Main.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import { Box } from '@mui/material'
|
||||
import Container from '@mui/material/Container'
|
||||
import { useEffect } from 'react'
|
||||
import { useDispatch } from 'react-redux'
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import { AppBar } from '../components/AppBar/AppBar'
|
||||
import { restoreState } from '../store/actions'
|
||||
import { loadState } from '../utils'
|
||||
|
||||
export const MainLayout = () => {
|
||||
const dispatch = useDispatch()
|
||||
|
||||
useEffect(() => {
|
||||
const restoredState = loadState()
|
||||
if (restoredState) dispatch(restoreState(restoredState))
|
||||
}, [dispatch])
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppBar />
|
||||
|
||||
<Box className='main'>
|
||||
<Container
|
||||
sx={{
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
<Outlet />
|
||||
</Container>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
}
|
22
src/layouts/style.module.scss
Normal file
22
src/layouts/style.module.scss
Normal file
@ -0,0 +1,22 @@
|
||||
@import '../colors.scss';
|
||||
|
||||
@font-face {
|
||||
font-family: 'Avenir';
|
||||
font-weight: normal;
|
||||
src: local('Avenir'),
|
||||
url(../assets/avenir-font/AvenirLTStd-Roman.otf) format('opentype');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Avenir';
|
||||
font-weight: bold;
|
||||
src: local('Avenir'),
|
||||
url(../assets/avenir-font/AvenirLTStd-Black.otf) format('opentype');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Avenir';
|
||||
font-weight: lighter;
|
||||
src: local('Avenir'),
|
||||
url(../assets/avenir-font/AvenirLTStd-Book.otf) format('opentype');
|
||||
}
|
24
src/main.tsx
24
src/main.tsx
@ -1,10 +1,30 @@
|
||||
import _ from 'lodash'
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { Provider } from 'react-redux'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { ToastContainer } from 'react-toastify'
|
||||
import 'react-toastify/dist/ReactToastify.css'
|
||||
import App from './App.tsx'
|
||||
import './index.css'
|
||||
import store from './store/store.ts'
|
||||
import { saveState } from './utils'
|
||||
|
||||
store.subscribe(
|
||||
_.throttle(() => {
|
||||
saveState({
|
||||
auth: store.getState().auth
|
||||
})
|
||||
}, 1000)
|
||||
)
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
<BrowserRouter>
|
||||
<Provider store={store}>
|
||||
<App />
|
||||
<ToastContainer />
|
||||
</Provider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
)
|
||||
|
108
src/pages/landing/LandingPage.tsx
Normal file
108
src/pages/landing/LandingPage.tsx
Normal file
@ -0,0 +1,108 @@
|
||||
import { Box, Button, Typography, useTheme } from '@mui/material'
|
||||
import { useEffect } from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { appPublicRoutes } from '../../routes'
|
||||
import { State } from '../../store/rootReducer'
|
||||
import { saveVisitedLink } from '../../utils'
|
||||
import styles from './style.module.scss'
|
||||
|
||||
const bodyBackgroundColor = document.body.style.backgroundColor
|
||||
|
||||
export const LandingPage = () => {
|
||||
const authState = useSelector((state: State) => state.auth)
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
const theme = useTheme()
|
||||
|
||||
useEffect(() => {
|
||||
saveVisitedLink(location.pathname, location.search)
|
||||
}, [location])
|
||||
|
||||
const onSignInClick = async () => {
|
||||
navigate(appPublicRoutes.login)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.landingPage}>
|
||||
<Box
|
||||
mt={10}
|
||||
sx={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
flexDirection: { xs: 'column', md: 'row' }
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
mr: {
|
||||
xs: 0,
|
||||
md: 5
|
||||
},
|
||||
mb: {
|
||||
xs: 5,
|
||||
md: 0
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 5,
|
||||
color: bodyBackgroundColor
|
||||
? theme.palette.getContrastText(bodyBackgroundColor)
|
||||
: ''
|
||||
}}
|
||||
variant='h4'
|
||||
>
|
||||
What is Nostr?
|
||||
</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
color: bodyBackgroundColor
|
||||
? theme.palette.getContrastText(bodyBackgroundColor)
|
||||
: ''
|
||||
}}
|
||||
variant='body1'
|
||||
>
|
||||
Nostr is a decentralised messaging protocol where YOU own your
|
||||
identity. To get started, you must have an existing{' '}
|
||||
<a
|
||||
className='bold-link'
|
||||
target='_blank'
|
||||
href='https://nostr.com/'
|
||||
>
|
||||
Nostr account
|
||||
</a>
|
||||
.
|
||||
<br />
|
||||
<br />
|
||||
No email required - all notifications are made using the nQuiz
|
||||
relay.
|
||||
<br />
|
||||
<br />
|
||||
If you no longer wish to hear from us, simply remove
|
||||
relay.nquiz.io from your list of relays.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{!authState?.loggedIn && (
|
||||
<div className={styles.loginBottomBar}>
|
||||
<Button
|
||||
className={styles.loginBtn}
|
||||
variant='contained'
|
||||
onClick={onSignInClick}
|
||||
>
|
||||
GET STARTED
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
29
src/pages/landing/style.module.scss
Normal file
29
src/pages/landing/style.module.scss
Normal file
@ -0,0 +1,29 @@
|
||||
@import '../../colors.scss';
|
||||
|
||||
.landingPage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
color: $text-color;
|
||||
min-height: 80vh;
|
||||
justify-content: center;
|
||||
padding-top: 20px;
|
||||
|
||||
.loginBottomBar {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
background-color: rgba(255, 255, 255, 0.7);
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.096);
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
|
||||
.loginBtn {
|
||||
// margin-top: 20px;
|
||||
min-width: 200px;
|
||||
}
|
||||
}
|
||||
}
|
240
src/pages/login/index.tsx
Normal file
240
src/pages/login/index.tsx
Normal file
@ -0,0 +1,240 @@
|
||||
import { Box, Button, TextField, Typography } from '@mui/material'
|
||||
import { getPublicKey, nip05, nip19 } from 'nostr-tools'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useDispatch } from 'react-redux'
|
||||
import { toast } from 'react-toastify'
|
||||
import { LoadingSpinner } from '../../components/LoadingSpinner'
|
||||
import {
|
||||
AuthController,
|
||||
MetadataController,
|
||||
NostrController
|
||||
} from '../../controllers'
|
||||
import {
|
||||
updateKeyPair,
|
||||
updateLoginMethod,
|
||||
updateNsecbunkerPubkey
|
||||
} from '../../store/actions'
|
||||
import { LoginMethods } from '../../store/auth/types'
|
||||
import { Dispatch } from '../../store/store'
|
||||
import { nsecToHex } from '../../utils'
|
||||
import styles from './style.module.scss'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
export const Login = () => {
|
||||
const dispatch: Dispatch = useDispatch()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const authController = new AuthController()
|
||||
const metadataController = new MetadataController()
|
||||
const nostrController = NostrController.getInstance()
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [loadingSpinnerDesc, setLoadingSpinnerDesc] = useState('')
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
|
||||
const [isNostrExtensionAvailable, setIsNostrExtensionAvailable] =
|
||||
useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
setIsNostrExtensionAvailable(!!window.nostr)
|
||||
}, 500)
|
||||
}, [])
|
||||
|
||||
const loginWithExtension = async () => {
|
||||
setIsLoading(true)
|
||||
setLoadingSpinnerDesc('Capturing pubkey from nostr extension')
|
||||
|
||||
nostrController
|
||||
.capturePublicKey()
|
||||
.then(async (pubkey) => {
|
||||
dispatch(updateLoginMethod(LoginMethods.extension))
|
||||
|
||||
setLoadingSpinnerDesc('Authenticating and finding metadata')
|
||||
const redirectPath = await authController.authenticateAndFindMetadata(
|
||||
pubkey
|
||||
)
|
||||
|
||||
navigate(redirectPath)
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error('Error capturing public key from nostr extension: ' + err)
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false)
|
||||
setLoadingSpinnerDesc('')
|
||||
})
|
||||
}
|
||||
|
||||
const loginWithNsec = async () => {
|
||||
const hexPrivateKey = nsecToHex(inputValue)
|
||||
|
||||
if (!hexPrivateKey) {
|
||||
toast.error(
|
||||
'Snap, we failed to convert the private key you provided. Please make sure key is valid.'
|
||||
)
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const publickey = getPublicKey(new TextEncoder().encode(hexPrivateKey))
|
||||
|
||||
dispatch(
|
||||
updateKeyPair({
|
||||
private: hexPrivateKey,
|
||||
public: publickey
|
||||
})
|
||||
)
|
||||
dispatch(updateLoginMethod(LoginMethods.privateKey))
|
||||
|
||||
setIsLoading(true)
|
||||
setLoadingSpinnerDesc('Authenticating and finding metadata')
|
||||
|
||||
const redirectPath = await authController
|
||||
.authenticateAndFindMetadata(publickey)
|
||||
.catch((err) => {
|
||||
toast.error('Error occurred in authentication: ' + err)
|
||||
return null
|
||||
})
|
||||
|
||||
if (redirectPath) navigate(redirectPath)
|
||||
|
||||
setIsLoading(false)
|
||||
setLoadingSpinnerDesc('')
|
||||
}
|
||||
|
||||
const loginWithNsecBunker = async () => {
|
||||
let relays: string[] | undefined
|
||||
let pubkey: string | undefined
|
||||
|
||||
setIsLoading(true)
|
||||
|
||||
if (inputValue.includes('@')) {
|
||||
const nip05Profile = await nip05.queryProfile(inputValue).catch((err) => {
|
||||
toast.error('An error occurred while querying nip05 profile: ' + err)
|
||||
return null
|
||||
})
|
||||
|
||||
if (nip05Profile) {
|
||||
pubkey = nip05Profile.pubkey
|
||||
relays = nip05Profile.relays
|
||||
}
|
||||
} else if (inputValue.startsWith('npub')) {
|
||||
pubkey = nip19.decode(inputValue).data as string
|
||||
const metadataEvent = await metadataController
|
||||
.findMetadata(pubkey)
|
||||
.catch(() => {
|
||||
return null
|
||||
})
|
||||
|
||||
if (!metadataEvent) {
|
||||
toast.error('metadata not found!')
|
||||
return
|
||||
}
|
||||
|
||||
const metadataContent =
|
||||
metadataController.extractProfileMetadataContent(metadataEvent)
|
||||
|
||||
if (!metadataContent.nip05) {
|
||||
toast.error('nip05 not present in metadata')
|
||||
return
|
||||
}
|
||||
|
||||
const nip05Profile = await nip05.queryProfile(inputValue).catch((err) => {
|
||||
toast.error('An error occurred while querying nip05 profile: ' + err)
|
||||
return null
|
||||
})
|
||||
|
||||
if (nip05Profile) {
|
||||
if (nip05Profile.pubkey !== pubkey) {
|
||||
toast.error('pubkey in nip05 does not match with provided npub')
|
||||
return
|
||||
}
|
||||
|
||||
relays = nip05Profile.relays
|
||||
}
|
||||
}
|
||||
|
||||
if (!relays || relays.length === 0) {
|
||||
toast.error('No relay found for nsecbunker')
|
||||
return
|
||||
}
|
||||
|
||||
if (!pubkey) {
|
||||
toast.error('pubkey not found')
|
||||
return
|
||||
}
|
||||
|
||||
setLoadingSpinnerDesc('Initializing nsecBunker')
|
||||
await nostrController.nsecBunkerInit(relays)
|
||||
|
||||
setLoadingSpinnerDesc('Creating nsecbunker singer')
|
||||
await nostrController
|
||||
.createNsecBunkerSigner(pubkey)
|
||||
.then(async () => {
|
||||
dispatch(updateLoginMethod(LoginMethods.privateKey))
|
||||
dispatch(updateNsecbunkerPubkey(pubkey))
|
||||
|
||||
setLoadingSpinnerDesc('Authenticating and finding metadata')
|
||||
|
||||
const redirectPath = await authController
|
||||
.authenticateAndFindMetadata(pubkey!)
|
||||
.catch((err) => {
|
||||
toast.error('Error occurred in authentication: ' + err)
|
||||
return null
|
||||
})
|
||||
|
||||
if (redirectPath) navigate(redirectPath)
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(
|
||||
'An error occurred while creating nsecbunker signer: ' + err
|
||||
)
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false)
|
||||
setLoadingSpinnerDesc('')
|
||||
})
|
||||
}
|
||||
|
||||
const login = () => {
|
||||
if (inputValue.startsWith('nsec')) {
|
||||
return loginWithNsec()
|
||||
}
|
||||
if (inputValue.startsWith('npub')) {
|
||||
return loginWithNsecBunker()
|
||||
}
|
||||
if (inputValue.includes('@')) {
|
||||
return loginWithNsecBunker()
|
||||
}
|
||||
|
||||
toast.error('Invalid Input!')
|
||||
return
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoading && <LoadingSpinner desc={loadingSpinnerDesc} />}
|
||||
<div className={styles.loginPage}>
|
||||
<Typography variant='h4'>Welcome to Sigit</Typography>
|
||||
<TextField
|
||||
label='nip05 / npub / nsec'
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
sx={{ width: '100%', mt: 2 }}
|
||||
/>
|
||||
{isNostrExtensionAvailable && (
|
||||
<Button onClick={loginWithExtension} variant='text'>
|
||||
Login with extension
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Box sx={{ mt: 1, display: 'flex', justifyContent: 'center' }}>
|
||||
<Button disabled={!inputValue} onClick={login} variant='contained'>
|
||||
Login
|
||||
</Button>
|
||||
</Box>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
6
src/pages/login/style.module.scss
Normal file
6
src/pages/login/style.module.scss
Normal file
@ -0,0 +1,6 @@
|
||||
@import '../../colors.scss';
|
||||
|
||||
.loginPage {
|
||||
color: $text-color;
|
||||
margin-top: 20px;
|
||||
}
|
26
src/routes/index.tsx
Normal file
26
src/routes/index.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import { LandingPage } from '../pages/landing/LandingPage'
|
||||
import { Login } from '../pages/login'
|
||||
|
||||
export const appPublicRoutes = {
|
||||
login: '/login',
|
||||
help: 'https://help.sigit.io'
|
||||
}
|
||||
|
||||
export const appPrivateRoutes = {
|
||||
homePage: '/'
|
||||
}
|
||||
|
||||
export const publicRoutes = [
|
||||
{
|
||||
path: appPublicRoutes.login,
|
||||
hiddenWhenLoggedIn: true,
|
||||
element: <Login />
|
||||
}
|
||||
]
|
||||
|
||||
export const privateRoutes = [
|
||||
{
|
||||
path: appPrivateRoutes.homePage,
|
||||
element: <LandingPage />
|
||||
}
|
||||
]
|
8
src/store/actionTypes.ts
Normal file
8
src/store/actionTypes.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export const RESTORE_STATE = 'RESTORE_STATE'
|
||||
|
||||
export const SET_AUTH_STATE = 'SET_AUTH_STATE'
|
||||
export const UPDATE_LOGIN_METHOD = 'UPDATE_LOGIN_METHOD'
|
||||
export const UPDATE_KEYPAIR = 'UPDATE_KEYPAIR'
|
||||
export const UPDATE_NSECBUNKER_PUBKEY = 'UPDATE_NSECBUNKER_PUBKEY'
|
||||
|
||||
export const SET_METADATA_EVENT = 'SET_METADATA_EVENT'
|
17
src/store/actions.ts
Normal file
17
src/store/actions.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import * as ActionTypes from './actionTypes'
|
||||
import { State } from './rootReducer'
|
||||
|
||||
export * from './auth/action'
|
||||
export * from './metadata/action'
|
||||
|
||||
export const restoreState = (payload: State) => {
|
||||
return {
|
||||
type: ActionTypes.RESTORE_STATE,
|
||||
payload
|
||||
}
|
||||
}
|
||||
|
||||
export interface RestoreState {
|
||||
type: typeof ActionTypes.RESTORE_STATE
|
||||
payload: State
|
||||
}
|
34
src/store/auth/action.ts
Normal file
34
src/store/auth/action.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import * as ActionTypes from '../actionTypes'
|
||||
import {
|
||||
AuthState,
|
||||
Keys,
|
||||
LoginMethods,
|
||||
SetAuthState,
|
||||
UpdateKeyPair,
|
||||
UpdateLoginMethod,
|
||||
UpdateNsecBunkerPubkey
|
||||
} from './types'
|
||||
|
||||
export const setAuthState = (payload: AuthState): SetAuthState => ({
|
||||
type: ActionTypes.SET_AUTH_STATE,
|
||||
payload
|
||||
})
|
||||
|
||||
export const updateLoginMethod = (
|
||||
payload: LoginMethods | undefined
|
||||
): UpdateLoginMethod => ({
|
||||
type: ActionTypes.UPDATE_LOGIN_METHOD,
|
||||
payload
|
||||
})
|
||||
|
||||
export const updateKeyPair = (payload: Keys | undefined): UpdateKeyPair => ({
|
||||
type: ActionTypes.UPDATE_KEYPAIR,
|
||||
payload
|
||||
})
|
||||
|
||||
export const updateNsecbunkerPubkey = (
|
||||
payload: string | undefined
|
||||
): UpdateNsecBunkerPubkey => ({
|
||||
type: ActionTypes.UPDATE_NSECBUNKER_PUBKEY,
|
||||
payload
|
||||
})
|
55
src/store/auth/reducer.ts
Normal file
55
src/store/auth/reducer.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import * as ActionTypes from '../actionTypes'
|
||||
import { AuthDispatchTypes, AuthState } from './types'
|
||||
|
||||
const initialState: AuthState = {
|
||||
loggedIn: false
|
||||
}
|
||||
|
||||
const reducer = (
|
||||
state = initialState,
|
||||
action: AuthDispatchTypes
|
||||
): AuthState | null => {
|
||||
switch (action.type) {
|
||||
case ActionTypes.SET_AUTH_STATE: {
|
||||
const { loginMethod, keyPair, nsecBunkerPubkey } = state
|
||||
|
||||
return {
|
||||
loginMethod,
|
||||
keyPair,
|
||||
nsecBunkerPubkey,
|
||||
...action.payload
|
||||
}
|
||||
}
|
||||
case ActionTypes.UPDATE_LOGIN_METHOD: {
|
||||
const { payload } = action
|
||||
|
||||
return {
|
||||
...state,
|
||||
loginMethod: payload
|
||||
}
|
||||
}
|
||||
|
||||
case ActionTypes.UPDATE_KEYPAIR: {
|
||||
const { payload } = action
|
||||
|
||||
return {
|
||||
...state,
|
||||
keyPair: payload
|
||||
}
|
||||
}
|
||||
|
||||
case ActionTypes.UPDATE_NSECBUNKER_PUBKEY: {
|
||||
const { payload } = action
|
||||
|
||||
return {
|
||||
...state,
|
||||
nsecBunkerPubkey: payload
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
export default reducer
|
46
src/store/auth/types.ts
Normal file
46
src/store/auth/types.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import * as ActionTypes from '../actionTypes'
|
||||
|
||||
export enum LoginMethods {
|
||||
extension = 'extension',
|
||||
privateKey = 'privateKey',
|
||||
nsecBunker = 'nsecBunker',
|
||||
register = 'register'
|
||||
}
|
||||
|
||||
export interface Keys {
|
||||
private: string
|
||||
public: string
|
||||
}
|
||||
|
||||
export interface AuthState {
|
||||
loggedIn: boolean
|
||||
loginMethod?: LoginMethods
|
||||
keyPair?: Keys
|
||||
nsecBunkerPubkey?: string
|
||||
}
|
||||
|
||||
export interface SetAuthState {
|
||||
type: typeof ActionTypes.SET_AUTH_STATE
|
||||
payload: AuthState
|
||||
}
|
||||
|
||||
export interface UpdateLoginMethod {
|
||||
type: typeof ActionTypes.UPDATE_LOGIN_METHOD
|
||||
payload: LoginMethods | undefined
|
||||
}
|
||||
|
||||
export interface UpdateKeyPair {
|
||||
type: typeof ActionTypes.UPDATE_KEYPAIR
|
||||
payload: Keys | undefined
|
||||
}
|
||||
|
||||
export interface UpdateNsecBunkerPubkey {
|
||||
type: typeof ActionTypes.UPDATE_NSECBUNKER_PUBKEY
|
||||
payload: string | undefined
|
||||
}
|
||||
|
||||
export type AuthDispatchTypes =
|
||||
| SetAuthState
|
||||
| UpdateLoginMethod
|
||||
| UpdateKeyPair
|
||||
| UpdateNsecBunkerPubkey
|
8
src/store/metadata/action.ts
Normal file
8
src/store/metadata/action.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import * as ActionTypes from '../actionTypes'
|
||||
import { SetMetadataEvent } from './types'
|
||||
import { Event } from 'nostr-tools'
|
||||
|
||||
export const setMetadataEvent = (payload: Event): SetMetadataEvent => ({
|
||||
type: ActionTypes.SET_METADATA_EVENT,
|
||||
payload
|
||||
})
|
25
src/store/metadata/reducer.ts
Normal file
25
src/store/metadata/reducer.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import * as ActionTypes from '../actionTypes'
|
||||
import { MetadataDispatchTypes } from './types'
|
||||
import { Event } from 'nostr-tools'
|
||||
|
||||
const initialState: Event | null = null
|
||||
|
||||
const reducer = (
|
||||
state = initialState,
|
||||
action: MetadataDispatchTypes
|
||||
): Event | null => {
|
||||
switch (action.type) {
|
||||
case ActionTypes.SET_METADATA_EVENT:
|
||||
return {
|
||||
...action.payload
|
||||
}
|
||||
|
||||
case ActionTypes.RESTORE_STATE:
|
||||
return action.payload.metadata || null
|
||||
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
export default reducer
|
10
src/store/metadata/types.ts
Normal file
10
src/store/metadata/types.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import * as ActionTypes from '../actionTypes'
|
||||
import { Event } from 'nostr-tools'
|
||||
import { RestoreState } from '../actions'
|
||||
|
||||
export interface SetMetadataEvent {
|
||||
type: typeof ActionTypes.SET_METADATA_EVENT
|
||||
payload: Event
|
||||
}
|
||||
|
||||
export type MetadataDispatchTypes = SetMetadataEvent | RestoreState
|
15
src/store/rootReducer.ts
Normal file
15
src/store/rootReducer.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { Event } from 'nostr-tools'
|
||||
import { combineReducers } from 'redux'
|
||||
import authReducer from './auth/reducer'
|
||||
import { AuthState } from './auth/types'
|
||||
import metadataReducer from './metadata/reducer'
|
||||
|
||||
export interface State {
|
||||
auth: AuthState
|
||||
metadata?: Event
|
||||
}
|
||||
|
||||
export default combineReducers({
|
||||
auth: authReducer,
|
||||
metadata: metadataReducer
|
||||
})
|
8
src/store/store.ts
Normal file
8
src/store/store.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { configureStore } from '@reduxjs/toolkit'
|
||||
import rootReducer from './rootReducer'
|
||||
|
||||
const store = configureStore({ reducer: rootReducer })
|
||||
|
||||
export default store
|
||||
|
||||
export type Dispatch = typeof store.dispatch
|
1
src/types/index.ts
Normal file
1
src/types/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './nostr'
|
9
src/types/nostr.ts
Normal file
9
src/types/nostr.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export interface SignedEvent {
|
||||
kind: number
|
||||
tags: string[][]
|
||||
content: string
|
||||
created_at: number
|
||||
pubkey: string
|
||||
id: string
|
||||
sig: string
|
||||
}
|
8
src/types/system/index.ts
Normal file
8
src/types/system/index.ts
Normal file
@ -0,0 +1,8 @@
|
||||
declare global {
|
||||
interface Window {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
nostr: any
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
3
src/utils/index.ts
Normal file
3
src/utils/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './localStorage'
|
||||
export * from './nostr'
|
||||
export * from './string'
|
55
src/utils/localStorage.ts
Normal file
55
src/utils/localStorage.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { State } from '../store/rootReducer'
|
||||
|
||||
export const saveState = (state: object) => {
|
||||
try {
|
||||
const serializedState = JSON.stringify(state)
|
||||
|
||||
localStorage.setItem('state', serializedState)
|
||||
} catch (err) {
|
||||
console.log(`Error while saving state. Error: `, err)
|
||||
}
|
||||
}
|
||||
|
||||
export const loadState = (): State | undefined => {
|
||||
try {
|
||||
const serializedState = localStorage.getItem('state')
|
||||
|
||||
if (serializedState === null) return undefined
|
||||
|
||||
return JSON.parse(serializedState)
|
||||
} catch (err) {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export const saveNsecBunkerDelegatedKey = (privateKey: string) => {
|
||||
localStorage.setItem('nsecbunker-delegated-key', privateKey)
|
||||
}
|
||||
|
||||
export const getNsecBunkerDelegatedKey = () => {
|
||||
return localStorage.getItem('nsecbunker-delegated-key')
|
||||
}
|
||||
|
||||
export const saveVisitedLink = (pathname: string, search: string) => {
|
||||
localStorage.setItem(
|
||||
'visitedLink',
|
||||
JSON.stringify({
|
||||
pathname,
|
||||
search
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export const getVisitedLink = () => {
|
||||
const visitedLink = localStorage.getItem('visitedLink')
|
||||
if (!visitedLink) return null
|
||||
|
||||
try {
|
||||
return JSON.parse(visitedLink) as {
|
||||
pathname: string
|
||||
search: string
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
73
src/utils/nostr.ts
Normal file
73
src/utils/nostr.ts
Normal file
@ -0,0 +1,73 @@
|
||||
import { nip05, nip19, verifyEvent } from 'nostr-tools'
|
||||
import { SignedEvent } from '../types'
|
||||
|
||||
/**
|
||||
* @param hexKey hex private or public key
|
||||
* @returns whether or not is key valid
|
||||
*/
|
||||
const validateHex = (hexKey: string) => {
|
||||
return hexKey.match(/^[a-f0-9]{64}$/)
|
||||
}
|
||||
|
||||
/**
|
||||
* NPUB provided - it will convert NPUB to HEX
|
||||
* NIP-05 provided - it will query NIP-05 profile and return HEX key if found
|
||||
* HEX provided - it will return HEX
|
||||
*
|
||||
* @param pubKey in NPUB, NIP-05 or HEX format
|
||||
* @returns HEX format
|
||||
*/
|
||||
export const pubToHex = async (pubKey: string): Promise<string | null> => {
|
||||
// If key is NIP-05
|
||||
if (pubKey.indexOf('@') !== -1)
|
||||
return Promise.resolve(
|
||||
(await nip05.queryProfile(pubKey).then((res) => res?.pubkey)) || null
|
||||
)
|
||||
|
||||
// If key is NPUB
|
||||
if (pubKey.startsWith('npub')) {
|
||||
try {
|
||||
return nip19.decode(pubKey).data as string
|
||||
} catch (error) {
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
}
|
||||
|
||||
// valid hex key
|
||||
if (validateHex(pubKey)) return Promise.resolve(pubKey)
|
||||
|
||||
// Not a valid hex key
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* If NSEC key is provided function will convert it to HEX
|
||||
* If HEX key Is provided function will validate the HEX format and return
|
||||
*
|
||||
* @param nsec or private key in HEX format
|
||||
*/
|
||||
export const nsecToHex = (nsec: string): string | null => {
|
||||
// If key is NSEC
|
||||
if (nsec.startsWith('nsec')) {
|
||||
try {
|
||||
return nip19.decode(nsec).data as string
|
||||
} catch (error) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// since it's not NSEC key we check if it's a valid hex key
|
||||
if (validateHex(nsec)) return nsec
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export const verifySignedEvent = (event: SignedEvent) => {
|
||||
const isGood = verifyEvent(event)
|
||||
|
||||
if (!isGood) {
|
||||
throw new Error(
|
||||
'Signed event did not pass verification. Check sig, id and pubkey.'
|
||||
)
|
||||
}
|
||||
}
|
9
src/utils/string.ts
Normal file
9
src/utils/string.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export const shorten = (str: string, offset = 9) => {
|
||||
// return original string if it is not long enough
|
||||
if (str.length < offset * 2 + 4) return str
|
||||
|
||||
return `${str.slice(0, offset)}...${str.slice(
|
||||
str.length - offset,
|
||||
str.length
|
||||
)}`
|
||||
}
|
8
src/vite-env.d.ts
vendored
8
src/vite-env.d.ts
vendored
@ -1 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_MOST_POPULAR_RELAYS: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user