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>
|
return <Badge className=" bg-blue-400 hover:bg-blue-400/80">Bunker</Badge>
|
||||||
} else if (signerType === 'ncryptsec') {
|
} else if (signerType === 'ncryptsec') {
|
||||||
return <Badge>NCRYPTSEC</Badge>
|
return <Badge>NCRYPTSEC</Badge>
|
||||||
} else {
|
} else if (signerType === 'nsec') {
|
||||||
return <Badge className=" bg-orange-400 hover:bg-orange-400/80">NSEC</Badge>
|
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 AccountList from '../AccountList'
|
||||||
import BunkerLogin from './BunkerLogin'
|
import BunkerLogin from './BunkerLogin'
|
||||||
import GenerateNewAccount from './GenerateNewAccount'
|
import GenerateNewAccount from './GenerateNewAccount'
|
||||||
|
import NpubLogin from './NpubLogin'
|
||||||
import PrivateKeyLogin from './PrivateKeyLogin'
|
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 }) {
|
export default function AccountManager({ close }: { close?: () => void }) {
|
||||||
const [page, setPage] = useState<TAccountManagerPage>(null)
|
const [page, setPage] = useState<TAccountManagerPage>(null)
|
||||||
@@ -23,6 +25,8 @@ export default function AccountManager({ close }: { close?: () => void }) {
|
|||||||
<BunkerLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} />
|
<BunkerLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} />
|
||||||
) : page === 'generate' ? (
|
) : page === 'generate' ? (
|
||||||
<GenerateNewAccount back={() => setPage(null)} onLoginSuccess={() => close?.()} />
|
<GenerateNewAccount back={() => setPage(null)} onLoginSuccess={() => close?.()} />
|
||||||
|
) : page === 'npub' ? (
|
||||||
|
<NpubLogin back={() => setPage(null)} onLoginSuccess={() => close?.()} />
|
||||||
) : (
|
) : (
|
||||||
<AccountManagerNav setPage={setPage} close={close} />
|
<AccountManagerNav setPage={setPage} close={close} />
|
||||||
)}
|
)}
|
||||||
@@ -59,6 +63,11 @@ function AccountManagerNav({
|
|||||||
<Button variant="secondary" onClick={() => setPage('nsec')} className="w-full">
|
<Button variant="secondary" onClick={() => setPage('nsec')} className="w-full">
|
||||||
{t('Login with Private Key')}
|
{t('Login with Private Key')}
|
||||||
</Button>
|
</Button>
|
||||||
|
{isDevEnv() && (
|
||||||
|
<Button variant="secondary" onClick={() => setPage('npub')} className="w-full">
|
||||||
|
Login with Public key (for development)
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<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 { TMailboxRelay, TMailboxRelayScope } from '@/types'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import CalculateOptimalReadRelaysButton from './CalculateOptimalReadRelaysButton'
|
|
||||||
import MailboxRelay from './MailboxRelay'
|
import MailboxRelay from './MailboxRelay'
|
||||||
import NewMailboxRelayInput from './NewMailboxRelayInput'
|
import NewMailboxRelayInput from './NewMailboxRelayInput'
|
||||||
import SaveButton from './SaveButton'
|
import SaveButton from './SaveButton'
|
||||||
@@ -56,13 +55,6 @@ export default function MailboxSetting() {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const mergeRelays = (newRelays: TMailboxRelay[]) => {
|
|
||||||
setRelays((pre) => {
|
|
||||||
return [...pre, ...newRelays.filter((r) => !pre.some((pr) => pr.url === r.url))]
|
|
||||||
})
|
|
||||||
setHasChange(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="text-xs text-muted-foreground space-y-1">
|
<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('write relays description')}</div>
|
||||||
<div>{t('read & write relays notice')}</div>
|
<div>{t('read & write relays notice')}</div>
|
||||||
</div>
|
</div>
|
||||||
<CalculateOptimalReadRelaysButton mergeRelays={mergeRelays} />
|
|
||||||
<SaveButton mailboxRelays={relays} hasChange={hasChange} setHasChange={setHasChange} />
|
<SaveButton mailboxRelays={relays} hasChange={hasChange} setHasChange={setHasChange} />
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{relays.map((relay) => (
|
{relays.map((relay) => (
|
||||||
|
|||||||
@@ -25,13 +25,13 @@ const ALGO_LIMIT = 500
|
|||||||
const SHOW_COUNT = 10
|
const SHOW_COUNT = 10
|
||||||
|
|
||||||
export default function NoteList({
|
export default function NoteList({
|
||||||
relayUrls,
|
relayUrls = [],
|
||||||
filter = {},
|
filter = {},
|
||||||
className,
|
className,
|
||||||
filterMutedNotes = true,
|
filterMutedNotes = true,
|
||||||
needCheckAlgoRelay = false
|
needCheckAlgoRelay = false
|
||||||
}: {
|
}: {
|
||||||
relayUrls: string[]
|
relayUrls?: string[]
|
||||||
filter?: Filter
|
filter?: Filter
|
||||||
className?: string
|
className?: string
|
||||||
filterMutedNotes?: boolean
|
filterMutedNotes?: boolean
|
||||||
@@ -60,7 +60,7 @@ export default function NoteList({
|
|||||||
const topRef = useRef<HTMLDivElement | null>(null)
|
const topRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (relayUrls.length === 0) return
|
if (relayUrls.length === 0 && !noteFilter.authors?.length) return
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
setRefreshing(true)
|
setRefreshing(true)
|
||||||
|
|||||||
@@ -62,7 +62,9 @@ export default function SeenOnButton({ event }: { event: Event }) {
|
|||||||
key={relay}
|
key={relay}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsDrawerOpen(false)
|
setIsDrawerOpen(false)
|
||||||
|
setTimeout(() => {
|
||||||
push(toRelay(relay))
|
push(toRelay(relay))
|
||||||
|
}, 50) // Timeout to allow the drawer to close before navigating
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<RelayIcon url={relay} /> {simplifyUrl(relay)}
|
<RelayIcon url={relay} /> {simplifyUrl(relay)}
|
||||||
|
|||||||
@@ -1,14 +1,29 @@
|
|||||||
import { Button } from '@/components/ui/button'
|
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 { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { useToast } from '@/hooks'
|
import { useToast } from '@/hooks'
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
import { useNoteStats } from '@/providers/NoteStatsProvider'
|
import { useNoteStats } from '@/providers/NoteStatsProvider'
|
||||||
|
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||||
import { useZap } from '@/providers/ZapProvider'
|
import { useZap } from '@/providers/ZapProvider'
|
||||||
import lightning from '@/services/lightning.service'
|
import lightning from '@/services/lightning.service'
|
||||||
import { Loader } from 'lucide-react'
|
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 { useTranslation } from 'react-i18next'
|
||||||
import UserAvatar from '../UserAvatar'
|
import UserAvatar from '../UserAvatar'
|
||||||
import Username from '../Username'
|
import Username from '../Username'
|
||||||
@@ -27,10 +42,62 @@ export default function ZapDialog({
|
|||||||
defaultAmount?: number
|
defaultAmount?: number
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
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 (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogContent>
|
<DialogOverlay onClick={() => setOpen(false)} />
|
||||||
|
<DialogContent hideOverlay onOpenAutoFocus={(e) => e.preventDefault()}>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex gap-2 items-center">
|
<DialogTitle className="flex gap-2 items-center">
|
||||||
<div className="shrink-0">{t('Zap to')}</div>
|
<div className="shrink-0">{t('Zap to')}</div>
|
||||||
|
|||||||
@@ -29,10 +29,13 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
|||||||
|
|
||||||
const DialogContent = React.forwardRef<
|
const DialogContent = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { withoutClose?: boolean }
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||||
>(({ className, children, withoutClose, ...props }, ref) => (
|
withoutClose?: boolean
|
||||||
|
hideOverlay?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, children, withoutClose, hideOverlay, ...props }, ref) => (
|
||||||
<DialogPortal>
|
<DialogPortal>
|
||||||
<DialogOverlay />
|
{!hideOverlay && <DialogOverlay />}
|
||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@@ -5,3 +5,7 @@ export function isTouchDevice() {
|
|||||||
export function isEmail(email: string) {
|
export function isEmail(email: string) {
|
||||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
|
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[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
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 NoteList from '@/components/NoteList'
|
||||||
import { SEARCHABLE_RELAY_URLS } from '@/constants'
|
import { BIG_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants'
|
||||||
import { useFetchRelayInfos } from '@/hooks'
|
|
||||||
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
|
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
|
||||||
import { useFeed } from '@/providers/FeedProvider'
|
|
||||||
import { Filter } from 'nostr-tools'
|
import { Filter } from 'nostr-tools'
|
||||||
import { forwardRef, useMemo } from 'react'
|
import { forwardRef, useMemo } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
const NoteListPage = forwardRef(({ index }: { index?: number }, ref) => {
|
const NoteListPage = forwardRef(({ index }: { index?: number }, ref) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { relayUrls } = useFeed()
|
|
||||||
const { searchableRelayUrls } = useFetchRelayInfos(relayUrls)
|
|
||||||
const {
|
const {
|
||||||
title = '',
|
title = '',
|
||||||
filter,
|
filter,
|
||||||
@@ -26,7 +22,7 @@ const NoteListPage = forwardRef(({ index }: { index?: number }, ref) => {
|
|||||||
return {
|
return {
|
||||||
title: `# ${hashtag}`,
|
title: `# ${hashtag}`,
|
||||||
filter: { '#t': [hashtag] },
|
filter: { '#t': [hashtag] },
|
||||||
urls: relayUrls,
|
urls: BIG_RELAY_URLS,
|
||||||
type: 'hashtag'
|
type: 'hashtag'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -35,12 +31,12 @@ const NoteListPage = forwardRef(({ index }: { index?: number }, ref) => {
|
|||||||
return {
|
return {
|
||||||
title: `${t('Search')}: ${search}`,
|
title: `${t('Search')}: ${search}`,
|
||||||
filter: { search },
|
filter: { search },
|
||||||
urls: searchableRelayUrls.concat(SEARCHABLE_RELAY_URLS).slice(0, 4),
|
urls: SEARCHABLE_RELAY_URLS,
|
||||||
type: 'search'
|
type: 'search'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { urls: relayUrls }
|
return { urls: BIG_RELAY_URLS }
|
||||||
}, [JSON.stringify(relayUrls)])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SecondaryPageLayout ref={ref} index={index} title={title} displayScrollToTopButton>
|
<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 { Button } from '@/components/ui/button'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { useFetchFollowings, useFetchProfile } from '@/hooks'
|
import { useFetchFollowings, useFetchProfile } from '@/hooks'
|
||||||
import { useFetchRelayList } from '@/hooks/useFetchRelayList'
|
|
||||||
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
|
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
|
||||||
import { toMuteList, toProfileEditor } from '@/lib/link'
|
import { toMuteList, toProfileEditor } from '@/lib/link'
|
||||||
import { generateImageByPubkey } from '@/lib/pubkey'
|
import { generateImageByPubkey } from '@/lib/pubkey'
|
||||||
import { SecondaryPageLink, useSecondaryPage } from '@/PageManager'
|
import { SecondaryPageLink, useSecondaryPage } from '@/PageManager'
|
||||||
import { useFeed } from '@/providers/FeedProvider'
|
|
||||||
import { useMuteList } from '@/providers/MuteListProvider'
|
import { useMuteList } from '@/providers/MuteListProvider'
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
import { Link, Zap } from 'lucide-react'
|
import { Link, Zap } from 'lucide-react'
|
||||||
@@ -30,15 +28,6 @@ const ProfilePage = forwardRef(({ id, index }: { id?: string; index?: number },
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { push } = useSecondaryPage()
|
const { push } = useSecondaryPage()
|
||||||
const { profile, isFetching } = useFetchProfile(id)
|
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 { pubkey: accountPubkey } = useNostr()
|
||||||
const { mutePubkeys } = useMuteList()
|
const { mutePubkeys } = useMuteList()
|
||||||
const { followings } = useFetchFollowings(profile?.pubkey)
|
const { followings } = useFetchFollowings(profile?.pubkey)
|
||||||
@@ -152,14 +141,7 @@ const ProfilePage = forwardRef(({ id, index }: { id?: string; index?: number },
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!isFetchingRelayInfo && (
|
<NoteList filter={{ authors: [pubkey] }} className="mt-2" filterMutedNotes={false} />
|
||||||
<NoteList
|
|
||||||
filter={{ authors: [pubkey] }}
|
|
||||||
relayUrls={relayUrls}
|
|
||||||
className="mt-2"
|
|
||||||
filterMutedNotes={false}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</SecondaryPageLayout>
|
</SecondaryPageLayout>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { BIG_RELAY_URLS } from '@/constants'
|
|
||||||
import { checkAlgoRelay } from '@/lib/relay'
|
import { checkAlgoRelay } from '@/lib/relay'
|
||||||
import { isWebsocketUrl, normalizeUrl } from '@/lib/url'
|
import { isWebsocketUrl, normalizeUrl } from '@/lib/url'
|
||||||
import client from '@/services/client.service'
|
import client from '@/services/client.service'
|
||||||
@@ -117,11 +116,8 @@ export function FeedProvider({ children }: { children: React.ReactNode }) {
|
|||||||
feedTypeRef.current = feedType
|
feedTypeRef.current = feedType
|
||||||
setFeedType(feedType)
|
setFeedType(feedType)
|
||||||
setActiveRelaySetId(null)
|
setActiveRelaySetId(null)
|
||||||
const [relayList, followings] = await Promise.all([
|
const followings = await client.fetchFollowings(options.pubkey, true)
|
||||||
client.fetchRelayList(options.pubkey),
|
setRelayUrls([])
|
||||||
client.fetchFollowings(options.pubkey, true)
|
|
||||||
])
|
|
||||||
setRelayUrls(relayList.read.concat(BIG_RELAY_URLS).slice(0, 4))
|
|
||||||
setFilter({
|
setFilter({
|
||||||
authors: followings.includes(options.pubkey) ? followings : [...followings, options.pubkey]
|
authors: followings.includes(options.pubkey) ? followings : [...followings, options.pubkey]
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import { BunkerSigner } from './bunker.signer'
|
import { BunkerSigner } from './bunker.signer'
|
||||||
import { Nip07Signer } from './nip-07.signer'
|
import { Nip07Signer } from './nip-07.signer'
|
||||||
import { NsecSigner } from './nsec.signer'
|
import { NsecSigner } from './nsec.signer'
|
||||||
|
import { NpubSigner } from './npub.signer'
|
||||||
|
|
||||||
type TNostrContext = {
|
type TNostrContext = {
|
||||||
pubkey: string | null
|
pubkey: string | null
|
||||||
@@ -38,6 +39,7 @@ type TNostrContext = {
|
|||||||
ncryptsecLogin: (ncryptsec: string) => Promise<string>
|
ncryptsecLogin: (ncryptsec: string) => Promise<string>
|
||||||
nip07Login: () => Promise<string>
|
nip07Login: () => Promise<string>
|
||||||
bunkerLogin: (bunker: string) => Promise<string>
|
bunkerLogin: (bunker: string) => Promise<string>
|
||||||
|
npubLogin(npub: string): Promise<string>
|
||||||
removeAccount: (account: TAccountPointer) => void
|
removeAccount: (account: TAccountPointer) => void
|
||||||
/**
|
/**
|
||||||
* Default publish the event to current relays, user's write relays and additional relays
|
* 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(() => {
|
useEffect(() => {
|
||||||
if (signer) {
|
if (signer) {
|
||||||
client.signer = signer.signEvent.bind(signer)
|
client.signer = signer
|
||||||
} else {
|
} else {
|
||||||
client.signer = undefined
|
client.signer = undefined
|
||||||
}
|
}
|
||||||
@@ -255,7 +257,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const nsecLogin = async (nsecOrHex: string, password?: string) => {
|
const nsecLogin = async (nsecOrHex: string, password?: string) => {
|
||||||
const browserNsecSigner = new NsecSigner()
|
const nsecSigner = new NsecSigner()
|
||||||
let privkey: Uint8Array
|
let privkey: Uint8Array
|
||||||
if (nsecOrHex.startsWith('nsec')) {
|
if (nsecOrHex.startsWith('nsec')) {
|
||||||
const { type, data } = nip19.decode(nsecOrHex)
|
const { type, data } = nip19.decode(nsecOrHex)
|
||||||
@@ -268,12 +270,12 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
|||||||
} else {
|
} else {
|
||||||
throw new Error('invalid nsec or hex')
|
throw new Error('invalid nsec or hex')
|
||||||
}
|
}
|
||||||
const pubkey = browserNsecSigner.login(privkey)
|
const pubkey = nsecSigner.login(privkey)
|
||||||
if (password) {
|
if (password) {
|
||||||
const ncryptsec = nip49.encrypt(privkey, 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) => {
|
const ncryptsecLogin = async (ncryptsec: string) => {
|
||||||
@@ -287,6 +289,12 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
|||||||
return login(browserNsecSigner, { pubkey, signerType: 'ncryptsec', ncryptsec })
|
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 () => {
|
const nip07Login = async () => {
|
||||||
try {
|
try {
|
||||||
const nip07Signer = new Nip07Signer()
|
const nip07Signer = new Nip07Signer()
|
||||||
@@ -369,6 +377,19 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
return login(bunkerSigner, account)
|
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)
|
storage.removeAccount(account)
|
||||||
return null
|
return null
|
||||||
@@ -510,6 +531,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
|||||||
ncryptsecLogin,
|
ncryptsecLogin,
|
||||||
nip07Login,
|
nip07Login,
|
||||||
bunkerLogin,
|
bunkerLogin,
|
||||||
|
npubLogin,
|
||||||
removeAccount,
|
removeAccount,
|
||||||
publish,
|
publish,
|
||||||
signHttpAuth,
|
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 { formatPubkey, userIdToPubkey } from '@/lib/pubkey'
|
||||||
import { extractPubkeysFromEventTags } from '@/lib/tag'
|
import { extractPubkeysFromEventTags } from '@/lib/tag'
|
||||||
import { isLocalNetworkUrl } from '@/lib/url'
|
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 { sha256 } from '@noble/hashes/sha2'
|
||||||
import DataLoader from 'dataloader'
|
import DataLoader from 'dataloader'
|
||||||
import FlexSearch from 'flexsearch'
|
import FlexSearch from 'flexsearch'
|
||||||
@@ -17,7 +18,7 @@ import {
|
|||||||
SimplePool,
|
SimplePool,
|
||||||
VerifiedEvent
|
VerifiedEvent
|
||||||
} from 'nostr-tools'
|
} from 'nostr-tools'
|
||||||
import { AbstractRelay, Subscription } from 'nostr-tools/abstract-relay'
|
import { AbstractRelay } from 'nostr-tools/abstract-relay'
|
||||||
import indexedDb from './indexed-db.service'
|
import indexedDb from './indexed-db.service'
|
||||||
|
|
||||||
type TTimelineRef = [string, number]
|
type TTimelineRef = [string, number]
|
||||||
@@ -25,7 +26,7 @@ type TTimelineRef = [string, number]
|
|||||||
class ClientService extends EventTarget {
|
class ClientService extends EventTarget {
|
||||||
static instance: ClientService
|
static instance: ClientService
|
||||||
|
|
||||||
signer?: (evt: TDraftEvent) => Promise<VerifiedEvent>
|
signer?: ISigner
|
||||||
private currentRelayUrls: string[] = []
|
private currentRelayUrls: string[] = []
|
||||||
private pool: SimplePool
|
private pool: SimplePool
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ class ClientService extends EventTarget {
|
|||||||
filter: Omit<Filter, 'since' | 'until'> & { limit: number }
|
filter: Omit<Filter, 'since' | 'until'> & { limit: number }
|
||||||
urls: string[]
|
urls: string[]
|
||||||
}
|
}
|
||||||
|
| string[]
|
||||||
| undefined
|
| undefined
|
||||||
> = {}
|
> = {}
|
||||||
private eventCache = new LRUCache<string, Promise<NEvent | undefined>>({ max: 10000 })
|
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>(
|
private fetchEventFromBigRelaysDataloader = new DataLoader<string, NEvent | undefined>(
|
||||||
this.fetchEventsFromBigRelays.bind(this),
|
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>(
|
private fetchProfileEventFromBigRelaysDataloader = new DataLoader<string, NEvent | undefined>(
|
||||||
this.profileEventBatchLoadFn.bind(this),
|
this.profileEventBatchLoadFn.bind(this),
|
||||||
{
|
{
|
||||||
batchScheduleFn: (callback) => setTimeout(callback, 200),
|
batchScheduleFn: (callback) => setTimeout(callback, 50),
|
||||||
cacheMap: new LRUCache<string, Promise<NEvent | undefined>>({ max: 1000 }),
|
maxBatchSize: 500
|
||||||
maxBatchSize: 20
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
private relayListEventDataLoader = new DataLoader<string, NEvent | undefined>(
|
private relayListEventDataLoader = new DataLoader<string, NEvent | undefined>(
|
||||||
this.relayListEventBatchLoadFn.bind(this),
|
this.relayListEventBatchLoadFn.bind(this),
|
||||||
{
|
{
|
||||||
batchScheduleFn: (callback) => setTimeout(callback, 200),
|
batchScheduleFn: (callback) => setTimeout(callback, 50),
|
||||||
cacheMap: new LRUCache<string, Promise<NEvent | undefined>>({ max: 1000 }),
|
maxBatchSize: 500
|
||||||
maxBatchSize: 20
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
private followListCache = new LRUCache<string, Promise<NEvent | undefined>>({
|
private followListCache = new LRUCache<string, Promise<NEvent | undefined>>({
|
||||||
@@ -122,7 +122,7 @@ class ClientService extends EventTarget {
|
|||||||
!!that.signer
|
!!that.signer
|
||||||
) {
|
) {
|
||||||
relay
|
relay
|
||||||
.auth((authEvt: EventTemplate) => that.signer!(authEvt))
|
.auth((authEvt: EventTemplate) => that.signer!.signEvent(authEvt))
|
||||||
.then(() => relay.publish(event))
|
.then(() => relay.publish(event))
|
||||||
} else {
|
} else {
|
||||||
throw error
|
throw error
|
||||||
@@ -135,7 +135,19 @@ class ClientService extends EventTarget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private generateTimelineKey(urls: string[], filter: Filter) {
|
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 encoder = new TextEncoder()
|
||||||
const data = encoder.encode(paramsStr)
|
const data = encoder.encode(paramsStr)
|
||||||
const hashBuffer = sha256(data)
|
const hashBuffer = sha256(data)
|
||||||
@@ -145,7 +157,7 @@ class ClientService extends EventTarget {
|
|||||||
|
|
||||||
async subscribeTimeline(
|
async subscribeTimeline(
|
||||||
urls: string[],
|
urls: string[],
|
||||||
filter: Omit<Filter, 'since' | 'until'> & { limit: number }, // filter with limit,
|
{ authors, ...filter }: Omit<Filter, 'since' | 'until'> & { limit: number },
|
||||||
{
|
{
|
||||||
onEvents,
|
onEvents,
|
||||||
onNew
|
onNew
|
||||||
@@ -161,122 +173,136 @@ class ClientService extends EventTarget {
|
|||||||
needSort?: boolean
|
needSort?: boolean
|
||||||
} = {}
|
} = {}
|
||||||
) {
|
) {
|
||||||
const relays = Array.from(new Set(urls))
|
if (urls.length || !authors?.length) {
|
||||||
const key = this.generateTimelineKey(relays, filter)
|
return this._subscribeTimeline(
|
||||||
const timeline = this.timelines[key]
|
urls.length ? urls : BIG_RELAY_URLS,
|
||||||
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 (!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
|
|
||||||
}
|
|
||||||
// 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,
|
filter,
|
||||||
urls
|
{ onEvents, onNew },
|
||||||
}
|
{ startLogin, needSort }
|
||||||
return onEvents([...events], true)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const newEvents = events.filter((evt) => {
|
const subRequests: { urls: string[]; authors: string[] }[] = []
|
||||||
const firstRef = timeline.refs[0]
|
// If many websocket connections are initiated simultaneously, it will be
|
||||||
return (
|
// very slow on Safari (for unknown reason)
|
||||||
evt.created_at > firstRef[1] || (evt.created_at === firstRef[1] && evt.id < firstRef[0])
|
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 })
|
||||||
|
}
|
||||||
|
} 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()
|
||||||
|
}
|
||||||
|
group[url].add(authors[index])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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 newRefs = newEvents.map((evt) => [evt.id, evt.created_at] as TTimelineRef)
|
)
|
||||||
|
|
||||||
if (newRefs.length >= filter.limit) {
|
const key = this.generateTimelineKey([], { ...filter, authors })
|
||||||
// if new refs are more than limit, means old refs are too old, replace them
|
this.timelines[key] = subs.map((sub) => sub.timelineKey)
|
||||||
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 {
|
return {
|
||||||
timelineKey: key,
|
|
||||||
closer: () => {
|
closer: () => {
|
||||||
onEvents = () => {}
|
onEvents = () => {}
|
||||||
onNew = () => {}
|
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(
|
subscribe(
|
||||||
@@ -305,15 +331,27 @@ class ClientService extends EventTarget {
|
|||||||
let eosed = false
|
let eosed = false
|
||||||
let closedCount = 0
|
let closedCount = 0
|
||||||
const closeReasons: string[] = []
|
const closeReasons: string[] = []
|
||||||
const subPromises: Promise<Subscription>[] = []
|
const subPromises: Promise<{ close: () => void }>[] = []
|
||||||
relays.forEach((url) => {
|
relays.forEach((url) => {
|
||||||
let hasAuthed = false
|
let hasAuthed = false
|
||||||
|
|
||||||
subPromises.push(startSub())
|
subPromises.push(startSub())
|
||||||
|
|
||||||
async function startSub() {
|
async function startSub() {
|
||||||
const relay = await that.pool.ensureRelay(url)
|
|
||||||
startedCount++
|
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, {
|
return relay.subscribe(filters, {
|
||||||
receivedEvent: (relay, id) => {
|
receivedEvent: (relay, id) => {
|
||||||
that.trackEventSeenOn(id, relay)
|
that.trackEventSeenOn(id, relay)
|
||||||
@@ -350,7 +388,7 @@ class ClientService extends EventTarget {
|
|||||||
if (that.signer) {
|
if (that.signer) {
|
||||||
relay
|
relay
|
||||||
.auth(async (authEvt: EventTemplate) => {
|
.auth(async (authEvt: EventTemplate) => {
|
||||||
const evt = await that.signer!(authEvt)
|
const evt = await that.signer!.signEvent(authEvt)
|
||||||
if (!evt) {
|
if (!evt) {
|
||||||
throw new Error('sign event failed')
|
throw new Error('sign event failed')
|
||||||
}
|
}
|
||||||
@@ -369,7 +407,7 @@ class ClientService extends EventTarget {
|
|||||||
startLogin()
|
startLogin()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
eoseTimeout: 10000 // 10s
|
eoseTimeout: 10_000 // 10s
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -415,7 +453,7 @@ class ClientService extends EventTarget {
|
|||||||
|
|
||||||
if (that.signer) {
|
if (that.signer) {
|
||||||
relay
|
relay
|
||||||
.auth((authEvt: EventTemplate) => that.signer!(authEvt))
|
.auth((authEvt: EventTemplate) => that.signer!.signEvent(authEvt))
|
||||||
.then(() => {
|
.then(() => {
|
||||||
hasAuthed = true
|
hasAuthed = true
|
||||||
startQuery()
|
startQuery()
|
||||||
@@ -442,9 +480,145 @@ class ClientService extends EventTarget {
|
|||||||
return events
|
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]
|
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 { filter, urls, refs } = timeline
|
||||||
const startIdx = refs.findIndex(([, createdAt]) => createdAt < until)
|
const startIdx = refs.findIndex(([, createdAt]) => createdAt < until)
|
||||||
@@ -456,17 +630,19 @@ class ClientService extends EventTarget {
|
|||||||
)
|
)
|
||||||
).filter(Boolean) as NEvent[])
|
).filter(Boolean) as NEvent[])
|
||||||
: []
|
: []
|
||||||
if (cachedEvents.length > 0) {
|
if (cachedEvents.length >= limit) {
|
||||||
return cachedEvents
|
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 })
|
let events = await this.query(urls, { ...filter, until: until, limit: limit })
|
||||||
events.forEach((evt) => {
|
events.forEach((evt) => {
|
||||||
this.eventDataLoader.prime(evt.id, Promise.resolve(evt))
|
this.eventDataLoader.prime(evt.id, Promise.resolve(evt))
|
||||||
})
|
})
|
||||||
events = events.sort((a, b) => b.created_at - a.created_at).slice(0, limit)
|
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))
|
timeline.refs.push(...events.map((evt) => [evt.id, evt.created_at] as TTimelineRef))
|
||||||
return events
|
return [...cachedEvents, ...events]
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchEvents(
|
async fetchEvents(
|
||||||
@@ -544,7 +720,6 @@ class ClientService extends EventTarget {
|
|||||||
if (!skipCache) {
|
if (!skipCache) {
|
||||||
const localProfile = await indexedDb.getReplaceableEvent(pubkey, kinds.Metadata)
|
const localProfile = await indexedDb.getReplaceableEvent(pubkey, kinds.Metadata)
|
||||||
if (localProfile) {
|
if (localProfile) {
|
||||||
this.addUsernameToIndex(localProfile)
|
|
||||||
return localProfile
|
return localProfile
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -623,6 +798,20 @@ class ClientService extends EventTarget {
|
|||||||
return getRelayListFromRelayListEvent(event)
|
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) {
|
async fetchFollowListEvent(pubkey: string, storeToIndexedDb = false) {
|
||||||
const event = await this.followListCache.fetch(pubkey)
|
const event = await this.followListCache.fetch(pubkey)
|
||||||
if (storeToIndexedDb && event) {
|
if (storeToIndexedDb && event) {
|
||||||
@@ -645,56 +834,6 @@ class ClientService extends EventTarget {
|
|||||||
this.relayListEventDataLoader.prime(event.pubkey, Promise.resolve(event))
|
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) {
|
async searchProfilesFromIndex(query: string, limit: number = 100) {
|
||||||
const result = await this.userIndex.searchAsync(query, { limit })
|
const result = await this.userIndex.searchAsync(query, { limit })
|
||||||
return Promise.all(result.map((pubkey) => this.fetchProfile(pubkey as string))).then(
|
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[]) {
|
private async relayListEventBatchLoadFn(pubkeys: readonly string[]) {
|
||||||
const relayEvents = await Promise.all(
|
const relayEvents = await indexedDb.getManyReplaceableEvents(pubkeys, kinds.RelayList)
|
||||||
pubkeys.map((pubkey) => indexedDb.getReplaceableEvent(pubkey, kinds.RelayList))
|
|
||||||
)
|
|
||||||
const nonExistingPubkeys = pubkeys.filter((_, i) => !relayEvents[i])
|
const nonExistingPubkeys = pubkeys.filter((_, i) => !relayEvents[i])
|
||||||
if (nonExistingPubkeys.length) {
|
if (nonExistingPubkeys.length) {
|
||||||
const events = await this.query(BIG_RELAY_URLS, {
|
const events = await this.query(BIG_RELAY_URLS, {
|
||||||
|
|||||||
@@ -88,19 +88,23 @@ class IndexedDbService {
|
|||||||
getRequest.onsuccess = () => {
|
getRequest.onsuccess = () => {
|
||||||
const oldValue = getRequest.result as TValue<Event> | undefined
|
const oldValue = getRequest.result as TValue<Event> | undefined
|
||||||
if (oldValue && oldValue.value.created_at >= event.created_at) {
|
if (oldValue && oldValue.value.created_at >= event.created_at) {
|
||||||
|
transaction.commit()
|
||||||
return resolve(oldValue.value)
|
return resolve(oldValue.value)
|
||||||
}
|
}
|
||||||
const putRequest = store.put(this.formatValue(event.pubkey, event))
|
const putRequest = store.put(this.formatValue(event.pubkey, event))
|
||||||
putRequest.onsuccess = () => {
|
putRequest.onsuccess = () => {
|
||||||
|
transaction.commit()
|
||||||
resolve(event)
|
resolve(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
putRequest.onerror = (event) => {
|
putRequest.onerror = (event) => {
|
||||||
|
transaction.commit()
|
||||||
reject(event)
|
reject(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getRequest.onerror = (event) => {
|
getRequest.onerror = (event) => {
|
||||||
|
transaction.commit()
|
||||||
reject(event)
|
reject(event)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -121,15 +125,59 @@ class IndexedDbService {
|
|||||||
const request = store.get(pubkey)
|
const request = store.get(pubkey)
|
||||||
|
|
||||||
request.onsuccess = () => {
|
request.onsuccess = () => {
|
||||||
|
transaction.commit()
|
||||||
resolve((request.result as TValue<Event>)?.value)
|
resolve((request.result as TValue<Event>)?.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
request.onerror = (event) => {
|
request.onerror = (event) => {
|
||||||
|
transaction.commit()
|
||||||
reject(event)
|
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[][]> {
|
async getMuteDecryptedTags(id: string): Promise<string[][]> {
|
||||||
await this.initPromise
|
await this.initPromise
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -141,10 +189,12 @@ class IndexedDbService {
|
|||||||
const request = store.get(id)
|
const request = store.get(id)
|
||||||
|
|
||||||
request.onsuccess = () => {
|
request.onsuccess = () => {
|
||||||
|
transaction.commit()
|
||||||
resolve((request.result as TValue<string[][]>)?.value)
|
resolve((request.result as TValue<string[][]>)?.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
request.onerror = (event) => {
|
request.onerror = (event) => {
|
||||||
|
transaction.commit()
|
||||||
reject(event)
|
reject(event)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -161,10 +211,12 @@ class IndexedDbService {
|
|||||||
|
|
||||||
const putRequest = store.put(this.formatValue(id, tags))
|
const putRequest = store.put(this.formatValue(id, tags))
|
||||||
putRequest.onsuccess = () => {
|
putRequest.onsuccess = () => {
|
||||||
|
transaction.commit()
|
||||||
resolve()
|
resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
putRequest.onerror = (event) => {
|
putRequest.onerror = (event) => {
|
||||||
|
transaction.commit()
|
||||||
reject(event)
|
reject(event)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -181,10 +233,12 @@ class IndexedDbService {
|
|||||||
const request = store.getAll()
|
const request = store.getAll()
|
||||||
|
|
||||||
request.onsuccess = () => {
|
request.onsuccess = () => {
|
||||||
|
transaction.commit()
|
||||||
resolve((request.result as TValue<Event>[])?.map((item) => item.value))
|
resolve((request.result as TValue<Event>[])?.map((item) => item.value))
|
||||||
}
|
}
|
||||||
|
|
||||||
request.onerror = (event) => {
|
request.onerror = (event) => {
|
||||||
|
transaction.commit()
|
||||||
reject(event)
|
reject(event)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -205,10 +259,12 @@ class IndexedDbService {
|
|||||||
|
|
||||||
const putRequest = store.put(this.formatValue(dValue, event))
|
const putRequest = store.put(this.formatValue(dValue, event))
|
||||||
putRequest.onsuccess = () => {
|
putRequest.onsuccess = () => {
|
||||||
|
transaction.commit()
|
||||||
resolve()
|
resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
putRequest.onerror = (event) => {
|
putRequest.onerror = (event) => {
|
||||||
|
transaction.commit()
|
||||||
reject(event)
|
reject(event)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -230,11 +286,13 @@ class IndexedDbService {
|
|||||||
callback((cursor.value as TValue<Event>).value)
|
callback((cursor.value as TValue<Event>).value)
|
||||||
cursor.continue()
|
cursor.continue()
|
||||||
} else {
|
} else {
|
||||||
|
transaction.commit()
|
||||||
resolve()
|
resolve()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
request.onerror = (event) => {
|
request.onerror = (event) => {
|
||||||
|
transaction.commit()
|
||||||
reject(event)
|
reject(event)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -296,7 +354,8 @@ class IndexedDbService {
|
|||||||
const cursor = (event.target as IDBRequest).result
|
const cursor = (event.target as IDBRequest).result
|
||||||
if (cursor) {
|
if (cursor) {
|
||||||
const value: TValue = cursor.value
|
const value: TValue = cursor.value
|
||||||
if (value.addedAt < expirationTimestamp) {
|
// 10% chance to delete
|
||||||
|
if (value.addedAt < expirationTimestamp && Math.random() < 0.1) {
|
||||||
cursor.delete()
|
cursor.delete()
|
||||||
}
|
}
|
||||||
cursor.continue()
|
cursor.continue()
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ class LightningService {
|
|||||||
.concat(BIG_RELAY_URLS),
|
.concat(BIG_RELAY_URLS),
|
||||||
comment
|
comment
|
||||||
})
|
})
|
||||||
const zapRequest = await client.signer(zapRequestDraft)
|
const zapRequest = await client.signer.signEvent(zapRequestDraft)
|
||||||
const zapRequestRes = await fetch(
|
const zapRequestRes = await fetch(
|
||||||
`${callback}?amount=${amount}&nostr=${encodeURI(JSON.stringify(zapRequest))}&lnurl=${lnurl}`
|
`${callback}?amount=${amount}&nostr=${encodeURI(JSON.stringify(zapRequest))}&lnurl=${lnurl}`
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -32,8 +32,9 @@ class RelayInfoService {
|
|||||||
.toLocaleLowerCase()
|
.toLocaleLowerCase()
|
||||||
.split(/\s+/)
|
.split(/\s+/)
|
||||||
})
|
})
|
||||||
private fetchDataloader = new DataLoader<string, TNip66RelayInfo | undefined>((urls) =>
|
private fetchDataloader = new DataLoader<string, TNip66RelayInfo | undefined>(
|
||||||
Promise.all(urls.map((url) => this._getRelayInfo(url)))
|
(urls) => Promise.all(urls.map((url) => this._getRelayInfo(url))),
|
||||||
|
{ maxBatchSize: 1 }
|
||||||
)
|
)
|
||||||
private relayUrlsForRandom: string[] = []
|
private relayUrlsForRandom: string[] = []
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import DataLoader from 'dataloader'
|
|||||||
class WebService {
|
class WebService {
|
||||||
static instance: WebService
|
static instance: WebService
|
||||||
|
|
||||||
private webMetadataDataLoader = new DataLoader<string, TWebMetadata>(async (urls) => {
|
private webMetadataDataLoader = new DataLoader<string, TWebMetadata>(
|
||||||
|
async (urls) => {
|
||||||
return await Promise.all(
|
return await Promise.all(
|
||||||
urls.map(async (url) => {
|
urls.map(async (url) => {
|
||||||
try {
|
try {
|
||||||
@@ -28,7 +29,9 @@ class WebService {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
})
|
},
|
||||||
|
{ maxBatchSize: 1 }
|
||||||
|
)
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
if (!WebService.instance) {
|
if (!WebService.instance) {
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export interface ISigner {
|
|||||||
nip04Decrypt: (pubkey: string, cipherText: string) => Promise<string>
|
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 = {
|
export type TAccount = {
|
||||||
pubkey: string
|
pubkey: string
|
||||||
@@ -89,6 +89,7 @@ export type TAccount = {
|
|||||||
nsec?: string
|
nsec?: string
|
||||||
bunker?: string
|
bunker?: string
|
||||||
bunkerClientSecretKey?: string
|
bunkerClientSecretKey?: string
|
||||||
|
npub?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TAccountPointer = Pick<TAccount, 'pubkey' | 'signerType'>
|
export type TAccountPointer = Pick<TAccount, 'pubkey' | 'signerType'>
|
||||||
|
|||||||
Reference in New Issue
Block a user