feat: outbox model for the following feed
This commit is contained in:
@@ -68,7 +68,9 @@ function SignerTypeBadge({ signerType }: { signerType: TSignerType }) {
|
||||
return <Badge className=" bg-blue-400 hover:bg-blue-400/80">Bunker</Badge>
|
||||
} else if (signerType === 'ncryptsec') {
|
||||
return <Badge>NCRYPTSEC</Badge>
|
||||
} else {
|
||||
} else if (signerType === 'nsec') {
|
||||
return <Badge className=" bg-orange-400 hover:bg-orange-400/80">NSEC</Badge>
|
||||
} else if (signerType === 'npub') {
|
||||
return <Badge className=" bg-yellow-400 hover:bg-yellow-400/80">NPUB</Badge>
|
||||
}
|
||||
}
|
||||
|
||||
56
src/components/AccountManager/NpubLogin.tsx
Normal file
56
src/components/AccountManager/NpubLogin.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { Loader } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function NpubLogin({
|
||||
back,
|
||||
onLoginSuccess
|
||||
}: {
|
||||
back: () => void
|
||||
onLoginSuccess: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { npubLogin } = useNostr()
|
||||
const [pending, setPending] = useState(false)
|
||||
const [npubInput, setNpubInput] = useState('')
|
||||
const [errMsg, setErrMsg] = useState<string | null>(null)
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setNpubInput(e.target.value)
|
||||
setErrMsg(null)
|
||||
}
|
||||
|
||||
const handleLogin = () => {
|
||||
if (npubInput === '') return
|
||||
|
||||
setPending(true)
|
||||
npubLogin(npubInput)
|
||||
.then(() => onLoginSuccess())
|
||||
.catch((err) => setErrMsg(err.message))
|
||||
.finally(() => setPending(false))
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
placeholder="npub..."
|
||||
value={npubInput}
|
||||
onChange={handleInputChange}
|
||||
className={errMsg ? 'border-destructive' : ''}
|
||||
/>
|
||||
{errMsg && <div className="text-xs text-destructive pl-3">{errMsg}</div>}
|
||||
</div>
|
||||
<Button onClick={handleLogin} disabled={pending}>
|
||||
<Loader className={pending ? 'animate-spin' : 'hidden'} />
|
||||
{t('Login')}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={back}>
|
||||
{t('Back')}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -8,9 +8,11 @@ import { useTranslation } from 'react-i18next'
|
||||
import AccountList from '../AccountList'
|
||||
import BunkerLogin from './BunkerLogin'
|
||||
import GenerateNewAccount from './GenerateNewAccount'
|
||||
import NpubLogin from './NpubLogin'
|
||||
import PrivateKeyLogin from './PrivateKeyLogin'
|
||||
import { isDevEnv } from '@/lib/common'
|
||||
|
||||
type TAccountManagerPage = 'nsec' | 'bunker' | 'generate' | null
|
||||
type TAccountManagerPage = 'nsec' | 'bunker' | 'generate' | 'npub' | null
|
||||
|
||||
export default function AccountManager({ close }: { close?: () => void }) {
|
||||
const [page, setPage] = useState<TAccountManagerPage>(null)
|
||||
@@ -23,6 +25,8 @@ export default function AccountManager({ close }: { close?: () => void }) {
|
||||
<BunkerLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} />
|
||||
) : page === 'generate' ? (
|
||||
<GenerateNewAccount back={() => setPage(null)} onLoginSuccess={() => close?.()} />
|
||||
) : page === 'npub' ? (
|
||||
<NpubLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} />
|
||||
) : (
|
||||
<AccountManagerNav setPage={setPage} close={close} />
|
||||
)}
|
||||
@@ -59,6 +63,11 @@ function AccountManagerNav({
|
||||
<Button variant="secondary" onClick={() => setPage('nsec')} className="w-full">
|
||||
{t('Login with Private Key')}
|
||||
</Button>
|
||||
{isDevEnv() && (
|
||||
<Button variant="secondary" onClick={() => setPage('npub')} className="w-full">
|
||||
Login with Public key (for development)
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
@@ -1,247 +0,0 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger
|
||||
} from '@/components/ui/drawer'
|
||||
import { toProfile } from '@/lib/link'
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import client from '@/services/client.service'
|
||||
import { TMailboxRelay } from '@/types'
|
||||
import { ChevronDown, Circle, CircleCheck, ScanSearch } from 'lucide-react'
|
||||
import { Dispatch, SetStateAction, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import RelayIcon from '../RelayIcon'
|
||||
import { SimpleUserAvatar } from '../UserAvatar'
|
||||
import { SimpleUsername } from '../Username'
|
||||
|
||||
export default function CalculateOptimalReadRelaysButton({
|
||||
mergeRelays
|
||||
}: {
|
||||
mergeRelays: (newRelays: TMailboxRelay[]) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const { pubkey } = useNostr()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const trigger = (
|
||||
<Button variant="secondary" className="w-full" disabled={!pubkey}>
|
||||
<ScanSearch />
|
||||
{t('Calculate optimal read relays')}
|
||||
</Button>
|
||||
)
|
||||
|
||||
if (isSmallScreen) {
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
|
||||
<DrawerContent className="max-h-[90vh]">
|
||||
<div className="flex flex-col p-4 gap-4 overflow-auto">
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>{t('Select relays to append')}</DrawerTitle>
|
||||
<DrawerDescription className="hidden" />
|
||||
</DrawerHeader>
|
||||
<OptimalReadRelays close={() => setOpen(false)} mergeRelays={mergeRelays} />
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||
<DialogContent className="max-h-[90vh] overflow-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('Select relays to append')}</DialogTitle>
|
||||
<DialogDescription className="hidden" />
|
||||
</DialogHeader>
|
||||
<OptimalReadRelays close={() => setOpen(false)} mergeRelays={mergeRelays} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function OptimalReadRelays({
|
||||
close,
|
||||
mergeRelays
|
||||
}: {
|
||||
close: () => void
|
||||
mergeRelays: (newRelays: TMailboxRelay[]) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { pubkey } = useNostr()
|
||||
const [isCalculating, setIsCalculating] = useState(false)
|
||||
const [optimalReadRelays, setOptimalReadRelays] = useState<{ url: string; pubkeys: string[] }[]>(
|
||||
[]
|
||||
)
|
||||
const [selectedRelayUrls, setSelectedRelayUrls] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (!pubkey) return
|
||||
|
||||
const init = async () => {
|
||||
setIsCalculating(true)
|
||||
const relays = await client.calculateOptimalReadRelays(pubkey)
|
||||
setOptimalReadRelays(relays)
|
||||
setIsCalculating(false)
|
||||
}
|
||||
init()
|
||||
}, [])
|
||||
|
||||
if (isCalculating) {
|
||||
return <div className="text-center text-sm text-muted-foreground">{t('calculating...')}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
{optimalReadRelays.map((relay) => (
|
||||
<RelayItem
|
||||
key={relay.url}
|
||||
relay={relay}
|
||||
close={close}
|
||||
selectedRelayUrls={selectedRelayUrls}
|
||||
setSelectedRelayUrls={setSelectedRelayUrls}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
disabled={selectedRelayUrls.length === 0}
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
mergeRelays(
|
||||
selectedRelayUrls.map((url) => ({
|
||||
url,
|
||||
scope: 'read'
|
||||
}))
|
||||
)
|
||||
close()
|
||||
}}
|
||||
>
|
||||
{selectedRelayUrls.length === 0
|
||||
? t('Append')
|
||||
: t('Append n relays', { n: selectedRelayUrls.length })}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RelayItem({
|
||||
relay,
|
||||
close,
|
||||
selectedRelayUrls,
|
||||
setSelectedRelayUrls
|
||||
}: {
|
||||
relay: { url: string; pubkeys: string[] }
|
||||
close: () => void
|
||||
selectedRelayUrls: string[]
|
||||
setSelectedRelayUrls: Dispatch<SetStateAction<string[]>>
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { push } = useSecondaryPage()
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
const selected = selectedRelayUrls.includes(relay.url)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-lg p-4 border select-none cursor-pointer ${selected ? 'border-highlight bg-highlight/5' : ''}`}
|
||||
onClick={() =>
|
||||
setSelectedRelayUrls((pre) =>
|
||||
pre.includes(relay.url) ? pre.filter((url) => url !== relay.url) : [...pre, relay.url]
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 flex-1 w-0">
|
||||
<SelectToggle
|
||||
select={selectedRelayUrls.includes(relay.url)}
|
||||
setSelect={(select) => {
|
||||
setSelectedRelayUrls((prev) =>
|
||||
select ? [...prev, relay.url] : prev.filter((url) => url !== relay.url)
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<RelayIcon url={relay.url} className="h-8 w-8" />
|
||||
<div className="font-semibold truncate text-lg">{relay.url}</div>
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center cursor-pointer gap-1 text-muted-foreground hover:text-foreground text-sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setExpanded((prev) => !prev)
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
{relay.pubkeys.length} {t('followings')}
|
||||
</div>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className={`transition-transform duration-200 ${expanded ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className="space-y-2 pt-2 pl-7">
|
||||
{relay.pubkeys.map((pubkey) => (
|
||||
<div
|
||||
key={pubkey}
|
||||
className="flex cursor-pointer items-center gap-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
close()
|
||||
push(toProfile(pubkey))
|
||||
}}
|
||||
>
|
||||
<SimpleUserAvatar userId={pubkey} size="small" />
|
||||
<SimpleUsername userId={pubkey} className="font-semibold truncate" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectToggle({
|
||||
select,
|
||||
setSelect
|
||||
}: {
|
||||
select: boolean
|
||||
setSelect: (select: boolean) => void
|
||||
}) {
|
||||
return select ? (
|
||||
<CircleCheck
|
||||
size={18}
|
||||
className="text-highlight shrink-0 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setSelect(false)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Circle
|
||||
size={18}
|
||||
className="shrink-0 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setSelect(true)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { useNostr } from '@/providers/NostrProvider'
|
||||
import { TMailboxRelay, TMailboxRelayScope } from '@/types'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import CalculateOptimalReadRelaysButton from './CalculateOptimalReadRelaysButton'
|
||||
import MailboxRelay from './MailboxRelay'
|
||||
import NewMailboxRelayInput from './NewMailboxRelayInput'
|
||||
import SaveButton from './SaveButton'
|
||||
@@ -56,13 +55,6 @@ export default function MailboxSetting() {
|
||||
return null
|
||||
}
|
||||
|
||||
const mergeRelays = (newRelays: TMailboxRelay[]) => {
|
||||
setRelays((pre) => {
|
||||
return [...pre, ...newRelays.filter((r) => !pre.some((pr) => pr.url === r.url))]
|
||||
})
|
||||
setHasChange(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
@@ -70,7 +62,6 @@ export default function MailboxSetting() {
|
||||
<div>{t('write relays description')}</div>
|
||||
<div>{t('read & write relays notice')}</div>
|
||||
</div>
|
||||
<CalculateOptimalReadRelaysButton mergeRelays={mergeRelays} />
|
||||
<SaveButton mailboxRelays={relays} hasChange={hasChange} setHasChange={setHasChange} />
|
||||
<div className="space-y-2">
|
||||
{relays.map((relay) => (
|
||||
|
||||
@@ -25,13 +25,13 @@ const ALGO_LIMIT = 500
|
||||
const SHOW_COUNT = 10
|
||||
|
||||
export default function NoteList({
|
||||
relayUrls,
|
||||
relayUrls = [],
|
||||
filter = {},
|
||||
className,
|
||||
filterMutedNotes = true,
|
||||
needCheckAlgoRelay = false
|
||||
}: {
|
||||
relayUrls: string[]
|
||||
relayUrls?: string[]
|
||||
filter?: Filter
|
||||
className?: string
|
||||
filterMutedNotes?: boolean
|
||||
@@ -60,7 +60,7 @@ export default function NoteList({
|
||||
const topRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (relayUrls.length === 0) return
|
||||
if (relayUrls.length === 0 && !noteFilter.authors?.length) return
|
||||
|
||||
async function init() {
|
||||
setRefreshing(true)
|
||||
|
||||
@@ -62,7 +62,9 @@ export default function SeenOnButton({ event }: { event: Event }) {
|
||||
key={relay}
|
||||
onClick={() => {
|
||||
setIsDrawerOpen(false)
|
||||
push(toRelay(relay))
|
||||
setTimeout(() => {
|
||||
push(toRelay(relay))
|
||||
}, 50) // Timeout to allow the drawer to close before navigating
|
||||
}}
|
||||
>
|
||||
<RelayIcon url={relay} /> {simplifyUrl(relay)}
|
||||
|
||||
@@ -1,14 +1,29 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerOverlay,
|
||||
DrawerTitle
|
||||
} from '@/components/ui/drawer'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useToast } from '@/hooks'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useNoteStats } from '@/providers/NoteStatsProvider'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import { useZap } from '@/providers/ZapProvider'
|
||||
import lightning from '@/services/lightning.service'
|
||||
import { Loader } from 'lucide-react'
|
||||
import { Dispatch, SetStateAction, useState } from 'react'
|
||||
import { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import UserAvatar from '../UserAvatar'
|
||||
import Username from '../Username'
|
||||
@@ -27,10 +42,62 @@ export default function ZapDialog({
|
||||
defaultAmount?: number
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const drawerContentRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (drawerContentRef.current) {
|
||||
drawerContentRef.current.style.setProperty('bottom', `env(safe-area-inset-bottom)`)
|
||||
}
|
||||
}
|
||||
|
||||
if (window.visualViewport) {
|
||||
window.visualViewport.addEventListener('resize', handleResize)
|
||||
handleResize() // Initial call in case the keyboard is already open
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (window.visualViewport) {
|
||||
window.visualViewport.removeEventListener('resize', handleResize)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (isSmallScreen) {
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<DrawerOverlay onClick={() => setOpen(false)} />
|
||||
<DrawerContent
|
||||
hideOverlay
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
ref={drawerContentRef}
|
||||
className="flex flex-col gap-4 px-4 mb-4"
|
||||
>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle className="flex gap-2 items-center">
|
||||
<div className="shrink-0">{t('Zap to')}</div>
|
||||
<UserAvatar size="small" userId={pubkey} />
|
||||
<Username userId={pubkey} className="truncate flex-1 w-0 text-start h-5" />
|
||||
</DrawerTitle>
|
||||
<DialogDescription></DialogDescription>
|
||||
</DrawerHeader>
|
||||
<ZapDialogContent
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
recipient={pubkey}
|
||||
eventId={eventId}
|
||||
defaultAmount={defaultAmount}
|
||||
/>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
<DialogOverlay onClick={() => setOpen(false)} />
|
||||
<DialogContent hideOverlay onOpenAutoFocus={(e) => e.preventDefault()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex gap-2 items-center">
|
||||
<div className="shrink-0">{t('Zap to')}</div>
|
||||
|
||||
@@ -29,10 +29,13 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { withoutClose?: boolean }
|
||||
>(({ className, children, withoutClose, ...props }, ref) => (
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||
withoutClose?: boolean
|
||||
hideOverlay?: boolean
|
||||
}
|
||||
>(({ className, children, withoutClose, hideOverlay, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
{!hideOverlay && <DialogOverlay />}
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
|
||||
@@ -5,3 +5,7 @@ export function isTouchDevice() {
|
||||
export function isEmail(email: string) {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
|
||||
}
|
||||
|
||||
export function isDevEnv() {
|
||||
return process.env.NODE_ENV === 'development'
|
||||
}
|
||||
|
||||
@@ -4,3 +4,10 @@ import { twMerge } from 'tailwind-merge'
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export function isSafari() {
|
||||
if (typeof window === 'undefined' || !window.navigator) return false
|
||||
const ua = window.navigator.userAgent
|
||||
const vendor = window.navigator.vendor
|
||||
return /Safari/.test(ua) && /Apple Computer/.test(vendor) && !/Chrome/.test(ua)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import NoteList from '@/components/NoteList'
|
||||
import { SEARCHABLE_RELAY_URLS } from '@/constants'
|
||||
import { useFetchRelayInfos } from '@/hooks'
|
||||
import { BIG_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants'
|
||||
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
|
||||
import { useFeed } from '@/providers/FeedProvider'
|
||||
import { Filter } from 'nostr-tools'
|
||||
import { forwardRef, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const NoteListPage = forwardRef(({ index }: { index?: number }, ref) => {
|
||||
const { t } = useTranslation()
|
||||
const { relayUrls } = useFeed()
|
||||
const { searchableRelayUrls } = useFetchRelayInfos(relayUrls)
|
||||
const {
|
||||
title = '',
|
||||
filter,
|
||||
@@ -26,7 +22,7 @@ const NoteListPage = forwardRef(({ index }: { index?: number }, ref) => {
|
||||
return {
|
||||
title: `# ${hashtag}`,
|
||||
filter: { '#t': [hashtag] },
|
||||
urls: relayUrls,
|
||||
urls: BIG_RELAY_URLS,
|
||||
type: 'hashtag'
|
||||
}
|
||||
}
|
||||
@@ -35,12 +31,12 @@ const NoteListPage = forwardRef(({ index }: { index?: number }, ref) => {
|
||||
return {
|
||||
title: `${t('Search')}: ${search}`,
|
||||
filter: { search },
|
||||
urls: searchableRelayUrls.concat(SEARCHABLE_RELAY_URLS).slice(0, 4),
|
||||
urls: SEARCHABLE_RELAY_URLS,
|
||||
type: 'search'
|
||||
}
|
||||
}
|
||||
return { urls: relayUrls }
|
||||
}, [JSON.stringify(relayUrls)])
|
||||
return { urls: BIG_RELAY_URLS }
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<SecondaryPageLayout ref={ref} index={index} title={title} displayScrollToTopButton>
|
||||
|
||||
@@ -11,12 +11,10 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useFetchFollowings, useFetchProfile } from '@/hooks'
|
||||
import { useFetchRelayList } from '@/hooks/useFetchRelayList'
|
||||
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
|
||||
import { toMuteList, toProfileEditor } from '@/lib/link'
|
||||
import { generateImageByPubkey } from '@/lib/pubkey'
|
||||
import { SecondaryPageLink, useSecondaryPage } from '@/PageManager'
|
||||
import { useFeed } from '@/providers/FeedProvider'
|
||||
import { useMuteList } from '@/providers/MuteListProvider'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { Link, Zap } from 'lucide-react'
|
||||
@@ -30,15 +28,6 @@ const ProfilePage = forwardRef(({ id, index }: { id?: string; index?: number },
|
||||
const { t } = useTranslation()
|
||||
const { push } = useSecondaryPage()
|
||||
const { profile, isFetching } = useFetchProfile(id)
|
||||
const { relayList, isFetching: isFetchingRelayInfo } = useFetchRelayList(profile?.pubkey)
|
||||
const { relayUrls: currentRelayUrls } = useFeed()
|
||||
const relayUrls = useMemo(
|
||||
() =>
|
||||
relayList.write.length < 4
|
||||
? relayList.write.concat(currentRelayUrls).slice(0, 4)
|
||||
: relayList.write.slice(0, 8),
|
||||
[relayList, currentRelayUrls]
|
||||
)
|
||||
const { pubkey: accountPubkey } = useNostr()
|
||||
const { mutePubkeys } = useMuteList()
|
||||
const { followings } = useFetchFollowings(profile?.pubkey)
|
||||
@@ -152,14 +141,7 @@ const ProfilePage = forwardRef(({ id, index }: { id?: string; index?: number },
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!isFetchingRelayInfo && (
|
||||
<NoteList
|
||||
filter={{ authors: [pubkey] }}
|
||||
relayUrls={relayUrls}
|
||||
className="mt-2"
|
||||
filterMutedNotes={false}
|
||||
/>
|
||||
)}
|
||||
<NoteList filter={{ authors: [pubkey] }} className="mt-2" filterMutedNotes={false} />
|
||||
</SecondaryPageLayout>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { BIG_RELAY_URLS } from '@/constants'
|
||||
import { checkAlgoRelay } from '@/lib/relay'
|
||||
import { isWebsocketUrl, normalizeUrl } from '@/lib/url'
|
||||
import client from '@/services/client.service'
|
||||
@@ -117,11 +116,8 @@ export function FeedProvider({ children }: { children: React.ReactNode }) {
|
||||
feedTypeRef.current = feedType
|
||||
setFeedType(feedType)
|
||||
setActiveRelaySetId(null)
|
||||
const [relayList, followings] = await Promise.all([
|
||||
client.fetchRelayList(options.pubkey),
|
||||
client.fetchFollowings(options.pubkey, true)
|
||||
])
|
||||
setRelayUrls(relayList.read.concat(BIG_RELAY_URLS).slice(0, 4))
|
||||
const followings = await client.fetchFollowings(options.pubkey, true)
|
||||
setRelayUrls([])
|
||||
setFilter({
|
||||
authors: followings.includes(options.pubkey) ? followings : [...followings, options.pubkey]
|
||||
})
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { BunkerSigner } from './bunker.signer'
|
||||
import { Nip07Signer } from './nip-07.signer'
|
||||
import { NsecSigner } from './nsec.signer'
|
||||
import { NpubSigner } from './npub.signer'
|
||||
|
||||
type TNostrContext = {
|
||||
pubkey: string | null
|
||||
@@ -38,6 +39,7 @@ type TNostrContext = {
|
||||
ncryptsecLogin: (ncryptsec: string) => Promise<string>
|
||||
nip07Login: () => Promise<string>
|
||||
bunkerLogin: (bunker: string) => Promise<string>
|
||||
npubLogin(npub: string): Promise<string>
|
||||
removeAccount: (account: TAccountPointer) => void
|
||||
/**
|
||||
* Default publish the event to current relays, user's write relays and additional relays
|
||||
@@ -204,7 +206,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
useEffect(() => {
|
||||
if (signer) {
|
||||
client.signer = signer.signEvent.bind(signer)
|
||||
client.signer = signer
|
||||
} else {
|
||||
client.signer = undefined
|
||||
}
|
||||
@@ -255,7 +257,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
const nsecLogin = async (nsecOrHex: string, password?: string) => {
|
||||
const browserNsecSigner = new NsecSigner()
|
||||
const nsecSigner = new NsecSigner()
|
||||
let privkey: Uint8Array
|
||||
if (nsecOrHex.startsWith('nsec')) {
|
||||
const { type, data } = nip19.decode(nsecOrHex)
|
||||
@@ -268,12 +270,12 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
||||
} else {
|
||||
throw new Error('invalid nsec or hex')
|
||||
}
|
||||
const pubkey = browserNsecSigner.login(privkey)
|
||||
const pubkey = nsecSigner.login(privkey)
|
||||
if (password) {
|
||||
const ncryptsec = nip49.encrypt(privkey, password)
|
||||
return login(browserNsecSigner, { pubkey, signerType: 'ncryptsec', ncryptsec })
|
||||
return login(nsecSigner, { pubkey, signerType: 'ncryptsec', ncryptsec })
|
||||
}
|
||||
return login(browserNsecSigner, { pubkey, signerType: 'nsec', nsec: nip19.nsecEncode(privkey) })
|
||||
return login(nsecSigner, { pubkey, signerType: 'nsec', nsec: nip19.nsecEncode(privkey) })
|
||||
}
|
||||
|
||||
const ncryptsecLogin = async (ncryptsec: string) => {
|
||||
@@ -287,6 +289,12 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
||||
return login(browserNsecSigner, { pubkey, signerType: 'ncryptsec', ncryptsec })
|
||||
}
|
||||
|
||||
const npubLogin = async (npub: string) => {
|
||||
const npubSigner = new NpubSigner()
|
||||
const pubkey = npubSigner.login(npub)
|
||||
return login(npubSigner, { pubkey, signerType: 'npub', npub })
|
||||
}
|
||||
|
||||
const nip07Login = async () => {
|
||||
try {
|
||||
const nip07Signer = new Nip07Signer()
|
||||
@@ -369,6 +377,19 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
return login(bunkerSigner, account)
|
||||
}
|
||||
} else if (account.signerType === 'npub' && account.npub) {
|
||||
const npubSigner = new NpubSigner()
|
||||
const pubkey = npubSigner.login(account.npub)
|
||||
if (!pubkey) {
|
||||
storage.removeAccount(account)
|
||||
return null
|
||||
}
|
||||
if (pubkey !== account.pubkey) {
|
||||
storage.removeAccount(account)
|
||||
account = { ...account, pubkey }
|
||||
storage.addAccount(account)
|
||||
}
|
||||
return login(npubSigner, account)
|
||||
}
|
||||
storage.removeAccount(account)
|
||||
return null
|
||||
@@ -510,6 +531,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
||||
ncryptsecLogin,
|
||||
nip07Login,
|
||||
bunkerLogin,
|
||||
npubLogin,
|
||||
removeAccount,
|
||||
publish,
|
||||
signHttpAuth,
|
||||
|
||||
34
src/providers/NostrProvider/npub.signer.ts
Normal file
34
src/providers/NostrProvider/npub.signer.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { ISigner } from '@/types'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
|
||||
export class NpubSigner implements ISigner {
|
||||
private pubkey: string | null = null
|
||||
|
||||
login(npub: string) {
|
||||
const { type, data } = nip19.decode(npub)
|
||||
if (type !== 'npub') {
|
||||
throw new Error('invalid nsec')
|
||||
}
|
||||
this.pubkey = data
|
||||
return this.pubkey
|
||||
}
|
||||
|
||||
async getPublicKey() {
|
||||
if (!this.pubkey) {
|
||||
throw new Error('Not logged in')
|
||||
}
|
||||
return this.pubkey
|
||||
}
|
||||
|
||||
async signEvent(): Promise<any> {
|
||||
throw new Error('Not logged in')
|
||||
}
|
||||
|
||||
async nip04Encrypt(): Promise<any> {
|
||||
throw new Error('Not logged in')
|
||||
}
|
||||
|
||||
async nip04Decrypt(): Promise<any> {
|
||||
throw new Error('Not logged in')
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,8 @@ import { getProfileFromProfileEvent, getRelayListFromRelayListEvent } from '@/li
|
||||
import { formatPubkey, userIdToPubkey } from '@/lib/pubkey'
|
||||
import { extractPubkeysFromEventTags } from '@/lib/tag'
|
||||
import { isLocalNetworkUrl } from '@/lib/url'
|
||||
import { TDraftEvent, TProfile, TRelayList } from '@/types'
|
||||
import { isSafari } from '@/lib/utils'
|
||||
import { ISigner, TProfile, TRelayList } from '@/types'
|
||||
import { sha256 } from '@noble/hashes/sha2'
|
||||
import DataLoader from 'dataloader'
|
||||
import FlexSearch from 'flexsearch'
|
||||
@@ -17,7 +18,7 @@ import {
|
||||
SimplePool,
|
||||
VerifiedEvent
|
||||
} from 'nostr-tools'
|
||||
import { AbstractRelay, Subscription } from 'nostr-tools/abstract-relay'
|
||||
import { AbstractRelay } from 'nostr-tools/abstract-relay'
|
||||
import indexedDb from './indexed-db.service'
|
||||
|
||||
type TTimelineRef = [string, number]
|
||||
@@ -25,7 +26,7 @@ type TTimelineRef = [string, number]
|
||||
class ClientService extends EventTarget {
|
||||
static instance: ClientService
|
||||
|
||||
signer?: (evt: TDraftEvent) => Promise<VerifiedEvent>
|
||||
signer?: ISigner
|
||||
private currentRelayUrls: string[] = []
|
||||
private pool: SimplePool
|
||||
|
||||
@@ -36,6 +37,7 @@ class ClientService extends EventTarget {
|
||||
filter: Omit<Filter, 'since' | 'until'> & { limit: number }
|
||||
urls: string[]
|
||||
}
|
||||
| string[]
|
||||
| undefined
|
||||
> = {}
|
||||
private eventCache = new LRUCache<string, Promise<NEvent | undefined>>({ max: 10000 })
|
||||
@@ -45,22 +47,20 @@ class ClientService extends EventTarget {
|
||||
)
|
||||
private fetchEventFromBigRelaysDataloader = new DataLoader<string, NEvent | undefined>(
|
||||
this.fetchEventsFromBigRelays.bind(this),
|
||||
{ cache: false, batchScheduleFn: (callback) => setTimeout(callback, 200) }
|
||||
{ cache: false, batchScheduleFn: (callback) => setTimeout(callback, 50) }
|
||||
)
|
||||
private fetchProfileEventFromBigRelaysDataloader = new DataLoader<string, NEvent | undefined>(
|
||||
this.profileEventBatchLoadFn.bind(this),
|
||||
{
|
||||
batchScheduleFn: (callback) => setTimeout(callback, 200),
|
||||
cacheMap: new LRUCache<string, Promise<NEvent | undefined>>({ max: 1000 }),
|
||||
maxBatchSize: 20
|
||||
batchScheduleFn: (callback) => setTimeout(callback, 50),
|
||||
maxBatchSize: 500
|
||||
}
|
||||
)
|
||||
private relayListEventDataLoader = new DataLoader<string, NEvent | undefined>(
|
||||
this.relayListEventBatchLoadFn.bind(this),
|
||||
{
|
||||
batchScheduleFn: (callback) => setTimeout(callback, 200),
|
||||
cacheMap: new LRUCache<string, Promise<NEvent | undefined>>({ max: 1000 }),
|
||||
maxBatchSize: 20
|
||||
batchScheduleFn: (callback) => setTimeout(callback, 50),
|
||||
maxBatchSize: 500
|
||||
}
|
||||
)
|
||||
private followListCache = new LRUCache<string, Promise<NEvent | undefined>>({
|
||||
@@ -122,7 +122,7 @@ class ClientService extends EventTarget {
|
||||
!!that.signer
|
||||
) {
|
||||
relay
|
||||
.auth((authEvt: EventTemplate) => that.signer!(authEvt))
|
||||
.auth((authEvt: EventTemplate) => that.signer!.signEvent(authEvt))
|
||||
.then(() => relay.publish(event))
|
||||
} else {
|
||||
throw error
|
||||
@@ -135,7 +135,19 @@ class ClientService extends EventTarget {
|
||||
}
|
||||
|
||||
private generateTimelineKey(urls: string[], filter: Filter) {
|
||||
const paramsStr = JSON.stringify({ urls: urls.sort(), filter })
|
||||
const stableFilter: any = {}
|
||||
Object.entries(filter)
|
||||
.sort()
|
||||
.forEach(([key, value]) => {
|
||||
if (Array.isArray(value)) {
|
||||
stableFilter[key] = [...value].sort()
|
||||
}
|
||||
stableFilter[key] = value
|
||||
})
|
||||
const paramsStr = JSON.stringify({
|
||||
urls: [...urls].sort(),
|
||||
filter: stableFilter
|
||||
})
|
||||
const encoder = new TextEncoder()
|
||||
const data = encoder.encode(paramsStr)
|
||||
const hashBuffer = sha256(data)
|
||||
@@ -145,7 +157,7 @@ class ClientService extends EventTarget {
|
||||
|
||||
async subscribeTimeline(
|
||||
urls: string[],
|
||||
filter: Omit<Filter, 'since' | 'until'> & { limit: number }, // filter with limit,
|
||||
{ authors, ...filter }: Omit<Filter, 'since' | 'until'> & { limit: number },
|
||||
{
|
||||
onEvents,
|
||||
onNew
|
||||
@@ -161,124 +173,138 @@ class ClientService extends EventTarget {
|
||||
needSort?: boolean
|
||||
} = {}
|
||||
) {
|
||||
const relays = Array.from(new Set(urls))
|
||||
const key = this.generateTimelineKey(relays, filter)
|
||||
const timeline = this.timelines[key]
|
||||
let cachedEvents: NEvent[] = []
|
||||
let since: number | undefined
|
||||
if (timeline && timeline.refs.length && needSort) {
|
||||
cachedEvents = (
|
||||
await Promise.all(
|
||||
timeline.refs.slice(0, filter.limit).map(([id]) => this.eventCache.get(id))
|
||||
)
|
||||
).filter(Boolean) as NEvent[]
|
||||
if (cachedEvents.length) {
|
||||
onEvents([...cachedEvents], false)
|
||||
since = cachedEvents[0].created_at + 1
|
||||
if (urls.length || !authors?.length) {
|
||||
return this._subscribeTimeline(
|
||||
urls.length ? urls : BIG_RELAY_URLS,
|
||||
filter,
|
||||
{ onEvents, onNew },
|
||||
{ startLogin, needSort }
|
||||
)
|
||||
}
|
||||
|
||||
const subRequests: { urls: string[]; authors: string[] }[] = []
|
||||
// If many websocket connections are initiated simultaneously, it will be
|
||||
// very slow on Safari (for unknown reason)
|
||||
if (authors.length > 5 && isSafari()) {
|
||||
const pubkey = await this.signer?.getPublicKey()
|
||||
if (!pubkey) {
|
||||
subRequests.push({ urls: BIG_RELAY_URLS, authors })
|
||||
} else {
|
||||
const relayList = await this.fetchRelayList(pubkey)
|
||||
const urls = relayList.read.concat(BIG_RELAY_URLS).slice(0, 5)
|
||||
subRequests.push({ urls, authors })
|
||||
}
|
||||
}
|
||||
|
||||
if (!timeline && needSort) {
|
||||
this.timelines[key] = { refs: [], filter, urls: relays }
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
||||
const that = this
|
||||
let events: NEvent[] = []
|
||||
let eosed = false
|
||||
const subCloser = this.subscribe(relays, since ? { ...filter, since } : filter, {
|
||||
startLogin,
|
||||
onevent: (evt: NEvent) => {
|
||||
that.eventDataLoader.prime(evt.id, Promise.resolve(evt))
|
||||
// not eosed yet, push to events
|
||||
if (!eosed) {
|
||||
return events.push(evt)
|
||||
}
|
||||
// eosed, (algo relay feeds) no need to sort and cache
|
||||
if (!needSort) {
|
||||
return onNew(evt)
|
||||
}
|
||||
|
||||
const timeline = that.timelines[key]
|
||||
if (!timeline || !timeline.refs.length) {
|
||||
return onNew(evt)
|
||||
}
|
||||
// the event is newer than the first ref, insert it to the front
|
||||
if (evt.created_at > timeline.refs[0][1]) {
|
||||
onNew(evt)
|
||||
return timeline.refs.unshift([evt.id, evt.created_at])
|
||||
}
|
||||
|
||||
let idx = 0
|
||||
for (const ref of timeline.refs) {
|
||||
if (evt.created_at > ref[1] || (evt.created_at === ref[1] && evt.id < ref[0])) {
|
||||
break
|
||||
} else {
|
||||
const relayLists = await this.fetchRelayLists(authors)
|
||||
const group: Record<string, Set<string>> = {}
|
||||
relayLists.forEach((relayList, index) => {
|
||||
relayList.write.slice(0, 4).forEach((url) => {
|
||||
if (!group[url]) {
|
||||
group[url] = new Set()
|
||||
}
|
||||
// the event is already in the cache
|
||||
if (evt.created_at === ref[1] && evt.id === ref[0]) {
|
||||
return
|
||||
}
|
||||
idx++
|
||||
}
|
||||
// the event is too old, ignore it
|
||||
if (idx >= timeline.refs.length) return
|
||||
|
||||
// insert the event to the right position
|
||||
timeline.refs.splice(idx, 0, [evt.id, evt.created_at])
|
||||
},
|
||||
oneose: (_eosed) => {
|
||||
eosed = _eosed
|
||||
// (algo feeds) no need to sort and cache
|
||||
if (!needSort) {
|
||||
return onEvents([...events], eosed)
|
||||
}
|
||||
if (!eosed) {
|
||||
events = events.sort((a, b) => b.created_at - a.created_at).slice(0, filter.limit)
|
||||
return onEvents([...events.concat(cachedEvents)], false)
|
||||
}
|
||||
|
||||
events = events.sort((a, b) => b.created_at - a.created_at).slice(0, filter.limit)
|
||||
const timeline = that.timelines[key]
|
||||
// no cache yet
|
||||
if (!timeline || !timeline.refs.length) {
|
||||
that.timelines[key] = {
|
||||
refs: events.map((evt) => [evt.id, evt.created_at]),
|
||||
filter,
|
||||
urls
|
||||
}
|
||||
return onEvents([...events], true)
|
||||
}
|
||||
|
||||
const newEvents = events.filter((evt) => {
|
||||
const firstRef = timeline.refs[0]
|
||||
return (
|
||||
evt.created_at > firstRef[1] || (evt.created_at === firstRef[1] && evt.id < firstRef[0])
|
||||
)
|
||||
group[url].add(authors[index])
|
||||
})
|
||||
const newRefs = newEvents.map((evt) => [evt.id, evt.created_at] as TTimelineRef)
|
||||
})
|
||||
|
||||
if (newRefs.length >= filter.limit) {
|
||||
// if new refs are more than limit, means old refs are too old, replace them
|
||||
timeline.refs = newRefs
|
||||
onEvents([...newEvents], true)
|
||||
} else {
|
||||
// merge new refs with old refs
|
||||
timeline.refs = newRefs.concat(timeline.refs)
|
||||
onEvents([...newEvents.concat(cachedEvents)], true)
|
||||
}
|
||||
}
|
||||
})
|
||||
const relayCount = Object.keys(group).length
|
||||
const coveredAuthorSet = new Set<string>()
|
||||
Object.entries(group)
|
||||
.sort(([, a], [, b]) => b.size - a.size)
|
||||
.forEach(([url, pubkeys]) => {
|
||||
if (
|
||||
relayCount > 10 &&
|
||||
pubkeys.size < 10 &&
|
||||
Array.from(pubkeys).every((pubkey) => coveredAuthorSet.has(pubkey))
|
||||
) {
|
||||
delete group[url]
|
||||
}
|
||||
pubkeys.forEach((pubkey) => {
|
||||
coveredAuthorSet.add(pubkey)
|
||||
})
|
||||
})
|
||||
|
||||
subRequests.push(
|
||||
...Object.entries(group).map(([url, authors]) => ({
|
||||
urls: [url],
|
||||
authors: Array.from(authors)
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
const newEventIdSet = new Set<string>()
|
||||
const requestCount = subRequests.length
|
||||
let eventIdSet = new Set<string>()
|
||||
let events: NEvent[] = []
|
||||
let eosedCount = 0
|
||||
|
||||
const subs = await Promise.all(
|
||||
subRequests.map(({ urls, authors }) => {
|
||||
return this._subscribeTimeline(
|
||||
urls,
|
||||
{ ...filter, authors },
|
||||
{
|
||||
onEvents: (_events, _eosed) => {
|
||||
if (_eosed) {
|
||||
eosedCount++
|
||||
}
|
||||
_events.forEach((evt) => {
|
||||
if (eventIdSet.has(evt.id)) return
|
||||
eventIdSet.add(evt.id)
|
||||
events.push(evt)
|
||||
})
|
||||
events = events.sort((a, b) => b.created_at - a.created_at).slice(0, filter.limit)
|
||||
eventIdSet = new Set(events.map((evt) => evt.id))
|
||||
onEvents(events, eosedCount >= requestCount)
|
||||
},
|
||||
onNew: (evt) => {
|
||||
if (newEventIdSet.has(evt.id)) return
|
||||
newEventIdSet.add(evt.id)
|
||||
onNew(evt)
|
||||
}
|
||||
},
|
||||
{ startLogin }
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
const key = this.generateTimelineKey([], { ...filter, authors })
|
||||
this.timelines[key] = subs.map((sub) => sub.timelineKey)
|
||||
|
||||
return {
|
||||
timelineKey: key,
|
||||
closer: () => {
|
||||
onEvents = () => {}
|
||||
onNew = () => {}
|
||||
subCloser.close()
|
||||
}
|
||||
subs.forEach((sub) => {
|
||||
sub.closer()
|
||||
})
|
||||
},
|
||||
timelineKey: key
|
||||
}
|
||||
}
|
||||
|
||||
async loadMoreTimeline(key: string, until: number, limit: number) {
|
||||
const timeline = this.timelines[key]
|
||||
if (!timeline) return []
|
||||
|
||||
if (!Array.isArray(timeline)) {
|
||||
return this._loadMoreTimeline(key, until, limit)
|
||||
}
|
||||
const timelines = await Promise.all(
|
||||
timeline.map((key) => this._loadMoreTimeline(key, until, limit))
|
||||
)
|
||||
|
||||
const eventIdSet = new Set<string>()
|
||||
const events: NEvent[] = []
|
||||
timelines.forEach((timeline) => {
|
||||
timeline.forEach((evt) => {
|
||||
if (eventIdSet.has(evt.id)) return
|
||||
eventIdSet.add(evt.id)
|
||||
events.push(evt)
|
||||
})
|
||||
})
|
||||
return events.sort((a, b) => b.created_at - a.created_at).slice(0, limit)
|
||||
}
|
||||
|
||||
subscribe(
|
||||
urls: string[],
|
||||
filter: Filter | Filter[],
|
||||
@@ -305,15 +331,27 @@ class ClientService extends EventTarget {
|
||||
let eosed = false
|
||||
let closedCount = 0
|
||||
const closeReasons: string[] = []
|
||||
const subPromises: Promise<Subscription>[] = []
|
||||
const subPromises: Promise<{ close: () => void }>[] = []
|
||||
relays.forEach((url) => {
|
||||
let hasAuthed = false
|
||||
|
||||
subPromises.push(startSub())
|
||||
|
||||
async function startSub() {
|
||||
const relay = await that.pool.ensureRelay(url)
|
||||
startedCount++
|
||||
const relay = await that.pool.ensureRelay(url, { connectionTimeout: 2000 }).catch(() => {
|
||||
return undefined
|
||||
})
|
||||
if (!relay) {
|
||||
if (!eosed) {
|
||||
eosedCount++
|
||||
eosed = eosedCount >= startedCount
|
||||
oneose?.(eosed)
|
||||
}
|
||||
return {
|
||||
close: () => {}
|
||||
}
|
||||
}
|
||||
return relay.subscribe(filters, {
|
||||
receivedEvent: (relay, id) => {
|
||||
that.trackEventSeenOn(id, relay)
|
||||
@@ -350,7 +388,7 @@ class ClientService extends EventTarget {
|
||||
if (that.signer) {
|
||||
relay
|
||||
.auth(async (authEvt: EventTemplate) => {
|
||||
const evt = await that.signer!(authEvt)
|
||||
const evt = await that.signer!.signEvent(authEvt)
|
||||
if (!evt) {
|
||||
throw new Error('sign event failed')
|
||||
}
|
||||
@@ -369,7 +407,7 @@ class ClientService extends EventTarget {
|
||||
startLogin()
|
||||
}
|
||||
},
|
||||
eoseTimeout: 10000 // 10s
|
||||
eoseTimeout: 10_000 // 10s
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -415,7 +453,7 @@ class ClientService extends EventTarget {
|
||||
|
||||
if (that.signer) {
|
||||
relay
|
||||
.auth((authEvt: EventTemplate) => that.signer!(authEvt))
|
||||
.auth((authEvt: EventTemplate) => that.signer!.signEvent(authEvt))
|
||||
.then(() => {
|
||||
hasAuthed = true
|
||||
startQuery()
|
||||
@@ -442,9 +480,145 @@ class ClientService extends EventTarget {
|
||||
return events
|
||||
}
|
||||
|
||||
async loadMoreTimeline(key: string, until: number, limit: number) {
|
||||
private async _subscribeTimeline(
|
||||
urls: string[],
|
||||
filter: Omit<Filter, 'since' | 'until'> & { limit: number }, // filter with limit,
|
||||
{
|
||||
onEvents,
|
||||
onNew
|
||||
}: {
|
||||
onEvents: (events: NEvent[], eosed: boolean) => void
|
||||
onNew: (evt: NEvent) => void
|
||||
},
|
||||
{
|
||||
startLogin,
|
||||
needSort = true
|
||||
}: {
|
||||
startLogin?: () => void
|
||||
needSort?: boolean
|
||||
} = {}
|
||||
) {
|
||||
const relays = Array.from(new Set(urls))
|
||||
const key = this.generateTimelineKey(relays, filter)
|
||||
const timeline = this.timelines[key]
|
||||
if (!timeline) return []
|
||||
let cachedEvents: NEvent[] = []
|
||||
let since: number | undefined
|
||||
if (timeline && !Array.isArray(timeline) && timeline.refs.length && needSort) {
|
||||
cachedEvents = (
|
||||
await Promise.all(
|
||||
timeline.refs.slice(0, filter.limit).map(([id]) => this.eventCache.get(id))
|
||||
)
|
||||
).filter(Boolean) as NEvent[]
|
||||
if (cachedEvents.length) {
|
||||
onEvents([...cachedEvents], false)
|
||||
since = cachedEvents[0].created_at + 1
|
||||
}
|
||||
}
|
||||
|
||||
if (!timeline && needSort) {
|
||||
this.timelines[key] = { refs: [], filter, urls: relays }
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
||||
const that = this
|
||||
let events: NEvent[] = []
|
||||
let eosed = false
|
||||
const subCloser = this.subscribe(relays, since ? { ...filter, since } : filter, {
|
||||
startLogin,
|
||||
onevent: (evt: NEvent) => {
|
||||
that.eventDataLoader.prime(evt.id, Promise.resolve(evt))
|
||||
// not eosed yet, push to events
|
||||
if (!eosed) {
|
||||
return events.push(evt)
|
||||
}
|
||||
// eosed, (algo relay feeds) no need to sort and cache
|
||||
if (!needSort) {
|
||||
return onNew(evt)
|
||||
}
|
||||
|
||||
const timeline = that.timelines[key]
|
||||
if (!timeline || Array.isArray(timeline) || !timeline.refs.length) {
|
||||
return onNew(evt)
|
||||
}
|
||||
// the event is newer than the first ref, insert it to the front
|
||||
if (evt.created_at > timeline.refs[0][1]) {
|
||||
onNew(evt)
|
||||
return timeline.refs.unshift([evt.id, evt.created_at])
|
||||
}
|
||||
|
||||
let idx = 0
|
||||
for (const ref of timeline.refs) {
|
||||
if (evt.created_at > ref[1] || (evt.created_at === ref[1] && evt.id < ref[0])) {
|
||||
break
|
||||
}
|
||||
// the event is already in the cache
|
||||
if (evt.created_at === ref[1] && evt.id === ref[0]) {
|
||||
return
|
||||
}
|
||||
idx++
|
||||
}
|
||||
// the event is too old, ignore it
|
||||
if (idx >= timeline.refs.length) return
|
||||
|
||||
// insert the event to the right position
|
||||
timeline.refs.splice(idx, 0, [evt.id, evt.created_at])
|
||||
},
|
||||
oneose: (_eosed) => {
|
||||
eosed = _eosed
|
||||
// (algo feeds) no need to sort and cache
|
||||
if (!needSort) {
|
||||
return onEvents([...events], eosed)
|
||||
}
|
||||
if (!eosed) {
|
||||
events = events.sort((a, b) => b.created_at - a.created_at).slice(0, filter.limit)
|
||||
return onEvents([...events.concat(cachedEvents)], false)
|
||||
}
|
||||
|
||||
events = events.sort((a, b) => b.created_at - a.created_at).slice(0, filter.limit)
|
||||
const timeline = that.timelines[key]
|
||||
// no cache yet
|
||||
if (!timeline || Array.isArray(timeline) || !timeline.refs.length) {
|
||||
that.timelines[key] = {
|
||||
refs: events.map((evt) => [evt.id, evt.created_at]),
|
||||
filter,
|
||||
urls
|
||||
}
|
||||
return onEvents([...events], true)
|
||||
}
|
||||
|
||||
const newEvents = events.filter((evt) => {
|
||||
const firstRef = timeline.refs[0]
|
||||
return (
|
||||
evt.created_at > firstRef[1] || (evt.created_at === firstRef[1] && evt.id < firstRef[0])
|
||||
)
|
||||
})
|
||||
const newRefs = newEvents.map((evt) => [evt.id, evt.created_at] as TTimelineRef)
|
||||
|
||||
if (newRefs.length >= filter.limit) {
|
||||
// if new refs are more than limit, means old refs are too old, replace them
|
||||
timeline.refs = newRefs
|
||||
onEvents([...newEvents], true)
|
||||
} else {
|
||||
// merge new refs with old refs
|
||||
timeline.refs = newRefs.concat(timeline.refs)
|
||||
onEvents([...newEvents.concat(cachedEvents)], true)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
timelineKey: key,
|
||||
closer: () => {
|
||||
onEvents = () => {}
|
||||
onNew = () => {}
|
||||
subCloser.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async _loadMoreTimeline(key: string, until: number, limit: number) {
|
||||
const timeline = this.timelines[key]
|
||||
if (!timeline || Array.isArray(timeline)) return []
|
||||
|
||||
const { filter, urls, refs } = timeline
|
||||
const startIdx = refs.findIndex(([, createdAt]) => createdAt < until)
|
||||
@@ -456,17 +630,19 @@ class ClientService extends EventTarget {
|
||||
)
|
||||
).filter(Boolean) as NEvent[])
|
||||
: []
|
||||
if (cachedEvents.length > 0) {
|
||||
if (cachedEvents.length >= limit) {
|
||||
return cachedEvents
|
||||
}
|
||||
|
||||
until = cachedEvents.length ? cachedEvents[cachedEvents.length - 1].created_at - 1 : until
|
||||
limit = limit - cachedEvents.length
|
||||
let events = await this.query(urls, { ...filter, until: until, limit: limit })
|
||||
events.forEach((evt) => {
|
||||
this.eventDataLoader.prime(evt.id, Promise.resolve(evt))
|
||||
})
|
||||
events = events.sort((a, b) => b.created_at - a.created_at).slice(0, limit)
|
||||
timeline.refs.push(...events.map((evt) => [evt.id, evt.created_at] as TTimelineRef))
|
||||
return events
|
||||
return [...cachedEvents, ...events]
|
||||
}
|
||||
|
||||
async fetchEvents(
|
||||
@@ -544,7 +720,6 @@ class ClientService extends EventTarget {
|
||||
if (!skipCache) {
|
||||
const localProfile = await indexedDb.getReplaceableEvent(pubkey, kinds.Metadata)
|
||||
if (localProfile) {
|
||||
this.addUsernameToIndex(localProfile)
|
||||
return localProfile
|
||||
}
|
||||
}
|
||||
@@ -623,6 +798,20 @@ class ClientService extends EventTarget {
|
||||
return getRelayListFromRelayListEvent(event)
|
||||
}
|
||||
|
||||
async fetchRelayLists(pubkeys: string[]) {
|
||||
const events = await this.relayListEventDataLoader.loadMany(pubkeys)
|
||||
return events.map((event) => {
|
||||
if (event && !(event instanceof Error)) {
|
||||
return getRelayListFromRelayListEvent(event)
|
||||
}
|
||||
return {
|
||||
write: BIG_RELAY_URLS,
|
||||
read: BIG_RELAY_URLS,
|
||||
originalRelays: []
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fetchFollowListEvent(pubkey: string, storeToIndexedDb = false) {
|
||||
const event = await this.followListCache.fetch(pubkey)
|
||||
if (storeToIndexedDb && event) {
|
||||
@@ -645,56 +834,6 @@ class ClientService extends EventTarget {
|
||||
this.relayListEventDataLoader.prime(event.pubkey, Promise.resolve(event))
|
||||
}
|
||||
|
||||
async calculateOptimalReadRelays(pubkey: string) {
|
||||
const followings = await this.fetchFollowings(pubkey, true)
|
||||
const [selfRelayListEvent, ...relayListEvents] = await this.relayListEventDataLoader.loadMany([
|
||||
pubkey,
|
||||
...followings
|
||||
])
|
||||
const selfReadRelays =
|
||||
selfRelayListEvent && !(selfRelayListEvent instanceof Error)
|
||||
? getRelayListFromRelayListEvent(selfRelayListEvent).read
|
||||
: []
|
||||
const pubkeyRelayListMap = new Map<string, string[]>()
|
||||
relayListEvents.forEach((evt) => {
|
||||
if (evt && !(evt instanceof Error)) {
|
||||
pubkeyRelayListMap.set(evt.pubkey, getRelayListFromRelayListEvent(evt).write)
|
||||
}
|
||||
})
|
||||
let uncoveredPubkeys = [...followings]
|
||||
const readRelays: { url: string; pubkeys: string[] }[] = []
|
||||
while (uncoveredPubkeys.length) {
|
||||
const relayMap = new Map<string, string[]>()
|
||||
uncoveredPubkeys.forEach((pubkey) => {
|
||||
const relays = pubkeyRelayListMap.get(pubkey)
|
||||
if (relays) {
|
||||
relays.forEach((url) => {
|
||||
relayMap.set(url, (relayMap.get(url) || []).concat(pubkey))
|
||||
})
|
||||
}
|
||||
})
|
||||
let maxCoveredRelay: { url: string; pubkeys: string[] } | undefined
|
||||
for (const [url, pubkeys] of relayMap.entries()) {
|
||||
if (!maxCoveredRelay) {
|
||||
maxCoveredRelay = { url, pubkeys }
|
||||
} else if (pubkeys.length > maxCoveredRelay.pubkeys.length) {
|
||||
maxCoveredRelay = { url, pubkeys }
|
||||
} else if (
|
||||
pubkeys.length === maxCoveredRelay.pubkeys.length &&
|
||||
selfReadRelays.includes(url)
|
||||
) {
|
||||
maxCoveredRelay = { url, pubkeys }
|
||||
}
|
||||
}
|
||||
if (!maxCoveredRelay) break
|
||||
readRelays.push(maxCoveredRelay)
|
||||
uncoveredPubkeys = uncoveredPubkeys.filter(
|
||||
(pubkey) => !maxCoveredRelay!.pubkeys.includes(pubkey)
|
||||
)
|
||||
}
|
||||
return readRelays
|
||||
}
|
||||
|
||||
async searchProfilesFromIndex(query: string, limit: number = 100) {
|
||||
const result = await this.userIndex.searchAsync(query, { limit })
|
||||
return Promise.all(result.map((pubkey) => this.fetchProfile(pubkey as string))).then(
|
||||
@@ -875,9 +1014,7 @@ class ClientService extends EventTarget {
|
||||
}
|
||||
|
||||
private async relayListEventBatchLoadFn(pubkeys: readonly string[]) {
|
||||
const relayEvents = await Promise.all(
|
||||
pubkeys.map((pubkey) => indexedDb.getReplaceableEvent(pubkey, kinds.RelayList))
|
||||
)
|
||||
const relayEvents = await indexedDb.getManyReplaceableEvents(pubkeys, kinds.RelayList)
|
||||
const nonExistingPubkeys = pubkeys.filter((_, i) => !relayEvents[i])
|
||||
if (nonExistingPubkeys.length) {
|
||||
const events = await this.query(BIG_RELAY_URLS, {
|
||||
|
||||
@@ -88,19 +88,23 @@ class IndexedDbService {
|
||||
getRequest.onsuccess = () => {
|
||||
const oldValue = getRequest.result as TValue<Event> | undefined
|
||||
if (oldValue && oldValue.value.created_at >= event.created_at) {
|
||||
transaction.commit()
|
||||
return resolve(oldValue.value)
|
||||
}
|
||||
const putRequest = store.put(this.formatValue(event.pubkey, event))
|
||||
putRequest.onsuccess = () => {
|
||||
transaction.commit()
|
||||
resolve(event)
|
||||
}
|
||||
|
||||
putRequest.onerror = (event) => {
|
||||
transaction.commit()
|
||||
reject(event)
|
||||
}
|
||||
}
|
||||
|
||||
getRequest.onerror = (event) => {
|
||||
transaction.commit()
|
||||
reject(event)
|
||||
}
|
||||
})
|
||||
@@ -121,15 +125,59 @@ class IndexedDbService {
|
||||
const request = store.get(pubkey)
|
||||
|
||||
request.onsuccess = () => {
|
||||
transaction.commit()
|
||||
resolve((request.result as TValue<Event>)?.value)
|
||||
}
|
||||
|
||||
request.onerror = (event) => {
|
||||
transaction.commit()
|
||||
reject(event)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async getManyReplaceableEvents(
|
||||
pubkeys: readonly string[],
|
||||
kind: number
|
||||
): Promise<(Event | undefined)[]> {
|
||||
const storeName = this.getStoreNameByKind(kind)
|
||||
if (!storeName) {
|
||||
return Promise.reject('store name not found')
|
||||
}
|
||||
await this.initPromise
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.db) {
|
||||
return reject('database not initialized')
|
||||
}
|
||||
const transaction = this.db.transaction(storeName, 'readonly')
|
||||
const store = transaction.objectStore(storeName)
|
||||
const events: Event[] = new Array(pubkeys.length).fill(undefined)
|
||||
let count = 0
|
||||
pubkeys.forEach((pubkey, i) => {
|
||||
const request = store.get(pubkey)
|
||||
|
||||
request.onsuccess = () => {
|
||||
const event = (request.result as TValue<Event>)?.value
|
||||
if (event) {
|
||||
events[i] = event
|
||||
}
|
||||
|
||||
if (++count === pubkeys.length) {
|
||||
transaction.commit()
|
||||
resolve(events)
|
||||
}
|
||||
}
|
||||
|
||||
request.onerror = () => {
|
||||
if (++count === pubkeys.length) {
|
||||
transaction.commit()
|
||||
resolve(events)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async getMuteDecryptedTags(id: string): Promise<string[][]> {
|
||||
await this.initPromise
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -141,10 +189,12 @@ class IndexedDbService {
|
||||
const request = store.get(id)
|
||||
|
||||
request.onsuccess = () => {
|
||||
transaction.commit()
|
||||
resolve((request.result as TValue<string[][]>)?.value)
|
||||
}
|
||||
|
||||
request.onerror = (event) => {
|
||||
transaction.commit()
|
||||
reject(event)
|
||||
}
|
||||
})
|
||||
@@ -161,10 +211,12 @@ class IndexedDbService {
|
||||
|
||||
const putRequest = store.put(this.formatValue(id, tags))
|
||||
putRequest.onsuccess = () => {
|
||||
transaction.commit()
|
||||
resolve()
|
||||
}
|
||||
|
||||
putRequest.onerror = (event) => {
|
||||
transaction.commit()
|
||||
reject(event)
|
||||
}
|
||||
})
|
||||
@@ -181,10 +233,12 @@ class IndexedDbService {
|
||||
const request = store.getAll()
|
||||
|
||||
request.onsuccess = () => {
|
||||
transaction.commit()
|
||||
resolve((request.result as TValue<Event>[])?.map((item) => item.value))
|
||||
}
|
||||
|
||||
request.onerror = (event) => {
|
||||
transaction.commit()
|
||||
reject(event)
|
||||
}
|
||||
})
|
||||
@@ -205,10 +259,12 @@ class IndexedDbService {
|
||||
|
||||
const putRequest = store.put(this.formatValue(dValue, event))
|
||||
putRequest.onsuccess = () => {
|
||||
transaction.commit()
|
||||
resolve()
|
||||
}
|
||||
|
||||
putRequest.onerror = (event) => {
|
||||
transaction.commit()
|
||||
reject(event)
|
||||
}
|
||||
})
|
||||
@@ -230,11 +286,13 @@ class IndexedDbService {
|
||||
callback((cursor.value as TValue<Event>).value)
|
||||
cursor.continue()
|
||||
} else {
|
||||
transaction.commit()
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
|
||||
request.onerror = (event) => {
|
||||
transaction.commit()
|
||||
reject(event)
|
||||
}
|
||||
})
|
||||
@@ -296,7 +354,8 @@ class IndexedDbService {
|
||||
const cursor = (event.target as IDBRequest).result
|
||||
if (cursor) {
|
||||
const value: TValue = cursor.value
|
||||
if (value.addedAt < expirationTimestamp) {
|
||||
// 10% chance to delete
|
||||
if (value.addedAt < expirationTimestamp && Math.random() < 0.1) {
|
||||
cursor.delete()
|
||||
}
|
||||
cursor.continue()
|
||||
|
||||
@@ -79,7 +79,7 @@ class LightningService {
|
||||
.concat(BIG_RELAY_URLS),
|
||||
comment
|
||||
})
|
||||
const zapRequest = await client.signer(zapRequestDraft)
|
||||
const zapRequest = await client.signer.signEvent(zapRequestDraft)
|
||||
const zapRequestRes = await fetch(
|
||||
`${callback}?amount=${amount}&nostr=${encodeURI(JSON.stringify(zapRequest))}&lnurl=${lnurl}`
|
||||
)
|
||||
|
||||
@@ -32,8 +32,9 @@ class RelayInfoService {
|
||||
.toLocaleLowerCase()
|
||||
.split(/\s+/)
|
||||
})
|
||||
private fetchDataloader = new DataLoader<string, TNip66RelayInfo | undefined>((urls) =>
|
||||
Promise.all(urls.map((url) => this._getRelayInfo(url)))
|
||||
private fetchDataloader = new DataLoader<string, TNip66RelayInfo | undefined>(
|
||||
(urls) => Promise.all(urls.map((url) => this._getRelayInfo(url))),
|
||||
{ maxBatchSize: 1 }
|
||||
)
|
||||
private relayUrlsForRandom: string[] = []
|
||||
|
||||
|
||||
@@ -4,31 +4,34 @@ import DataLoader from 'dataloader'
|
||||
class WebService {
|
||||
static instance: WebService
|
||||
|
||||
private webMetadataDataLoader = new DataLoader<string, TWebMetadata>(async (urls) => {
|
||||
return await Promise.all(
|
||||
urls.map(async (url) => {
|
||||
try {
|
||||
const res = await fetch(url)
|
||||
const html = await res.text()
|
||||
const parser = new DOMParser()
|
||||
const doc = parser.parseFromString(html, 'text/html')
|
||||
private webMetadataDataLoader = new DataLoader<string, TWebMetadata>(
|
||||
async (urls) => {
|
||||
return await Promise.all(
|
||||
urls.map(async (url) => {
|
||||
try {
|
||||
const res = await fetch(url)
|
||||
const html = await res.text()
|
||||
const parser = new DOMParser()
|
||||
const doc = parser.parseFromString(html, 'text/html')
|
||||
|
||||
const title =
|
||||
doc.querySelector('meta[property="og:title"]')?.getAttribute('content') ||
|
||||
doc.querySelector('title')?.textContent
|
||||
const description =
|
||||
doc.querySelector('meta[property="og:description"]')?.getAttribute('content') ||
|
||||
(doc.querySelector('meta[name="description"]') as HTMLMetaElement | null)?.content
|
||||
const image = (doc.querySelector('meta[property="og:image"]') as HTMLMetaElement | null)
|
||||
?.content
|
||||
const title =
|
||||
doc.querySelector('meta[property="og:title"]')?.getAttribute('content') ||
|
||||
doc.querySelector('title')?.textContent
|
||||
const description =
|
||||
doc.querySelector('meta[property="og:description"]')?.getAttribute('content') ||
|
||||
(doc.querySelector('meta[name="description"]') as HTMLMetaElement | null)?.content
|
||||
const image = (doc.querySelector('meta[property="og:image"]') as HTMLMetaElement | null)
|
||||
?.content
|
||||
|
||||
return { title, description, image }
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
return { title, description, image }
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
})
|
||||
)
|
||||
},
|
||||
{ maxBatchSize: 1 }
|
||||
)
|
||||
|
||||
constructor() {
|
||||
if (!WebService.instance) {
|
||||
|
||||
@@ -80,7 +80,7 @@ export interface ISigner {
|
||||
nip04Decrypt: (pubkey: string, cipherText: string) => Promise<string>
|
||||
}
|
||||
|
||||
export type TSignerType = 'nsec' | 'nip-07' | 'bunker' | 'browser-nsec' | 'ncryptsec'
|
||||
export type TSignerType = 'nsec' | 'nip-07' | 'bunker' | 'browser-nsec' | 'ncryptsec' | 'npub'
|
||||
|
||||
export type TAccount = {
|
||||
pubkey: string
|
||||
@@ -89,6 +89,7 @@ export type TAccount = {
|
||||
nsec?: string
|
||||
bunker?: string
|
||||
bunkerClientSecretKey?: string
|
||||
npub?: string
|
||||
}
|
||||
|
||||
export type TAccountPointer = Pick<TAccount, 'pubkey' | 'signerType'>
|
||||
|
||||
Reference in New Issue
Block a user