Browse Source

implemented cache relays

imwald
Silberengel 5 months ago
parent
commit
e695529548
  1. 216
      src/components/CacheRelaysSetting/index.tsx
  2. 12
      src/components/MailboxSetting/DiscoveredRelays.tsx
  3. 6
      src/components/Tabs/index.tsx
  4. 1
      src/constants.ts
  5. 9
      src/lib/draft-event.ts
  6. 8
      src/pages/secondary/RelaySettingsPage/index.tsx
  7. 46
      src/providers/NostrProvider/index.tsx
  8. 312
      src/services/client.service.ts
  9. 35
      src/services/indexed-db.service.ts

216
src/components/CacheRelaysSetting/index.tsx

@ -0,0 +1,216 @@ @@ -0,0 +1,216 @@
import { Button } from '@/components/ui/button'
import { normalizeUrl, isLocalNetworkUrl } from '@/lib/url'
import { useNostr } from '@/providers/NostrProvider'
import { TMailboxRelay, TMailboxRelayScope } from '@/types'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
TouchSensor,
useSensor,
useSensors,
DragEndEvent
} from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy
} from '@dnd-kit/sortable'
import { restrictToVerticalAxis, restrictToParentElement } from '@dnd-kit/modifiers'
import MailboxRelay from '../MailboxSetting/MailboxRelay'
import NewMailboxRelayInput from '../MailboxSetting/NewMailboxRelayInput'
import RelayCountWarning from '../MailboxSetting/RelayCountWarning'
import DiscoveredRelays from '../MailboxSetting/DiscoveredRelays'
import { createCacheRelaysDraftEvent } from '@/lib/draft-event'
import { getRelayListFromEvent } from '@/lib/event-metadata'
import { showPublishingFeedback, showSimplePublishSuccess, showPublishingError } from '@/lib/publishing-feedback'
import { CloudUpload, Loader } from 'lucide-react'
export default function CacheRelaysSetting() {
const { t } = useTranslation()
const { pubkey, cacheRelayListEvent, checkLogin, publish, updateCacheRelayListEvent } = useNostr()
const [relays, setRelays] = useState<TMailboxRelay[]>([])
const [hasChange, setHasChange] = useState(false)
const [pushing, setPushing] = useState(false)
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8
}
}),
useSensor(TouchSensor, {
activationConstraint: {
delay: 200,
tolerance: 8
}
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates
})
)
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event
if (active.id !== over?.id) {
const oldIndex = relays.findIndex((relay) => relay.url === active.id)
const newIndex = relays.findIndex((relay) => relay.url === over?.id)
if (oldIndex !== -1 && newIndex !== -1) {
setRelays((relays) => arrayMove(relays, oldIndex, newIndex))
setHasChange(true)
}
}
}
useEffect(() => {
if (!cacheRelayListEvent) {
setRelays([])
setHasChange(false)
return
}
const cacheRelayList = getRelayListFromEvent(cacheRelayListEvent)
setRelays(cacheRelayList.originalRelays)
setHasChange(false)
}, [cacheRelayListEvent])
if (!pubkey) {
return (
<div className="flex flex-col w-full items-center">
<Button size="lg" onClick={() => checkLogin()}>
{t('Login to set')}
</Button>
</div>
)
}
if (cacheRelayListEvent === undefined) {
return <div className="text-center text-sm text-muted-foreground">{t('loading...')}</div>
}
const changeCacheRelayScope = (url: string, scope: TMailboxRelayScope) => {
setRelays((prev) => prev.map((r) => (r.url === url ? { ...r, scope } : r)))
setHasChange(true)
}
const removeCacheRelay = (url: string) => {
setRelays((prev) => prev.filter((r) => r.url !== url))
setHasChange(true)
}
const saveNewCacheRelay = (url: string) => {
if (url === '') return null
const normalizedUrl = normalizeUrl(url)
if (!normalizedUrl) {
return t('Invalid relay URL')
}
// Cache relays must be local network URLs only
if (!isLocalNetworkUrl(normalizedUrl)) {
return t('Cache relays must be local network URLs only (e.g., ws://localhost:4869 or ws://127.0.0.1:4869)')
}
if (relays.some((r) => r.url === normalizedUrl)) {
return t('Relay already exists')
}
setRelays([...relays, { url: normalizedUrl, scope: 'both' }])
setHasChange(true)
return null
}
const handleAddDiscoveredRelays = (newRelays: TMailboxRelay[]) => {
// Filter to only local network URLs for cache relays
const localRelays = newRelays.filter(newRelay => isLocalNetworkUrl(newRelay.url))
const relaysToAdd = localRelays.filter(
newRelay => !relays.some(r => r.url === newRelay.url)
)
if (relaysToAdd.length > 0) {
setRelays([...relays, ...relaysToAdd])
setHasChange(true)
}
}
const save = async () => {
if (!pubkey) return
setPushing(true)
try {
const event = createCacheRelaysDraftEvent(relays)
const result = await publish(event)
await updateCacheRelayListEvent(result)
setHasChange(false)
// Show publishing feedback
if ((result as any).relayStatuses) {
showPublishingFeedback({
success: true,
relayStatuses: (result as any).relayStatuses,
successCount: (result as any).relayStatuses.filter((s: any) => s.success).length,
totalCount: (result as any).relayStatuses.length
}, {
message: t('Cache relays saved'),
duration: 6000
})
} else {
showSimplePublishSuccess(t('Cache relays saved'))
}
} catch (error) {
console.error('Failed to save cache relays:', error)
// Show error feedback
if (error instanceof Error && (error as any).relayStatuses) {
showPublishingFeedback({
success: false,
relayStatuses: (error as any).relayStatuses,
successCount: (error as any).relayStatuses.filter((s: any) => s.success).length,
totalCount: (error as any).relayStatuses.length
}, {
message: error.message || t('Failed to save cache relays'),
duration: 6000
})
} else {
showPublishingError(error instanceof Error ? error : new Error(t('Failed to save cache relays')))
}
} finally {
setPushing(false)
}
}
return (
<div className="space-y-4">
<div className="text-xs text-muted-foreground space-y-1">
<div>{t('Cache relays are used to store and retrieve events locally. These relays are merged with your inbox and outbox relays.')}</div>
</div>
<DiscoveredRelays onAdd={handleAddDiscoveredRelays} localOnly={true} />
<RelayCountWarning relays={relays} />
<Button className="w-full" disabled={!pubkey || pushing || !hasChange} onClick={save}>
{pushing ? <Loader className="animate-spin" /> : <CloudUpload />}
{t('Save')}
</Button>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
>
<SortableContext items={relays.map((r) => r.url)} strategy={verticalListSortingStrategy}>
<div className="space-y-2">
{relays.map((relay) => (
<MailboxRelay
key={relay.url}
mailboxRelay={relay}
changeMailboxRelayScope={changeCacheRelayScope}
removeMailboxRelay={removeCacheRelay}
/>
))}
</div>
</SortableContext>
</DndContext>
<NewMailboxRelayInput saveNewMailboxRelay={saveNewCacheRelay} />
</div>
)
}

12
src/components/MailboxSetting/DiscoveredRelays.tsx

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { normalizeUrl } from '@/lib/url'
import { normalizeUrl, isLocalNetworkUrl } from '@/lib/url'
import { getRelaysFromNip07Extension, verifyNip05 } from '@/lib/nip05'
import { useNostr } from '@/providers/NostrProvider'
import { TMailboxRelay } from '@/types'
@ -15,7 +15,7 @@ interface DiscoveredRelay { @@ -15,7 +15,7 @@ interface DiscoveredRelay {
selected: boolean
}
export default function DiscoveredRelays({ onAdd }: { onAdd: (relays: TMailboxRelay[]) => void }) {
export default function DiscoveredRelays({ onAdd, localOnly = false }: { onAdd: (relays: TMailboxRelay[]) => void; localOnly?: boolean }) {
const { t } = useTranslation()
const { profile, account } = useNostr()
const [discoveredRelays, setDiscoveredRelays] = useState<DiscoveredRelay[]>([])
@ -79,7 +79,13 @@ export default function DiscoveredRelays({ onAdd }: { onAdd: (relays: TMailboxRe @@ -79,7 +79,13 @@ export default function DiscoveredRelays({ onAdd }: { onAdd: (relays: TMailboxRe
// Note: Bunker relays are from the bunker connection URL itself
// We could add logic here to extract relays from the bunker URL if needed
setDiscoveredRelays(Array.from(discovered.values()))
// Filter to only local relays if localOnly is true
let discoveredArray = Array.from(discovered.values())
if (localOnly) {
discoveredArray = discoveredArray.filter(relay => isLocalNetworkUrl(relay.url))
}
setDiscoveredRelays(discoveredArray)
} catch (error) {
console.error('Error discovering relays:', error)
setErrorMsg(t('Failed to discover relays'))

6
src/components/Tabs/index.tsx

@ -2,7 +2,6 @@ import { cn } from '@/lib/utils' @@ -2,7 +2,6 @@ import { cn } from '@/lib/utils'
import { useDeepBrowsing } from '@/providers/DeepBrowsingProvider'
import { ReactNode, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ScrollArea, ScrollBar } from '../ui/scroll-area'
type TabDefinition = {
value: string
@ -91,7 +90,7 @@ export default function Tabs({ @@ -91,7 +90,7 @@ export default function Tabs({
deepBrowsing && lastScrollTop > threshold ? '-translate-y-[calc(100%+12rem)]' : ''
)}
>
<ScrollArea className="flex-1 w-0">
<div className="flex-1 w-0 overflow-hidden">
<div className="flex w-fit relative">
{tabs.map((tab, index) => (
<div
@ -116,8 +115,7 @@ export default function Tabs({ @@ -116,8 +115,7 @@ export default function Tabs({
}}
/>
</div>
<ScrollBar orientation="horizontal" className="opacity-0 pointer-events-none" />
</ScrollArea>
</div>
{options && <div className="py-1 flex items-center">{options}</div>}
</div>
)

1
src/constants.ts

@ -130,6 +130,7 @@ export const ExtendedKind = { @@ -130,6 +130,7 @@ export const ExtendedKind = {
FAVORITE_RELAYS: 10012,
BLOCKED_RELAYS: 10006,
BLOSSOM_SERVER_LIST: 10063,
CACHE_RELAYS: 10432,
RELAY_REVIEW: 31987,
GROUP_METADATA: 39000,
GROUP_LIST: 10009, // NIP-51 Group List

9
src/lib/draft-event.ts

@ -437,6 +437,15 @@ export function createRelayListDraftEvent(mailboxRelays: TMailboxRelay[]): TDraf @@ -437,6 +437,15 @@ export function createRelayListDraftEvent(mailboxRelays: TMailboxRelay[]): TDraf
}
}
export function createCacheRelaysDraftEvent(mailboxRelays: TMailboxRelay[]): TDraftEvent {
return {
kind: ExtendedKind.CACHE_RELAYS,
content: '',
tags: mailboxRelays.map(({ url, scope }) => buildRTag(url, scope)),
created_at: dayjs().unix()
}
}
export function createFollowListDraftEvent(tags: string[][], content?: string): TDraftEvent {
return {
kind: kinds.Contacts,

8
src/pages/secondary/RelaySettingsPage/index.tsx

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
import MailboxSetting from '@/components/MailboxSetting'
import FavoriteRelaysSetting from '@/components/FavoriteRelaysSetting'
import CacheRelaysSetting from '@/components/CacheRelaysSetting'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { forwardRef, useEffect, useState } from 'react'
@ -14,6 +15,9 @@ const RelaySettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?: @@ -14,6 +15,9 @@ const RelaySettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?:
case '#mailbox':
setTabValue('mailbox')
break
case '#cache-relays':
setTabValue('cache-relays')
break
case '#favorite-relays':
setTabValue('favorite-relays')
break
@ -26,6 +30,7 @@ const RelaySettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?: @@ -26,6 +30,7 @@ const RelaySettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?:
<TabsList>
<TabsTrigger value="favorite-relays">{t('Favorite Relays')}</TabsTrigger>
<TabsTrigger value="mailbox">{t('Read & Write Relays')}</TabsTrigger>
<TabsTrigger value="cache-relays">{t('Cache Relays')}</TabsTrigger>
</TabsList>
<TabsContent value="favorite-relays">
<FavoriteRelaysSetting />
@ -33,6 +38,9 @@ const RelaySettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?: @@ -33,6 +38,9 @@ const RelaySettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?:
<TabsContent value="mailbox">
<MailboxSetting />
</TabsContent>
<TabsContent value="cache-relays">
<CacheRelaysSetting />
</TabsContent>
</Tabs>
</SecondaryPageLayout>
)

46
src/providers/NostrProvider/index.tsx

@ -51,6 +51,7 @@ type TNostrContext = { @@ -51,6 +51,7 @@ type TNostrContext = {
profile: TProfile | null
profileEvent: Event | null
relayList: TRelayList | null
cacheRelayListEvent: Event | null
followListEvent: Event | null
muteListEvent: Event | null
bookmarkListEvent: Event | null
@ -83,6 +84,7 @@ type TNostrContext = { @@ -83,6 +84,7 @@ type TNostrContext = {
startLogin: () => void
checkLogin: <T>(cb?: () => T) => Promise<T | void>
updateRelayListEvent: (relayListEvent: Event) => Promise<void>
updateCacheRelayListEvent: (cacheRelayListEvent: Event) => Promise<void>
updateProfileEvent: (profileEvent: Event) => Promise<void>
updateFollowListEvent: (followListEvent: Event) => Promise<void>
updateMuteListEvent: (muteListEvent: Event, privateTags: string[][]) => Promise<void>
@ -156,6 +158,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -156,6 +158,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
}, [signer])
const [profileEvent, setProfileEvent] = useState<Event | null>(null)
const [relayList, setRelayList] = useState<TRelayList | null>(null)
const [cacheRelayListEvent, setCacheRelayListEvent] = useState<Event | null>(null)
const [followListEvent, setFollowListEvent] = useState<Event | null>(null)
const [muteListEvent, setMuteListEvent] = useState<Event | null>(null)
const [bookmarkListEvent, setBookmarkListEvent] = useState<Event | null>(null)
@ -228,6 +231,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -228,6 +231,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
const [
storedRelayListEvent,
storedCacheRelayListEvent,
storedProfileEvent,
storedFollowListEvent,
storedMuteListEvent,
@ -237,6 +241,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -237,6 +241,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
storedUserEmojiListEvent
] = await Promise.all([
indexedDb.getReplaceableEvent(account.pubkey, kinds.RelayList),
indexedDb.getReplaceableEvent(account.pubkey, ExtendedKind.CACHE_RELAYS),
indexedDb.getReplaceableEvent(account.pubkey, kinds.Metadata),
indexedDb.getReplaceableEvent(account.pubkey, kinds.Contacts),
indexedDb.getReplaceableEvent(account.pubkey, kinds.Mutelist),
@ -283,17 +288,32 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -283,17 +288,32 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
setUserEmojiListEvent(storedUserEmojiListEvent)
}
const relayListEvents = await client.fetchEvents(BIG_RELAY_URLS, {
kinds: [kinds.RelayList],
authors: [account.pubkey]
})
const [relayListEvents, cacheRelayListEvents] = await Promise.all([
client.fetchEvents(BIG_RELAY_URLS, {
kinds: [kinds.RelayList],
authors: [account.pubkey]
}),
client.fetchEvents(BIG_RELAY_URLS, {
kinds: [ExtendedKind.CACHE_RELAYS],
authors: [account.pubkey]
})
])
const relayListEvent = getLatestEvent(relayListEvents) ?? storedRelayListEvent
const cacheRelayListEvent = getLatestEvent(cacheRelayListEvents) ?? storedCacheRelayListEvent
const relayList = getRelayListFromEvent(relayListEvent, blockedRelays)
if (relayListEvent) {
client.updateRelayListCache(relayListEvent)
await indexedDb.putReplaceableEvent(relayListEvent)
}
setRelayList(relayList)
if (cacheRelayListEvent) {
await indexedDb.putReplaceableEvent(cacheRelayListEvent)
setCacheRelayListEvent(cacheRelayListEvent)
} else {
setCacheRelayListEvent(null)
}
// Fetch updated relay list (which merges both 10002 and 10432)
const mergedRelayList = await client.fetchRelayList(account.pubkey)
setRelayList(mergedRelayList)
// Note: Deletion event fetching is now handled locally by individual components
// for better performance and accuracy
@ -872,8 +892,18 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -872,8 +892,18 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
}
const updateRelayListEvent = async (relayListEvent: Event) => {
const newRelayList = await indexedDb.putReplaceableEvent(relayListEvent)
setRelayList(getRelayListFromEvent(newRelayList))
await indexedDb.putReplaceableEvent(relayListEvent)
// Fetch updated relay list (which merges both 10002 and 10432)
const mergedRelayList = await client.fetchRelayList(account?.pubkey || '')
setRelayList(mergedRelayList)
}
const updateCacheRelayListEvent = async (cacheRelayListEvent: Event) => {
const newCacheRelayList = await indexedDb.putReplaceableEvent(cacheRelayListEvent)
setCacheRelayListEvent(newCacheRelayList)
// Fetch updated relay list (which merges both 10002 and 10432)
const mergedRelayList = await client.fetchRelayList(account?.pubkey || '')
setRelayList(mergedRelayList)
}
const updateProfileEvent = async (profileEvent: Event) => {
@ -956,6 +986,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -956,6 +986,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
profile,
profileEvent,
relayList,
cacheRelayListEvent,
followListEvent,
muteListEvent,
bookmarkListEvent,
@ -985,6 +1016,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -985,6 +1016,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
checkLogin,
signEvent,
updateRelayListEvent,
updateCacheRelayListEvent,
updateProfileEvent,
updateFollowListEvent,
updateMuteListEvent,

312
src/services/client.service.ts

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
import { BIG_RELAY_URLS, ExtendedKind } from '@/constants'
import { BIG_RELAY_URLS, ExtendedKind, PROFILE_FETCH_RELAY_URLS, PROFILE_RELAY_URLS } from '@/constants'
import {
compareEvents,
getReplaceableCoordinate,
@ -10,7 +10,7 @@ import { formatPubkey, isValidPubkey, pubkeyToNpub, userIdToPubkey } from '@/lib @@ -10,7 +10,7 @@ import { formatPubkey, isValidPubkey, pubkeyToNpub, userIdToPubkey } from '@/lib
import { getPubkeysFromPTags, getServersFromServerTags, tagNameEquals } from '@/lib/tag'
import { isLocalNetworkUrl, isWebsocketUrl, normalizeUrl } from '@/lib/url'
import { isSafari } from '@/lib/utils'
import { ISigner, TProfile, TPublishOptions, TRelayList, TSubRequestFilter } from '@/types'
import { ISigner, TProfile, TPublishOptions, TRelayList, TMailboxRelay, TSubRequestFilter } from '@/types'
import { sha256 } from '@noble/hashes/sha2'
import DataLoader from 'dataloader'
import dayjs from 'dayjs'
@ -122,13 +122,14 @@ class ClientService extends EventTarget { @@ -122,13 +122,14 @@ class ClientService extends EventTarget {
if (
[
kinds.RelayList,
ExtendedKind.CACHE_RELAYS,
kinds.Contacts,
ExtendedKind.FAVORITE_RELAYS,
ExtendedKind.BLOSSOM_SERVER_LIST,
ExtendedKind.RELAY_REVIEW
].includes(event.kind)
) {
_additionalRelayUrls.push(...BIG_RELAY_URLS)
_additionalRelayUrls.push(...BIG_RELAY_URLS, ...PROFILE_RELAY_URLS)
}
const relayList = await this.fetchRelayList(event.pubkey)
@ -153,57 +154,105 @@ class ClientService extends EventTarget { @@ -153,57 +154,105 @@ class ClientService extends EventTarget {
let finishedCount = 0
const errors: { url: string; error: any }[] = []
// Add a global timeout to prevent hanging for more than 2 minutes
const globalTimeout = setTimeout(() => {
// Mark any unfinished relays as failed
uniqueRelayUrls.forEach(url => {
const alreadyFinished = relayStatuses.some(rs => rs.url === url)
if (!alreadyFinished) {
relayStatuses.push({ url, success: false, error: 'Timeout: Operation took too long' })
finishedCount++
}
})
// Ensure we resolve even if not all relays finished
if (finishedCount < uniqueRelayUrls.length) {
finishedCount = uniqueRelayUrls.length
resolve({
success: successCount >= uniqueRelayUrls.length / 3,
relayStatuses,
successCount,
totalCount: uniqueRelayUrls.length
})
}
}, 120_000) // 2 minutes global timeout
Promise.allSettled(
uniqueRelayUrls.map(async (url) => {
// eslint-disable-next-line @typescript-eslint/no-this-alias
const that = this
const relay = await this.pool.ensureRelay(url)
relay.publishTimeout = 10_000 // 10s
return relay
.publish(event)
.then(() => {
this.trackEventSeenOn(event.id, relay)
successCount++
relayStatuses.push({ url, success: true })
})
.catch((error) => {
if (
error instanceof Error &&
error.message.startsWith('auth-required') &&
!!that.signer
) {
return relay
.auth((authEvt: EventTemplate) => that.signer!.signEvent(authEvt))
.then(() => relay.publish(event))
.then(() => {
this.trackEventSeenOn(event.id, relay)
successCount++
relayStatuses.push({ url, success: true })
})
.catch((authError) => {
errors.push({ url, error: authError })
relayStatuses.push({ url, success: false, error: authError.message })
})
} else {
errors.push({ url, error })
relayStatuses.push({ url, success: false, error: error.message })
}
})
.finally(() => {
// If one third of the relays have accepted the event, consider it a success
const isSuccess = successCount >= uniqueRelayUrls.length / 3
if (isSuccess) {
this.emitNewEvent(event)
}
if (++finishedCount >= uniqueRelayUrls.length) {
resolve({
success: successCount >= uniqueRelayUrls.length / 3,
relayStatuses,
successCount,
totalCount: uniqueRelayUrls.length
})
}
const isLocal = isLocalNetworkUrl(url)
const timeout = isLocal ? 5_000 : 10_000 // 5s for local, 10s for remote
try {
// For local relays, add a connection timeout
let relay: Relay
if (isLocal) {
relay = await Promise.race([
this.pool.ensureRelay(url),
new Promise<Relay>((_, reject) =>
setTimeout(() => reject(new Error('Local relay connection timeout')), timeout)
)
])
} else {
relay = await this.pool.ensureRelay(url)
}
relay.publishTimeout = timeout
await relay
.publish(event)
.then(() => {
this.trackEventSeenOn(event.id, relay)
successCount++
relayStatuses.push({ url, success: true })
})
.catch((error) => {
if (
error instanceof Error &&
error.message.startsWith('auth-required') &&
!!that.signer
) {
return relay
.auth((authEvt: EventTemplate) => that.signer!.signEvent(authEvt))
.then(() => relay.publish(event))
.then(() => {
this.trackEventSeenOn(event.id, relay)
successCount++
relayStatuses.push({ url, success: true })
})
.catch((authError) => {
errors.push({ url, error: authError })
relayStatuses.push({ url, success: false, error: authError.message })
})
} else {
errors.push({ url, error })
relayStatuses.push({ url, success: false, error: error.message })
}
})
} catch (error) {
errors.push({ url, error })
relayStatuses.push({
url,
success: false,
error: error instanceof Error ? error.message : 'Connection failed'
})
} finally {
// If one third of the relays have accepted the event, consider it a success
const isSuccess = successCount >= uniqueRelayUrls.length / 3
if (isSuccess) {
this.emitNewEvent(event)
}
if (++finishedCount >= uniqueRelayUrls.length) {
clearTimeout(globalTimeout)
resolve({
success: successCount >= uniqueRelayUrls.length / 3,
relayStatuses,
successCount,
totalCount: uniqueRelayUrls.length
})
}
}
})
)
})
@ -1120,17 +1169,83 @@ class ClientService extends EventTarget { @@ -1120,17 +1169,83 @@ class ClientService extends EventTarget {
}
async fetchRelayLists(pubkeys: string[]): Promise<TRelayList[]> {
// First check IndexedDB for offline/quick access (prioritizes cache relays for offline use)
const storedRelayEvents = await Promise.all(
pubkeys.map(pubkey => indexedDb.getReplaceableEvent(pubkey, kinds.RelayList))
)
const storedCacheRelayEvents = await Promise.all(
pubkeys.map(pubkey => indexedDb.getReplaceableEvent(pubkey, ExtendedKind.CACHE_RELAYS))
)
// Then fetch from relays (will update cache if newer)
const relayEvents = await this.fetchReplaceableEventsFromBigRelays(pubkeys, kinds.RelayList)
// Fetch cache relays from multiple sources: BIG_RELAY_URLS, PROFILE_FETCH_RELAY_URLS, and user's inboxes/outboxes
const cacheRelayEvents = await this.fetchCacheRelayEventsFromMultipleSources(pubkeys, relayEvents, storedRelayEvents)
return relayEvents.map((event) => {
if (event) {
return getRelayListFromEvent(event)
}
return {
write: BIG_RELAY_URLS,
read: BIG_RELAY_URLS,
return relayEvents.map((event, index) => {
// Use stored cache relay event if available (for offline), otherwise use fetched one
const storedCacheEvent = storedCacheRelayEvents[index]
const cacheEvent = cacheRelayEvents[index] || storedCacheEvent
// Use stored relay event if no network event (for offline), otherwise use fetched one
const storedRelayEvent = storedRelayEvents[index]
const relayEvent = event || storedRelayEvent
const relayList = relayEvent ? getRelayListFromEvent(relayEvent) : {
write: [],
read: [],
originalRelays: []
}
// Merge cache relays (kind 10432) into the relay list
// Prioritize cache relays by placing them first in the list (for offline functionality)
if (cacheEvent) {
const cacheRelayList = getRelayListFromEvent(cacheEvent)
// Merge read relays - cache relays first, then others (for offline priority)
const mergedRead = [...cacheRelayList.read, ...relayList.read]
const mergedWrite = [...cacheRelayList.write, ...relayList.write]
const mergedOriginalRelays = new Map<string, TMailboxRelay>()
// Add cache relay original relays first (prioritized)
cacheRelayList.originalRelays.forEach(relay => {
mergedOriginalRelays.set(relay.url, relay)
})
// Then add regular relay original relays
relayList.originalRelays.forEach(relay => {
if (!mergedOriginalRelays.has(relay.url)) {
mergedOriginalRelays.set(relay.url, relay)
}
})
// Deduplicate while preserving order (cache relays first)
return {
write: Array.from(new Set(mergedWrite)),
read: Array.from(new Set(mergedRead)),
originalRelays: Array.from(mergedOriginalRelays.values())
}
}
// If no cache event, return original relay list or default (with cache as fallback)
if (!relayEvent) {
// Check if we have a stored cache relay event as fallback
if (storedCacheEvent) {
const cacheRelayList = getRelayListFromEvent(storedCacheEvent)
return {
write: cacheRelayList.write.length > 0 ? cacheRelayList.write : BIG_RELAY_URLS,
read: cacheRelayList.read.length > 0 ? cacheRelayList.read : BIG_RELAY_URLS,
originalRelays: cacheRelayList.originalRelays
}
}
return {
write: BIG_RELAY_URLS,
read: BIG_RELAY_URLS,
originalRelays: []
}
}
return relayList
})
}
@ -1138,6 +1253,91 @@ class ClientService extends EventTarget { @@ -1138,6 +1253,91 @@ class ClientService extends EventTarget {
await this.replaceableEventBatchLoadFn([{ pubkey, kind: kinds.RelayList }])
}
/**
* Fetch cache relay events (kind 10432) from multiple sources:
* - BIG_RELAY_URLS
* - PROFILE_FETCH_RELAY_URLS
* - User's inboxes (read relays from kind 10002)
* - User's outboxes (write relays from kind 10002)
*/
private async fetchCacheRelayEventsFromMultipleSources(
pubkeys: string[],
relayEvents: (NEvent | null | undefined)[],
storedRelayEvents: (NEvent | null | undefined)[]
): Promise<(NEvent | null | undefined)[]> {
// Start with events from IndexedDB
const storedCacheRelayEvents = await Promise.all(
pubkeys.map(pubkey => indexedDb.getReplaceableEvent(pubkey, ExtendedKind.CACHE_RELAYS))
)
// Determine which pubkeys need fetching (don't have stored events)
const pubkeysToFetch = pubkeys.filter((_, index) => !storedCacheRelayEvents[index])
if (pubkeysToFetch.length === 0) {
return storedCacheRelayEvents
}
// Build list of relays to query from
const relayUrls = new Set<string>([...BIG_RELAY_URLS, ...PROFILE_FETCH_RELAY_URLS])
// Add user's inboxes and outboxes from their relay list (kind 10002)
pubkeys.forEach((_pubkey, index) => {
const relayEvent = relayEvents[index] || storedRelayEvents[index]
if (relayEvent) {
const relayList = getRelayListFromEvent(relayEvent)
// Add read relays (inboxes)
relayList.read.forEach(url => relayUrls.add(url))
// Add write relays (outboxes)
relayList.write.forEach(url => relayUrls.add(url))
}
})
// Fetch cache relay events from all sources
const cacheRelayEvents: (NEvent | null | undefined)[] = new Array(pubkeys.length).fill(undefined)
// Initialize with stored events
storedCacheRelayEvents.forEach((event, index) => {
if (event) {
cacheRelayEvents[index] = event
}
})
// Fetch missing cache relay events
if (pubkeysToFetch.length > 0) {
try {
const events = await this.query(Array.from(relayUrls), pubkeysToFetch.map(pubkey => ({
authors: [pubkey],
kinds: [ExtendedKind.CACHE_RELAYS]
})))
// Map fetched events back to original pubkey order
const eventMap = new Map<string, NEvent>()
events.forEach(event => {
const key = event.pubkey
const existing = eventMap.get(key)
if (!existing || existing.created_at < event.created_at) {
eventMap.set(key, event)
}
})
pubkeysToFetch.forEach((pubkey) => {
const pubkeyIndex = pubkeys.indexOf(pubkey)
if (pubkeyIndex !== -1) {
const event = eventMap.get(pubkey)
if (event) {
cacheRelayEvents[pubkeyIndex] = event
// Cache the event
indexedDb.putReplaceableEvent(event)
}
}
})
} catch (error) {
console.warn('[ClientService] Error fetching cache relay events:', error)
}
}
return cacheRelayEvents
}
async updateRelayListCache(event: NEvent) {
await this.updateReplaceableEventFromBigRelaysCache(event)
}

35
src/services/indexed-db.service.ts

@ -23,6 +23,7 @@ const StoreNames = { @@ -23,6 +23,7 @@ const StoreNames = {
EMOJI_SET_EVENTS: 'emojiSetEvents',
FAVORITE_RELAYS: 'favoriteRelays',
BLOCKED_RELAYS_EVENTS: 'blockedRelaysEvents',
CACHE_RELAYS_EVENTS: 'cacheRelaysEvents',
RELAY_SETS: 'relaySets',
FOLLOWING_FAVORITE_RELAYS: 'followingFavoriteRelays',
RELAY_INFOS: 'relayInfos',
@ -46,7 +47,7 @@ class IndexedDbService { @@ -46,7 +47,7 @@ class IndexedDbService {
init(): Promise<void> {
if (!this.initPromise) {
this.initPromise = new Promise((resolve, reject) => {
const request = window.indexedDB.open('jumble', 12)
const request = window.indexedDB.open('jumble', 13)
request.onerror = (event) => {
reject(event)
@ -57,8 +58,8 @@ class IndexedDbService { @@ -57,8 +58,8 @@ class IndexedDbService {
resolve()
}
request.onupgradeneeded = () => {
const db = request.result
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result
if (!db.objectStoreNames.contains(StoreNames.PROFILE_EVENTS)) {
db.createObjectStore(StoreNames.PROFILE_EVENTS, { keyPath: 'key' })
}
@ -113,7 +114,9 @@ class IndexedDbService { @@ -113,7 +114,9 @@ class IndexedDbService {
if (!db.objectStoreNames.contains(StoreNames.PUBLICATION_EVENTS)) {
db.createObjectStore(StoreNames.PUBLICATION_EVENTS, { keyPath: 'key' })
}
this.db = db
if (!db.objectStoreNames.contains(StoreNames.CACHE_RELAYS_EVENTS)) {
db.createObjectStore(StoreNames.CACHE_RELAYS_EVENTS, { keyPath: 'key' })
}
}
})
setTimeout(() => this.cleanUp(), 1000 * 60) // 1 minute
@ -167,10 +170,27 @@ class IndexedDbService { @@ -167,10 +170,27 @@ class IndexedDbService {
return Promise.reject('store name not found')
}
await this.initPromise
// Wait a bit for database upgrade to complete if store doesn't exist
if (this.db && !this.db.objectStoreNames.contains(storeName)) {
// Wait up to 2 seconds for store to be created (database upgrade)
let retries = 20
while (retries > 0 && this.db && !this.db.objectStoreNames.contains(storeName)) {
await new Promise(resolve => setTimeout(resolve, 100))
retries--
}
}
return new Promise((resolve, reject) => {
if (!this.db) {
return reject('database not initialized')
}
// Check if the store exists before trying to access it
if (!this.db.objectStoreNames.contains(storeName)) {
console.warn(`Store ${storeName} not found in database. Cannot save event.`)
// Return the event anyway (don't reject) - caching is optional
return resolve(event)
}
const transaction = this.db.transaction(storeName, 'readwrite')
const store = transaction.objectStore(storeName)
@ -215,6 +235,11 @@ class IndexedDbService { @@ -215,6 +235,11 @@ class IndexedDbService {
if (!this.db) {
return reject('database not initialized')
}
// Check if the store exists before trying to access it
if (!this.db.objectStoreNames.contains(storeName)) {
console.warn(`Store ${storeName} not found in database. Returning null.`)
return resolve(null)
}
const transaction = this.db.transaction(storeName, 'readonly')
const store = transaction.objectStore(storeName)
const key = this.getReplaceableEventKey(pubkey, d)
@ -477,6 +502,8 @@ class IndexedDbService { @@ -477,6 +502,8 @@ class IndexedDbService {
return StoreNames.FAVORITE_RELAYS
case ExtendedKind.BLOCKED_RELAYS:
return StoreNames.BLOCKED_RELAYS_EVENTS
case ExtendedKind.CACHE_RELAYS:
return StoreNames.CACHE_RELAYS_EVENTS
case kinds.UserEmojiList:
return StoreNames.USER_EMOJI_LIST_EVENTS
case kinds.Emojisets:

Loading…
Cancel
Save