chore: implemented basic layout and login page

This commit is contained in:
Sabir Hassan 2024-02-28 21:49:44 +05:00
parent 4dfb379921
commit aa991be416
48 changed files with 3286 additions and 75 deletions

1
.env.example Normal file
View 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

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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",

View File

@ -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>
)
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

View File

@ -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
View 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;

View 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>
)
}

View 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;
}
}
}

View 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>
)
}

View 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);
}
}

View 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

View 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)
}
}
}

View 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
}
}
}

View 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
View File

@ -0,0 +1,3 @@
export * from './AuthController'
export * from './MetadataController'
export * from './NostrController'

View File

@ -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
View 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>
</>
)
}

View 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');
}

View File

@ -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>
)

View 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>
</>
)
}

View 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
View 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>
</>
)
}

View File

@ -0,0 +1,6 @@
@import '../../colors.scss';
.loginPage {
color: $text-color;
margin-top: 20px;
}

26
src/routes/index.tsx Normal file
View 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
View 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
View 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
View 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
View 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
View 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

View 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
})

View 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

View 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
View 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
View 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
View File

@ -0,0 +1 @@
export * from './nostr'

9
src/types/nostr.ts Normal file
View File

@ -0,0 +1,9 @@
export interface SignedEvent {
kind: number
tags: string[][]
content: string
created_at: number
pubkey: string
id: string
sig: string
}

View 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
View File

@ -0,0 +1,3 @@
export * from './localStorage'
export * from './nostr'
export * from './string'

55
src/utils/localStorage.ts Normal file
View 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
View 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
View 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
View File

@ -1 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_MOST_POPULAR_RELAYS: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}