Compare commits

...

29 Commits

Author SHA1 Message Date
ba3775e6c6 Make username settings button red if name empty, add sections to username settings, UI fixes to username settings, remove qs params from username settings 2024-02-19 10:37:06 +03:00
4ad66c8711 add transfer name field 2024-02-16 19:47:25 +06:00
6a04c3ec4b Merge branch 'develop' of https://github.com/nostrband/noauth into feature/edit-name 2024-02-16 18:14:59 +06:00
6d72cf1f82 implement edit username logic in edit modal 2024-02-16 17:59:59 +06:00
2e522b79ad Merge pull request #84 from nostrband/main
Merge w/ main
2024-02-16 14:48:48 +03:00
453a16690f Merge pull request #83 from nostrband/feature/ignore
Add ignore logic to stop interfering with replies from other instances
2024-02-16 14:48:16 +03:00
46336d817f Add ignore logic to stop interfering with replies from other instances 2024-02-16 14:46:36 +03:00
8ef8157c38 Merge pull request #81 from nostrband/feature/watcher
Feature/watcher
2024-02-16 13:34:29 +03:00
4f00a014d0 Merge pull request #80 from nostrband/feature/watcher
Feature/watcher
2024-02-16 13:33:50 +03:00
a500a2e2a5 Merge w/ develop 2024-02-16 13:33:04 +03:00
1e6bf8679c Fix isLoading reset in popup confirms 2024-02-16 13:30:04 +03:00
04373e7991 Merge pull request #79 from nostrband/develop
Show app npubs
2024-02-16 12:02:24 +03:00
6acd00ca3b Merge pull request #78 from nostrband/refactor/display-app-npub
show appNpub in apps list & in app details page
2024-02-16 11:45:44 +03:00
6186f3dd3d Make url optional, move name to top on app detail modal 2024-02-16 11:44:50 +03:00
87ec23c737 Added watcher, deletes pending if watcher has concurrent reply, fixing popup closing issues 2024-02-16 11:28:02 +03:00
d199dcf9f7 Merge branch 'feature/edit-name' of https://github.com/nostrband/noauth into feature/edit-name 2024-02-16 14:22:30 +06:00
04c425c32c show appNpub in apps list & in app details page 2024-02-16 14:20:51 +06:00
0f28c80a15 Add editName and transferName to backend 2024-02-16 09:47:46 +03:00
34b516a1e3 Merge pull request #71 from nostrband/develop
Many minor fixes in UI, spinners etc.
2024-02-15 09:28:45 +03:00
aac537c7a2 Merge pull request #67 from nostrband/refactor/edit-app-info
Refactor/edit app info
2024-02-15 09:19:19 +03:00
40f4a9922a Merge pull request #69 from nostrband/develop
Fix redirect to confirm connect w/ popup=true after login
2024-02-15 09:00:24 +03:00
2058b900ac Fix redirect to confirm connect w/ popup=true after login 2024-02-15 08:58:49 +03:00
4b1f7564e7 Merge pull request #68 from nostrband/develop
Add logic to confirm after login
2024-02-15 08:42:14 +03:00
43e375efe9 Add logic to confirm after login 2024-02-14 16:15:50 +03:00
83d5c013cf Merge pull request #65 from nostrband/develop
Show kind in sign-event in activity history, show import key without …
2024-02-14 11:40:45 +03:00
0be2159efb Show kind in sign-event in activity history, show import key without advanced section 2024-02-14 11:39:37 +03:00
e96edf90fe Merge pull request #64 from nostrband/develop
Fix - close confirm event popup after confirmed
2024-02-14 10:51:12 +03:00
56e71219a5 Merge pull request #63 from nostrband/develop
Readme
2024-02-14 10:17:22 +03:00
67b6a3bfcf Merge pull request #62 from nostrband/develop
Develop
2024-02-14 09:58:06 +03:00
24 changed files with 813 additions and 252 deletions

View File

@ -18,7 +18,7 @@ function App() {
const load = useCallback(async () => {
const keys: DbKey[] = await dbi.listKeys()
console.log(keys, 'keys')
// console.log(keys, 'keys')
dispatch(setKeys({ keys }))
const loadProfiles = async () => {
@ -65,7 +65,7 @@ function App() {
useEffect(() => {
ndk.connect().then(() => {
console.log('NDK connected', { ndk })
console.log('NDK connected')
setIsConnected(true)
})
// eslint-disable-next-line

View File

@ -68,6 +68,7 @@ export const ModalAppDetails = () => {
if (isEmptyString(url)) return
try {
const u = new URL(url)
if (isEmptyString(name)) setDetails((prev) => ({ ...prev, name: u.hostname }))
@ -119,7 +120,7 @@ export const ModalAppDetails = () => {
}
}
const isFormValid = !isEmptyString(url) && !isEmptyString(name)
const isFormValid = !isEmptyString(name)
return (
<Modal open={isModalOpened} onClose={handleCloseModal}>
@ -130,6 +131,13 @@ export const ModalAppDetails = () => {
</Typography>
</Stack>
<Input
label="Name"
fullWidth
placeholder="Enter app name"
onChange={handleInputChange('name')}
value={details.name}
/>
<Autocomplete
options={[]}
freeSolo
@ -150,13 +158,6 @@ export const ModalAppDetails = () => {
)
}}
/>
<Input
label="Name"
fullWidth
placeholder="Enter app name"
onChange={handleInputChange('name')}
value={details.name}
/>
<Input
label="Icon"
fullWidth

View File

@ -1,15 +1,22 @@
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { Modal } from '@/shared/Modal/Modal'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
import { askNotificationPermission, call, getAppIconTitle, getDomain, getReferrerAppUrl, getShortenNpub } from '@/utils/helpers/helpers'
import {
askNotificationPermission,
call,
getAppIconTitle,
getDomain,
getReferrerAppUrl,
getShortenNpub,
} from '@/utils/helpers/helpers'
import { Avatar, Box, Stack, Typography } from '@mui/material'
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { useAppSelector } from '@/store/hooks/redux'
import { selectAppsByNpub, selectKeys, selectPendingsByNpub } from '@/store'
import { StyledButton, StyledToggleButtonsGroup } from './styled'
import { ActionToggleButton } from './сomponents/ActionToggleButton'
import { useState } from 'react'
import { swicCall } from '@/modules/swic'
import { useEffect, useState } from 'react'
import { swicCall, swicWaitStarted } from '@/modules/swic'
import { ACTION_TYPE } from '@/utils/consts'
import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar'
@ -28,6 +35,7 @@ export const ModalConfirmConnect = () => {
const pending = useAppSelector((state) => selectPendingsByNpub(state, npub))
const [selectedActionType, setSelectedActionType] = useState<ACTION_TYPE>(ACTION_TYPE.BASIC)
const [isLoaded, setIsLoaded] = useState(false)
const appNpub = searchParams.get('appNpub') || ''
const pendingReqId = searchParams.get('reqId') || ''
@ -37,7 +45,7 @@ export const ModalConfirmConnect = () => {
const triggerApp = apps.find((app) => app.appNpub === appNpub)
const { name, url = '', icon = '' } = triggerApp || {}
const appUrl = url || searchParams.get('appUrl') || getReferrerAppUrl();
const appUrl = url || searchParams.get('appUrl') || getReferrerAppUrl()
const appDomain = getDomain(appUrl)
const appName = name || appDomain || getShortenNpub(appNpub)
const appAvatarTitle = getAppIconTitle(name || appDomain, appNpub)
@ -53,14 +61,44 @@ export const ModalConfirmConnect = () => {
},
})
const isNpubExists = npub.trim().length && keys.some((key) => key.npub === npub)
// App doesn't exist yet!
// const isAppNpubExists = appNpub.trim().length && apps.some((app) => app.appNpub === appNpub)
const isPendingReqIdExists = pendingReqId.trim().length && pending.some((p) => p.id === pendingReqId)
// console.log("pending", {isModalOpened, isPendingReqIdExists, isNpubExists, /*isAppNpubExists,*/ pendingReqId, pending});
if (!isPopup && isModalOpened && (!isNpubExists /*|| !isAppNpubExists*/ || (pendingReqId && !isPendingReqIdExists))) {
closeModalAfterRequest()
return null
// NOTE: when opened directly to this modal using authUrl,
// we might not have pending requests visible yet bcs we haven't
// loaded them yet, which means this modal will be closed with
// the logic below. So now if it's popup then we wait for SW
// and then wait a little more to give it time to fetch
// pending reqs from db. Same logic implemented in confirm-event.
// FIXME move to a separate hook and reuse?
useEffect(() => {
if (isModalOpened) {
if (isPopup) {
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")
setTimeout(() => setIsLoaded(true), 500)
})
} else {
setIsLoaded(true)
}
} else {
setIsLoaded(false)
}
}, [isModalOpened, isPopup])
if (isLoaded) {
const isNpubExists = npub.trim().length && keys.some((key) => key.npub === npub)
// NOTE: app doesn't exist yet!
// const isAppNpubExists = appNpub.trim().length && apps.some((app) => app.appNpub === appNpub)
const isPendingReqIdExists = pendingReqId.trim().length && pending.some((p) => p.id === pendingReqId)
// console.log("pending", {isModalOpened, isPendingReqIdExists, isNpubExists, /*isAppNpubExists,*/ pendingReqId, pending});
if (isModalOpened && (!isNpubExists /*|| !isAppNpubExists*/ || (pendingReqId && !isPendingReqIdExists))) {
if (isPopup) window.close()
else closeModalAfterRequest()
return null
}
}
const handleActionTypeChange = (_: any, value: ACTION_TYPE | null) => {
@ -179,7 +217,7 @@ export const ModalConfirmConnect = () => {
</StyledToggleButtonsGroup>
<Stack direction={'row'} gap={'1rem'}>
<StyledButton onClick={disallow} varianttype="secondary">
Disallow
Ignore
</StyledButton>
<StyledButton fullWidth onClick={allow}>
Connect

View File

@ -10,7 +10,7 @@ import { ActionToggleButton } from './сomponents/ActionToggleButton'
import { FC, useEffect, useMemo, useState } from 'react'
import { StyledActionsListContainer, StyledButton, StyledToggleButtonsGroup } from './styled'
import { SectionTitle } from '@/shared/SectionTitle/SectionTitle'
import { swicCall } from '@/modules/swic'
import { swicCall, swicWaitStarted } from '@/modules/swic'
import { Checkbox } from '@/shared/Checkbox/Checkbox'
import { DbPending } from '@/modules/db'
import { IPendingsByAppNpub } from '@/pages/KeyPage/hooks/useTriggerConfirmModal'
@ -47,6 +47,7 @@ export const ModalConfirmEvent: FC<ModalConfirmEventProps> = ({ confirmEventReqs
const [selectedActionType, setSelectedActionType] = useState<ACTION_TYPE>(ACTION_TYPE.ALWAYS)
const [pendingRequests, setPendingRequests] = useState<PendingRequest[]>([])
const [isLoaded, setIsLoaded] = useState(false)
const currentAppPendingReqs = useMemo(() => confirmEventReqs[appNpub]?.pending || [], [confirmEventReqs, appNpub])
@ -61,25 +62,31 @@ export const ModalConfirmEvent: FC<ModalConfirmEventProps> = ({ confirmEventReqs
},
})
// FIXME: when opened directly to this modal using authUrl,
// we might not have pending requests visible yet bcs we haven't
// loaded them yet, which means this modal will be closed with
// the login below. It's fine if only one app has sent pending
// requests atm, bcs the modal would re-appear as soon as we load
// the requests. But if there are several pending reqs from other
// apps then popup might show a different one! Which is very
// contrary to what user expects. So:
// - if isPopup - dont close the modal with logic below
// - show some 'loading' indicator until we've got some requests
// for the specified appNpub
// FIXME is the same logic valid for Connect modal?
useEffect(() => {
if (isModalOpened) {
if (isPopup) {
// wait for SW to start
swicWaitStarted().then(() => {
// give it some time to load the pending reqs etc
setTimeout(() => setIsLoaded(true), 500)
})
} else {
setIsLoaded(true)
}
} else {
setIsLoaded(false)
}
}, [isModalOpened, isPopup])
const isNpubExists = npub.trim().length && keys.some((key) => key.npub === npub)
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)) {
closeModalAfterRequest()
return null
if (isLoaded) {
const isNpubExists = npub.trim().length && keys.some((key) => key.npub === npub)
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()
return null
}
}
const triggerApp = apps.find((app) => app.appNpub === appNpub)

View File

@ -0,0 +1,174 @@
import { CheckmarkIcon } from '@/assets'
import { useEnqueueSnackbar } from '@/hooks/useEnqueueSnackbar'
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
import { swicCall } from '@/modules/swic'
import { Button } from '@/shared/Button/Button'
import { Input } from '@/shared/Input/Input'
import { LoadingSpinner } from '@/shared/LoadingSpinner/LoadingSpinner'
import { Modal } from '@/shared/Modal/Modal'
import { selectKeys } from '@/store'
import { useAppSelector } from '@/store/hooks/redux'
import { MODAL_PARAMS_KEYS } from '@/types/modal'
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 { useDebounce } from 'use-debounce'
import { StyledSettingContainer } from './styled'
import { SectionTitle } from '@/shared/SectionTitle/SectionTitle'
export const ModalEditName = () => {
const keys = useAppSelector(selectKeys)
const notify = useEnqueueSnackbar()
const { npub = '' } = useParams<{ npub: string }>()
const key = keys.find((k) => k.npub === npub)
const name = key?.name || ''
const { palette } = useTheme()
const { getModalOpened, createHandleCloseReplace } = useModalSearchParams()
const isModalOpened = getModalOpened(MODAL_PARAMS_KEYS.EDIT_NAME)
const handleCloseModal = createHandleCloseReplace(MODAL_PARAMS_KEYS.EDIT_NAME)
const [enteredName, setEnteredName] = useState('')
const [debouncedName] = useDebounce(enteredName, 300)
const isNameEqual = debouncedName === name
const [receiverNpub, setReceiverNpub] = useState('')
const [isAvailable, setIsAvailable] = useState(true)
const [isChecking, setIsChecking] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [isTransferLoading, setIsTransferLoading] = useState(false)
const checkIsUsernameAvailable = useCallback(async () => {
if (!debouncedName.trim().length) return undefined
try {
setIsChecking(true)
const npubNip05 = await fetchNip05(`${debouncedName}@${DOMAIN}`)
setIsAvailable(!npubNip05 || npubNip05 === npub)
setIsChecking(false)
} catch (error) {
setIsAvailable(true)
setIsChecking(false)
}
}, [debouncedName])
useEffect(() => {
checkIsUsernameAvailable()
}, [checkIsUsernameAvailable])
useEffect(() => {
setEnteredName(name)
return () => {
if (isModalOpened) {
setEnteredName('')
setReceiverNpub('')
}
}
// eslint-disable-next-line
}, [isModalOpened])
const handleNameChange = (e: ChangeEvent<HTMLInputElement>) => setEnteredName(e.target.value)
const handleReceiverNpubChange = (e: ChangeEvent<HTMLInputElement>) => setReceiverNpub(e.target.value)
const getInputHelperText = () => {
if (!debouncedName.trim().length || isNameEqual) return ''
if (isChecking) return 'Loading...'
if (!isAvailable) return 'Already taken'
return (
<Fragment>
<CheckmarkIcon /> Available
</Fragment>
)
}
const inputHelperText = getInputHelperText()
const getHelperTextColor = useCallback(() => {
if (!debouncedName || isChecking || isNameEqual) return palette.textSecondaryDecorate.main
return isAvailable ? palette.success.main : palette.error.main
// deps
}, [debouncedName, isAvailable, isChecking, isNameEqual, palette])
const isNpubExists = npub.trim().length && keys.some((key) => key.npub === npub)
if (isModalOpened && !isNpubExists) {
handleCloseModal()
return null
}
const isEditButtonDisabled = isNameEqual || !isAvailable || isChecking || isLoading || !enteredName.trim().length
const isTransferButtonDisabled = !name.length || !receiverNpub.trim().length || isTransferLoading
const handleEditName = async () => {
if (isEditButtonDisabled) return
try {
setIsLoading(true)
await swicCall('editName', npub, enteredName)
notify('Username updated!', 'success')
setIsLoading(false)
} catch (error: any) {
setIsLoading(false)
notify(error?.message || 'Failed to edit username!', 'error')
}
}
const handleTransferName = async () => {
if (isTransferButtonDisabled) return
try {
setIsTransferLoading(true)
await swicCall('transferName', npub, enteredName, receiverNpub)
notify('Username transferred!', 'success')
setIsTransferLoading(false)
setEnteredName('')
} catch (error: any) {
setIsTransferLoading(false)
notify(error?.message || 'Failed to transfer username!', 'error')
}
}
return (
<Modal open={isModalOpened} title="Username Settings" onClose={handleCloseModal}>
<Stack gap={'1rem'}>
<StyledSettingContainer>
<SectionTitle>Change name</SectionTitle>
<Input
label="User name"
fullWidth
placeholder="Enter a Username"
endAdornment={<Typography color={'#FFFFFFA8'}>@{DOMAIN}</Typography>}
helperText={inputHelperText}
onChange={handleNameChange}
value={enteredName}
helperTextProps={{
sx: {
'&.helper_text': {
color: getHelperTextColor(),
},
},
}}
/>
<Button fullWidth disabled={isEditButtonDisabled} onClick={handleEditName}>
Save name {isLoading && <LoadingSpinner />}
</Button>
</StyledSettingContainer>
<StyledSettingContainer>
<SectionTitle>Transfer name</SectionTitle>
<Input
label="Receiver npub"
fullWidth
placeholder="npub1..."
onChange={handleReceiverNpubChange}
value={receiverNpub}
/>
<Button fullWidth onClick={handleTransferName} disabled={isTransferButtonDisabled}>
Transfer name
</Button>
</StyledSettingContainer>
</Stack>
</Modal>
)
}

View File

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

View File

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

View File

@ -8,12 +8,12 @@ import { Stack, Typography } from '@mui/material'
import { StyledAppLogo } from './styled'
import { Input } from '@/shared/Input/Input'
import { Button } from '@/shared/Button/Button'
import { useNavigate } from 'react-router-dom'
import { useNavigate, useSearchParams } 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 { fetchNip05, fetchNpubNames } from '@/utils/helpers/helpers'
import { usePassword } from '@/hooks/usePassword'
import { dbi } from '@/modules/db'
import { LoadingSpinner } from '@/shared/LoadingSpinner/LoadingSpinner'
@ -32,11 +32,14 @@ export const ModalLogin = () => {
const navigate = useNavigate()
const { hidePassword, inputProps } = usePassword()
const [isLoading, setIsLoading] = useState(false)
const [searchParams] = useSearchParams()
const isPopup = searchParams.get('popup') === 'true'
const {
handleSubmit,
reset,
register,
setValue,
formState: { errors },
} = useForm<FormInputType>({
defaultValues: FORM_DEFAULT_VALUES,
@ -79,7 +82,10 @@ export const ModalLogin = () => {
notify(`Fetched ${k.npub}`, 'success')
dbi.addSynced(k.npub)
cleanUpStates()
navigate(`/key/${k.npub}`)
setTimeout(() => {
// give frontend time to read the new key first
navigate(`/key/${k.npub}${isPopup ? '?popup=true' : ''}`)
}, 300)
} catch (error: any) {
console.log('error', error)
notify(error?.message || 'Something went wrong!', 'error')
@ -87,6 +93,22 @@ export const ModalLogin = () => {
}
}
useEffect(() => {
if (isModalOpened) {
const npub = searchParams.get('npub') || ''
const appNpub = searchParams.get('appNpub') || ''
if (isPopup && isModalOpened) {
swicCall('fetchPendingRequests', npub, appNpub)
fetchNpubNames(npub).then((names) => {
if (names.length) {
setValue('username', `${names[0]}@${DOMAIN}`)
}
})
}
}
}, [searchParams, isModalOpened, isPopup, setValue])
useEffect(() => {
return () => {
if (isModalOpened) {

View File

@ -62,8 +62,11 @@ export const ModalSignUp = () => {
setIsLoading(true)
const k: any = await swicCall('generateKey', name)
notify(`Account created for "${name}"`, 'success')
navigate(`/key/${k.npub}`)
setIsLoading(false)
setTimeout(() => {
// give frontend time to read the new key first
navigate(`/key/${k.npub}`)
}, 300)
} catch (error: any) {
notify(error?.message || 'Something went wrong!', 'error')
setIsLoading(false)

View File

@ -31,7 +31,7 @@ export const useModalSearchParams = () => {
const enumKey = getEnumParam(modal)
searchParams.delete(enumKey)
extraOptions?.onClose && extraOptions?.onClose(searchParams)
console.log({ searchParams })
// console.log({ searchParams })
setSearchParams(searchParams, { replace: !!extraOptions?.replace })
}

View File

@ -1,21 +1,30 @@
import { generatePrivateKey, getPublicKey, nip19 } from 'nostr-tools'
import { Event, generatePrivateKey, getPublicKey, nip19, verifySignature } from 'nostr-tools'
import { DbApp, dbi, DbKey, DbPending, DbPerm } from './db'
import { Keys } from './keys'
import NDK, {
IEventHandlingStrategy,
NDKEvent,
NDKNip46Backend,
NDKPrivateKeySigner,
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 { Nip04 } from './nip04'
// import { Nip04 } from './nip04'
import { fetchNip05, getReqPerm, getShortenNpub, isPackagePerm } from '@/utils/helpers/helpers'
import { NostrPowEvent, minePow } from './pow'
//import { PrivateKeySigner } from './signer'
//const PERF_TEST = false
enum DECISION {
ASK = '',
ALLOW = 'allow',
DISALLOW = 'disallow',
IGNORE = 'ignore',
}
export interface KeyInfo {
npub: string
nip05?: string
@ -28,11 +37,12 @@ interface Key {
backoff: number
signer: NDKSigner
backend: NDKNip46Backend
watcher: Watcher
}
interface Pending {
req: DbPending
cb: (allow: boolean, remember: boolean, options?: any) => void
cb: (allow: DECISION, remember: boolean, options?: any) => void
notified?: boolean
}
@ -46,86 +56,171 @@ interface IAllowCallbackParams {
params?: any
}
class Nip04KeyHandlingStrategy implements IEventHandlingStrategy {
private privkey: string
private nip04 = new Nip04()
class Watcher {
private ndk: NDK
private signer: NDKSigner
private onReply: (id: string) => void
private sub?: NDKSubscription
constructor(privkey: string) {
this.privkey = privkey
constructor(ndk: NDK, signer: NDKSigner, onReply: (id: string) => void) {
this.ndk = ndk
this.signer = signer
this.onReply = onReply
}
private async getKey(backend: NDKNip46Backend, id: string, remotePubkey: string, recipientPubkey: string) {
if (
!(await backend.pubkeyAllowed({
id,
pubkey: remotePubkey,
// @ts-ignore
method: 'get_nip04_key',
params: recipientPubkey,
}))
) {
backend.debug(`get_nip04_key request from ${remotePubkey} rejected`)
return undefined
async start() {
this.sub = this.ndk.subscribe(
{
kinds: [KIND_RPC],
authors: [(await this.signer.user()).pubkey],
since: Math.floor(Date.now() / 1000 - 10),
},
{
closeOnEose: false,
cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY,
}
)
this.sub.on('event', async (e: NDKEvent) => {
const peer = e.tags.find((t) => t.length >= 2 && t[0] === 'p')
console.log('watcher got event', { e, peer })
if (!peer) return
const decryptedContent = await this.signer.decrypt(new NDKUser({ pubkey: peer[1] }), e.content)
const parsedContent = JSON.parse(decryptedContent)
const { id, method, params, result, error } = parsedContent
console.log('watcher got', { peer, id, method, params, result, error })
if (method || result === 'auth_url') return
this.onReply(id)
})
}
stop() {
this.sub!.stop()
}
}
class Nip46Backend extends NDKNip46Backend {
private allowCb: (params: IAllowCallbackParams) => Promise<DECISION>
private npub: string = ''
public constructor(ndk: NDK, signer: NDKSigner, allowCb: (params: IAllowCallbackParams) => Promise<DECISION>) {
super(ndk, signer, () => Promise.resolve(true))
this.allowCb = allowCb
signer.user().then((u) => (this.npub = nip19.npubEncode(u.pubkey)))
}
public async processEvent(event: NDKEvent) {
this.handleIncomingEvent(event)
}
protected async handleIncomingEvent(event: NDKEvent) {
const { id, method, params } = (await this.rpc.parseEvent(event)) as any
const remotePubkey = event.pubkey
let response: string | undefined
this.debug('incoming event', { id, method, params })
// validate signature explicitly
if (!verifySignature(event.rawEvent() as Event)) {
this.debug('invalid signature', event.rawEvent())
return
}
return Buffer.from(this.nip04.createKey(this.privkey, recipientPubkey)).toString('hex')
}
async handle(backend: NDKNip46Backend, id: string, remotePubkey: string, params: string[]) {
const [recipientPubkey] = params
return await this.getKey(backend, id, remotePubkey, recipientPubkey)
}
}
class EventHandlingStrategyWrapper implements IEventHandlingStrategy {
readonly backend: NDKNip46Backend
readonly npub: string
readonly method: string
private body: IEventHandlingStrategy
private allowCb: (params: IAllowCallbackParams) => Promise<boolean>
constructor(
backend: NDKNip46Backend,
npub: string,
method: string,
body: IEventHandlingStrategy,
allowCb: (params: IAllowCallbackParams) => Promise<boolean>
) {
this.backend = backend
this.npub = npub
this.method = method
this.body = body
this.allowCb = allowCb
}
async handle(
backend: NDKNip46Backend,
id: string,
remotePubkey: string,
params: string[]
): Promise<string | undefined> {
console.log(Date.now(), 'handle', {
method: this.method,
id,
remotePubkey,
params,
})
const allow = await this.allowCb({
backend: this.backend,
const decision = await this.allowCb({
backend: this,
npub: this.npub,
id,
method: this.method,
method,
remotePubkey,
params,
})
if (!allow) return undefined
return this.body.handle(backend, id, remotePubkey, params).then((r) => {
console.log(Date.now(), 'req', id, 'method', this.method, 'result', r)
return r
})
console.log(Date.now(), 'handle', { method, id, decision, remotePubkey, params })
if (decision === DECISION.IGNORE) return
const allow = decision === DECISION.ALLOW
const strategy = this.handlers[method]
if (allow) {
if (strategy) {
try {
response = await strategy.handle(this, id, remotePubkey, params)
console.log(Date.now(), 'req', id, 'method', method, 'result', response)
} catch (e: any) {
this.debug('error handling event', e, { id, method, params })
this.rpc.sendResponse(id, remotePubkey, 'error', undefined, e.message)
}
} else {
this.debug('unsupported method', { method, params })
}
}
if (response) {
this.debug(`sending response to ${remotePubkey}`, response)
this.rpc.sendResponse(id, remotePubkey, response)
} else {
this.rpc.sendResponse(id, remotePubkey, 'error', undefined, 'Not authorized')
}
}
}
// class Nip04KeyHandlingStrategy implements IEventHandlingStrategy {
// private privkey: string
// private nip04 = new Nip04()
// constructor(privkey: string) {
// this.privkey = privkey
// }
// private async getKey(backend: NDKNip46Backend, id: string, remotePubkey: string, recipientPubkey: string) {
// if (
// !(await backend.pubkeyAllowed({
// id,
// pubkey: remotePubkey,
// // @ts-ignore
// method: 'get_nip04_key',
// params: recipientPubkey,
// }))
// ) {
// backend.debug(`get_nip04_key request from ${remotePubkey} rejected`)
// return undefined
// }
// return Buffer.from(this.nip04.createKey(this.privkey, recipientPubkey)).toString('hex')
// }
// async handle(backend: NDKNip46Backend, id: string, remotePubkey: string, params: string[]) {
// const [recipientPubkey] = params
// return await this.getKey(backend, id, remotePubkey, recipientPubkey)
// }
// }
// FIXME why do we need it? Just to print
// class EventHandlingStrategyWrapper implements IEventHandlingStrategy {
// readonly backend: NDKNip46Backend
// readonly method: string
// private body: IEventHandlingStrategy
// constructor(
// backend: NDKNip46Backend,
// method: string,
// body: IEventHandlingStrategy
// ) {
// this.backend = backend
// this.method = method
// this.body = body
// }
// async handle(
// backend: NDKNip46Backend,
// id: string,
// remotePubkey: string,
// params: string[]
// ): Promise<string | undefined> {
// return this.body.handle(backend, id, remotePubkey, params).then((r) => {
// console.log(Date.now(), 'req', id, 'method', this.method, 'result', r)
// return r
// })
// }
// }
export class NoauthBackend {
readonly swg: ServiceWorkerGlobalScope
private keysModule: Keys
@ -137,10 +232,16 @@ export class NoauthBackend {
private confirmBuffer: Pending[] = []
private accessBuffer: DbPending[] = []
private notifCallback: (() => void) | null = null
private pendingNpubEvents = new Map<string, NDKEvent[]>()
private ndk = new NDK({
explicitRelayUrls: NIP46_RELAYS,
enableOutboxModel: false,
})
public constructor(swg: ServiceWorkerGlobalScope) {
this.swg = swg
this.keysModule = new Keys(swg.crypto.subtle)
this.ndk.connect()
const self = this
swg.addEventListener('activate', (event) => {
@ -407,6 +508,41 @@ export class NoauthBackend {
throw new Error('Too many requests, retry later')
}
private async sendDeleteNameToServer(npub: string, name: string) {
const body = JSON.stringify({
npub,
name,
})
const method = 'DELETE'
const url = `${NOAUTHD_URL}/name`
return this.sendPostAuthd({
npub,
url,
method,
body,
})
}
private async sendTransferNameToServer(npub: string, name: string, newNpub: string) {
const body = JSON.stringify({
npub,
name,
newNpub,
})
const method = 'PUT'
const url = `${NOAUTHD_URL}/name`
return this.sendPostAuthd({
npub,
url,
method,
body,
})
}
private async sendTokenToServer(npub: string, token: string) {
const body = JSON.stringify({
npub,
@ -554,7 +690,7 @@ export class NoauthBackend {
return this.keyInfo(dbKey)
}
private getPerm(req: DbPending): string {
private getDecision(req: DbPending): DECISION {
const reqPerm = getReqPerm(req)
const appPerms = this.perms.filter((p) => p.npub === req.npub && p.appNpub === req.appNpub)
@ -563,27 +699,36 @@ export class NoauthBackend {
// non-exact next
if (!perm) perm = appPerms.find((p) => isPackagePerm(p.perm, reqPerm))
console.log('req', req, 'perm', reqPerm, 'value', perm, appPerms)
return perm?.value || ''
if (perm) {
console.log('req', req, 'perm', reqPerm, 'value', perm, appPerms)
return perm.value === '1' ? DECISION.ALLOW : DECISION.DISALLOW
}
const conn = appPerms.find((p) => p.perm === 'connect')
if (conn && conn.value === '0') {
console.log('req', req, 'perm', reqPerm, 'ignore by connect disallow')
return DECISION.IGNORE
}
return DECISION.ASK
}
private async connectApp({
npub,
appNpub,
appUrl,
perms,
appName = '',
appIcon = ''
}: {
npub: string,
appNpub: string,
appUrl: string,
appName?: string,
appIcon?: string,
perms: string[]
}) {
await dbi.addApp({
npub,
appNpub,
appUrl,
perms,
appName = '',
appIcon = '',
}: {
npub: string
appNpub: string
appUrl: string
appName?: string
appIcon?: string
perms: string[]
}) {
await dbi.addApp({
appNpub: appNpub,
npub: npub,
timestamp: Date.now(),
@ -618,19 +763,19 @@ export class NoauthBackend {
method,
remotePubkey,
params,
}: IAllowCallbackParams): Promise<boolean> {
}: IAllowCallbackParams): Promise<DECISION> {
// same reqs usually come on reconnects
if (this.doneReqIds.includes(id)) {
console.log('request already done', id)
// FIXME maybe repeat the reply, but without the Notification?
return false
return DECISION.IGNORE
}
const appNpub = nip19.npubEncode(remotePubkey)
const connected = !!this.apps.find((a) => a.appNpub === appNpub)
if (!connected && method !== 'connect') {
console.log('ignoring request before connect', method, id, appNpub, npub)
return false
return DECISION.IGNORE
}
const req: DbPending = {
@ -645,9 +790,21 @@ export class NoauthBackend {
const self = this
return new Promise(async (ok) => {
// called when it's decided whether to allow this or not
const onAllow = async (manual: boolean, allow: boolean, remember: boolean, options?: any) => {
const onAllow = async (manual: boolean, decision: DECISION, remember: boolean, options?: any) => {
// confirm
console.log(Date.now(), allow ? 'allowed' : 'disallowed', npub, method, options, params)
console.log(Date.now(), decision, npub, method, options, params)
switch (decision) {
case DECISION.ASK:
throw new Error('Make a decision!')
case DECISION.IGNORE:
return // noop
case DECISION.ALLOW:
case DECISION.DISALLOW:
// fall through
}
const allow = decision === DECISION.ALLOW
if (manual) {
await dbi.confirmPending(id, allow)
@ -700,35 +857,40 @@ export class NoauthBackend {
// reload
this.perms = await dbi.listPerms()
// confirm pending requests that might now have
// the proper perms
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) {
let perm = this.getPerm(r.req)
if (perm) {
r.cb(perm === '1', false)
}
}
}
// release this promise to send reply
// to this req
ok(decision)
// notify UI that it was confirmed
// if (!PERF_TEST)
this.updateUI()
// return to let nip46 flow proceed
ok(allow)
// after replying to this req check pending
// reqs maybe they can be replied right away
if (remember) {
// confirm pending requests that might now have
// the proper perms
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)
if (dec !== DECISION.ASK) {
r.cb(dec, false)
}
}
}
}
// check perms
const perm = this.getPerm(req)
console.log(Date.now(), 'perm', req.id, perm)
const dec = this.getDecision(req)
console.log(Date.now(), 'decision', req.id, dec)
// have perm?
if (perm) {
if (dec !== DECISION.ASK) {
// reply immediately
onAllow(false, perm === '1', false)
onAllow(false, dec, false)
} else {
// put pending req to db
await dbi.addPending(req)
@ -739,7 +901,7 @@ export class NoauthBackend {
// put to a list of pending requests
this.confirmBuffer.push({
req,
cb: (allow, remember, options) => onAllow(true, allow, remember, options),
cb: (decision, remember, options) => onAllow(true, decision, remember, options),
})
// OAuth flow
@ -772,25 +934,28 @@ export class NoauthBackend {
ndk.connect()
const signer = new NDKPrivateKeySigner(sk) // PrivateKeySigner
const backend = new NDKNip46Backend(ndk, signer, () => Promise.resolve(true))
this.keys.push({ npub, backend, signer, ndk, backoff })
const backend = new Nip46Backend(ndk, signer, this.allowPermitCallback.bind(this)) // , () => Promise.resolve(true)
const watcher = new Watcher(ndk, signer, (id) => {
// drop pending request
dbi.removePending(id).then(() => this.updateUI())
})
this.keys.push({ npub, backend, signer, ndk, backoff, watcher })
// new method
backend.handlers['get_nip04_key'] = new Nip04KeyHandlingStrategy(sk)
// backend.handlers['get_nip04_key'] = new Nip04KeyHandlingStrategy(sk)
// assign our own permission callback
for (const method in backend.handlers) {
backend.handlers[method] = new EventHandlingStrategyWrapper(
backend,
npub,
method,
backend.handlers[method],
this.allowPermitCallback.bind(this)
)
}
// // assign our own permission callback
// for (const method in backend.handlers) {
// backend.handlers[method] = new EventHandlingStrategyWrapper(
// backend,
// method,
// backend.handlers[method]
// )
// }
// start
backend.start()
watcher.start()
console.log('started', npub)
// backoff reset on successfull connection
@ -814,11 +979,13 @@ export class NoauthBackend {
const bo = self.keys.find((k) => k.npub === npub)?.backoff || 1000
setTimeout(() => {
console.log(new Date(), 'reconnect relays for key', npub, 'backoff', bo)
// @ts-ignore
for (const r of ndk.pool.relays.values()) r.disconnect()
// make sure it no longer activates
backend.handlers = {}
// stop watching
watcher.stop()
self.keys = self.keys.filter((k) => k.npub !== npub)
self.startKey({ npub, sk, backoff: Math.min(bo * 2, 60000) })
}, bo)
@ -829,6 +996,27 @@ export class NoauthBackend {
r.on('connect', onConnect)
r.on('disconnect', onDisconnect)
}
const pendingEvents = this.pendingNpubEvents.get(npub)
if (pendingEvents) {
this.pendingNpubEvents.delete(npub)
for (const e of pendingEvents) {
backend.processEvent(e)
}
}
}
private async fetchPendingRequests(npub: string, appNpub: string) {
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],
})
console.log('fetched pending for', npub, events.size)
this.pendingNpubEvents.set(npub, [...events.values()])
}
public async unlock(npub: string) {
@ -945,7 +1133,7 @@ export class NoauthBackend {
this.updateUI()
} else {
console.log('confirming req', id, allow, remember, options)
req.cb(allow, remember, options)
req.cb(allow ? DECISION.ALLOW : DECISION.DISALLOW, remember, options)
}
}
@ -963,6 +1151,31 @@ export class NoauthBackend {
this.updateUI()
}
private async editName(npub: string, name: string) {
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)
}
if (name) {
await this.sendNameToServer(npub, name)
}
await dbi.editName(npub, name)
key.name = name
this.updateUI()
}
private async transferName(npub: string, name: string, newNpub: string) {
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')
await this.sendTransferNameToServer(npub, key.name, newNpub)
await dbi.editName(npub, '')
key.name = ''
this.updateUI()
}
private async enablePush(): Promise<boolean> {
const options = {
userVisibleOnly: true,
@ -1009,8 +1222,14 @@ export class NoauthBackend {
result = await this.deleteApp(args[0])
} else if (method === 'deletePerm') {
result = await this.deletePerm(args[0])
} else if (method === 'editName') {
result = await this.editName(args[0], args[1])
} else if (method === 'transferName') {
result = await this.transferName(args[0], args[1], args[2])
} else if (method === 'enablePush') {
result = await this.enablePush()
} else if (method === 'fetchPendingRequests') {
result = await this.fetchPendingRequests(args[0], args[1])
} else {
console.log('unknown method from UI ', method)
}

View File

@ -89,6 +89,16 @@ export const dbi = {
return []
}
},
editName: async (npub: string, name: string): Promise<void> => {
try {
await db.keys.where({ npub }).modify({
name,
})
} catch (error) {
console.log(`db editName error: ${error}`)
return
}
},
getApp: async (appNpub: string) => {
try {
return await db.apps.get(appNpub)

View File

@ -5,6 +5,7 @@ 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
const queue: (() => Promise<void> | void)[] = []
export async function swicRegister() {
serviceWorkerRegistration.register({
@ -17,14 +18,17 @@ export async function swicRegister() {
},
})
navigator.serviceWorker.ready.then((r) => {
console.log('sw ready')
navigator.serviceWorker.ready.then(async (r) => {
console.log('sw ready, queue', queue.length)
swr = r
if (navigator.serviceWorker.controller) {
console.log(`This page is currently controlled by: ${navigator.serviceWorker.controller}`)
} else {
console.log('This page is not currently controlled by a service worker.')
}
while (queue.length)
await (queue.shift()!)()
})
navigator.serviceWorker.addEventListener('message', (event) => {
@ -32,6 +36,13 @@ export async function swicRegister() {
})
}
export function swicWaitStarted() {
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)
@ -57,19 +68,25 @@ export async function swicCall(method: string, ...args: any[]) {
nextReqId++
return new Promise((ok, rej) => {
if (!swr || !swr.active) {
rej(new Error('No active service worker'))
return
const call = async () => {
if (!swr || !swr.active) {
rej(new Error('No active service worker'))
return
}
reqs.set(id, { ok, rej })
const msg = {
id,
method,
args: [...args],
}
console.log('sending to SW', msg)
swr.active.postMessage(msg)
}
reqs.set(id, { ok, rej })
const msg = {
id,
method,
args: [...args],
}
console.log('sending to SW', msg)
swr.active.postMessage(msg)
if (swr && swr.active) call()
else queue.push(call)
})
}

View File

@ -43,8 +43,10 @@ const AppPage = () => {
const { icon = '', name = '', url = '' } = currentApp || {}
const appDomain = getDomain(url)
const appName = name || appDomain || getShortenNpub(appNpub)
const shortAppNpub = getShortenNpub(appNpub)
const appName = name || appDomain || shortAppNpub
const appAvatarTitle = getAppIconTitle(name || appDomain, appNpub)
const isAppNameExists = !!name
const { timestamp } = connectPerm || {}
const connectedOn = connectPerm && timestamp ? `Connected at ${formatTimestampDate(timestamp)}` : 'Not connected'
@ -65,13 +67,20 @@ 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%'}>
<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={'center'} gap={'0.5rem'}>
<Typography variant="h4" noWrap flex={1}>
{appName}
</Typography>
<Stack direction={'row'} alignItems={'flex-start'} gap={'0.5rem'} marginBottom={'0.5rem'}>
<Box display={'flex'} flexDirection={'column'} flex={1}>
<Typography variant="h4" noWrap>
{appName}
</Typography>
{isAppNameExists && (
<Typography noWrap display={'block'} variant="body1" color={'GrayText'}>
{shortAppNpub}
</Typography>
)}
</Box>
<IconButton onClick={handleShowAppDetailsModal}>
<MoreIcon />
</IconButton>

View File

@ -1,4 +1,4 @@
import React, { FC } from 'react'
import { FC } from 'react'
import { DbHistory } from '@/modules/db'
import { Box, IconButton, Typography } from '@mui/material'
import { StyledActivityItem } from './styled'
@ -6,16 +6,17 @@ import { formatTimestampDate } from '@/utils/helpers/date'
import ClearRoundedIcon from '@mui/icons-material/ClearRounded'
import DoneRoundedIcon from '@mui/icons-material/DoneRounded'
import MoreVertRoundedIcon from '@mui/icons-material/MoreVertRounded'
import { ACTIONS } from '@/utils/consts'
import { getReqActionName } from '@/utils/helpers/helpers'
type ItemActivityProps = DbHistory
export const ItemActivity: FC<ItemActivityProps> = ({ allowed, method, timestamp }) => {
export const ItemActivity: FC<ItemActivityProps> = (req) => {
const { allowed, timestamp } = req
return (
<StyledActivityItem>
<Box display={'flex'} flexDirection={'column'} gap={'0.5rem'} flex={1}>
<Typography flex={1} fontWeight={700}>
{ACTIONS[method] || method}
{getReqActionName(req)}
</Typography>
<Typography variant="body2">{formatTimestampDate(timestamp)}</Typography>
</Box>

View File

@ -1,6 +1,7 @@
import { useCallback, useState } from 'react'
import { useAppSelector } from '../../store/hooks/redux'
import { Navigate, useParams } from 'react-router-dom'
import { Stack } from '@mui/material'
import { Navigate, useParams, useSearchParams } from 'react-router-dom'
import { Box, IconButton, Stack } from '@mui/material'
import { StyledIconButton } from './styled'
import { SettingsIcon, ShareIcon } from '@/assets'
import { Apps } from './components/Apps'
@ -18,13 +19,20 @@ import { useTriggerConfirmModal } from './hooks/useTriggerConfirmModal'
import { useLiveQuery } from 'dexie-react-hooks'
import { checkNpubSyncQuerier } from './utils'
import { DOMAIN } from '@/utils/consts'
import { useCallback } from 'react'
import { InputCopyButton } from '@/shared/InputCopyButton/InputCopyButton'
import MoreHorizRoundedIcon from '@mui/icons-material/MoreHorizRounded'
import { ModalEditName } from '@/components/Modal/ModalEditName/ModalEditName'
const KeyPage = () => {
const { npub = '' } = useParams<{ npub: string }>()
const { keys, apps, pending, perms } = useAppSelector((state) => state.content)
const [searchParams] = useSearchParams()
const [isCheckingSync, setIsChecking] = useState(true)
const handleStopChecking = () => setIsChecking(false)
const isSynced = useLiveQuery(checkNpubSyncQuerier(npub, handleStopChecking), [npub], false)
const isSynced = useLiveQuery(checkNpubSyncQuerier(npub), [npub], false)
const { handleOpen } = useModalSearchParams()
const { handleEnableBackground, showWarning, isEnabling } = useBackgroundSigning()
@ -41,10 +49,21 @@ const KeyPage = () => {
const { prepareEventPendings } = useTriggerConfirmModal(npub, pending, perms)
const isKeyExists = npub.trim().length && key
const isPopup = searchParams.get('popup') === 'true'
// console.log({ isKeyExists, isPopup })
if (isPopup && !isKeyExists) {
searchParams.set('login', 'true')
searchParams.set('npub', npub)
const url = `/home?${searchParams.toString()}`
return <Navigate to={url} />
}
if (!isKeyExists) return <Navigate to={`/home`} />
const handleOpenConnectAppModal = () => handleOpen(MODAL_PARAMS_KEYS.CONNECT_APP)
const handleOpenSettingsModal = () => handleOpen(MODAL_PARAMS_KEYS.SETTINGS)
const handleOpenEditNameModal = () => handleOpen(MODAL_PARAMS_KEYS.EDIT_NAME)
return (
<>
@ -55,13 +74,20 @@ const KeyPage = () => {
<UserValueSection
title="Your login"
value={username}
copyValue={username}
endAdornment={
<Box display={'flex'} alignItems={'center'} gap={'0.25rem'}>
<IconButton onClick={handleOpenEditNameModal} color={username ? 'default' : 'error'}>
<MoreHorizRoundedIcon />
</IconButton>
<InputCopyButton value={username} />
</Box>
}
explanationType={EXPLANATION_MODAL_KEYS.LOGIN}
/>
<UserValueSection
title="Your NPUB"
value={npub}
copyValue={npub}
endAdornment={<InputCopyButton value={npub} />}
explanationType={EXPLANATION_MODAL_KEYS.NPUB}
/>
@ -71,7 +97,11 @@ const KeyPage = () => {
Connect app
</StyledIconButton>
<StyledIconButton bgcolor_variant="secondary" onClick={handleOpenSettingsModal} withBadge={!isSynced}>
<StyledIconButton
bgcolor_variant="secondary"
onClick={handleOpenSettingsModal}
withBadge={!isCheckingSync && !isSynced}
>
<SettingsIcon />
Settings
</StyledIconButton>
@ -79,11 +109,13 @@ const KeyPage = () => {
<Apps apps={filteredApps} npub={npub} />
</Stack>
<ModalConnectApp />
<ModalSettings isSynced={isSynced} />
<ModalExplanation />
<ModalConfirmConnect />
<ModalConfirmEvent confirmEventReqs={prepareEventPendings} />
<ModalEditName />
</>
)
}

View File

@ -9,9 +9,12 @@ type ItemAppProps = DbApp
export const ItemApp: FC<ItemAppProps> = ({ npub, appNpub, icon, name, url }) => {
const appDomain = getDomain(url)
const appName = name || appDomain || getShortenNpub(appNpub)
const shortAppNpub = getShortenNpub(appNpub)
const appName = name || appDomain || shortAppNpub
const appIcon = icon || `https://${appDomain}/favicon.ico`
const appAvatarTitle = getAppIconTitle(name || appDomain, appNpub)
const appAvatarTitle = getAppIconTitle(name || appDomain, appNpub)
const isAppNameExists = !!name
return (
<StyledItemAppContainer
direction={'row'}
@ -21,18 +24,18 @@ export const ItemApp: FC<ItemAppProps> = ({ npub, appNpub, icon, name, url }) =>
component={Link}
to={`/key/${npub}/app/${appNpub}`}
>
<Avatar
variant="rounded"
sx={{ width: 56, height: 56 }}
src={appIcon}
alt={appName}
>
<Avatar variant="rounded" sx={{ width: 56, height: 56 }} src={appIcon} alt={appName}>
{appAvatarTitle}
</Avatar>
<Stack>
<Typography noWrap display={'block'} variant="body2">
<Typography noWrap display={'block'} variant="body1">
{appName}
</Typography>
{isAppNameExists && (
<Typography noWrap display={'block'} variant="body2" color={'GrayText'}>
{shortAppNpub}
</Typography>
)}
<Typography noWrap display={'block'} variant="caption" color={'GrayText'}>
Basic actions
</Typography>

View File

@ -3,7 +3,6 @@ import { Box, Stack } from '@mui/material'
import { EXPLANATION_MODAL_KEYS, MODAL_PARAMS_KEYS } from '@/types/modal'
import { SectionTitle } from '@/shared/SectionTitle/SectionTitle'
import { AppLink } from '@/shared/AppLink/AppLink'
import { InputCopyButton } from '@/shared/InputCopyButton/InputCopyButton'
import { StyledInput } from '../styled'
import { useModalSearchParams } from '@/hooks/useModalSearchParams'
@ -11,10 +10,10 @@ type UserValueSectionProps = {
title: string
value: string
explanationType: EXPLANATION_MODAL_KEYS
copyValue: string
endAdornment?: React.ReactNode
}
const UserValueSection: FC<UserValueSectionProps> = ({ title, value, explanationType, copyValue }) => {
const UserValueSection: FC<UserValueSectionProps> = ({ title, value, explanationType, endAdornment }) => {
const { handleOpen } = useModalSearchParams()
const handleOpenExplanationModal = (type: EXPLANATION_MODAL_KEYS) => {
@ -30,7 +29,7 @@ const UserValueSection: FC<UserValueSectionProps> = ({ title, value, explanation
<SectionTitle>{title}</SectionTitle>
<AppLink title="What is this?" onClick={() => handleOpenExplanationModal(explanationType)} />
</Stack>
<StyledInput value={value} readOnly endAdornment={<InputCopyButton value={copyValue} />} />
<StyledInput value={value} readOnly endAdornment={endAdornment} />
</Box>
)
}

View File

@ -1,6 +1,7 @@
import { db } from '@/modules/db'
export const checkNpubSyncQuerier = (npub: string) => async () => {
export const checkNpubSyncQuerier = (npub: string, onResolve: () => void) => async () => {
const count = await db.syncHistory.where('npub').equals(npub).count()
if (!count) onResolve()
return count > 0
}

View File

@ -39,9 +39,9 @@ const StyledButton = styled(
background: theme.palette.primary.main,
},
color: theme.palette.text.secondary,
'&.disabled': {
'&.button.disabled': {
color: theme.palette.text.secondary,
background: `${theme.palette.primary.main}50`,
background: `${theme.palette.primary.main}75`,
cursor: 'not-allowed',
},
}

View File

@ -26,7 +26,12 @@ export const Input = forwardRef<HTMLInputElement, AppInputProps>(
{label}
</FormLabel>
) : null}
<InputBase autoComplete="off" className="input" {...props} classes={{ error: 'error' }} ref={ref} />
<InputBase
autoComplete="off"
{...props}
classes={{ error: 'error', root: 'input_root', input: 'input', disabled: 'disabled' }}
ref={ref}
/>
{helperText ? (
<FormHelperText {...helperTextProps} className="helper_text">
{helperText}
@ -41,20 +46,20 @@ const StyledInputContainer = styled((props: BoxProps) => <Box {...props} />)(({
const isDark = theme.palette.mode === 'dark'
return {
width: '100%',
'& > .input': {
'& > .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',
'& input::placeholder': {
color: '#fff',
},
'&.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,

View File

@ -10,6 +10,7 @@ export enum MODAL_PARAMS_KEYS {
CONFIRM_EVENT = 'confirm-event',
ACTIVITY = 'activity',
APP_DETAILS = 'app-details',
EDIT_NAME = 'edit-name',
}
export enum EXPLANATION_MODAL_KEYS {

View File

@ -1,6 +1,6 @@
import { nip19 } from 'nostr-tools'
import { ACTIONS, ACTION_TYPE, DOMAIN, NIP46_RELAYS } from '../consts'
import { DbPending, DbPerm } from '@/modules/db'
import { ACTIONS, ACTION_TYPE, DOMAIN, NIP46_RELAYS, NOAUTHD_URL } from '../consts'
import { DbHistory, DbPending, DbPerm } from '@/modules/db'
import { MetaEvent } from '@/types/meta-event'
export async function call(cb: () => any) {
@ -97,6 +97,21 @@ export async function fetchNip05(value: string, origin?: string) {
}
}
export async function fetchNpubNames(npub: string) {
try {
const url = `${NOAUTHD_URL}/name?npub=${npub}`
const response = await fetch(url)
const names: {
names: string[]
} = await response.json()
return names.names
} catch (e) {
console.log('Failed to fetch names for', npub, 'error: ' + e)
return []
}
}
export const getDomain = (url: string) => {
try {
return new URL(url).hostname
@ -106,7 +121,7 @@ export const getDomain = (url: string) => {
}
export const getReferrerAppUrl = () => {
console.log('referrer', window.document.referrer)
// console.log('referrer', window.document.referrer)
if (!window.document.referrer) return ''
try {
const u = new URL(window.document.referrer.toLocaleLowerCase())
@ -119,7 +134,7 @@ export const getAppIconTitle = (name: string | undefined, appNpub: string) => {
return name ? name[0].toLocaleUpperCase() : appNpub.substring(4, 7)
}
export function getReqActionName(req: DbPending) {
export function getReqActionName(req: DbPending | DbHistory) {
const action = ACTIONS[req.method]
if (req.method === 'sign_event') {
const kind = getSignReqKind(req)

View File

@ -13,6 +13,7 @@
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"downlevelIteration": true,
"noEmit": true,
"jsx": "react-jsx",
"baseUrl": ".",