Merge pull request #26 from nostrband/develop

Lots of minor fixes
This commit is contained in:
Nostr.Band 2024-02-12 10:29:57 +03:00 committed by GitHub
commit f45300583c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
39 changed files with 821 additions and 372 deletions

View File

@ -45,10 +45,7 @@ function App() {
const apps = await dbi.listApps()
dispatch(
setApps({
apps: apps.map((app) => ({
...app,
//icon: 'https://nostr.band/android-chrome-192x192.png',
})),
apps,
})
)

View File

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

View File

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

View File

@ -5,7 +5,7 @@ import { askNotificationPermission, call, getAppIconTitle, getDomain, getShorten
import { Avatar, Box, Stack, Typography } from '@mui/material'
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { useAppSelector } from '@/store/hooks/redux'
import { selectAppsByNpub } from '@/store'
import { selectAppsByNpub, selectKeys, selectPendingsByNpub } from '@/store'
import { StyledButton, StyledToggleButtonsGroup } from './styled'
import { ActionToggleButton } from './сomponents/ActionToggleButton'
import { useState } from 'react'
@ -14,6 +14,8 @@ import { ACTION_TYPE } from '@/utils/consts'
import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar'
export const ModalConfirmConnect = () => {
const keys = useAppSelector(selectKeys)
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
const notify = useEnqueueSnackbar()
const navigate = useNavigate()
@ -23,6 +25,7 @@ export const ModalConfirmConnect = () => {
const paramNpub = searchParams.get('npub') || ''
const { npub = paramNpub } = useParams<{ npub: string }>()
const apps = useAppSelector((state) => selectAppsByNpub(state, npub))
const pending = useAppSelector((state) => selectPendingsByNpub(state, npub))
const [selectedActionType, setSelectedActionType] = useState<ACTION_TYPE>(ACTION_TYPE.BASIC)
@ -34,32 +37,20 @@ export const ModalConfirmConnect = () => {
const triggerApp = apps.find((app) => app.appNpub === appNpub)
const { name, url = '', icon = '' } = triggerApp || {}
let appUrl = url || searchParams.get('appUrl') || ''
console.log("referrer", window.document.referrer, appUrl)
if (!appUrl && window.document.referrer) {
try {
const u = new URL(window.document.referrer)
appUrl = u.origin
} catch {}
}
let appUrl = url || searchParams.get('appUrl') || ''
// console.log('referrer', window.document.referrer, appUrl)
if (!appUrl && window.document.referrer) {
try {
const u = new URL(window.document.referrer)
appUrl = u.origin
} catch {}
}
const appDomain = getDomain(appUrl)
const appName = name || appDomain || getShortenNpub(appNpub)
const appAvatarTitle = getAppIconTitle(name || appDomain, appNpub)
const appIcon = icon || (appDomain ? `https://${appDomain}/favicon.ico` : '')
const handleActionTypeChange = (_: any, value: ACTION_TYPE | null) => {
if (!value) return undefined
return setSelectedActionType(value)
}
// const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.CONFIRM_CONNECT, {
// onClose: async (sp) => {
// sp.delete('appNpub')
// sp.delete('reqId')
// await swicCall('confirm', pendingReqId, false, false)
// },
// })
const closeModalAfterRequest = createHandleCloseReplace(MODAL_PARAMS_KEYS.CONFIRM_CONNECT, {
onClose: (sp) => {
sp.delete('appNpub')
@ -70,6 +61,19 @@ export const ModalConfirmConnect = () => {
},
})
const isNpubExists = npub.trim().length && keys.some((key) => key.npub === npub)
const isAppNpubExists = appNpub.trim().length && apps.some((app) => app.appNpub === appNpub)
const isPendingReqIdExists = pendingReqId.trim().length && pending.some((p) => p.id === pendingReqId)
if (isModalOpened && (!isNpubExists || !isAppNpubExists || !isPendingReqIdExists)) {
closeModalAfterRequest()
return null
}
const handleActionTypeChange = (_: any, value: ACTION_TYPE | null) => {
if (!value) return undefined
return setSelectedActionType(value)
}
async function confirmPending(id: string, allow: boolean, remember: boolean, options?: any) {
call(async () => {
await swicCall('confirm', id, allow, remember, options)
@ -139,17 +143,12 @@ export const ModalConfirmConnect = () => {
}
return (
<Modal
title="Connection request"
open={isModalOpened}
withCloseButton={false}
// withCloseButton={!isPopup} onClose={!isPopup ? handleCloseModal : undefined}
>
<Modal title="Connection request" open={isModalOpened} withCloseButton={false}>
<Stack gap={'1rem'} paddingTop={'1rem'}>
{!pendingReqId && (
<Typography variant="body1" color={'GrayText'}>
You will be asked to <b>enable notifications</b>, this
is needed for a reliable communication with Nostr apps.
You will be asked to <b>enable notifications</b>, this is needed for a reliable communication with Nostr
apps.
</Typography>
)}
<Stack direction={'row'} gap={'1rem'} alignItems={'center'} marginBottom={'1rem'}>
@ -177,14 +176,7 @@ export const ModalConfirmConnect = () => {
value={ACTION_TYPE.BASIC}
title="Basic permissions"
description="Read your public key, sign notes, reactions, zaps, etc"
// hasinfo
/>
{/* <ActionToggleButton
value={ACTION_TYPE.ADVANCED}
title='Advanced'
description='Use for trusted apps only'
hasinfo
/> */}
<ActionToggleButton
value={ACTION_TYPE.CUSTOM}
title="On demand"
@ -196,7 +188,6 @@ export const ModalConfirmConnect = () => {
Disallow
</StyledButton>
<StyledButton fullWidth onClick={allow}>
{/* Allow {selectedActionType} actions */}
Connect
</StyledButton>
</Stack>

View File

@ -5,7 +5,7 @@ import { call, getAppIconTitle, getReqActionName, getShortenNpub } from '@/utils
import { Avatar, Box, List, ListItem, ListItemIcon, ListItemText, Stack, Typography } from '@mui/material'
import { useParams, useSearchParams } from 'react-router-dom'
import { useAppSelector } from '@/store/hooks/redux'
import { selectAppsByNpub } from '@/store'
import { selectAppsByNpub, selectKeys } from '@/store'
import { ActionToggleButton } from './сomponents/ActionToggleButton'
import { FC, useEffect, useMemo, useState } from 'react'
import { StyledActionsListContainer, StyledButton, StyledToggleButtonsGroup } from './styled'
@ -34,13 +34,14 @@ type ModalConfirmEventProps = {
type PendingRequest = DbPending & { checked: boolean }
export const ModalConfirmEvent: FC<ModalConfirmEventProps> = ({ confirmEventReqs }) => {
const keys = useAppSelector(selectKeys)
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.CONFIRM_EVENT)
const [searchParams] = useSearchParams()
const appNpub = searchParams.get('appNpub') || ''
const isPopup = searchParams.get('popup') === 'true'
const { npub = '' } = useParams<{ npub: string }>()
const apps = useAppSelector((state) => selectAppsByNpub(state, npub))
@ -53,10 +54,25 @@ export const ModalConfirmEvent: FC<ModalConfirmEventProps> = ({ confirmEventReqs
setPendingRequests(currentAppPendingReqs.map((pr) => ({ ...pr, checked: true })))
}, [currentAppPendingReqs])
const closeModalAfterRequest = createHandleCloseReplace(MODAL_PARAMS_KEYS.CONFIRM_EVENT, {
onClose: (sp) => {
sp.delete('appNpub')
sp.delete('reqId')
},
})
const isNpubExists = npub.trim().length && keys.some((key) => key.npub === npub)
const isAppNpubExists = appNpub.trim().length && apps.some((app) => app.appNpub === appNpub)
if (isModalOpened && (!isNpubExists || !isAppNpubExists)) {
closeModalAfterRequest()
return null
}
const triggerApp = apps.find((app) => app.appNpub === appNpub)
const { name, icon = '' } = triggerApp || {}
const appName = name || getShortenNpub(appNpub)
const appAvatarTitle = getAppIconTitle(name, appNpub)
const appAvatarTitle = getAppIconTitle(name, appNpub)
const handleActionTypeChange = (_: any, value: ACTION_TYPE | null) => {
if (!value) return undefined
@ -65,21 +81,6 @@ export const ModalConfirmEvent: FC<ModalConfirmEventProps> = ({ confirmEventReqs
const selectedPendingRequests = pendingRequests.filter((pr) => pr.checked)
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.CONFIRM_EVENT, {
onClose: (sp) => {
sp.delete('appNpub')
sp.delete('reqId')
selectedPendingRequests.forEach(async (req) => await swicCall('confirm', req.id, false, false))
},
})
const closeModalAfterRequest = createHandleCloseReplace(MODAL_PARAMS_KEYS.CONFIRM_EVENT, {
onClose: (sp) => {
sp.delete('appNpub')
sp.delete('reqId')
},
})
async function confirmPending(allow: boolean) {
selectedPendingRequests.forEach((req) => {
call(async () => {
@ -109,9 +110,7 @@ export const ModalConfirmEvent: FC<ModalConfirmEventProps> = ({ confirmEventReqs
}
return (
<Modal title='Permission request' open={isModalOpened} withCloseButton={false}
// withCloseButton={!isPopup} onClose={!isPopup ? handleCloseModal : undefined}
>
<Modal title="Permission request" open={isModalOpened} withCloseButton={false}>
<Stack gap={'1rem'} paddingTop={'1rem'}>
<Stack direction={'row'} gap={'1rem'} alignItems={'center'} marginBottom={'1rem'}>
<Avatar
@ -123,8 +122,8 @@ export const ModalConfirmEvent: FC<ModalConfirmEventProps> = ({ confirmEventReqs
}}
src={icon}
>
{appAvatarTitle}
</Avatar>
{appAvatarTitle}
</Avatar>
<Box>
<Typography variant="h5" fontWeight={600}>
{appName}
@ -153,11 +152,6 @@ export const ModalConfirmEvent: FC<ModalConfirmEventProps> = ({ confirmEventReqs
<StyledToggleButtonsGroup value={selectedActionType} onChange={handleActionTypeChange} exclusive>
<ActionToggleButton value={ACTION_TYPE.ALWAYS} title="Always" />
<ActionToggleButton value={ACTION_TYPE.ONCE} title="Just once" />
{/* <ActionToggleButton
value={ACTION_TYPE.ALLOW_ALL}
title='Allow All Advanced Actions'
hasinfo
/> */}
</StyledToggleButtonsGroup>
<Stack direction={'row'} gap={'1rem'}>

View File

@ -5,16 +5,23 @@ import { Button } from '@/shared/Button/Button'
import { Input } from '@/shared/Input/Input'
import { InputCopyButton } from '@/shared/InputCopyButton/InputCopyButton'
import { Modal } from '@/shared/Modal/Modal'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { selectKeys } from '@/store'
import { useAppSelector } from '@/store/hooks/redux'
import { EXPLANATION_MODAL_KEYS, MODAL_PARAMS_KEYS } from '@/types/modal'
import { getBunkerLink } from '@/utils/helpers/helpers'
import { Stack, Typography } from '@mui/material'
import { useRef } from 'react'
import { useParams } from 'react-router-dom'
export const ModalConnectApp = () => {
const { getModalOpened, createHandleCloseReplace, handleOpen } = useModalSearchParams()
const timerRef = useRef<NodeJS.Timeout>()
const keys = useAppSelector(selectKeys)
const timerRef = useRef<NodeJS.Timeout>()
const notify = useEnqueueSnackbar()
const { npub = '' } = useParams<{ npub: string }>()
const bunkerStr = getBunkerLink(npub)
const { getModalOpened, createHandleCloseReplace, handleOpen } = useModalSearchParams()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.CONNECT_APP)
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.CONNECT_APP, {
onClose: () => {
@ -22,11 +29,11 @@ export const ModalConnectApp = () => {
},
})
const notify = useEnqueueSnackbar()
const { npub = '' } = useParams<{ npub: string }>()
const bunkerStr = getBunkerLink(npub)
const isNpubExists = npub.trim().length && keys.some((key) => key.npub === npub)
if (isModalOpened && !isNpubExists) {
handleCloseModal()
return null
}
const handleShareBunker = async () => {
const shareData = {
@ -62,7 +69,10 @@ export const ModalConnectApp = () => {
value={bunkerStr}
endAdornment={<InputCopyButton value={bunkerStr} onCopy={handleCopy} />}
/>
<AppLink title="What is this?" onClick={() => handleOpen(MODAL_PARAMS_KEYS.EXPLANATION)} />
<AppLink
title="What is this?"
onClick={() => handleOpen(MODAL_PARAMS_KEYS.EXPLANATION, { search: { type: EXPLANATION_MODAL_KEYS.BUNKER } })}
/>
<Button fullWidth onClick={handleShareBunker}>
Share it
</Button>

View File

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

View File

@ -5,56 +5,151 @@ import { Button } from '@/shared/Button/Button'
import { Input } from '@/shared/Input/Input'
import { Modal } from '@/shared/Modal/Modal'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { Stack, Typography } from '@mui/material'
import React, { ChangeEvent, FormEvent, useState } from 'react'
import { CircularProgress, Stack, Typography, useTheme } from '@mui/material'
import { StyledAppLogo } from './styled'
import { useNavigate } from 'react-router-dom'
import { useForm } from 'react-hook-form'
import { FormInputType, schema } from './const'
import { yupResolver } from '@hookform/resolvers/yup'
import { usePassword } from '@/hooks/usePassword'
import { useCallback, useEffect, useState } from 'react'
import { useDebounce } from 'use-debounce'
import { fetchNip05 } from '@/utils/helpers/helpers'
import { DOMAIN } from '@/utils/consts'
import { CheckmarkIcon } from '@/assets'
const FORM_DEFAULT_VALUES = {
username: '',
nsec: '',
}
export const ModalImportKeys = () => {
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.IMPORT_KEYS)
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.IMPORT_KEYS)
const { hidePassword, inputProps } = usePassword()
const theme = useTheme()
const {
handleSubmit,
reset,
register,
formState: { errors },
watch,
} = useForm<FormInputType>({
defaultValues: FORM_DEFAULT_VALUES,
resolver: yupResolver(schema),
mode: 'onSubmit',
})
const [isLoading, setIsLoading] = useState(false)
const [isAvailable, setIsAvailable] = useState(false)
const enteredUsername = watch('username')
const [debouncedUsername] = useDebounce(enteredUsername, 100)
const checkIsUsernameAvailable = useCallback(async () => {
if (!debouncedUsername.trim().length) return undefined
const npubNip05 = await fetchNip05(`${debouncedUsername}@${DOMAIN}`)
setIsAvailable(!npubNip05)
}, [debouncedUsername])
useEffect(() => {
checkIsUsernameAvailable()
}, [checkIsUsernameAvailable])
const cleanUpStates = useCallback(() => {
hidePassword()
reset()
setIsLoading(false)
setIsAvailable(false)
}, [reset, hidePassword])
const notify = useEnqueueSnackbar()
const navigate = useNavigate()
const [enteredNsec, setEnteredNsec] = useState('')
const handleNsecChange = (e: ChangeEvent<HTMLInputElement>) => {
setEnteredNsec(e.target.value)
}
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
const submitHandler = async (values: FormInputType) => {
if (isLoading || !isAvailable) return undefined
try {
if (!enteredNsec.trim().length) return
const enteredName = '' // FIXME get from input
const k: any = await swicCall('importKey', enteredName, enteredNsec)
const { nsec, username } = values
setIsLoading(true)
const k: any = await swicCall('importKey', username, nsec)
notify('Key imported!', 'success')
navigate(`/key/${k.npub}`)
cleanUpStates()
} catch (error: any) {
notify(error.message, 'error')
notify(error?.message || 'Something went wrong!', 'error')
cleanUpStates()
}
}
useEffect(() => {
return () => {
isModalOpened && cleanUpStates()
}
}, [isModalOpened, cleanUpStates])
const getInputHelperText = () => {
if (!enteredUsername) return "Don't worry, username can be changed later."
if (!isAvailable) return 'Already taken'
return (
<>
<CheckmarkIcon /> Available
</>
)
}
const inputHelperText = getInputHelperText()
return (
<Modal open={isModalOpened} onClose={handleCloseModal}>
<Stack gap={'1rem'} component={'form'} onSubmit={handleSubmit}>
<Stack gap={'1rem'} component={'form'} onSubmit={handleSubmit(submitHandler)}>
<Stack direction={'row'} gap={'1rem'} alignItems={'center'} alignSelf={'flex-start'}>
<StyledAppLogo />
<Typography fontWeight={600} variant="h5">
Import keys
Import key
</Typography>
</Stack>
<Input
label="Enter a NSEC"
placeholder="Your NSEC"
value={enteredNsec}
onChange={handleNsecChange}
label="Choose a username"
fullWidth
type="password"
placeholder="Enter a Username"
endAdornment={<Typography color={'#FFFFFFA8'}>@{DOMAIN}</Typography>}
{...register('username')}
error={!!errors.username}
helperText={inputHelperText}
helperTextProps={{
sx: {
'&.helper_text': {
color:
enteredUsername && isAvailable
? theme.palette.success.main
: enteredUsername && !isAvailable
? theme.palette.error.main
: theme.palette.textSecondaryDecorate.main,
},
},
}}
/>
<Button type="submit">Import nsec</Button>
<Input
label="Paste your private key"
placeholder="nsec1..."
fullWidth
{...register('nsec')}
error={!!errors.nsec}
{...inputProps}
helperText="Keys stay on your device."
helperTextProps={{
sx: {
'&.helper_text': {
color: theme.palette.textSecondaryDecorate.main,
},
},
}}
/>
<Button type="submit" disabled={isLoading}>
Import key {isLoading && <CircularProgress sx={{ marginLeft: '0.5rem' }} size={'1rem'} />}
</Button>
</Stack>
</Modal>
)

View File

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

View File

@ -35,7 +35,7 @@ export const ModalInitial = () => {
{showAdvancedContent && (
<Fade in>
<Button onClick={() => handleOpen(MODAL_PARAMS_KEYS.IMPORT_KEYS)}>Import keys</Button>
<Button onClick={() => handleOpen(MODAL_PARAMS_KEYS.IMPORT_KEYS)}>Import key</Button>
</Fade>
)}
</Stack>

View File

@ -4,18 +4,23 @@ import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { swicCall } from '@/modules/swic'
import { Modal } from '@/shared/Modal/Modal'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { IconButton, Stack, Typography } from '@mui/material'
import { CircularProgress, Stack, Typography } from '@mui/material'
import { StyledAppLogo } from './styled'
import { Input } from '@/shared/Input/Input'
import { Button } from '@/shared/Button/Button'
import VisibilityOffOutlinedIcon from '@mui/icons-material/VisibilityOffOutlined'
import VisibilityOutlinedIcon from '@mui/icons-material/VisibilityOutlined'
import { useNavigate } from 'react-router-dom'
import { useForm } from 'react-hook-form'
import { FormInputType, schema } from './const'
import { yupResolver } from '@hookform/resolvers/yup'
import { DOMAIN } from '@/utils/consts'
import { fetchNip05 } from '@/utils/helpers/helpers'
import { usePassword } from '@/hooks/usePassword'
import { dbi } from '@/modules/db'
const FORM_DEFAULT_VALUES = {
username: '',
password: '',
}
export const ModalLogin = () => {
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
@ -23,8 +28,9 @@ export const ModalLogin = () => {
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.LOGIN)
const notify = useEnqueueSnackbar()
const navigate = useNavigate()
const { hidePassword, inputProps } = usePassword()
const [isLoading, setIsLoading] = useState(false)
const {
handleSubmit,
@ -32,27 +38,25 @@ export const ModalLogin = () => {
register,
formState: { errors },
} = useForm<FormInputType>({
defaultValues: {
username: '',
password: '',
},
defaultValues: FORM_DEFAULT_VALUES,
resolver: yupResolver(schema),
mode: 'onSubmit',
})
const [isPasswordShown, setIsPasswordShown] = useState(false)
const handlePasswordTypeChange = () => setIsPasswordShown((prevState) => !prevState)
const cleanUpStates = useCallback(() => {
setIsPasswordShown(false)
hidePassword()
reset()
}, [reset])
setIsLoading(false)
}, [reset, hidePassword])
const submitHandler = async (values: FormInputType) => {
if (isLoading) return undefined
try {
setIsLoading(true)
let npub = values.username
let name = ''
if (!npub.startsWith('npub1')) {
name = npub
if (!npub.includes('@')) {
@ -72,11 +76,13 @@ export const ModalLogin = () => {
console.log('fetch', npub, name)
const k: any = await swicCall('fetchKey', npub, passphrase, name)
notify(`Fetched ${k.npub}`, 'success')
dbi.addSynced(k.npub)
cleanUpStates()
navigate(`/key/${k.npub}`)
} catch (error: any) {
console.log('error', error)
notify(error?.message || 'Something went wrong!', 'error')
setIsLoading(false)
}
}
@ -110,16 +116,11 @@ export const ModalLogin = () => {
fullWidth
placeholder="Your password"
{...register('password')}
endAdornment={
<IconButton size="small" onClick={handlePasswordTypeChange}>
{isPasswordShown ? <VisibilityOffOutlinedIcon /> : <VisibilityOutlinedIcon />}
</IconButton>
}
type={isPasswordShown ? 'text' : 'password'}
{...inputProps}
error={!!errors.password}
/>
<Button type="submit" fullWidth>
Add account
<Button type="submit" fullWidth disabled={isLoading}>
Add account {isLoading && <CircularProgress sx={{ marginLeft: '0.5rem' }} size={'1rem'} />}
</Button>
</Stack>
</Modal>

View File

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

View File

@ -2,19 +2,20 @@ import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { Button } from '@/shared/Button/Button'
import { Modal } from '@/shared/Modal/Modal'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { Box, CircularProgress, IconButton, Stack, Typography } from '@mui/material'
import { Box, CircularProgress, Stack, Typography } from '@mui/material'
import { StyledButton, StyledSettingContainer, StyledSynchedText } from './styled'
import { SectionTitle } from '@/shared/SectionTitle/SectionTitle'
import { CheckmarkIcon } from '@/assets'
import { Input } from '@/shared/Input/Input'
import VisibilityOffOutlinedIcon from '@mui/icons-material/VisibilityOffOutlined'
import VisibilityOutlinedIcon from '@mui/icons-material/VisibilityOutlined'
import { ChangeEvent, FC, useEffect, useState } from 'react'
import { Checkbox } from '@/shared/Checkbox/Checkbox'
import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar'
import { swicCall } from '@/modules/swic'
import { useParams } from 'react-router-dom'
import { dbi } from '@/modules/db'
import { usePassword } from '@/hooks/usePassword'
import { useAppSelector } from '@/store/hooks/redux'
import { selectKeys } from '@/store'
type ModalSettingsProps = {
isSynced: boolean
@ -23,29 +24,44 @@ type ModalSettingsProps = {
export const ModalSettings: FC<ModalSettingsProps> = ({ isSynced }) => {
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
const { npub = '' } = useParams<{ npub: string }>()
const keys = useAppSelector(selectKeys)
const notify = useEnqueueSnackbar()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.SETTINGS)
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.SETTINGS)
const { hidePassword, inputProps } = usePassword()
const [enteredPassword, setEnteredPassword] = useState('')
const [isPasswordShown, setIsPasswordShown] = useState(false)
const [isPasswordInvalid, setIsPasswordInvalid] = useState(false)
const [isChecked, setIsChecked] = useState(false)
const [isLoading, setIsLoading] = useState(false)
useEffect(() => setIsChecked(isSynced), [isModalOpened, isSynced])
useEffect(() => {
return () => {
if (isModalOpened) {
// modal closed
hidePassword()
}
}
}, [hidePassword, isModalOpened])
const isNpubExists = npub.trim().length && keys.some((key) => key.npub === npub)
if (isModalOpened && !isNpubExists) {
handleCloseModal()
return null
}
const handlePasswordChange = (e: ChangeEvent<HTMLInputElement>) => {
setIsPasswordInvalid(false)
setEnteredPassword(e.target.value)
}
const handlePasswordTypeChange = () => setIsPasswordShown((prevState) => !prevState)
const onClose = () => {
handleCloseModal()
setEnteredPassword('')
@ -95,16 +111,7 @@ export const ModalSettings: FC<ModalSettingsProps> = ({ isSynced }) => {
</Box>
<Input
fullWidth
endAdornment={
<IconButton size="small" onClick={handlePasswordTypeChange}>
{isPasswordShown ? (
<VisibilityOffOutlinedIcon htmlColor="#6b6b6b" />
) : (
<VisibilityOutlinedIcon htmlColor="#6b6b6b" />
)}
</IconButton>
}
type={isPasswordShown ? 'text' : 'password'}
{...inputProps}
onChange={handlePasswordChange}
value={enteredPassword}
helperText={isPasswordInvalid ? 'Invalid password' : ''}

View File

@ -2,15 +2,15 @@ import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar'
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { Modal } from '@/shared/Modal/Modal'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { Stack, Typography, useTheme } from '@mui/material'
import React, { ChangeEvent, useState } from 'react'
import { CircularProgress, Stack, Typography, useTheme } from '@mui/material'
import React, { ChangeEvent, useEffect, useState } from 'react'
import { StyledAppLogo } from './styled'
import { Input } from '@/shared/Input/Input'
import { Button } from '@/shared/Button/Button'
import { CheckmarkIcon } from '@/assets'
import { swicCall } from '@/modules/swic'
import { useNavigate } from 'react-router-dom'
import { DOMAIN, NOAUTHD_URL } from '@/utils/consts'
import { DOMAIN } from '@/utils/consts'
import { fetchNip05 } from '@/utils/helpers/helpers'
export const ModalSignUp = () => {
@ -25,6 +25,8 @@ export const ModalSignUp = () => {
const [enteredValue, setEnteredValue] = useState('')
const [isAvailable, setIsAvailable] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const handleInputChange = async (e: ChangeEvent<HTMLInputElement>) => {
setEnteredValue(e.target.value)
const name = e.target.value.trim()
@ -36,31 +38,47 @@ export const ModalSignUp = () => {
}
}
const inputHelperText = enteredValue ? (
isAvailable ? (
const getInputHelperText = () => {
if (!enteredValue) return "Don't worry, username can be changed later."
if (!isAvailable) return 'Already taken'
return (
<>
<CheckmarkIcon /> Available
</>
) : (
<>Already taken</>
)
) : (
"Don't worry, username can be changed later."
)
}
const inputHelperText = getInputHelperText()
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (isLoading || !isAvailable) return undefined
const name = enteredValue.trim()
if (!name.length) return
e.preventDefault()
try {
setIsLoading(true)
const k: any = await swicCall('generateKey', name)
notify(`Account created for "${name}"`, 'success')
navigate(`/key/${k.npub}`)
setIsLoading(false)
} catch (error: any) {
notify(error.message, 'error')
notify(error?.message || 'Something went wrong!', 'error')
setIsLoading(false)
}
}
useEffect(() => {
return () => {
if (isModalOpened) {
// modal closed
setIsLoading(false)
setIsAvailable(false)
}
}
}, [isModalOpened])
return (
<Modal open={isModalOpened} onClose={handleCloseModal}>
<Stack paddingTop={'1rem'} gap={'1rem'} component={'form'} onSubmit={handleSubmit}>
@ -71,9 +89,9 @@ export const ModalSignUp = () => {
</Typography>
</Stack>
<Input
label="Enter a Username"
label="Username"
fullWidth
placeholder="Username"
placeholder="Enter a Username"
helperText={inputHelperText}
endAdornment={<Typography color={'#FFFFFFA8'}>@{DOMAIN}</Typography>}
onChange={handleInputChange}
@ -91,8 +109,8 @@ export const ModalSignUp = () => {
},
}}
/>
<Button fullWidth type="submit">
Create account
<Button fullWidth type="submit" disabled={isLoading}>
Create account {isLoading && <CircularProgress sx={{ marginLeft: '0.5rem' }} size={'1rem'} />}
</Button>
</Stack>
</Modal>

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -9,11 +9,9 @@ import LoginIcon from '@mui/icons-material/Login'
import KeyboardArrowDownRoundedIcon from '@mui/icons-material/KeyboardArrowDownRounded'
import HomeRoundedIcon from '@mui/icons-material/HomeRounded'
import PersonAddAltRoundedIcon from '@mui/icons-material/PersonAddAltRounded'
import { useAppDispatch, useAppSelector } from '@/store/hooks/redux'
import { useAppSelector } from '@/store/hooks/redux'
import { selectKeys } from '@/store'
import { setThemeMode } from '@/store/reducers/ui.slice'
import DarkModeIcon from '@mui/icons-material/DarkMode'
import LightModeIcon from '@mui/icons-material/LightMode'
import { ListProfiles } from './ListProfiles'
import { DbKey } from '@/modules/db'
@ -23,10 +21,7 @@ export const ProfileMenu = () => {
const keys = useAppSelector(selectKeys)
const isNoKeys = !keys || keys.length === 0
const themeMode = useAppSelector((state) => state.ui.themeMode)
const isDarkMode = themeMode === 'dark'
const dispatch = useAppDispatch()
const navigate = useNavigate()
const handleNavigateToAuth = () => {
@ -39,17 +34,11 @@ export const ProfileMenu = () => {
handleClose()
}
const handleChangeMode = () => {
dispatch(setThemeMode({ mode: isDarkMode ? 'light' : 'dark' }))
}
const handleNavigateToKeyInnerPage = (key: DbKey) => {
navigate('/key/' + key.npub)
handleClose()
}
const themeIcon = isDarkMode ? <DarkModeIcon htmlColor="#fff" /> : <LightModeIcon htmlColor="#feb94a" />
return (
<>
<MenuButton onClick={handleOpenMenu}>
@ -71,7 +60,6 @@ export const ProfileMenu = () => {
onClick={handleNavigateToAuth}
title={isNoKeys ? 'Sign up' : 'Add account'}
/>
<MenuItem Icon={themeIcon} onClick={handleChangeMode} title="Change theme" />
</Menu>
</>
)

View File

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

View File

@ -103,6 +103,17 @@ export const dbi = {
console.log(`db addApp error: ${error}`)
}
},
updateApp: async (app: Omit<DbApp, 'npub' | 'timestamp'>) => {
try {
await db.apps.where({ appNpub: app.appNpub }).modify({
name: app.name,
icon: app.icon,
url: app.url,
})
} catch (error) {
console.log(`db updateApp error: ${error}`)
}
},
listApps: async (): Promise<DbApp[]> => {
try {
return await db.apps.toArray()

View File

@ -1,9 +1,9 @@
import { useParams } from 'react-router'
import { useAppSelector } from '@/store/hooks/redux'
import { selectAppByAppNpub, selectPermsByNpubAndAppNpub } from '@/store'
import { selectAppByAppNpub, selectKeys, selectPermsByNpubAndAppNpub } from '@/store'
import { Navigate, useNavigate } from 'react-router-dom'
import { formatTimestampDate } from '@/utils/helpers/date'
import { Box, Stack, Typography } from '@mui/material'
import { Box, IconButton, Stack, Typography } from '@mui/material'
import { SectionTitle } from '@/shared/SectionTitle/SectionTitle'
import { getAppIconTitle, getDomain, getShortenNpub } from '@/utils/helpers/helpers'
import { Button } from '@/shared/Button/Button'
@ -18,31 +18,35 @@ import { IOSBackButton } from '@/shared/IOSBackButton/IOSBackButton'
import { ModalActivities } from './components/Activities/ModalActivities'
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
import MoreIcon from '@mui/icons-material/MoreVertRounded'
import { ModalAppDetails } from '@/components/Modal/ModalAppDetails/ModalAppDetails'
const AppPage = () => {
const keys = useAppSelector(selectKeys)
const { appNpub = '', npub = '' } = useParams()
const currentApp = useAppSelector((state) => selectAppByAppNpub(state, appNpub))
const perms = useAppSelector((state) => selectPermsByNpubAndAppNpub(state, npub, appNpub))
const navigate = useNavigate()
const notify = useEnqueueSnackbar()
const perms = useAppSelector((state) => selectPermsByNpubAndAppNpub(state, npub, appNpub))
const currentApp = useAppSelector((state) => selectAppByAppNpub(state, appNpub))
const { open, handleClose, handleShow } = useToggleConfirm()
const { handleOpen: handleOpenModal } = useModalSearchParams()
const connectPerm = perms.find((perm) => perm.perm === 'connect' || perm.perm === ACTION_TYPE.BASIC)
if (!currentApp) {
const isNpubExists = npub.trim().length && keys.some((key) => key.npub === npub)
if (!isNpubExists || !currentApp) {
return <Navigate to={`/key/${npub}`} />
}
const { icon = '', name = '', url = '' } = currentApp || {}
const appDomain = getDomain(url)
const appName = name || appDomain || getShortenNpub(appNpub)
const appAvatarTitle = getAppIconTitle(name || appDomain, appNpub)
const appAvatarTitle = getAppIconTitle(name || appDomain, appNpub)
const { timestamp } = connectPerm || {}
const connectedOn = connectPerm && timestamp ? `Connected at ${formatTimestampDate(timestamp)}` : 'Not connected'
const handleDeleteApp = async () => {
@ -55,18 +59,23 @@ const AppPage = () => {
}
}
const handleShowAppDetailsModal = () => handleOpenModal(MODAL_PARAMS_KEYS.APP_DETAILS)
return (
<>
<Stack maxHeight={'100%'} overflow={'auto'} alignItems={'flex-start'} height={'100%'}>
<IOSBackButton onNavigate={() => navigate(`key/${npub}`)} />
<Stack marginBottom={'1rem'} direction={'row'} gap={'1rem'} width={'100%'}>
<StyledAppIcon src={icon}>
{appAvatarTitle}
</StyledAppIcon>
<StyledAppIcon src={icon}>{appAvatarTitle}</StyledAppIcon>
<Box flex={'1'} overflow={'hidden'}>
<Typography variant="h4" noWrap>
{appName}
</Typography>
<Stack direction={'row'} alignItems={'center'} gap={'0.5rem'}>
<Typography variant="h4" noWrap flex={1}>
{appName}
</Typography>
<IconButton onClick={handleShowAppDetailsModal}>
<MoreIcon />
</IconButton>
</Stack>
<Typography variant="body2" noWrap>
{connectedOn}
</Typography>
@ -94,6 +103,7 @@ const AppPage = () => {
onClose={handleClose}
/>
<ModalActivities appNpub={appNpub} />
<ModalAppDetails />
</>
)
}

View File

@ -1,26 +1,26 @@
import { FC } from 'react'
import { DbKey } from '../../../modules/db'
import { Avatar, Stack, StackProps, Typography, TypographyProps, styled } from '@mui/material'
import { getShortenNpub } from '../../../utils/helpers/helpers'
import { useNavigate } from 'react-router-dom'
import { useProfile } from '@/hooks/useProfile'
type ItemKeyProps = DbKey
export const ItemKey: FC<ItemKeyProps> = (props) => {
const { npub, profile } = props
const { npub } = props
const navigate = useNavigate()
const { userName, userAvatar, avatarTitle } = useProfile(npub)
const handleNavigate = () => {
navigate('/key/' + npub)
}
const { name = '', picture = '' } = profile?.info || {}
const userName = name || getShortenNpub(npub)
const userAvatar = picture || ''
return (
<StyledKeyContainer onClick={handleNavigate}>
<Stack direction={'row'} alignItems={'center'} gap="1rem">
<Avatar src={userAvatar} alt={userName} />
<Avatar src={userAvatar} alt={userName}>
{avatarTitle}
</Avatar>
<StyledText variant="body1">{userName}</StyledText>
</Stack>
</StyledKeyContainer>

View File

@ -1,5 +1,5 @@
import { useAppSelector } from '../../store/hooks/redux'
import { useParams } from 'react-router-dom'
import { Navigate, useParams } from 'react-router-dom'
import { Stack } from '@mui/material'
import { StyledIconButton } from './styled'
import { SettingsIcon, ShareIcon } from '@/assets'
@ -11,7 +11,6 @@ import { ModalSettings } from '@/components/Modal/ModalSettings/ModalSettings'
import { ModalExplanation } from '@/components/Modal/ModalExplanation/ModalExplanation'
import { ModalConfirmConnect } from '@/components/Modal/ModalConfirmConnect/ModalConfirmConnect'
import { ModalConfirmEvent } from '@/components/Modal/ModalConfirmEvent/ModalConfirmEvent'
import { useProfile } from './hooks/useProfile'
import { useBackgroundSigning } from './hooks/useBackgroundSigning'
import { BackgroundSigningWarning } from './components/BackgroundSigningWarning'
import UserValueSection from './components/UserValueSection'
@ -19,29 +18,32 @@ import { useTriggerConfirmModal } from './hooks/useTriggerConfirmModal'
import { useLiveQuery } from 'dexie-react-hooks'
import { checkNpubSyncQuerier } from './utils'
import { DOMAIN } from '@/utils/consts'
import { useCallback } from 'react'
const KeyPage = () => {
const { npub = '' } = useParams<{ npub: string }>()
const { keys, apps, pending, perms } = useAppSelector((state) => state.content)
const isSynced = useLiveQuery(checkNpubSyncQuerier(npub), [npub], false)
const { handleOpen } = useModalSearchParams()
// const { userNameWithPrefix } = useProfile(npub)
const { handleEnableBackground, showWarning, isEnabling } = useBackgroundSigning()
const key = keys.find((k) => k.npub === npub)
let username = ''
if (key?.name) {
if (key.name.includes('@')) username = key.name
else username = `${key?.name}@${DOMAIN}`
}
const getUsername = useCallback(() => {
if (!key || !key?.name) return ''
if (key.name.includes('@')) return key.name
return `${key?.name}@${DOMAIN}`
}, [key])
const username = getUsername()
const filteredApps = apps.filter((a) => a.npub === npub)
const { prepareEventPendings } = useTriggerConfirmModal(npub, pending, perms)
const handleOpenConnectAppModal = () => handleOpen(MODAL_PARAMS_KEYS.CONNECT_APP)
const isKeyExists = npub.trim().length && key
if (!isKeyExists) return <Navigate to={`/home`} />
const handleOpenConnectAppModal = () => handleOpen(MODAL_PARAMS_KEYS.CONNECT_APP)
const handleOpenSettingsModal = () => handleOpen(MODAL_PARAMS_KEYS.SETTINGS)
return (
@ -54,7 +56,7 @@ const KeyPage = () => {
title="Your login"
value={username}
copyValue={username}
explanationType={EXPLANATION_MODAL_KEYS.NPUB}
explanationType={EXPLANATION_MODAL_KEYS.LOGIN}
/>
<UserValueSection
title="Your NPUB"

View File

@ -26,18 +26,22 @@ export const Apps: FC<AppsProps> = ({ apps = [] }) => {
})
}
const openAppStore = () => {
window.open('https://nostrapp.link', '_blank')
}
return (
<Box flex={1} marginBottom={'1rem'} display={'flex'} flexDirection={'column'} overflow={'auto'}>
<Stack direction={'row'} alignItems={'center'} justifyContent={'space-between'} marginBottom={'0.5rem'}>
<SectionTitle>Connected apps</SectionTitle>
<AppLink title="Discover Apps" />
<AppLink title="Discover Apps" onClick={openAppStore} />
</Stack>
{!apps.length && (
<StyledEmptyAppsBox>
<Typography className="message" variant="h5" fontWeight={600} textAlign={'center'}>
No connected apps
</Typography>
<Button>Discover Nostr Apps</Button>
<Button onClick={openAppStore}>Discover Nostr Apps</Button>
</StyledEmptyAppsBox>
)}

View File

@ -1,9 +1,9 @@
import { Input, InputProps } from '@/shared/Input/Input'
import { Input, AppInputProps } from '@/shared/Input/Input'
import { Stack, StackProps, styled } from '@mui/material'
import { forwardRef } from 'react'
export const StyledInput = styled(
forwardRef<HTMLInputElement, InputProps>(({ className, ...props }, ref) => {
forwardRef<HTMLInputElement, AppInputProps>(({ className, ...props }, ref) => {
return (
<Input
{...props}

View File

@ -1,31 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
import { fetchProfile } from '@/modules/nostr'
import { MetaEvent } from '@/types/meta-event'
import { getProfileUsername } from '@/utils/helpers/helpers'
import { DOMAIN } from '@/utils/consts'
export const useProfile = (npub: string) => {
const [profile, setProfile] = useState<MetaEvent | null>(null)
const userName = getProfileUsername(profile, npub)
// FIXME use nip05?
const userNameWithPrefix = userName + '@' + DOMAIN
const loadProfile = useCallback(async () => {
try {
const response = await fetchProfile(npub)
setProfile(response)
} catch (error) {
console.error('Failed to fetch profile:', error)
}
}, [npub])
useEffect(() => {
loadProfile()
}, [loadProfile])
return {
profile,
userNameWithPrefix,
}
}

View File

@ -1,4 +1,4 @@
import { Input, InputProps } from '@/shared/Input/Input'
import { Input, AppInputProps } from '@/shared/Input/Input'
import { Box, Button, ButtonProps, styled, Badge } from '@mui/material'
import { forwardRef } from 'react'
@ -53,7 +53,7 @@ export const StyledEmptyAppsBox = styled(Box)(({ theme }) => {
})
export const StyledInput = styled(
forwardRef<HTMLInputElement, InputProps>(({ className, ...props }, ref) => {
forwardRef<HTMLInputElement, AppInputProps>(({ className, ...props }, ref) => {
return (
<Input
{...props}

View File

@ -1,7 +1,6 @@
import { Suspense, lazy } from 'react'
import { Route, Routes, Navigate } from 'react-router-dom'
import HomePage from '../pages/HomePage/Home.Page'
import WelcomePage from '../pages/Welcome.Page'
import { Layout } from '../layout/Layout'
import { CircularProgress, Stack } from '@mui/material'
import CreatePage from '@/pages/CreatePage/Create.Page'

View File

@ -7,7 +7,7 @@ export type AppButtonProps = MuiButtonProps & {
export const Button = forwardRef<HTMLButtonElement, AppButtonProps>(({ children, ...restProps }, ref) => {
return (
<StyledButton classes={{ root: 'button' }} {...restProps} ref={ref}>
<StyledButton classes={{ root: 'button', disabled: 'disabled' }} {...restProps} ref={ref}>
{children}
</StyledButton>
)
@ -27,19 +27,22 @@ const StyledButton = styled(
background: theme.palette.backgroundSecondary.default,
},
color: theme.palette.text.primary,
'&.disabled': {
opacity: 0.5,
cursor: 'not-allowed',
},
}
}
return {
...commonStyles,
'&.button:is(:hover, :active, &)': {
'&.button:is(:hover, :active, &, .disabled)': {
background: theme.palette.primary.main,
},
color: theme.palette.text.secondary,
':disabled': {
'&.button:is(:hover, :active, &)': {
background: theme.palette.backgroundSecondary.default,
},
color: theme.palette.backgroundSecondary.paper,
'&.disabled': {
color: theme.palette.text.secondary,
opacity: 0.5,
cursor: 'not-allowed',
},
}
})

View File

@ -1,12 +1,12 @@
import { forwardRef, useRef } from 'react'
import { Input, InputProps } from '../Input/Input'
import { useRef } from 'react'
import { Input, AppInputProps } from '../Input/Input'
export type DebounceProps = {
handleDebounce: (value: string) => void
debounceTimeout: number
}
export const DebounceInput = (props: InputProps & DebounceProps) => {
export const DebounceInput = (props: AppInputProps & DebounceProps) => {
const { handleDebounce, debounceTimeout, ...rest } = props
const timerRef = useRef<number>()

View File

@ -10,14 +10,14 @@ import {
styled,
} from '@mui/material'
export type InputProps = InputBaseProps & {
export type AppInputProps = InputBaseProps & {
helperText?: string | ReactNode
helperTextProps?: FormHelperTextProps
containerProps?: BoxProps
label?: string
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
export const Input = forwardRef<HTMLInputElement, AppInputProps>(
({ helperText, containerProps, helperTextProps, label, ...props }, ref) => {
return (
<StyledInputContainer {...containerProps}>
@ -26,7 +26,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(
{label}
</FormLabel>
) : null}
<InputBase className="input" {...props} classes={{ error: 'error' }} inputRef={ref} />
<InputBase autoComplete="off" className="input" {...props} classes={{ error: 'error' }} ref={ref} />
{helperText ? (
<FormHelperText {...helperTextProps} className="helper_text">
{helperText}

View File

@ -20,7 +20,7 @@ const Transition = forwardRef(function Transition(
export const Modal: FC<ModalProps> = ({ children, title, onClose, withCloseButton = true, fixedHeight, ...props }) => {
return (
<StyledDialog fixedHeight={fixedHeight} {...props} onClose={onClose} TransitionComponent={Transition}>
<StyledDialog fixedheight={fixedHeight} {...props} onClose={onClose} TransitionComponent={Transition}>
{withCloseButton && (
<StyledCloseButtonWrapper>
<IconButton onClick={() => onClose && onClose({}, 'backdropClick')} className="close_btn">

View File

@ -10,7 +10,7 @@ import {
styled,
} from '@mui/material'
export const StyledDialog = styled((props: DialogProps & { fixedHeight?: string }) => (
export const StyledDialog = styled((props: DialogProps & { fixedheight?: string }) => (
<Dialog
{...props}
classes={{
@ -26,8 +26,8 @@ export const StyledDialog = styled((props: DialogProps & { fixedHeight?: string
}}
fullWidth
/>
))(({ theme, fixedHeight = '' }) => {
const fixedHeightStyles = fixedHeight ? { height: fixedHeight } : {}
))(({ theme, fixedheight = '' }) => {
const fixedHeightStyles = fixedheight ? { height: fixedheight } : {}
return {
'& .container': {
alignItems: 'flex-end',

View File

@ -43,6 +43,11 @@ export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch
export const selectKeys = (state: RootState) => state.content.keys
export const selectApps = (state: RootState) => state.content.apps
export const selectKeyByNpub = (state: RootState, npub: string) => {
return state.content.keys.find((key) => key.npub === npub)
}
export const selectAppsByNpub = memoizeOne((state: RootState, npub: string) => {
return state.content.apps.filter((app) => app.npub === npub)

View File

@ -9,9 +9,11 @@ export enum MODAL_PARAMS_KEYS {
CONFIRM_CONNECT = 'confirm-connect',
CONFIRM_EVENT = 'confirm-event',
ACTIVITY = 'activity',
APP_DETAILS = 'app-details',
}
export enum EXPLANATION_MODAL_KEYS {
BUNKER = 'bunker',
NPUB = 'npub',
LOGIN = 'login',
}

View File

@ -3,35 +3,21 @@ import { ACTIONS, ACTION_TYPE, NIP46_RELAYS } from '../consts'
import { DbPending, DbPerm } from '@/modules/db'
import { MetaEvent } from '@/types/meta-event'
export async function call(cb: () => any, err?: (e: string) => void) {
export async function call(cb: () => any) {
try {
return await cb()
} catch (e: any) {
} catch (e) {
console.log(`Error: ${e}`)
err?.(e.toString());
}
}
export const getDomain = (url: string) => {
try {
return new URL(url).hostname
} catch {
return ''
}
}
export const getShortenNpub = (npub = '') => {
return npub.substring(0, 10) + '...' + npub.slice(-4)
}
export const getAppIconTitle = (name: string | undefined, appNpub: string) => {
return name
? name[0].toLocaleUpperCase()
: appNpub.substring(4, 7);
}
export const getProfileUsername = (profile: MetaEvent | null, npub: string) => {
return profile?.info?.name || profile?.info?.display_name || getShortenNpub(npub)
export const getProfileUsername = (profile: MetaEvent | null) => {
if (!profile) return null
return profile?.info?.name || profile?.info?.display_name
}
export const getBunkerLink = (npub = '') => {
@ -62,25 +48,6 @@ export function getSignReqKind(req: DbPending): number | undefined {
return undefined
}
export function getReqActionName(req: DbPending) {
const action = ACTIONS[req.method]
if (req.method === 'sign_event') {
const kind = getSignReqKind(req)
if (kind !== undefined) return `${action} of kind ${kind}`
}
return action
}
export function getPermActionName(req: DbPerm) {
const method = req.perm.split(':')[0]
const action = ACTIONS[method]
if (method === 'sign_event') {
const kind = req.perm.split(':')[1]
if (kind !== undefined) return `${action} of kind ${kind}`
}
return action
}
export function getReqPerm(req: DbPending): string {
if (req.method === 'sign_event') {
const kind = getSignReqKind(req)
@ -113,7 +80,7 @@ export function isPackagePerm(perm: string, reqPerm: string) {
export async function fetchNip05(value: string, origin?: string) {
try {
const [username, domain] = value.split('@')
const [username, domain] = value.toLocaleLowerCase().split('@')
if (!origin) origin = `https://${domain}`
const response = await fetch(`${origin}/.well-known/nostr.json?name=${username}`)
const getNpub: {
@ -129,3 +96,38 @@ export async function fetchNip05(value: string, origin?: string) {
return ''
}
}
export const getDomain = (url: string) => {
try {
return new URL(url).hostname
} catch {
return ''
}
}
export const getAppIconTitle = (name: string | undefined, appNpub: string) => {
return name ? name[0].toLocaleUpperCase() : appNpub.substring(4, 7)
}
export function getReqActionName(req: DbPending) {
const action = ACTIONS[req.method]
if (req.method === 'sign_event') {
const kind = getSignReqKind(req)
if (kind !== undefined) return `${action} of kind ${kind}`
}
return action
}
export function getPermActionName(req: DbPerm) {
const method = req.perm.split(':')[0]
const action = ACTIONS[method]
if (method === 'sign_event') {
const kind = req.perm.split(':')[1]
if (kind !== undefined) return `${action} of kind ${kind}`
}
return action
}
export const isEmptyString = (str = '') => {
return str.trim().length === 0
}