Compare commits

...

42 Commits

Author SHA1 Message Date
artur
5ca798958f Key export fails if password not set, minor UX changes 2024-02-23 13:55:31 +03:00
artur
33b088383d Add export key nip49 2024-02-23 10:27:04 +03:00
artur
fe462376c6 Added passphrase param to generateKey and importKey 2024-02-23 09:38:51 +03:00
artur
a9b4b22c34 Merge branch 'develop' into feature/add-password 2024-02-23 09:33:29 +03:00
Nostr.Band
3c2d2f9f84 Merge pull request #122 from nostrband/feature/perm-sync
Feature/perm sync
2024-02-23 09:32:49 +03:00
artur
cbd16d1e35 Turn off the subscribeAppPerms for now 2024-02-23 09:31:29 +03:00
artur
88f559b8f1 Started adding password to signup/login/import 2024-02-23 09:28:10 +03:00
artur
6a6b18bcad Change login button name to Login 2024-02-23 08:29:12 +03:00
artur
c977b96eae Add updateTimestamp to apps, start mergeAppPerms 2024-02-22 16:50:24 +03:00
artur
17c1c13ad7 Fix ignore disallowed conn reqs, fix publishAppPerms 2024-02-22 16:08:12 +03:00
artur
b8a57c33d6 Add publishAppPerms 2024-02-22 15:37:12 +03:00
artur
8c0b5f379e Make nip05 name take priority over profile.name 2024-02-22 14:21:24 +03:00
Nostr.Band
905dc7ac1b Merge pull request #112 from nostrband/feature/adaptive-styles
Feature/adaptive styles
2024-02-22 13:43:27 +03:00
artur
ca25712d20 Drop old pending requests 2024-02-22 13:07:56 +03:00
Bekbolsun
6e334c5078 fix adaptive styles 2024-02-21 20:51:07 +06:00
artur
051eaf001f Add 10000 kind to basic perms 2024-02-21 14:10:57 +03:00
artur
4a2362f6b9 Add 27235 kind to basic perms 2024-02-21 14:09:47 +03:00
Bekbolsun
da6f68e00a Merge branch 'develop' of https://github.com/nostrband/noauth into feature/adaptive-styles 2024-02-20 18:33:50 +06:00
Bekbolsun
c08e26629a add adaptive styles in modal connect 2024-02-20 18:33:36 +06:00
Nostr.Band
7aba51b103 Merge pull request #104 from nostrband/refactor/reload-badge
refactor reload badge showing logic
2024-02-20 11:13:27 +03:00
Nostr.Band
c61869b2a2 Merge pull request #105 from nostrband/feature/local-fonts
Feature/local fonts
2024-02-20 11:12:02 +03:00
Bekbolsun
47dc8e20fe add local fonts 2024-02-20 10:39:46 +06:00
Bekbolsun
f55ba7e6f2 refactor reload badge showing logic 2024-02-20 10:20:37 +06:00
artur
05bd08e86d Change reload message 2024-02-19 19:42:53 +03:00
Nostr.Band
4fa5c57a69 Merge pull request #100 from nostrband/feature/reload-badge
Feature/reload badge
2024-02-19 19:40:38 +03:00
artur
3de4a508be Remove disallow/remember on popup close, add pause before sending auth_url 2024-02-19 19:31:02 +03:00
artur
ecf27d8d23 Fix remove auto-close in popup if req not exists 2024-02-19 18:47:42 +03:00
Bekbolsun
59c03d16eb add reload badge on sw update 2024-02-19 21:41:10 +06:00
Bekbolsun
adbc7d455d format code 2024-02-19 19:35:12 +06:00
Bekbolsun
7379d75002 trying add fonts locally 2024-02-19 19:31:14 +06:00
artur
13f9bb13fd Fix ignore unknown methods 2024-02-19 14:54:00 +03:00
Nostr.Band
b1dd6a5424 Merge pull request #98 from nostrband/feature/sw-update
Feature/sw update
2024-02-19 14:14:37 +03:00
artur
e3feb8b5a2 Merge w/ develop 2024-02-19 14:12:51 +03:00
artur
06fa8ffbd7 Leave name empty if failed to assign at addKey 2024-02-19 14:10:15 +03:00
Nostr.Band
22753a8d89 Merge pull request #90 from nostrband/feature/hints
add hints
2024-02-19 11:03:22 +03:00
artur
6d4a8b4f64 Remove logos from signup modals, move signup hints to the top of modals, fix signup hints 2024-02-19 11:01:34 +03:00
Nostr.Band
425f7277fc Merge pull request #89 from nostrband/feature/edit-name
Feature/edit name
2024-02-19 10:38:45 +03:00
artur
8c77b10b60 Add sw activation logic 2024-02-19 09:56:46 +03:00
Bekbolsun
b98339e177 add hints 2024-02-16 20:09:33 +06:00
artur
a60fcd65b5 Show app npub if app only has url 2024-02-16 15:29:06 +03:00
Nostr.Band
93f6135baf Merge pull request #86 from nostrband/fix/enable-push-signup-error
Don't stop signup if enable-push failed
2024-02-16 15:08:20 +03:00
artur
3813cef605 Don't stop signup if enable-push failed 2024-02-16 14:55:35 +03:00
70 changed files with 1125 additions and 340 deletions

View File

@@ -3,37 +3,37 @@ const path = require('path')
const ModuleScopePlugin = require('react-dev-utils/ModuleScopePlugin')
module.exports = function override(config) {
const fallback = config.resolve.fallback || {}
Object.assign(fallback, {
crypto: require.resolve('crypto-browserify'),
stream: require.resolve('stream-browserify'),
assert: require.resolve('assert'),
http: require.resolve('stream-http'),
https: require.resolve('https-browserify'),
os: require.resolve('os-browserify'),
url: require.resolve('url'),
})
config.resolve.fallback = fallback
config.plugins = (config.plugins || []).concat([
new webpack.ProvidePlugin({
process: 'process/browser',
Buffer: ['buffer', 'Buffer'],
}),
])
config.module.rules.unshift({
test: /\.m?js$/,
resolve: {
fullySpecified: false, // disable the behavior
},
})
// turns off the plugin that forbids importing from node_modules for the above-mentioned stuff
config.resolve.plugins = config.resolve.plugins.filter((plugin) => {
return !(plugin instanceof ModuleScopePlugin)
})
const fallback = config.resolve.fallback || {}
Object.assign(fallback, {
crypto: require.resolve('crypto-browserify'),
stream: require.resolve('stream-browserify'),
assert: require.resolve('assert'),
http: require.resolve('stream-http'),
https: require.resolve('https-browserify'),
os: require.resolve('os-browserify'),
url: require.resolve('url'),
})
config.resolve.fallback = fallback
config.plugins = (config.plugins || []).concat([
new webpack.ProvidePlugin({
process: 'process/browser',
Buffer: ['buffer', 'Buffer'],
}),
])
config.module.rules.unshift({
test: /\.m?js$/,
resolve: {
fullySpecified: false, // disable the behavior
},
})
// turns off the plugin that forbids importing from node_modules for the above-mentioned stuff
config.resolve.plugins = config.resolve.plugins.filter((plugin) => {
return !(plugin instanceof ModuleScopePlugin)
})
config.resolve.alias = {
'@': path.resolve(__dirname, 'src'),
}
config.resolve.alias = {
'@': path.resolve(__dirname, 'src'),
}
return config
return config
}

23
package-lock.json generated
View File

@@ -41,6 +41,7 @@
"redux-persist": "^6.0.0",
"typescript": "^5.3.2",
"use-debounce": "^10.0.0",
"usehooks-ts": "^2.14.0",
"web-vitals": "^2.1.4",
"workbox-background-sync": "^6.6.0",
"workbox-broadcast-update": "^6.6.0",
@@ -17214,6 +17215,20 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/usehooks-ts": {
"version": "2.14.0",
"resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-2.14.0.tgz",
"integrity": "sha512-jnhrjTRJoJS7cFxz63tRYc5mzTKf/h+Ii8P0PDHymT9qDe4ZA2/gzDRmDR4WGausg5X8wMIdghwi3BBCN9JKow==",
"dependencies": {
"lodash.debounce": "^4.0.8"
},
"engines": {
"node": ">=16.15.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18"
}
},
"node_modules/utf-8-validate": {
"version": "5.0.10",
"resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz",
@@ -30591,6 +30606,14 @@
"integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==",
"requires": {}
},
"usehooks-ts": {
"version": "2.14.0",
"resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-2.14.0.tgz",
"integrity": "sha512-jnhrjTRJoJS7cFxz63tRYc5mzTKf/h+Ii8P0PDHymT9qDe4ZA2/gzDRmDR4WGausg5X8wMIdghwi3BBCN9JKow==",
"requires": {
"lodash.debounce": "^4.0.8"
}
},
"utf-8-validate": {
"version": "5.0.10",
"resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz",

View File

@@ -36,6 +36,7 @@
"redux-persist": "^6.0.0",
"typescript": "^5.3.2",
"use-debounce": "^10.0.0",
"usehooks-ts": "^2.14.0",
"web-vitals": "^2.1.4",
"workbox-background-sync": "^6.6.0",
"workbox-broadcast-update": "^6.6.0",

View File

@@ -10,12 +10,6 @@
<link rel="icon" type="image/png" sizes="32x32" href="%PUBLIC_URL%/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="%PUBLIC_URL%/favicon-16x16.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@200;300;400;500;600;700&display=swap"
rel="stylesheet"
/>
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.

View File

@@ -1,6 +1,6 @@
{
"name": "Nsec.app",
"short_name": "Nsec.app - Nostr key management tool",
"name": "Nsec.app - Nostr key management tool",
"short_name": "Nsec.app",
"start_url": ".",
"icons": [
{

View File

@@ -1,6 +1,6 @@
import { DbKey, dbi } from './modules/db'
import { useCallback, useEffect, useState } from 'react'
import { swicOnRender } from './modules/swic'
import { swicOnReload, swicOnRender } from './modules/swic'
import { useAppDispatch } from './store/hooks/redux'
import { setApps, setKeys, setPending, setPerms } from './store/reducers/content.slice'
import AppRoutes from './routes/AppRoutes'
@@ -9,16 +9,20 @@ import { ModalInitial } from './components/Modal/ModalInitial/ModalInitial'
import { ModalImportKeys } from './components/Modal/ModalImportKeys/ModalImportKeys'
import { ModalSignUp } from './components/Modal/ModalSignUp/ModalSignUp'
import { ModalLogin } from './components/Modal/ModalLogin/ModalLogin'
import { useSessionStorage } from 'usehooks-ts'
import { RELOAD_STORAGE_KEY } from './utils/consts'
function App() {
const [render, setRender] = useState(0)
const dispatch = useAppDispatch()
// eslint-disable-next-line
const [_, setNeedReload] = useSessionStorage(RELOAD_STORAGE_KEY, false)
const [isConnected, setIsConnected] = useState(false)
const load = useCallback(async () => {
const keys: DbKey[] = await dbi.listKeys()
// console.log(keys, 'keys')
dispatch(setKeys({ keys }))
const loadProfiles = async () => {
@@ -55,7 +59,6 @@ function App() {
// rerender
// setRender((r) => r + 1)
// if (!keys.length) handleOpen(MODAL_PARAMS_KEYS.INITIAL)
// eslint-disable-next-line
}, [dispatch])
@@ -77,6 +80,24 @@ function App() {
setRender((r) => r + 1)
})
// subscribe to service worker updates
swicOnReload(() => {
console.log('reload')
setNeedReload(true)
})
useEffect(() => {
const handleBeforeUnload = () => {
setNeedReload(false)
}
window.addEventListener('beforeunload', handleBeforeUnload)
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload)
}
// eslint-disable-next-line
}, [])
return (
<>
<AppRoutes />

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -10,7 +10,7 @@ import { isEmptyString } from '@/utils/helpers/helpers'
import { useParams } from 'react-router-dom'
import { useAppDispatch, useAppSelector } from '@/store/hooks/redux'
import { selectApps } from '@/store'
import { dbi } from '@/modules/db'
import { DbApp, dbi } from '@/modules/db'
import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar'
import { setApps } from '@/store/reducers/content.slice'
import { LoadingSpinner } from '@/shared/LoadingSpinner/LoadingSpinner'
@@ -20,7 +20,7 @@ export const ModalAppDetails = () => {
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.APP_DETAILS)
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.APP_DETAILS)
const { appNpub = '' } = useParams()
const { npub = '', appNpub = '' } = useParams()
const apps = useAppSelector(selectApps)
const dispatch = useAppDispatch()
@@ -33,8 +33,8 @@ export const ModalAppDetails = () => {
})
const [isLoading, setIsLoading] = useState(false)
const currentApp = apps.find((app) => app.appNpub === appNpub && app.npub === npub)
useEffect(() => {
const currentApp = apps.find((app) => app.appNpub === appNpub)
if (!currentApp) return
setDetails({
@@ -68,7 +68,6 @@ export const ModalAppDetails = () => {
if (isEmptyString(url)) return
try {
const u = new URL(url)
if (isEmptyString(name)) setDetails((prev) => ({ ...prev, name: u.hostname }))
@@ -95,14 +94,15 @@ export const ModalAppDetails = () => {
const submitHandler = async (e: FormEvent) => {
e.preventDefault()
if (isLoading) return undefined
if (isLoading || !currentApp) return undefined
try {
setIsLoading(true)
const updatedApp = {
const updatedApp: DbApp = {
...currentApp,
url,
name,
icon,
appNpub,
updateTimestamp: Date.now()
}
await dbi.updateApp(updatedApp)
const apps = await dbi.listApps()

View File

@@ -1,4 +1,5 @@
import { AppInputProps, Input } from '@/shared/Input/Input'
import { Input } from '@/shared/Input/Input'
import { AppInputProps } from '@/shared/Input/types'
import { styled } from '@mui/material'
import { forwardRef } from 'react'

View File

@@ -73,11 +73,11 @@ export const ModalConfirmConnect = () => {
useEffect(() => {
if (isModalOpened) {
if (isPopup) {
console.log("waiting for sw")
console.log('waiting for sw')
// wait for SW to start
swicWaitStarted().then(() => {
// give it some time to load the pending reqs etc
console.log("waiting for sw done")
console.log('waiting for sw done')
setTimeout(() => setIsLoaded(true), 500)
})
} else {
@@ -95,8 +95,9 @@ export const ModalConfirmConnect = () => {
const isPendingReqIdExists = pendingReqId.trim().length && pending.some((p) => p.id === pendingReqId)
// console.log("pending", {isModalOpened, isPendingReqIdExists, isNpubExists, /*isAppNpubExists,*/ pendingReqId, pending});
if (isModalOpened && (!isNpubExists /*|| !isAppNpubExists*/ || (pendingReqId && !isPendingReqIdExists))) {
if (isPopup) window.close()
else closeModalAfterRequest()
// if (isPopup) window.close()
// else closeModalAfterRequest()
if (!isPopup) closeModalAfterRequest()
return null
}
}
@@ -131,7 +132,7 @@ export const ModalConfirmConnect = () => {
} catch (e: any) {
console.log('error', e)
notify('Please enable Notifications in website settings!', 'error')
return
// keep going
}
try {
@@ -169,7 +170,9 @@ export const ModalConfirmConnect = () => {
if (isPopup) {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
disallow()
// FIXME it should be 'ignore once',
// not 'disallow & remember' - this is too strict
// disallow()
}
})
}
@@ -194,11 +197,11 @@ export const ModalConfirmConnect = () => {
>
{appAvatarTitle}
</Avatar>
<Box>
<Typography variant="h5" fontWeight={600}>
<Box overflow={'auto'}>
<Typography variant="h5" fontWeight={600} noWrap>
{appName}
</Typography>
<Typography variant="body2" color={'GrayText'}>
<Typography variant="body2" color={'GrayText'} noWrap>
New app would like to connect
</Typography>
</Box>

View File

@@ -21,5 +21,8 @@ export const StyledToggleButtonsGroup = styled((props: ToggleButtonGroupProps) =
border: 'initial',
borderRadius: '1rem',
},
'@media screen and (max-width: 320px)': {
marginBottom: '0.25rem',
},
})
)

View File

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

View File

@@ -29,4 +29,17 @@ export const StyledToggleButton = styled((props: ToggleButtonProps) => (
fontSize: '10px',
fontWeight: 500,
},
'@media screen and (max-width: 320px)': {
'& .title': {
fontSize: '14px',
},
'& .description': {
margin: '0.25rem 0',
display: '-webkit-box',
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
textOverflow: 'ellipsis',
},
},
}))

View File

@@ -83,8 +83,10 @@ export const ModalConfirmEvent: FC<ModalConfirmEventProps> = ({ confirmEventReqs
const isAppNpubExists = appNpub.trim().length && apps.some((app) => app.appNpub === appNpub)
// console.log("confirm event", { confirmEventReqs, isModalOpened, isNpubExists, isAppNpubExists });
if (isModalOpened && (!currentAppPendingReqs.length || !isNpubExists || !isAppNpubExists)) {
if (isPopup) window.close()
else closeModalAfterRequest()
// if (isPopup) window.close()
// else closeModalAfterRequest()
if (!isPopup)
closeModalAfterRequest()
return null
}
}

View File

@@ -13,7 +13,7 @@ import { DOMAIN } from '@/utils/consts'
import { fetchNip05 } from '@/utils/helpers/helpers'
import { Stack, Typography, useTheme } from '@mui/material'
import { ChangeEvent, Fragment, useCallback, useEffect, useState } from 'react'
import { useParams, useSearchParams } from 'react-router-dom'
import { useParams } from 'react-router-dom'
import { useDebounce } from 'use-debounce'
import { StyledSettingContainer } from './styled'
import { SectionTitle } from '@/shared/SectionTitle/SectionTitle'
@@ -55,7 +55,7 @@ export const ModalEditName = () => {
setIsAvailable(true)
setIsChecking(false)
}
}, [debouncedName])
}, [debouncedName, npub])
useEffect(() => {
checkIsUsernameAvailable()

View File

@@ -1,4 +1,4 @@
import { Stack, StackProps, styled } from "@mui/material";
import { Stack, StackProps, styled } from '@mui/material'
export const StyledSettingContainer = styled((props: StackProps) => (
<Stack gap={'0.75rem'} component={'form'} {...props} />
@@ -7,4 +7,4 @@ export const StyledSettingContainer = styled((props: StackProps) => (
borderRadius: '1rem',
background: theme.palette.background.default,
color: theme.palette.text.primary,
}))
}))

View File

@@ -6,7 +6,6 @@ import { Input } from '@/shared/Input/Input'
import { Modal } from '@/shared/Modal/Modal'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { Stack, Typography, useTheme } from '@mui/material'
import { StyledAppLogo } from './styled'
import { useNavigate } from 'react-router-dom'
import { useForm } from 'react-hook-form'
import { FormInputType, schema } from './const'
@@ -19,6 +18,7 @@ import { DOMAIN } from '@/utils/consts'
import { CheckmarkIcon } from '@/assets'
import { getPublicKey, nip19 } from 'nostr-tools'
import { LoadingSpinner } from '@/shared/LoadingSpinner/LoadingSpinner'
import { HeadingContainer } from './styled'
const FORM_DEFAULT_VALUES = {
username: '',
@@ -149,14 +149,16 @@ export const ModalImportKeys = () => {
const nsecHelperText = getNsecHelperText()
return (
<Modal open={isModalOpened} onClose={handleCloseModal}>
<Stack gap={'1rem'} component={'form'} onSubmit={handleSubmit(submitHandler)}>
<Stack direction={'row'} gap={'1rem'} alignItems={'center'} alignSelf={'flex-start'}>
<StyledAppLogo />
<Modal open={isModalOpened} onClose={handleCloseModal} withCloseButton={false}>
<Stack paddingTop={'1rem'} gap={'1rem'} component={'form'} onSubmit={handleSubmit(submitHandler)}>
<HeadingContainer>
<Typography fontWeight={600} variant="h5">
Import key
</Typography>
</Stack>
<Typography noWrap variant="body2" color={'GrayText'}>
Bring your existing Nostr keys to Nsec.app
</Typography>
</HeadingContainer>
<Input
label="Choose a username"
fullWidth

View File

@@ -1,5 +1,5 @@
import { AppLogo } from '@/assets'
import { Box, styled } from '@mui/material'
import { Box, Stack, StackProps, styled } from '@mui/material'
export const StyledAppLogo = styled((props) => (
<Box {...props}>
@@ -12,3 +12,14 @@ export const StyledAppLogo = styled((props) => (
display: 'grid',
placeItems: 'center',
}))
export const HeadingContainer = styled((props: StackProps) => <Stack {...props} />)(() => ({
gap: '0.2rem',
padding: '0 1rem',
alignSelf: 'flex-start',
overflow: 'auto',
width: '100%',
'@media screen and (max-width: 320px)': {
padding: '0 0.75rem',
},
}))

View File

@@ -5,7 +5,6 @@ import { swicCall } from '@/modules/swic'
import { Modal } from '@/shared/Modal/Modal'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { Stack, Typography } from '@mui/material'
import { StyledAppLogo } from './styled'
import { Input } from '@/shared/Input/Input'
import { Button } from '@/shared/Button/Button'
import { useNavigate, useSearchParams } from 'react-router-dom'
@@ -119,13 +118,15 @@ export const ModalLogin = () => {
}, [isModalOpened, cleanUpStates])
return (
<Modal open={isModalOpened} onClose={handleCloseModal}>
<Stack gap={'1rem'} component={'form'} onSubmit={handleSubmit(submitHandler)}>
<Stack direction={'row'} gap={'1rem'} alignItems={'center'} alignSelf={'flex-start'}>
<StyledAppLogo />
<Modal open={isModalOpened} onClose={handleCloseModal} withCloseButton={false}>
<Stack paddingTop={'1rem'} gap={'1rem'} component={'form'} onSubmit={handleSubmit(submitHandler)}>
<Stack gap={'0.2rem'} padding={'0 1rem'} alignSelf={'flex-start'}>
<Typography fontWeight={600} variant="h5">
Login
</Typography>
<Typography noWrap variant="body2" color={'GrayText'}>
Sync keys from the cloud to this device
</Typography>
</Stack>
<Input
label="Username or nip05 or npub"
@@ -141,10 +142,14 @@ export const ModalLogin = () => {
{...register('password')}
{...inputProps}
error={!!errors.password}
helperText={'Password you set in Cloud Sync settings'}
/>
<Button type="submit" fullWidth disabled={isLoading}>
Add account {isLoading && <LoadingSpinner />}
</Button>
<Stack gap={'0.5rem'}>
<Button type="submit" fullWidth disabled={isLoading}>
Login {isLoading && <LoadingSpinner />}
</Button>
</Stack>
</Stack>
</Modal>
)

View File

@@ -2,9 +2,10 @@ import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { Modal } from '@/shared/Modal/Modal'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { Box, Stack, Typography } from '@mui/material'
import { StyledButton, StyledSettingContainer, StyledSynchedText } from './styled'
import { StyledButton, StyledSettingContainer, StyledSynchText, StyledSynchedText } from './styled'
import { SectionTitle } from '@/shared/SectionTitle/SectionTitle'
import { CheckmarkIcon } from '@/assets'
import GppMaybeOutlinedIcon from '@mui/icons-material/GppMaybeOutlined'
import { Input } from '@/shared/Input/Input'
import { ChangeEvent, FC, useEffect, useState } from 'react'
import { Checkbox } from '@/shared/Checkbox/Checkbox'
@@ -17,6 +18,7 @@ import { useAppSelector } from '@/store/hooks/redux'
import { selectKeys } from '@/store'
import { isValidPassphase, isWeakPassphase } from '@/modules/keys'
import { LoadingSpinner } from '@/shared/LoadingSpinner/LoadingSpinner'
import { useCopyToClipboard } from 'usehooks-ts'
type ModalSettingsProps = {
isSynced: boolean
@@ -26,6 +28,7 @@ export const ModalSettings: FC<ModalSettingsProps> = ({ isSynced }) => {
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
const { npub = '' } = useParams<{ npub: string }>()
const keys = useAppSelector(selectKeys)
const [, copyToClipboard] = useCopyToClipboard()
const notify = useEnqueueSnackbar()
@@ -95,17 +98,37 @@ export const ModalSettings: FC<ModalSettingsProps> = ({ isSynced }) => {
}
}
const exportKey = async (e: React.FormEvent) => {
e.preventDefault()
try {
const key = (await swicCall('exportKey', npub)) as string
if (!key) notify('Specify Cloud Sync password first!', 'error')
else if (await copyToClipboard(key)) notify('Key copied to clipboard!')
else notify('Failed to copy to clipboard', 'error')
} catch (error) {
console.log('error', error)
notify(`Failed to copy to clipboard: ${error}`, 'error')
}
}
return (
<Modal open={isModalOpened} onClose={onClose} title="Settings">
<Stack gap={'1rem'}>
<StyledSettingContainer onSubmit={handleSubmit}>
<Stack direction={'row'} justifyContent={'space-between'}>
<Stack direction={'row'} justifyContent={'space-between'} alignItems={'start'}>
<SectionTitle>Cloud sync</SectionTitle>
{isSynced && (
<StyledSynchedText>
<CheckmarkIcon /> Synched
</StyledSynchedText>
)}
{!isSynced && (
<StyledSynchText>
{/* <GppMaybeOutlinedIcon style={{ transform: 'scale(0.8)' }} /> */}
Not enabled
</StyledSynchText>
)}
</Stack>
<Box>
<Checkbox onChange={handleChangeCheckbox} checked={isChecked} />
@@ -145,6 +168,18 @@ export const ModalSettings: FC<ModalSettingsProps> = ({ isSynced }) => {
Sync {isLoading && <LoadingSpinner mode="secondary" />}
</StyledButton>
</StyledSettingContainer>
<StyledSettingContainer>
<Stack direction={'row'} justifyContent={'space-between'}>
<SectionTitle>Export key</SectionTitle>
</Stack>
<Typography variant="body2" color={'GrayText'}>
Export your key encrypted with your password (NIP49)
</Typography>
<StyledButton type="submit" fullWidth onClick={exportKey}>
Export
</StyledButton>
</StyledSettingContainer>
</Stack>
</Modal>
)

View File

@@ -29,3 +29,11 @@ export const StyledSynchedText = styled((props: TypographyProps) => <Typography
color: theme.palette.success.main,
}
})
export const StyledSynchText = styled((props: TypographyProps) => <Typography variant="caption" {...props} />)(({
theme,
}) => {
return {
color: theme.palette.error.main,
}
})

View File

@@ -4,7 +4,6 @@ import { Modal } from '@/shared/Modal/Modal'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { Stack, Typography, useTheme } from '@mui/material'
import React, { ChangeEvent, useEffect, useState } from 'react'
import { StyledAppLogo } from './styled'
import { Input } from '@/shared/Input/Input'
import { Button } from '@/shared/Button/Button'
import { CheckmarkIcon } from '@/assets'
@@ -61,7 +60,8 @@ export const ModalSignUp = () => {
try {
setIsLoading(true)
const k: any = await swicCall('generateKey', name)
notify(`Account created for "${name}"`, 'success')
if (k.name) notify(`Account created for "${k.name}"`, 'success')
else notify(`Failed to assign name "${name}", try again`, 'error')
setIsLoading(false)
setTimeout(() => {
// give frontend time to read the new key first
@@ -84,13 +84,15 @@ export const ModalSignUp = () => {
}, [isModalOpened])
return (
<Modal open={isModalOpened} onClose={handleCloseModal}>
<Modal open={isModalOpened} onClose={handleCloseModal} withCloseButton={false}>
<Stack paddingTop={'1rem'} gap={'1rem'} component={'form'} onSubmit={handleSubmit}>
<Stack direction={'row'} gap={'1rem'} alignItems={'center'} alignSelf={'flex-start'}>
<StyledAppLogo />
<Stack gap={'0.2rem'} padding={'0 1rem'} alignSelf={'flex-start'}>
<Typography fontWeight={600} variant="h5">
Sign up
</Typography>
<Typography noWrap variant="body2" color={'GrayText'}>
Generate new Nostr keys
</Typography>
</Stack>
<Input
label="Username"
@@ -113,9 +115,11 @@ export const ModalSignUp = () => {
},
}}
/>
<Button fullWidth type="submit" disabled={isLoading}>
Create account {isLoading && <LoadingSpinner />}
</Button>
<Stack gap={'0.5rem'}>
<Button fullWidth type="submit" disabled={isLoading}>
Create account {isLoading && <LoadingSpinner />}
</Button>
</Stack>
</Stack>
</Modal>
)

View File

@@ -0,0 +1,33 @@
import { FC, memo, useCallback } from 'react'
import { Stack, Typography } from '@mui/material'
import { StyledAlert, StyledReloadButton } from './styled'
import { useSessionStorage } from 'usehooks-ts'
import { RELOAD_STORAGE_KEY } from '@/utils/consts'
type ReloadBadgeContentProps = {
onReload: () => void
}
const ReloadBadgeContent: FC<ReloadBadgeContentProps> = memo(({ onReload }) => {
return (
<StyledAlert>
<Stack direction={'row'} className="content">
<Typography flex={1} className="title">
New version available!
</Typography>
<StyledReloadButton onClick={onReload}>Reload</StyledReloadButton>
</Stack>
</StyledAlert>
)
})
export const ReloadBadge = () => {
const [needReload, setNeedReload] = useSessionStorage(RELOAD_STORAGE_KEY, false)
const handleReload = useCallback(() => {
setNeedReload(false)
window.location.reload()
}, [setNeedReload])
return <>{needReload && <ReloadBadgeContent onReload={handleReload} />}</>
}

View File

@@ -0,0 +1,50 @@
import { AppButtonProps, Button } from '@/shared/Button/Button'
import { Alert, AlertProps, styled } from '@mui/material'
import RefreshIcon from '@mui/icons-material/Refresh'
export const StyledAlert = styled((props: AlertProps) => (
<Alert {...props} variant="outlined" severity="info" classes={{ message: 'message' }} />
))(() => {
return {
height: 'auto',
marginTop: '0.5rem',
alignItems: 'center',
'& .message': {
flex: 1,
overflow: 'initial',
},
'& .content': {
width: '100%',
alignItems: 'center',
gap: '1rem',
},
'& .title': {
display: '-webkit-box',
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
textOverflow: 'ellipsis',
},
'@media screen and (max-width: 320px)': {
'& .title': {
fontSize: '14px',
WebkitLineClamp: 2,
},
},
}
})
export const StyledReloadButton = styled((props: AppButtonProps) => <Button startIcon={<RefreshIcon />} {...props} />)(
({ theme }) => {
const isDarkTheme = theme.palette.mode === 'dark'
return {
padding: '0.25rem 1rem',
'&.button:is(:hover, :active, &)': {
background: isDarkTheme ? '#b8e7fb' : '#014361',
},
'@media screen and (max-width: 320px)': {
padding: '0.25rem 0.5rem',
},
}
}
)

View File

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

View File

@@ -7,13 +7,14 @@ export const StyledContainer = styled((props: BoxProps) => <Box {...props} />)((
padding: '0.5rem',
display: 'flex',
alignItems: 'center',
gap: '1rem',
gap: '0.5rem',
cursor: 'pointer',
}
})
export const IconContainer = styled((props: BoxProps) => <Box {...props} />)(() => ({
width: '40px',
minWidth: '40px',
height: '40px',
borderRadius: '50%',
background: 'grey',

View File

@@ -14,7 +14,7 @@ export const useProfile = (npub: string) => {
const [profile, setProfile] = useState<MetaEvent | null>(null)
const currentKey = useAppSelector((state) => selectKeyByNpub(state, npub))
const userName = getProfileUsername(profile) || currentKey?.name
const userName = currentKey?.name || getProfileUsername(profile)
const userAvatar = profile?.info?.picture || ''
const avatarTitle = getFirstLetter(userName)

View File

@@ -1,17 +1,63 @@
@font-face {
font-family: 'Inter';
src:
local('Inter ExtraLight'),
local('Inter-ExtraLight'),
url('./assets/fonts/Inter/Inter-ExtraLight.ttf') format('truetype');
font-weight: 200;
font-display: swap;
}
@font-face {
font-family: 'Inter';
src:
local('Inter Light'),
local('Inter-Light'),
url('./assets/fonts/Inter/Inter-Light.ttf') format('truetype');
font-weight: 300;
font-display: swap;
}
@font-face {
font-family: 'Inter';
src:
local('Inter Regular'),
local('Inter-Regular'),
url('./assets/fonts/Inter/Inter-Regular.ttf') format('truetype');
font-weight: 400;
font-display: swap;
}
@font-face {
font-family: 'Inter';
src:
local('Inter Medium'),
local('Inter-Medium'),
url('./assets/fonts/Inter/Inter-Medium.ttf') format('truetype');
font-weight: 500;
font-display: swap;
}
@font-face {
font-family: 'Inter';
src:
local('Inter SemiBold'),
local('Inter-SemiBold'),
url('./assets/fonts/Inter/Inter-SemiBold.ttf') format('truetype');
font-weight: 600;
font-display: swap;
}
@font-face {
font-family: 'Inter';
src:
local('Inter Bold'),
local('Inter-Bold'),
url('./assets/fonts/Inter/Inter-Bold.ttf') format('truetype');
font-weight: 700;
font-display: swap;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans',
'Droid Sans', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
font-family: 'Inter', sans-serif;
}
html,

View File

@@ -1,13 +1,13 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import './index.css'
import ThemeProvider from './modules/theme/ThemeProvider'
import App from './App'
import './index.css'
import reportWebVitals from './reportWebVitals'
import { swicRegister } from './modules/swic'
import { BrowserRouter } from 'react-router-dom'
import { Provider } from 'react-redux'
import { persistor, store } from './store'
import ThemeProvider from './modules/theme/ThemeProvider'
import { PersistGate } from 'redux-persist/integration/react'
import { SnackbarProvider } from 'notistack'

View File

@@ -1,4 +1,4 @@
import { Avatar, Stack, Toolbar, Typography } from '@mui/material'
import { Avatar, Stack, Toolbar, Typography, Divider, DividerProps, styled } from '@mui/material'
import { StyledAppBar, StyledAppLogo, StyledAppName, StyledProfileContainer, StyledThemeButton } from './styled'
import { Menu } from './components/Menu'
import { useNavigate, useParams } from 'react-router-dom'
@@ -8,11 +8,14 @@ import DarkModeIcon from '@mui/icons-material/DarkMode'
import LightModeIcon from '@mui/icons-material/LightMode'
import { useAppDispatch, useAppSelector } from '@/store/hooks/redux'
import { setThemeMode } from '@/store/reducers/ui.slice'
import { useSessionStorage } from 'usehooks-ts'
import { RELOAD_STORAGE_KEY } from '@/utils/consts'
export const Header = () => {
const themeMode = useAppSelector((state) => state.ui.themeMode)
const navigate = useNavigate()
const dispatch = useAppDispatch()
const [needReload] = useSessionStorage(RELOAD_STORAGE_KEY, false)
const { npub = '' } = useParams<{ npub: string }>()
const { userName, userAvatar, avatarTitle } = useProfile(npub)
@@ -30,7 +33,7 @@ export const Header = () => {
}
return (
<StyledAppBar position="fixed">
<StyledAppBar position={needReload ? 'relative' : 'fixed'}>
<Toolbar sx={{ padding: '12px' }}>
<Stack direction={'row'} justifyContent={'space-between'} alignItems={'center'} width={'100%'}>
{showProfile && (
@@ -56,6 +59,15 @@ export const Header = () => {
{showProfile ? <ProfileMenu /> : <Menu />}
</Stack>
</Toolbar>
<StyledDivider />
</StyledAppBar>
)
}
const StyledDivider = styled((props: DividerProps) => <Divider {...props} />)({
position: 'absolute',
bottom: 0,
width: '100%',
left: 0,
height: '2px',
})

View File

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

View File

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

View File

@@ -5,15 +5,30 @@ import NDK, {
NDKEvent,
NDKNip46Backend,
NDKPrivateKeySigner,
NDKRelaySet,
NDKSigner,
NDKSubscription,
NDKSubscriptionCacheUsage,
NDKUser,
} from '@nostr-dev-kit/ndk'
import { NOAUTHD_URL, WEB_PUSH_PUBKEY, NIP46_RELAYS, MIN_POW, MAX_POW, KIND_RPC, DOMAIN } from '../utils/consts'
import {
NOAUTHD_URL,
WEB_PUSH_PUBKEY,
NIP46_RELAYS,
MIN_POW,
MAX_POW,
KIND_RPC,
DOMAIN,
REQ_TTL,
KIND_DATA,
OUTBOX_RELAYS,
BROADCAST_RELAY,
APP_TAG,
} from '../utils/consts'
// import { Nip04 } from './nip04'
import { fetchNip05, getReqPerm, getShortenNpub, isPackagePerm } from '@/utils/helpers/helpers'
import { NostrPowEvent, minePow } from './pow'
import { encrypt as encryptNip49 } from './nip49'
//import { PrivateKeySigner } from './signer'
//const PERF_TEST = false
@@ -28,6 +43,7 @@ enum DECISION {
export interface KeyInfo {
npub: string
nip05?: string
name?: string
locked: boolean
}
@@ -47,7 +63,7 @@ interface Pending {
}
interface IAllowCallbackParams {
backend: NDKNip46Backend
backend: Nip46Backend
npub: string
id: string
method: string
@@ -233,8 +249,9 @@ export class NoauthBackend {
private accessBuffer: DbPending[] = []
private notifCallback: (() => void) | null = null
private pendingNpubEvents = new Map<string, NDKEvent[]>()
private permSub?: NDKSubscription
private ndk = new NDK({
explicitRelayUrls: NIP46_RELAYS,
explicitRelayUrls: [...NIP46_RELAYS, ...OUTBOX_RELAYS, BROADCAST_RELAY],
enableOutboxModel: false,
})
@@ -245,13 +262,8 @@ export class NoauthBackend {
const self = this
swg.addEventListener('activate', (event) => {
console.log('activate')
// swg.addEventListener('activate', event => event.waitUntil(swg.clients.claim()));
})
swg.addEventListener('install', (event) => {
console.log('install')
// swg.addEventListener('install', event => event.waitUntil(swg.skipWaiting()));
console.log('activate new sw worker')
this.reloadUI()
})
swg.addEventListener('push', (event) => {
@@ -314,6 +326,12 @@ export class NoauthBackend {
this.apps = await dbi.listApps()
console.log('started apps', this.apps)
// drop old pending reqs
const pending = await dbi.listPending()
for (const p of pending) {
if (p.timestamp < Date.now() - REQ_TTL) await dbi.removePending(p.id)
}
const sub = await this.swg.registration.pushManager.getSubscription()
for (const k of this.enckeys) {
@@ -322,6 +340,82 @@ export class NoauthBackend {
// ensure we're subscribed on the server
if (sub) await this.sendSubscriptionToServer(k.npub, sub)
}
// this.subscribeToAppPerms()
}
private async subscribeToAppPerms() {
if (this.permSub) {
this.permSub.stop()
this.permSub.removeAllListeners()
this.permSub = undefined
}
const authors = this.keys.map((k) => nip19.decode(k.npub).data as string)
this.permSub = this.ndk.subscribe(
{
authors,
kinds: [KIND_DATA],
'#t': [APP_TAG],
limit: 100,
},
{
closeOnEose: false,
cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY,
},
NDKRelaySet.fromRelayUrls(OUTBOX_RELAYS, this.ndk),
true // auto-start
)
this.permSub.on('event', async (e) => {
const npub = nip19.npubEncode(e.pubkey)
const key = this.keys.find((k) => k.npub === npub)
if (!key) return
// parse
try {
const payload = await key.signer.decrypt(new NDKUser({ pubkey: e.pubkey }), e.content)
const data = JSON.parse(payload)
console.log('Got app perm event', { e, data })
// FIXME validate first!
await this.mergeAppPerms(key, data)
} catch (err) {
console.log('Bad app perm event', e, err)
}
// notify UI
this.updateUI()
})
}
private async mergeAppPerms(key: Key, data: any) {
let app = this.apps.find(a => a.appNpub === data.appNpub)
const appFromData = (): DbApp => {
return {
npub: data.npub,
appNpub: data.appNpub,
name: data.name,
icon: data.icon,
url: data.url,
// choose older creation timestamp
timestamp: app ? Math.min(app.timestamp, data.timestamp) : data.timestamp,
updateTimestamp: data.updateTimestamp
}
}
if (!app) {
// new app
app = appFromData()
console.log("New app from event", { data, app })
await dbi.addApp(app)
} else if (app.updateTimestamp < data.updateTimestamp) {
// update existing app
app = appFromData()
await dbi.updateApp(app)
} else {
// old data
console.log("skip old app perms", { data, app })
}
// FIXME merge perms
}
public setNotifCallback(cb: () => void) {
@@ -366,7 +460,7 @@ export class NoauthBackend {
if (r.status !== 200 && r.status !== 201) {
console.log('Fetch error', url, method, r.status)
const body = await r.json()
throw new Error('Failed to fetch ' + url, { cause: body })
throw new Error('Failed to fetch ' + url, { cause: { body, status: r.status } })
}
return await r.json()
@@ -501,7 +595,7 @@ export class NoauthBackend {
})
} catch (e: any) {
console.log('error', e.cause)
if (e.cause && e.cause.minPow > pow) pow = e.cause.minPow
if (e.cause && e.cause.body && e.cause.body.minPow > pow) pow = e.cause.body.minPow
else throw e
}
}
@@ -637,6 +731,7 @@ export class NoauthBackend {
return {
npub: k.npub,
nip05: k.nip05,
name: k.name,
locked: this.isLocked(k.npub),
}
}
@@ -649,10 +744,12 @@ export class NoauthBackend {
name,
nsec,
existingName,
passphrase
}: {
name: string
nsec?: string
existingName?: boolean
passphrase?: string
}): Promise<KeyInfo> {
// lowercase
name = name.trim().toLocaleLowerCase()
@@ -670,18 +767,35 @@ export class NoauthBackend {
const localKey = await this.keysModule.generateLocalKey()
const enckey = await this.keysModule.encryptKeyLocal(sk, localKey)
// @ts-ignore
const dbKey: DbKey = { npub, name, enckey, localKey }
// nip49
if (passphrase)
dbKey.ncryptsec = encryptNip49(Buffer.from(sk, 'hex'), passphrase, 16, nsec ? 0x01 : 0x00)
// FIXME this is all one big complex TX and if something fails
// we have to gracefully proceed somehow
await dbi.addKey(dbKey)
this.enckeys.push(dbKey)
await this.startKey({ npub, sk })
if (passphrase)
await this.saveKey(npub, passphrase)
// assign nip05 before adding the key
// FIXME set name to db and if this call to 'send' fails
// then retry later
if (!existingName && name && !name.includes('@')) {
console.log('adding key', npub, name)
await this.sendNameToServer(npub, name)
try {
await this.sendNameToServer(npub, name)
} catch (e) {
console.log('create name failed', e)
// clear it
await dbi.editName(npub, '')
dbKey.name = ''
}
}
const sub = await this.swg.registration.pushManager.getSubscription()
@@ -690,7 +804,9 @@ export class NoauthBackend {
return this.keyInfo(dbKey)
}
private getDecision(req: DbPending): DECISION {
private getDecision(backend: Nip46Backend, req: DbPending): DECISION {
if (!(req.method in backend.handlers)) return DECISION.IGNORE
const reqPerm = getReqPerm(req)
const appPerms = this.perms.filter((p) => p.npub === req.npub && p.appNpub === req.appNpub)
@@ -701,6 +817,10 @@ export class NoauthBackend {
if (perm) {
console.log('req', req, 'perm', reqPerm, 'value', perm, appPerms)
// connect reqs are always 'ignore' if were disallowed
if (perm.perm === 'connect' && perm.value === '0') return DECISION.IGNORE
// all other reqs are not ignored
return perm.value === '1' ? DECISION.ALLOW : DECISION.DISALLOW
}
@@ -713,6 +833,42 @@ export class NoauthBackend {
return DECISION.ASK
}
private async publishAppPerms({ npub, appNpub }: { npub: string; appNpub: string }) {
const key = this.keys.find((k) => k.npub === npub)
if (!key) throw new Error('Key not found')
const app = this.apps.find((a) => a.appNpub === appNpub && a.npub === npub)
if (!app) throw new Error('App not found')
const perms = this.perms.filter((p) => p.appNpub === appNpub && p.npub === npub)
const data = {
appNpub,
npub,
name: app.name,
icon: app.icon,
url: app.url,
timestamp: app.timestamp,
updateTimestamp: app.updateTimestamp,
perms,
}
const id = await this.sha256(`nsec.app_${npub}_${appNpub}`)
const { type, data: pubkey } = nip19.decode(npub)
if (type !== 'npub') throw new Error('Bad npub')
const content = await key.signer.encrypt(new NDKUser({ pubkey }), JSON.stringify(data))
const event = new NDKEvent(this.ndk, {
pubkey,
kind: KIND_DATA,
content,
created_at: Math.floor(Date.now() / 1000),
tags: [
['d', id],
['t', APP_TAG],
],
})
event.sig = await event.sign(key.signer)
console.log('app perms event', event.rawEvent(), 'payload', data)
const relays = await event.publish(NDKRelaySet.fromRelayUrls([...OUTBOX_RELAYS, BROADCAST_RELAY], this.ndk))
console.log('app perm event published', event.id, 'to', relays)
}
private async connectApp({
npub,
appNpub,
@@ -735,6 +891,7 @@ export class NoauthBackend {
name: appName,
icon: appIcon,
url: appUrl,
updateTimestamp: Date.now()
})
// reload
@@ -754,6 +911,9 @@ export class NoauthBackend {
// reload
this.perms = await dbi.listPerms()
// async
this.publishAppPerms({ npub, appNpub })
}
private async allowPermitCallback({
@@ -819,6 +979,7 @@ export class NoauthBackend {
name: '',
icon: '',
url: options.appUrl || '',
updateTimestamp: Date.now()
})
// reload
@@ -863,6 +1024,9 @@ export class NoauthBackend {
// to this req
ok(decision)
// async
this.publishAppPerms({ npub: req.npub, appNpub: req.appNpub })
// notify UI that it was confirmed
// if (!PERF_TEST)
this.updateUI()
@@ -875,7 +1039,7 @@ export class NoauthBackend {
const otherReqs = self.confirmBuffer.filter((r) => r.req.appNpub === req.appNpub)
console.log('updated perms', this.perms, 'otherReqs', otherReqs, 'connected', connected)
for (const r of otherReqs) {
const dec = this.getDecision(r.req)
const dec = this.getDecision(backend, r.req)
if (dec !== DECISION.ASK) {
r.cb(dec, false)
}
@@ -884,7 +1048,7 @@ export class NoauthBackend {
}
// check perms
const dec = this.getDecision(req)
const dec = this.getDecision(backend, req)
console.log(Date.now(), 'decision', req.id, dec)
// have perm?
@@ -909,12 +1073,22 @@ export class NoauthBackend {
const confirmMethod = isConnect ? 'confirm-connect' : 'confirm-event'
const authUrl = `${self.swg.location.origin}/key/${npub}?${confirmMethod}=true&appNpub=${appNpub}&reqId=${id}&popup=true`
console.log('sending authUrl', authUrl, 'for', req)
// NOTE: if you set 'Update on reload' in the Chrome SW console
// then this message will cause a new tab opened by the peer,
// which will cause SW (this code) to reload, to fetch
// the pending requests and to re-send this event,
// looping for 10 seconds (our request age threshold)
backend.rpc.sendResponse(id, remotePubkey, 'auth_url', KIND_RPC, authUrl)
// NOTE: don't send auth_url immediately, wait some time
// to make sure other bunkers aren't replying
setTimeout(() => {
// request still there? (not dropped by the watcher)
if (self.confirmBuffer.find((r) => r.req.id === id)) {
// NOTE: if you set 'Update on reload' in the Chrome SW console
// then this message will cause a new tab opened by the peer,
// which will cause SW (this code) to reload, to fetch
// the pending requests and to re-send this event,
// looping for 10 seconds (our request age threshold)
backend.rpc.sendResponse(id, remotePubkey, 'auth_url', KIND_RPC, authUrl)
} else {
console.log('skip sending auth_url')
}
}, 500)
// show notifs
// this.notify()
@@ -937,6 +1111,8 @@ export class NoauthBackend {
const backend = new Nip46Backend(ndk, signer, this.allowPermitCallback.bind(this)) // , () => Promise.resolve(true)
const watcher = new Watcher(ndk, signer, (id) => {
// drop pending request
const index = self.confirmBuffer.findIndex((r) => r.req.id === id)
if (index >= 0) self.confirmBuffer.splice(index, 1)
dbi.removePending(id).then(() => this.updateUI())
})
this.keys.push({ npub, backend, signer, ndk, backoff, watcher })
@@ -1010,11 +1186,15 @@ export class NoauthBackend {
const { data: pubkey } = nip19.decode(npub)
const { data: appPubkey } = nip19.decode(appNpub)
const events = await this.ndk.fetchEvents({
kinds: [KIND_RPC],
'#p': [pubkey as string],
authors: [appPubkey as string],
})
const events = await this.ndk.fetchEvents(
{
kinds: [KIND_RPC],
'#p': [pubkey as string],
authors: [appPubkey as string],
},
undefined,
NDKRelaySet.fromRelayUrls(NIP46_RELAYS, this.ndk)
)
console.log('fetched pending for', npub, events.size)
this.pendingNpubEvents.set(npub, [...events.values()])
}
@@ -1034,8 +1214,8 @@ export class NoauthBackend {
await this.startKey({ npub, sk })
}
private async generateKey(name: string) {
const k = await this.addKey({ name })
private async generateKey(name: string, passphrase: string) {
const k = await this.addKey({ name, passphrase })
this.updateUI()
return k
}
@@ -1045,8 +1225,8 @@ export class NoauthBackend {
await this.sendTokenToServer(npub, token)
}
private async importKey(name: string, nsec: string) {
const k = await this.addKey({ name, nsec })
private async importKey(name: string, nsec: string, passphrase: string) {
const k = await this.addKey({ name, nsec, passphrase })
this.updateUI()
return k
}
@@ -1119,7 +1299,7 @@ export class NoauthBackend {
enckey,
passphrase,
})
const k = await this.addKey({ name, nsec, existingName })
const k = await this.addKey({ name, nsec, existingName, passphrase })
this.updateUI()
return k
}
@@ -1152,10 +1332,15 @@ export class NoauthBackend {
}
private async editName(npub: string, name: string) {
const key = this.enckeys.find((k) => k.npub == npub)
const key = this.enckeys.find((k) => k.npub === npub)
if (!key) throw new Error('Npub not found')
if (key.name) {
await this.sendDeleteNameToServer(npub, key.name)
try {
await this.sendDeleteNameToServer(npub, key.name)
} catch (e: any) {
if (e.cause && e.cause.status !== 404) throw e
console.log("Deleted name didn't exist")
}
}
if (name) {
await this.sendNameToServer(npub, name)
@@ -1166,7 +1351,7 @@ export class NoauthBackend {
}
private async transferName(npub: string, name: string, newNpub: string) {
const key = this.enckeys.find((k) => k.npub == npub)
const key = this.enckeys.find((k) => k.npub === npub)
if (!key) throw new Error('Npub not found')
if (!name) throw new Error('Empty name')
if (key.name !== name) throw new Error('Name changed, please reload')
@@ -1199,17 +1384,23 @@ export class NoauthBackend {
return true
}
private async exportKey(npub: string): Promise<string> {
const dbKey = await dbi.getKey(npub)
if (!dbKey) throw new Error("Key not found")
return dbKey.ncryptsec || ''
}
public async onMessage(event: any) {
const { id, method, args } = event.data
try {
//console.log("UI message", id, method, args)
let result = undefined
if (method === 'generateKey') {
result = await this.generateKey(args[0])
result = await this.generateKey(args[0], args[1])
} else if (method === 'redeemToken') {
result = await this.redeemToken(args[0], args[1])
} else if (method === 'importKey') {
result = await this.importKey(args[0], args[1])
result = await this.importKey(args[0], args[1], args[2])
} else if (method === 'saveKey') {
result = await this.saveKey(args[0], args[1])
} else if (method === 'fetchKey') {
@@ -1230,6 +1421,8 @@ export class NoauthBackend {
result = await this.enablePush()
} else if (method === 'fetchPendingRequests') {
result = await this.fetchPendingRequests(args[0], args[1])
} else if (method === 'exportKey') {
result = await this.exportKey(args[0])
} else {
console.log('unknown method from UI ', method)
}
@@ -1256,10 +1449,20 @@ export class NoauthBackend {
}
}
private async reloadUI() {
const clients = await this.swg.clients.matchAll({
includeUncontrolled: true,
})
console.log('reloadUI clients', clients.length)
for (const client of clients) {
client.postMessage({ result: 'reload' })
}
}
public async onPush(event: any) {
console.log('push', { data: event.data })
// noop - we just need browser to launch this worker
// FIXME use event.waitUntil and and unblock after we
// show a notification
// show a notification to avoid annoying the browser
}
}

View File

@@ -9,6 +9,7 @@ export interface DbKey {
relays?: string[]
enckey: string
profile?: MetaEvent | null
ncryptsec?: string
}
export interface DbApp {
@@ -18,6 +19,7 @@ export interface DbApp {
icon: string
url: string
timestamp: number
updateTimestamp: number
}
export interface DbPerm {
@@ -63,9 +65,9 @@ export interface DbSchema extends Dexie {
export const db = new Dexie('noauthdb') as DbSchema
db.version(8).stores({
db.version(9).stores({
keys: 'npub',
apps: 'appNpub,npub,name,timestamp',
apps: 'appNpub,npub,name,timestamp,updateTimestamp',
perms: 'id,npub,appNpub,perm,value,timestamp',
pending: 'id,npub,appNpub,timestamp,method',
history: 'id,npub,appNpub,timestamp,method,allowed',
@@ -81,6 +83,13 @@ export const dbi = {
console.log(`db addKey error: ${error}`)
}
},
getKey: async (npub: string) => {
try {
return await db.keys.get(npub)
} catch (error) {
console.log(`db getKey error: ${error}`)
}
},
listKeys: async (): Promise<DbKey[]> => {
try {
return await db.keys.toArray()
@@ -113,12 +122,13 @@ export const dbi = {
console.log(`db addApp error: ${error}`)
}
},
updateApp: async (app: Omit<DbApp, 'npub' | 'timestamp'>) => {
updateApp: async (app: DbApp) => {
try {
await db.apps.where({ appNpub: app.appNpub }).modify({
name: app.name,
icon: app.icon,
url: app.url,
updateTimestamp: app.updateTimestamp
})
} catch (error) {
console.log(`db updateApp error: ${error}`)

View File

@@ -43,7 +43,7 @@ export function isWeakPassphase(passphrase: string): boolean {
const compl = Math.pow(base, passphrase.length)
const thresh = Math.pow(11, 14)
// console.log({ big, small, number, punct, base, compl, thresh });
return compl < thresh;
return compl < thresh
}
export class Keys {
@@ -61,7 +61,7 @@ export class Keys {
// We could use string.normalize() to make sure all JS implementations
// are compatible, but since we're looking to make this thing a standard
// then the simplest way is to exclude unicode and only work with ASCII
if (!isValidPassphase(passphrase)) throw new Error('Password must be 4+ ASCII chars')
if (!isValidPassphase(passphrase)) throw new Error('Password must be 6+ ASCII chars')
return new Promise((ok, fail) => {
// NOTE: we should use Argon2 or scrypt later, for now

58
src/modules/nip49.ts Normal file
View File

@@ -0,0 +1,58 @@
// copied from https://github.com/nbd-wtf/nostr-tools/blob/master/nip49.ts
// remove when we migrate to nostr-tools@2.x.x
import { scrypt } from '@noble/hashes/scrypt'
import { xchacha20poly1305 } from '@noble/ciphers/chacha'
import { concatBytes, randomBytes } from '@noble/hashes/utils'
import { bech32 } from '@scure/base'
export const Bech32MaxSize = 5000
function encodeBech32<Prefix extends string>(prefix: Prefix, data: Uint8Array): `${Prefix}1${string}` {
let words = bech32.toWords(data)
return bech32.encode(prefix, words, Bech32MaxSize) as `${Prefix}1${string}`
}
export function encodeBytes<Prefix extends string>(prefix: Prefix, bytes: Uint8Array): `${Prefix}1${string}` {
return encodeBech32(prefix, bytes)
}
export function encrypt(sec: Uint8Array, password: string, logn: number = 16, ksb: 0x00 | 0x01 | 0x02 = 0x02): string {
let salt = randomBytes(16)
let n = 2 ** logn
let key = scrypt(password.normalize('NFKC'), salt, { N: n, r: 8, p: 1, dkLen: 32 })
let nonce = randomBytes(24)
let aad = Uint8Array.from([ksb])
let xc2p1 = xchacha20poly1305(key, nonce, aad)
let ciphertext = xc2p1.encrypt(sec)
let b = concatBytes(Uint8Array.from([0x02]), Uint8Array.from([logn]), salt, nonce, aad, ciphertext)
return encodeBytes('ncryptsec', b)
}
export function decrypt(ncryptsec: string, password: string): Uint8Array {
let { prefix, words } = bech32.decode(ncryptsec, Bech32MaxSize)
if (prefix !== 'ncryptsec') {
throw new Error(`invalid prefix ${prefix}, expected 'ncryptsec'`)
}
let b = new Uint8Array(bech32.fromWords(words))
let version = b[0]
if (version !== 0x02) {
throw new Error(`invalid version ${version}, expected 0x02`)
}
let logn = b[1]
let n = 2 ** logn
let salt = b.slice(2, 2 + 16)
let nonce = b.slice(2 + 16, 2 + 16 + 24)
let ksb = b[2 + 16 + 24]
let aad = Uint8Array.from([ksb])
let ciphertext = b.slice(2 + 16 + 24 + 1)
let key = scrypt(password.normalize('NFKC'), salt, { N: n, r: 8, p: 1, dkLen: 32 })
let xc2p1 = xchacha20poly1305(key, nonce, aad)
let sec = xc2p1.decrypt(ciphertext)
return sec
}

View File

@@ -1,10 +1,12 @@
// service-worker client interface
// service-worker client interface,
// works on the frontend, not sw
import * as serviceWorkerRegistration from '../serviceWorkerRegistration'
export let swr: ServiceWorkerRegistration | null = null
const reqs = new Map<number, { ok: (r: any) => void; rej: (r: any) => void }>()
let nextReqId = 1
let onRender: (() => void) | null = null
let onReload: (() => void) | null = null
const queue: (() => Promise<void> | void)[] = []
export async function swicRegister() {
@@ -14,7 +16,11 @@ export async function swicRegister() {
swr = registration
},
onError(e) {
console.log(`error ${e}`)
console.log('sw error', e)
},
onUpdate() {
// tell new SW that it should activate immediately
swr?.waiting?.postMessage({ type: 'SKIP_WAITING' })
},
})
@@ -27,8 +33,7 @@ export async function swicRegister() {
console.log('This page is not currently controlled by a service worker.')
}
while (queue.length)
await (queue.shift()!)()
while (queue.length) await queue.shift()!()
})
navigator.serviceWorker.addEventListener('message', (event) => {
@@ -37,18 +42,22 @@ export async function swicRegister() {
}
export function swicWaitStarted() {
return new Promise<void>(ok => {
return new Promise<void>((ok) => {
if (swr && swr.active) ok()
else queue.push(ok)
})
}
}
function onMessage(data: any) {
const { id, result, error } = data
console.log('SW message', id, result, error)
if (!id) {
if (onRender) onRender()
if (result === 'reload') {
if (onReload) onReload()
} else {
if (onRender) onRender()
}
return
}
@@ -68,7 +77,6 @@ export async function swicCall(method: string, ...args: any[]) {
nextReqId++
return new Promise((ok, rej) => {
const call = async () => {
if (!swr || !swr.active) {
rej(new Error('No active service worker'))
@@ -93,3 +101,7 @@ export async function swicCall(method: string, ...args: any[]) {
export function swicOnRender(cb: () => void) {
onRender = cb
}
export function swicOnReload(cb: () => void) {
onReload = cb
}

View File

@@ -21,6 +21,7 @@ const commonTheme: Theme = createTheme({
styleOverrides: {
root: {
textTransform: 'initial',
color: 'red',
},
},
},

View File

@@ -9,7 +9,6 @@ import { getAppIconTitle, getDomain, getShortenNpub } from '@/utils/helpers/help
import { Button } from '@/shared/Button/Button'
import { ACTION_TYPE } from '@/utils/consts'
import { Permissions } from './components/Permissions/Permissions'
import { StyledAppIcon } from './styled'
import { useToggleConfirm } from '@/hooks/useToggleConfirm'
import { ConfirmModal } from '@/shared/ConfirmModal/ConfirmModal'
import { swicCall } from '@/modules/swic'
@@ -20,6 +19,8 @@ import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
import MoreIcon from '@mui/icons-material/MoreVertRounded'
import { ModalAppDetails } from '@/components/Modal/ModalAppDetails/ModalAppDetails'
import { IconApp } from '@/shared/IconApp/IconApp'
import { HeadingContainer, AppInfoContainer, AppNameContainer } from './styled'
const AppPage = () => {
const keys = useAppSelector(selectKeys)
@@ -46,7 +47,7 @@ const AppPage = () => {
const shortAppNpub = getShortenNpub(appNpub)
const appName = name || appDomain || shortAppNpub
const appAvatarTitle = getAppIconTitle(name || appDomain, appNpub)
const isAppNameExists = !!name
const isAppNameExists = !!name || !!appDomain
const { timestamp } = connectPerm || {}
const connectedOn = connectPerm && timestamp ? `Connected at ${formatTimestampDate(timestamp)}` : 'Not connected'
@@ -67,12 +68,13 @@ const AppPage = () => {
<>
<Stack maxHeight={'100%'} overflow={'auto'} alignItems={'flex-start'} height={'100%'}>
<IOSBackButton onNavigate={() => navigate(`key/${npub}`)} />
<Stack marginBottom={'1rem'} direction={'row'} gap={'1rem'} width={'100%'} alignItems={'center'}>
<StyledAppIcon src={icon}>{appAvatarTitle}</StyledAppIcon>
<Box flex={'1'} overflow={'hidden'}>
<Stack direction={'row'} alignItems={'flex-start'} gap={'0.5rem'} marginBottom={'0.5rem'}>
<Box display={'flex'} flexDirection={'column'} flex={1}>
<Typography variant="h4" noWrap>
<HeadingContainer>
<IconApp size="big" picture={icon} alt={appAvatarTitle} />
<Box flex={'1'} overflow={'auto'} alignSelf={'flex-start'} width={'100%'}>
<AppInfoContainer>
<AppNameContainer>
<Typography className="app_name" variant="h4" noWrap>
{appName}
</Typography>
{isAppNameExists && (
@@ -80,16 +82,19 @@ const AppPage = () => {
{shortAppNpub}
</Typography>
)}
</Box>
</AppNameContainer>
<IconButton onClick={handleShowAppDetailsModal}>
<MoreIcon />
</IconButton>
</Stack>
</AppInfoContainer>
<Typography variant="body2" noWrap>
{connectedOn}
</Typography>
</Box>
</Stack>
</HeadingContainer>
<Box marginBottom={'1rem'}>
<SectionTitle marginBottom={'0.5rem'}>Disconnect</SectionTitle>
<Button fullWidth onClick={handleShow}>

View File

@@ -1,6 +1,32 @@
import { Avatar, AvatarProps, styled } from '@mui/material'
import { Box, BoxProps, Stack, StackProps, styled } from '@mui/material'
export const StyledAppIcon = styled((props: AvatarProps) => <Avatar {...props} variant="rounded" />)(() => ({
width: 70,
height: 70,
export const HeadingContainer = styled((props: StackProps) => <Stack {...props} />)(() => ({
width: '100%',
marginBottom: '1rem',
flexDirection: 'row',
gap: '1rem',
alignItems: 'center',
'@media screen and (max-width: 320px)': {
flexDirection: 'column',
gap: '0.5rem',
},
}))
export const AppInfoContainer = styled((props: StackProps) => <Stack {...props} direction={'row'} />)(() => ({
width: '100%',
flex: 1,
alignItems: 'flex-start',
gap: '0.5rem',
marginBottom: '0.5rem',
overflow: 'hidden',
'@media screen and (max-width: 320px)': {
alignSelf: 'flex-start',
},
}))
export const AppNameContainer = styled((props: BoxProps) => <Box {...props} />)(() => ({
display: 'flex',
flexDirection: 'column',
flex: 1,
overflow: 'auto',
}))

View File

@@ -5,9 +5,7 @@ import { Box, Stack, Typography } from '@mui/material'
import { FC } from 'react'
import { StyledEmptyAppsBox } from '../styled'
import { Button } from '@/shared/Button/Button'
import { call } from '@/utils/helpers/helpers'
import { swicCall } from '@/modules/swic'
import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar'
import { ItemApp } from './ItemApp'
type AppsProps = {
@@ -16,22 +14,12 @@ type AppsProps = {
}
export const Apps: FC<AppsProps> = ({ apps = [] }) => {
const notify = useEnqueueSnackbar()
// eslint-disable-next-line
async function deletePerm(id: string) {
call(async () => {
await swicCall('deletePerm', id)
notify('Perm deleted!', 'success')
})
}
const openAppStore = () => {
window.open('https://nostrapp.link', '_blank')
}
return (
<Box flex={1} marginBottom={'1rem'} display={'flex'} flexDirection={'column'} overflow={'auto'}>
<Box marginBottom={'1rem'} display={'flex'} flexDirection={'column'}>
<Stack direction={'row'} alignItems={'center'} justifyContent={'space-between'} marginBottom={'0.5rem'}>
<SectionTitle>Connected apps</SectionTitle>
<AppLink title="Discover Apps" onClick={openAppStore} />
@@ -45,7 +33,7 @@ export const Apps: FC<AppsProps> = ({ apps = [] }) => {
</StyledEmptyAppsBox>
)}
<Stack gap={'0.5rem'} overflow={'auto'} flex={1}>
<Stack gap={'0.5rem'} overflow={'auto'} flex={1} paddingBottom={'0.75rem'}>
{apps.map((a) => (
<ItemApp {...a} key={a.appNpub} />
))}

View File

@@ -1,6 +1,6 @@
import { FC } from 'react'
import { Warning } from '@/components/Warning/Warning'
import { CircularProgress, Stack, Typography } from '@mui/material'
import { CircularProgress, Stack, Typography, TypographyProps, styled } from '@mui/material'
import AutoModeOutlinedIcon from '@mui/icons-material/AutoModeOutlined'
type BackgroundSigningWarningProps = {
@@ -12,18 +12,29 @@ export const BackgroundSigningWarning: FC<BackgroundSigningWarningProps> = ({ is
return (
<Warning
message={
<Stack direction={'row'} alignItems={'center'} gap={'1rem'}>
Enable background service {isEnabling ? <CircularProgress size={'1.5rem'} /> : null}
<Stack gap={'0.25rem'} overflow={'auto'} width={'100%'}>
<Typography variant="body1" noWrap fontWeight={'500'}>
Enable background service
</Typography>
<StyledHint>Please allow notifications for background operation.</StyledHint>
</Stack>
}
hint={
<Typography variant='body2'>
Please allow notifications
for background operation.
</Typography>
icon={
isEnabling ? (
<CircularProgress size={'1.5rem'} sx={{ color: '#fff' }} />
) : (
<AutoModeOutlinedIcon htmlColor="white" />
)
}
icon={<AutoModeOutlinedIcon htmlColor="white" />}
onClick={isEnabling ? undefined : onEnableBackSigning}
/>
)
}
const StyledHint = styled((props: TypographyProps) => <Typography variant="body2" {...props} />)(() => ({
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
textOverflow: 'ellipsis',
}))

View File

@@ -13,7 +13,7 @@ export const ItemApp: FC<ItemAppProps> = ({ npub, appNpub, icon, name, url }) =>
const appName = name || appDomain || shortAppNpub
const appIcon = icon || `https://${appDomain}/favicon.ico`
const appAvatarTitle = getAppIconTitle(name || appDomain, appNpub)
const isAppNameExists = !!name
const isAppNameExists = !!name || !!appDomain
return (
<StyledItemAppContainer

View File

@@ -1,4 +1,5 @@
import { Input, AppInputProps } from '@/shared/Input/Input'
import { Input } from '@/shared/Input/Input'
import { AppInputProps } from '@/shared/Input/types'
import { Stack, StackProps, styled } from '@mui/material'
import { forwardRef } from 'react'

View File

@@ -1,7 +1,7 @@
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { DbPending, DbPerm } from '@/modules/db'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { ACTION_TYPE } from '@/utils/consts'
import { ACTION_TYPE, REQ_TTL } from '@/utils/consts'
import { useCallback, useEffect, useRef } from 'react'
import { useSearchParams } from 'react-router-dom'
@@ -25,7 +25,7 @@ export const useTriggerConfirmModal = (npub: string, pending: DbPending[], perms
const isConfirmConnectModalOpened = getModalOpened(MODAL_PARAMS_KEYS.CONFIRM_CONNECT)
const isConfirmEventModalOpened = getModalOpened(MODAL_PARAMS_KEYS.CONFIRM_EVENT)
const filteredPendingReqs = pending.filter((p) => p.npub === npub)
const filteredPendingReqs = pending.filter((p) => p.npub === npub && p.timestamp > Date.now() - REQ_TTL)
const filteredPerms = perms.filter((p) => p.npub === npub)
const npubConnectPerms = filteredPerms.filter((perm) => perm.perm === 'connect' || perm.perm === ACTION_TYPE.BASIC)

View File

@@ -1,4 +1,5 @@
import { Input, AppInputProps } from '@/shared/Input/Input'
import { Input } from '@/shared/Input/Input'
import { AppInputProps } from '@/shared/Input/types'
import { Box, Button, ButtonProps, styled, Badge } from '@mui/material'
import { forwardRef } from 'react'

View File

@@ -1,13 +1,15 @@
import { Suspense, lazy } from 'react'
import { Route, Routes, Navigate } from 'react-router-dom'
import HomePage from '../pages/HomePage/Home.Page'
import { Layout } from '../layout/Layout'
import { CircularProgress, Stack } from '@mui/material'
import CreatePage from '@/pages/CreatePage/Create.Page'
const KeyPage = lazy(() => import('../pages/KeyPage/Key.Page'))
const ConfirmPage = lazy(() => import('../pages/Confirm.Page'))
const AppPage = lazy(() => import('../pages/AppPage/App.Page'))
// Pages
import CreatePage from '@/pages/CreatePage/Create.Page'
import HomePage from '../pages/HomePage/Home.Page'
import KeyPage from '../pages/KeyPage/Key.Page'
const ConfirmPage = lazy(() => import('@/pages/Confirm.Page'))
const AppPage = lazy(() => import('@/pages/AppPage/App.Page'))
const LoadingSpinner = () => (
<Stack height={'100%'} justifyContent={'center'} alignItems={'center'}>
@@ -21,7 +23,6 @@ const AppRoutes = () => {
<Routes>
<Route path="/" element={<Layout />}>
<Route path="/" element={<Navigate to={'/home'} />} />
{/* <Route path='/welcome' element={<WelcomePage />} /> */}
<Route path="/home" element={<HomePage />} />
<Route path="/key/:npub" element={<KeyPage />} />
<Route path="/key/:npub/app/:appNpub" element={<AppPage />} />

View File

@@ -19,6 +19,9 @@ const StyledButton = styled(
const commonStyles = {
fontWeight: 500,
borderRadius: '1rem',
'@media screen and (max-width: 320px)': {
padding: '0.25rem 0.75rem',
},
}
if (varianttype === 'secondary') {
return {

View File

@@ -1,5 +1,6 @@
import { useRef } from 'react'
import { Input, AppInputProps } from '../Input/Input'
import { Input } from '../Input/Input'
import { AppInputProps } from '../Input/types'
export type DebounceProps = {
handleDebounce: (value: string) => void

View File

@@ -9,7 +9,8 @@ export const StyledButton = styled((props: ButtonProps) => (
startIcon: 'icon',
}}
/>
))(() => ({
))(({ theme }) => ({
color: theme.palette.primary.main,
marginBottom: '0.5rem',
borderRadius: '8px',
'&:is(:hover,:active)': {
@@ -18,4 +19,7 @@ export const StyledButton = styled((props: ButtonProps) => (
'& .icon': {
marginRight: '5px',
},
'@media screen and (max-width: 320px)': {
marginBottom: '0.25rem',
},
}))

View File

@@ -1,8 +1,11 @@
import React, { FC, useEffect, useState } from 'react'
import { FC, useEffect, useState } from 'react'
import { StyledAppIcon, StyledAppImg } from './styled'
import ImageOutlinedIcon from '@mui/icons-material/ImageOutlined'
import { IIconApp } from './types'
const failedCache = new Map<string, boolean>()
export const IconApp: FC<{ picture: string }> = ({ picture }) => {
export const IconApp: FC<IIconApp> = ({ picture = '', alt, isRounded, isSmall, onClick, size, ...rest }) => {
const c = failedCache.get(picture)
const [isFailed, setIsFailed] = useState(c !== undefined ? c : true)
@@ -26,5 +29,21 @@ export const IconApp: FC<{ picture: string }> = ({ picture }) => {
}
}, [picture])
return <div>IconApp</div>
return (
<StyledAppIcon isNotLoaded={isFailed} size={size} onClick={onClick} {...rest}>
{alt ? (
<StyledAppImg size={size} alt={alt} isSmall={isSmall} src={isFailed ? '' : picture}>
{isFailed && (
<div className="MuiAvatar-root MuiAvatar-square MuiAvatar-colorDefault">
{alt.substring(0, 1).toUpperCase()}
</div>
)}
</StyledAppImg>
) : (
<StyledAppImg size={size} alt={alt} isSmall={isSmall} src={isFailed ? '/' : picture}>
<ImageOutlinedIcon fontSize="inherit" />
</StyledAppImg>
)}
</StyledAppIcon>
)
}

View File

@@ -0,0 +1,53 @@
import { APP_NSEC_SIZE } from '@/utils/consts'
const SIZE_VALUE = {
[APP_NSEC_SIZE.BIG]: 70,
[APP_NSEC_SIZE.LARGE]: 56,
[APP_NSEC_SIZE.MEDIUM]: 40,
[APP_NSEC_SIZE.SMALL]: 36,
[APP_NSEC_SIZE.EXTRA_SMALL]: 30,
}
export const APP_SIZE_VALUE = {
[APP_NSEC_SIZE.BIG]: {
height: SIZE_VALUE[APP_NSEC_SIZE.BIG],
minWidth: SIZE_VALUE[APP_NSEC_SIZE.BIG],
maxWidth: SIZE_VALUE[APP_NSEC_SIZE.BIG],
},
[APP_NSEC_SIZE.LARGE]: {
height: SIZE_VALUE[APP_NSEC_SIZE.LARGE],
minWidth: SIZE_VALUE[APP_NSEC_SIZE.LARGE],
maxWidth: SIZE_VALUE[APP_NSEC_SIZE.LARGE],
},
[APP_NSEC_SIZE.MEDIUM]: {
height: SIZE_VALUE[APP_NSEC_SIZE.MEDIUM],
minWidth: SIZE_VALUE[APP_NSEC_SIZE.MEDIUM],
maxWidth: SIZE_VALUE[APP_NSEC_SIZE.MEDIUM],
},
[APP_NSEC_SIZE.SMALL]: {
height: SIZE_VALUE[APP_NSEC_SIZE.SMALL],
minWidth: SIZE_VALUE[APP_NSEC_SIZE.SMALL],
maxWidth: SIZE_VALUE[APP_NSEC_SIZE.SMALL],
},
[APP_NSEC_SIZE.EXTRA_SMALL]: {
height: SIZE_VALUE[APP_NSEC_SIZE.EXTRA_SMALL],
minWidth: SIZE_VALUE[APP_NSEC_SIZE.EXTRA_SMALL],
maxWidth: SIZE_VALUE[APP_NSEC_SIZE.EXTRA_SMALL],
},
}
const FONT_SIZE_VALUE = {
[APP_NSEC_SIZE.BIG]: 24,
[APP_NSEC_SIZE.LARGE]: 20,
[APP_NSEC_SIZE.MEDIUM]: 16,
[APP_NSEC_SIZE.SMALL]: 12,
[APP_NSEC_SIZE.EXTRA_SMALL]: 10,
}
export const APP_NAME_FONT_SIZE_VALUE = {
[APP_NSEC_SIZE.LARGE]: FONT_SIZE_VALUE[APP_NSEC_SIZE.LARGE],
[APP_NSEC_SIZE.BIG]: FONT_SIZE_VALUE[APP_NSEC_SIZE.BIG],
[APP_NSEC_SIZE.MEDIUM]: FONT_SIZE_VALUE[APP_NSEC_SIZE.MEDIUM],
[APP_NSEC_SIZE.SMALL]: FONT_SIZE_VALUE[APP_NSEC_SIZE.SMALL],
[APP_NSEC_SIZE.EXTRA_SMALL]: FONT_SIZE_VALUE[APP_NSEC_SIZE.EXTRA_SMALL],
}

View File

@@ -0,0 +1,54 @@
import { Avatar, Box, styled } from '@mui/material'
import { forwardRef } from 'react'
import { IAvatarStyled, IBoxStyled } from './types'
import { grey } from '@mui/material/colors'
import { AppNostrSize } from '@/types/app-nsec'
import { APP_NAME_FONT_SIZE_VALUE, APP_SIZE_VALUE } from './const'
import { APP_NSEC_SIZE } from '@/utils/consts'
const color = grey[500]
const getVariantApp = (isRounded: boolean, size: AppNostrSize) => {
if (isRounded) {
return {
height: 34,
minWidth: 34,
maxWidth: 34,
borderRadius: '7px',
}
}
return APP_SIZE_VALUE[size]
}
export const StyledAppIcon = styled(
forwardRef<HTMLAnchorElement, IBoxStyled>(function BoxDisplayName(props, ref) {
return <Box ref={ref} {...props} />
})
)(({ theme, isNotLoaded, isRounded = false, size = APP_NSEC_SIZE.MEDIUM }) => ({
position: 'relative',
borderRadius: theme.shape.borderRadius,
overflow: 'hidden',
...getVariantApp(isRounded, size),
transition: theme.transitions.create(['border-color', 'transition']),
backgroundColor: isNotLoaded ? color : theme.palette.background.default,
boxSizing: 'border-box',
':active': {
borderColor: 'rgba(255, 255, 255, 0.3)',
},
}))
export const StyledAppImg = styled(function BoxDisplayName(props: IAvatarStyled) {
return <Avatar variant="square" {...props} />
})(({ isSmall = false, size = APP_NSEC_SIZE.MEDIUM }) => ({
position: 'absolute',
left: 0,
top: 0,
height: '100%',
width: '100%',
fontWeight: '500',
fontSize: APP_NAME_FONT_SIZE_VALUE[size],
'.MuiAvatar-img': {
objectFit: isSmall ? 'scale-down' : 'cover',
},
}))

View File

@@ -0,0 +1,24 @@
import { AppNostrSize } from '@/types/app-nsec'
import { AvatarProps, BoxProps } from '@mui/material'
export type IconAppProps = {
size?: AppNostrSize
isRounded?: boolean
isSmall?: boolean
onClick?: () => void
isNotLoaded?: boolean
}
export type IconAppBase = {
picture: string
alt?: string
}
export type IIconApp = Omit<IconAppProps, 'isNotLoaded'> & IconAppBase
export type IBoxStyled = IconAppProps & BoxProps
export type IAvatarStyled = {
size?: AppNostrSize
isSmall?: boolean
} & AvatarProps

View File

@@ -1,74 +1,34 @@
import { ReactNode, forwardRef } from 'react'
import {
Box,
BoxProps,
FormHelperText,
FormHelperTextProps,
FormLabel,
InputBase,
InputBaseProps,
styled,
} from '@mui/material'
import { FormHelperText, FormLabel, InputBase } from '@mui/material'
import { StyledInputContainer } from './styled'
import { AppInputProps } from './types'
export type AppInputProps = InputBaseProps & {
helperText?: string | ReactNode
helperTextProps?: FormHelperTextProps
containerProps?: BoxProps
label?: string
}
const renderItem = <T,>(item: T, value: ReactNode) => (item ? value : null)
export const Input = forwardRef<HTMLInputElement, AppInputProps>(
({ helperText, containerProps, helperTextProps, label, ...props }, ref) => {
return (
<StyledInputContainer {...containerProps}>
{label ? (
{renderItem(
label,
<FormLabel className="label" htmlFor={props.id}>
{label}
</FormLabel>
) : null}
)}
<InputBase
autoComplete="off"
{...props}
classes={{ error: 'error', root: 'input_root', input: 'input', disabled: 'disabled' }}
ref={ref}
/>
{helperText ? (
{renderItem(
helperText,
<FormHelperText {...helperTextProps} className="helper_text">
{helperText}
</FormHelperText>
) : null}
)}
</StyledInputContainer>
)
}
)
const StyledInputContainer = styled((props: BoxProps) => <Box {...props} />)(({ theme }) => {
const isDark = theme.palette.mode === 'dark'
return {
width: '100%',
'& > .input_root': {
background: isDark ? '#000000A8' : '#000',
color: theme.palette.common.white,
padding: '0.75rem 1rem',
borderRadius: '1rem',
border: '0.3px solid #FFFFFF54',
fontSize: '0.875rem',
'&.error': {
border: '0.3px solid ' + theme.palette.error.main,
},
},
'& .input:is(.disabled, &)': {
WebkitTextFillColor: '#ffffff80',
},
'& > .helper_text': {
margin: '0.5rem 1rem 0',
color: theme.palette.text.primary,
},
'& > .label': {
margin: '0 1rem 0.5rem',
display: 'block',
color: theme.palette.primary.main,
fontSize: '0.875rem',
},
}
})

View File

@@ -0,0 +1,45 @@
import { Box, BoxProps, styled } from '@mui/material'
export const StyledInputContainer = styled((props: BoxProps) => <Box {...props} />)(({ theme }) => {
const isDark = theme.palette.mode === 'dark'
return {
width: '100%',
'& > .input_root': {
background: isDark ? '#000000A8' : '#000',
color: theme.palette.common.white,
padding: '0.75rem 1rem',
borderRadius: '1rem',
border: '0.3px solid #FFFFFF54',
fontSize: '0.875rem',
'&.error': {
border: '0.3px solid ' + theme.palette.error.main,
},
},
'& .input:is(.disabled, &)': {
WebkitTextFillColor: '#ffffff80',
},
'& > .helper_text': {
margin: '0.5rem 0.5rem 0',
color: theme.palette.text.primary,
},
'& > .label': {
margin: '0 1rem 0.5rem',
display: 'block',
color: theme.palette.primary.main,
fontSize: '0.875rem',
},
'@media screen and (max-width: 320px)': {
'& > .input_root': {
padding: '0.5rem 0.75rem',
borderRadius: '0.75rem',
},
'& > .label': {
margin: '0 0.25rem 0.25rem',
fontSize: '0.75rem',
},
'& > .helper_text': {
fontSize: '0.75rem',
},
},
}
})

View File

@@ -0,0 +1,9 @@
import { BoxProps, FormHelperTextProps, InputBaseProps } from '@mui/material'
import { ReactNode } from 'react'
export type AppInputProps = InputBaseProps & {
helperText?: string | ReactNode
helperTextProps?: FormHelperTextProps
containerProps?: BoxProps
label?: string
}

View File

@@ -1,8 +1,7 @@
import { FC, useEffect, useState } from 'react'
import { Fade, IconButton, Typography } from '@mui/material'
import { Fade, Typography } from '@mui/material'
import CopyToClipboard from 'react-copy-to-clipboard'
import { CopyIcon } from '@/assets'
import { StyledContainer } from './styled'
import { StyledContainer, StyledCopyButton } from './styled'
type InputCopyButtonProps = {
value: string
@@ -40,9 +39,7 @@ export const InputCopyButton: FC<InputCopyButtonProps> = ({ value, onCopy = () =
</Fade>
)}
<CopyToClipboard text={value} onCopy={handleCopy}>
<IconButton color="inherit">
<CopyIcon />
</IconButton>
<StyledCopyButton />
</CopyToClipboard>
</StyledContainer>
)

View File

@@ -1,7 +1,17 @@
import { Stack, StackProps, styled } from '@mui/material'
import { IconButton, Stack, StackProps, styled } from '@mui/material'
import { CopyIcon } from '@/assets'
export const StyledContainer = styled((props: StackProps & { copied: number }) => (
<Stack {...props} direction={'row'} alignItems={'center'} />
))(({ theme, copied }) => ({
color: copied ? theme.palette.success.main : theme.palette.textSecondaryDecorate.main,
}))
export const StyledCopyButton = styled((props) => (
<IconButton color="inherit" {...props}>
<CopyIcon />
</IconButton>
))(() => ({
width: 40,
height: 40,
}))

View File

@@ -2,16 +2,7 @@ import { combineReducers, configureStore } from '@reduxjs/toolkit'
import { contentSlice } from './reducers/content.slice'
import { uiSlice } from './reducers/ui.slice'
import {
persistStore,
persistReducer,
// FLUSH,
// REGISTER,
// REHYDRATE,
// PAUSE,
// PERSIST,
// PURGE,
} from 'redux-persist'
import { persistStore, persistReducer } from 'redux-persist'
import storage from 'redux-persist/lib/storage' // defaults to localStorage for web
import memoizeOne from 'memoize-one'
import isDeepEqual from 'lodash.isequal'

6
src/types/app-nsec.ts Normal file
View File

@@ -0,0 +1,6 @@
import { APP_NSEC_SIZE } from '@/utils/consts'
import { OverridableStringUnion } from './utils'
export type AppNostrSizeUnion = (typeof APP_NSEC_SIZE)[keyof typeof APP_NSEC_SIZE]
export type AppNostrSize = OverridableStringUnion<AppNostrSizeUnion>

10
src/types/utils.ts Normal file
View File

@@ -0,0 +1,10 @@
export type OverridableStringUnion<T extends string> = GenerateStringUnion<DistributiveOmit<Record<T, true>>>
type GenerateStringUnion<T> = Extract<
{
[Key in keyof T]: true extends T[Key] ? Key : never
}[keyof T],
string
>
type DistributiveOmit<T> = T extends T ? T : never

View File

@@ -3,11 +3,20 @@ export const WEB_PUSH_PUBKEY = process.env.REACT_APP_WEB_PUSH_PUBKEY
export const DOMAIN = process.env.REACT_APP_DOMAIN
export const RELAY = process.env.REACT_APP_RELAY || 'wss://relay.nsec.app'
export const NIP46_RELAYS = [RELAY]
export const OUTBOX_RELAYS = ['wss://relay.nostr.band', 'wss://nos.lol', 'wss://purplepag.es']
export const BROADCAST_RELAY = 'wss://nostr.mutinywallet.com'
export const APP_TAG = 'nsec.app/perm'
export const MIN_POW = 14
export const MAX_POW = 19
export const KIND_RPC = 24133
export const KIND_DATA = 30078
export const RELOAD_STORAGE_KEY = 'reload'
export const REQ_TTL = 60000 // 1 min
export enum ACTION_TYPE {
BASIC = 'basic',
@@ -16,10 +25,18 @@ export enum ACTION_TYPE {
}
export const ACTIONS: { [type: string]: string } = {
basic: 'Basic permissions',
basic: 'Basic permissions',
get_public_key: 'Get public key',
sign_event: 'Sign event',
connect: 'Connect',
nip04_encrypt: 'Encrypt message',
nip04_decrypt: 'Decrypt message',
}
export const APP_NSEC_SIZE = {
BIG: 'big',
LARGE: 'large',
MEDIUM: 'medium',
SMALL: 'small',
EXTRA_SMALL: 'extra-small',
} as const

View File

@@ -3,7 +3,7 @@ import { format } from 'date-fns'
export const formatTimestampDate = (timestamp: number) => {
try {
const date = new Date(timestamp)
const formattedDate = format(date, "dd-MM-yyyy HH:mm")
const formattedDate = format(date, 'dd-MM-yyyy HH:mm')
return formattedDate
} catch (error) {
return ''

View File

@@ -16,7 +16,7 @@ export const getShortenNpub = (npub = '') => {
}
export const getProfileUsername = (profile: MetaEvent | null) => {
if (!profile) return null
if (!profile) return undefined
return profile?.info?.name || profile?.info?.display_name
}
@@ -72,6 +72,7 @@ export function isPackagePerm(perm: string, reqPerm: string) {
case 'sign_event:10002':
case 'sign_event:30023':
case 'sign_event:10000':
case 'sign_event:27235':
return true
}
}
@@ -125,7 +126,8 @@ export const getReferrerAppUrl = () => {
if (!window.document.referrer) return ''
try {
const u = new URL(window.document.referrer.toLocaleLowerCase())
if (u.hostname !== DOMAIN && !u.hostname.endsWith('.' + DOMAIN)) return u.origin
if (u.hostname !== DOMAIN && !u.hostname.endsWith('.' + DOMAIN) && u.origin !== window.location.origin)
return u.origin
} catch {}
return ''
}