From d24e208f0b1c79e6ff5882745f031c8945c0df47 Mon Sep 17 00:00:00 2001 From: codytseng Date: Thu, 27 Mar 2025 22:37:06 +0800 Subject: [PATCH] feat: outbox model for the following feed --- src/components/AccountList/index.tsx | 4 +- src/components/AccountManager/NpubLogin.tsx | 56 ++ src/components/AccountManager/index.tsx | 11 +- .../CalculateOptimalReadRelaysButton.tsx | 247 --------- src/components/MailboxSetting/index.tsx | 9 - src/components/NoteList/index.tsx | 6 +- src/components/NoteStats/SeenOnButton.tsx | 4 +- src/components/ZapDialog/index.tsx | 73 ++- src/components/ui/dialog.tsx | 9 +- src/lib/common.ts | 4 + src/lib/utils.ts | 7 + src/pages/secondary/NoteListPage/index.tsx | 14 +- src/pages/secondary/ProfilePage/index.tsx | 20 +- src/providers/FeedProvider.tsx | 8 +- src/providers/NostrProvider/index.tsx | 32 +- src/providers/NostrProvider/npub.signer.ts | 34 ++ src/services/client.service.ts | 501 +++++++++++------- src/services/indexed-db.service.ts | 61 ++- src/services/lightning.service.ts | 2 +- src/services/relay-info.service.ts | 5 +- src/services/web.service.ts | 49 +- src/types.ts | 3 +- 22 files changed, 642 insertions(+), 517 deletions(-) create mode 100644 src/components/AccountManager/NpubLogin.tsx delete mode 100644 src/components/MailboxSetting/CalculateOptimalReadRelaysButton.tsx create mode 100644 src/providers/NostrProvider/npub.signer.ts diff --git a/src/components/AccountList/index.tsx b/src/components/AccountList/index.tsx index 712d0c8c..a9ffc8a6 100644 --- a/src/components/AccountList/index.tsx +++ b/src/components/AccountList/index.tsx @@ -68,7 +68,9 @@ function SignerTypeBadge({ signerType }: { signerType: TSignerType }) { return Bunker } else if (signerType === 'ncryptsec') { return NCRYPTSEC - } else { + } else if (signerType === 'nsec') { return NSEC + } else if (signerType === 'npub') { + return NPUB } } diff --git a/src/components/AccountManager/NpubLogin.tsx b/src/components/AccountManager/NpubLogin.tsx new file mode 100644 index 00000000..eeb9c5c3 --- /dev/null +++ b/src/components/AccountManager/NpubLogin.tsx @@ -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(null) + + const handleInputChange = (e: React.ChangeEvent) => { + 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 ( + <> +
+ + {errMsg &&
{errMsg}
} +
+ + + + ) +} diff --git a/src/components/AccountManager/index.tsx b/src/components/AccountManager/index.tsx index 5c26249a..7b1ac33b 100644 --- a/src/components/AccountManager/index.tsx +++ b/src/components/AccountManager/index.tsx @@ -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(null) @@ -23,6 +25,8 @@ export default function AccountManager({ close }: { close?: () => void }) { setPage(null)} onLoginSuccess={() => close?.()} /> ) : page === 'generate' ? ( setPage(null)} onLoginSuccess={() => close?.()} /> + ) : page === 'npub' ? ( + setPage(null)} onLoginSuccess={() => close?.()} /> ) : ( )} @@ -59,6 +63,11 @@ function AccountManagerNav({ + {isDevEnv() && ( + + )} diff --git a/src/components/MailboxSetting/CalculateOptimalReadRelaysButton.tsx b/src/components/MailboxSetting/CalculateOptimalReadRelaysButton.tsx deleted file mode 100644 index e16265d2..00000000 --- a/src/components/MailboxSetting/CalculateOptimalReadRelaysButton.tsx +++ /dev/null @@ -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 = ( - - ) - - if (isSmallScreen) { - return ( - - {trigger} - -
- - {t('Select relays to append')} - - - setOpen(false)} mergeRelays={mergeRelays} /> -
-
-
- ) - } - - return ( - - {trigger} - - - {t('Select relays to append')} - - - setOpen(false)} mergeRelays={mergeRelays} /> - - - ) -} - -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([]) - - useEffect(() => { - if (!pubkey) return - - const init = async () => { - setIsCalculating(true) - const relays = await client.calculateOptimalReadRelays(pubkey) - setOptimalReadRelays(relays) - setIsCalculating(false) - } - init() - }, []) - - if (isCalculating) { - return
{t('calculating...')}
- } - - return ( -
-
- {optimalReadRelays.map((relay) => ( - - ))} -
- -
- ) -} - -function RelayItem({ - relay, - close, - selectedRelayUrls, - setSelectedRelayUrls -}: { - relay: { url: string; pubkeys: string[] } - close: () => void - selectedRelayUrls: string[] - setSelectedRelayUrls: Dispatch> -}) { - const { t } = useTranslation() - const { push } = useSecondaryPage() - const [expanded, setExpanded] = useState(false) - - const selected = selectedRelayUrls.includes(relay.url) - - return ( -
- setSelectedRelayUrls((pre) => - pre.includes(relay.url) ? pre.filter((url) => url !== relay.url) : [...pre, relay.url] - ) - } - > -
-
- { - setSelectedRelayUrls((prev) => - select ? [...prev, relay.url] : prev.filter((url) => url !== relay.url) - ) - }} - /> - -
{relay.url}
-
-
{ - e.stopPropagation() - setExpanded((prev) => !prev) - }} - > -
- {relay.pubkeys.length} {t('followings')} -
- -
-
- {expanded && ( -
- {relay.pubkeys.map((pubkey) => ( -
{ - e.stopPropagation() - close() - push(toProfile(pubkey)) - }} - > - - -
- ))} -
- )} -
- ) -} - -function SelectToggle({ - select, - setSelect -}: { - select: boolean - setSelect: (select: boolean) => void -}) { - return select ? ( - { - e.stopPropagation() - setSelect(false) - }} - /> - ) : ( - { - e.stopPropagation() - setSelect(true) - }} - /> - ) -} diff --git a/src/components/MailboxSetting/index.tsx b/src/components/MailboxSetting/index.tsx index 54785df1..5db8041a 100644 --- a/src/components/MailboxSetting/index.tsx +++ b/src/components/MailboxSetting/index.tsx @@ -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 (
@@ -70,7 +62,6 @@ export default function MailboxSetting() {
{t('write relays description')}
{t('read & write relays notice')}
-
{relays.map((relay) => ( diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index cad007fa..f802e66b 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -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(null) useEffect(() => { - if (relayUrls.length === 0) return + if (relayUrls.length === 0 && !noteFilter.authors?.length) return async function init() { setRefreshing(true) diff --git a/src/components/NoteStats/SeenOnButton.tsx b/src/components/NoteStats/SeenOnButton.tsx index 7cb2ebd4..2ad4753e 100644 --- a/src/components/NoteStats/SeenOnButton.tsx +++ b/src/components/NoteStats/SeenOnButton.tsx @@ -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 }} > {simplifyUrl(relay)} diff --git a/src/components/ZapDialog/index.tsx b/src/components/ZapDialog/index.tsx index ded731a6..08d87426 100644 --- a/src/components/ZapDialog/index.tsx +++ b/src/components/ZapDialog/index.tsx @@ -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(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 ( + + setOpen(false)} /> + e.preventDefault()} + ref={drawerContentRef} + className="flex flex-col gap-4 px-4 mb-4" + > + + +
{t('Zap to')}
+ + +
+ +
+ +
+
+ ) + } return ( - + setOpen(false)} /> + e.preventDefault()}>
{t('Zap to')}
diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index 353b5463..fb442d3f 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -29,10 +29,13 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName const DialogContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef & { withoutClose?: boolean } ->(({ className, children, withoutClose, ...props }, ref) => ( + React.ComponentPropsWithoutRef & { + withoutClose?: boolean + hideOverlay?: boolean + } +>(({ className, children, withoutClose, hideOverlay, ...props }, ref) => ( - + {!hideOverlay && } { 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 ( diff --git a/src/pages/secondary/ProfilePage/index.tsx b/src/pages/secondary/ProfilePage/index.tsx index ab0b843e..d43e2cdb 100644 --- a/src/pages/secondary/ProfilePage/index.tsx +++ b/src/pages/secondary/ProfilePage/index.tsx @@ -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 },
- {!isFetchingRelayInfo && ( - - )} + ) }) diff --git a/src/providers/FeedProvider.tsx b/src/providers/FeedProvider.tsx index 8ecd1d0c..5c43a8f1 100644 --- a/src/providers/FeedProvider.tsx +++ b/src/providers/FeedProvider.tsx @@ -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] }) diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index a077dcbc..5dc09c32 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -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 nip07Login: () => Promise bunkerLogin: (bunker: string) => Promise + npubLogin(npub: string): Promise 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, diff --git a/src/providers/NostrProvider/npub.signer.ts b/src/providers/NostrProvider/npub.signer.ts new file mode 100644 index 00000000..68b32b3f --- /dev/null +++ b/src/providers/NostrProvider/npub.signer.ts @@ -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 { + throw new Error('Not logged in') + } + + async nip04Encrypt(): Promise { + throw new Error('Not logged in') + } + + async nip04Decrypt(): Promise { + throw new Error('Not logged in') + } +} diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 14381e0c..d13a897c 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -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 + signer?: ISigner private currentRelayUrls: string[] = [] private pool: SimplePool @@ -36,6 +37,7 @@ class ClientService extends EventTarget { filter: Omit & { limit: number } urls: string[] } + | string[] | undefined > = {} private eventCache = new LRUCache>({ max: 10000 }) @@ -45,22 +47,20 @@ class ClientService extends EventTarget { ) private fetchEventFromBigRelaysDataloader = new DataLoader( this.fetchEventsFromBigRelays.bind(this), - { cache: false, batchScheduleFn: (callback) => setTimeout(callback, 200) } + { cache: false, batchScheduleFn: (callback) => setTimeout(callback, 50) } ) private fetchProfileEventFromBigRelaysDataloader = new DataLoader( this.profileEventBatchLoadFn.bind(this), { - batchScheduleFn: (callback) => setTimeout(callback, 200), - cacheMap: new LRUCache>({ max: 1000 }), - maxBatchSize: 20 + batchScheduleFn: (callback) => setTimeout(callback, 50), + maxBatchSize: 500 } ) private relayListEventDataLoader = new DataLoader( this.relayListEventBatchLoadFn.bind(this), { - batchScheduleFn: (callback) => setTimeout(callback, 200), - cacheMap: new LRUCache>({ max: 1000 }), - maxBatchSize: 20 + batchScheduleFn: (callback) => setTimeout(callback, 50), + maxBatchSize: 500 } ) private followListCache = new LRUCache>({ @@ -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 & { limit: number }, // filter with limit, + { authors, ...filter }: Omit & { 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> = {} + 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() + 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() + const requestCount = subRequests.length + let eventIdSet = new Set() + 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() + 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[] = [] + 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 & { 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() - 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() - 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, { diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts index 7b4d1787..a2c88057 100644 --- a/src/services/indexed-db.service.ts +++ b/src/services/indexed-db.service.ts @@ -88,19 +88,23 @@ class IndexedDbService { getRequest.onsuccess = () => { const oldValue = getRequest.result as TValue | 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)?.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)?.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 { 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)?.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[])?.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).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() diff --git a/src/services/lightning.service.ts b/src/services/lightning.service.ts index 2012b183..fce62030 100644 --- a/src/services/lightning.service.ts +++ b/src/services/lightning.service.ts @@ -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}` ) diff --git a/src/services/relay-info.service.ts b/src/services/relay-info.service.ts index c98bab1b..59f49804 100644 --- a/src/services/relay-info.service.ts +++ b/src/services/relay-info.service.ts @@ -32,8 +32,9 @@ class RelayInfoService { .toLocaleLowerCase() .split(/\s+/) }) - private fetchDataloader = new DataLoader((urls) => - Promise.all(urls.map((url) => this._getRelayInfo(url))) + private fetchDataloader = new DataLoader( + (urls) => Promise.all(urls.map((url) => this._getRelayInfo(url))), + { maxBatchSize: 1 } ) private relayUrlsForRandom: string[] = [] diff --git a/src/services/web.service.ts b/src/services/web.service.ts index a4ad1b51..449babc6 100644 --- a/src/services/web.service.ts +++ b/src/services/web.service.ts @@ -4,31 +4,34 @@ import DataLoader from 'dataloader' class WebService { static instance: WebService - private webMetadataDataLoader = new DataLoader(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( + 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) { diff --git a/src/types.ts b/src/types.ts index e22b1de5..14345a5f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -80,7 +80,7 @@ export interface ISigner { nip04Decrypt: (pubkey: string, cipherText: string) => Promise } -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