diff --git a/package.json b/package.json
index 4e63402d..68a6f09d 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "smesh",
- "version": "0.2.2",
+ "version": "0.2.4",
"description": "A user-friendly Nostr client for exploring relay feeds",
"private": true,
"type": "module",
diff --git a/src/components/Inbox/MessageContent.tsx b/src/components/Inbox/MessageContent.tsx
new file mode 100644
index 00000000..753f60f8
--- /dev/null
+++ b/src/components/Inbox/MessageContent.tsx
@@ -0,0 +1,120 @@
+import { useSecondaryPage } from '@/PageManager'
+import {
+ EmbeddedEventParser,
+ EmbeddedMentionParser,
+ EmbeddedUrlParser,
+ parseContent
+} from '@/lib/content-parser'
+import { toNote, toProfile } from '@/lib/link'
+import { truncateUrl } from '@/lib/url'
+import { cn } from '@/lib/utils'
+import { useMemo } from 'react'
+
+interface MessageContentProps {
+ content: string
+ className?: string
+ /** If true, links will be styled for dark background (primary-foreground color) */
+ isOwnMessage?: boolean
+}
+
+/**
+ * Renders DM message content with linkified URLs and nostr entities.
+ * - URLs open in new tab
+ * - nostr:npub/nprofile opens user profile in secondary pane
+ * - nostr:note1/nevent opens note in secondary pane
+ */
+export default function MessageContent({ content, className, isOwnMessage }: MessageContentProps) {
+ const { push } = useSecondaryPage()
+
+ const nodes = useMemo(() => {
+ return parseContent(content, [EmbeddedEventParser, EmbeddedMentionParser, EmbeddedUrlParser])
+ }, [content])
+
+ const linkClass = cn(
+ 'underline cursor-pointer hover:opacity-80',
+ isOwnMessage ? 'text-primary-foreground' : 'text-primary'
+ )
+
+ return (
+
+ {nodes.map((node, index) => {
+ if (node.type === 'text') {
+ return node.data
+ }
+
+ // URLs - open in new tab
+ if (node.type === 'url' || node.type === 'image' || node.type === 'media') {
+ const url = node.data as string
+ return (
+ e.stopPropagation()}
+ >
+ {truncateUrl(url)}
+
+ )
+ }
+
+ // YouTube and X posts - open in new tab
+ if (node.type === 'youtube' || node.type === 'x-post') {
+ const url = node.data as string
+ return (
+ e.stopPropagation()}
+ >
+ {truncateUrl(url)}
+
+ )
+ }
+
+ // nostr: mention (npub/nprofile) - open profile in secondary pane
+ if (node.type === 'mention') {
+ const bech32 = (node.data as string).replace('nostr:', '')
+ return (
+
+ )
+ }
+
+ // nostr: event (note1/nevent/naddr) - open note in secondary pane
+ if (node.type === 'event') {
+ const bech32 = (node.data as string).replace('nostr:', '')
+ // Determine display based on prefix
+ const isNote = bech32.startsWith('note1')
+ const prefix = isNote ? 'note' : bech32.startsWith('nevent') ? 'nevent' : 'naddr'
+ return (
+
+ )
+ }
+
+ return null
+ })}
+
+ )
+}
diff --git a/src/components/Inbox/MessageView.tsx b/src/components/Inbox/MessageView.tsx
index a8c844fd..4023d0c5 100644
--- a/src/components/Inbox/MessageView.tsx
+++ b/src/components/Inbox/MessageView.tsx
@@ -6,7 +6,7 @@ import { useNostr } from '@/providers/NostrProvider'
import client from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service'
import { TDirectMessage, TProfile } from '@/types'
-import { ArrowLeft, ChevronDown, Loader2, MoreVertical, RefreshCw, Settings, Trash2, Undo2, Users, X } from 'lucide-react'
+import { ArrowLeft, ChevronDown, ChevronUp, Loader2, MoreVertical, RefreshCw, Settings, Trash2, Undo2, Users, X } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '../ui/button'
@@ -19,6 +19,7 @@ import {
DropdownMenuTrigger
} from '../ui/dropdown-menu'
import MessageComposer from './MessageComposer'
+import MessageContent from './MessageContent'
import MessageInfoModal from './MessageInfoModal'
import ConversationSettingsModal from './ConversationSettingsModal'
import { useFollowList } from '@/providers/FollowListProvider'
@@ -58,9 +59,28 @@ export default function MessageView({ onBack }: MessageViewProps) {
const [newMessageCount, setNewMessageCount] = useState(0)
const lastMessageCountRef = useRef(0)
const isAtBottomRef = useRef(true)
+ // Progressive loading: start with 20 messages, load more on demand
+ const [visibleLimit, setVisibleLimit] = useState(20)
+ const LOAD_MORE_INCREMENT = 20
const isFollowing = currentConversation ? followingSet.has(currentConversation) : false
+ // Calculate visible messages (show most recent, allow loading older)
+ const hasMoreMessages = messages.length > visibleLimit
+ const visibleMessages = hasMoreMessages
+ ? messages.slice(-visibleLimit) // Show last N messages (most recent)
+ : messages
+
+ // Load more older messages
+ const loadMoreMessages = () => {
+ setVisibleLimit((prev) => prev + LOAD_MORE_INCREMENT)
+ }
+
+ // Reset visible limit when conversation changes
+ useEffect(() => {
+ setVisibleLimit(20)
+ }, [currentConversation])
+
// Handle pulsing animation for new conversations
useEffect(() => {
if (isNewConversation) {
@@ -288,12 +308,26 @@ export default function MessageView({ onBack }: MessageViewProps) {
) : (
+ {/* Load more button at top */}
+ {hasMoreMessages && (
+
+
+
+ )}
{isLoadingConversation && (
)}
- {messages.map((message) => {
+ {visibleMessages.map((message) => {
const isOwn = message.senderPubkey === pubkey
const isSelected = selectedMessages.has(message.id)
return (
@@ -328,7 +362,11 @@ export default function MessageView({ onBack }: MessageViewProps) {
isSelected && 'ring-2 ring-primary ring-offset-2'
)}
>
-
{message.content}
+
(null)
+ // Track if we've already initialized to avoid reloading on navigation
+ const hasInitializedRef = useRef(false)
+ const lastPubkeyRef = useRef(null)
// Create encryption wrapper object for dm.service
const encryption: IDMEncryption | null = useMemo(() => {
@@ -106,6 +115,15 @@ export function DMProvider({ children }: { children: React.ReactNode }) {
// Load deleted state and conversations when user is logged in
useEffect(() => {
if (pubkey && encryption) {
+ // Skip if already initialized for this pubkey (e.g., navigating back)
+ if (hasInitializedRef.current && lastPubkeyRef.current === pubkey) {
+ return
+ }
+
+ // Mark as initialized for this pubkey
+ hasInitializedRef.current = true
+ lastPubkeyRef.current = pubkey
+
// Load deleted state FIRST before anything else
const loadDeletedStateAndConversations = async () => {
// Step 1: Load deleted state from IndexedDB
@@ -160,12 +178,13 @@ export function DMProvider({ children }: { children: React.ReactNode }) {
setHasMoreConversations(conversations.length > CONVERSATIONS_PER_PAGE)
}
- // Step 4: Refresh from network
- refreshConversations()
+ // Step 4: Background refresh from network (don't clear existing data)
+ backgroundRefreshConversations()
}
loadDeletedStateAndConversations()
} else {
+ // Clear all state on logout
setConversations([])
setAllConversations([])
setMessages([])
@@ -175,6 +194,11 @@ export function DMProvider({ children }: { children: React.ReactNode }) {
setDeletedState(null)
setSelectedMessages(new Set())
setIsSelectionMode(false)
+ // Clear in-memory plaintext cache
+ clearPlaintextCache()
+ // Reset initialization flag so we reload on next login
+ hasInitializedRef.current = false
+ lastPubkeyRef.current = null
}
}, [pubkey, encryption])
@@ -235,28 +259,43 @@ export function DMProvider({ children }: { children: React.ReactNode }) {
return // Abort - user switched conversations
}
- // Decrypt all messages in parallel for speed
- const decryptedResults = await Promise.all(
- events.map((event) => dmService.decryptMessage(event, encryption, pubkey))
- )
+ // Decrypt messages in batches to avoid blocking UI
+ // Progressive updates: show messages as they're decrypted
+ const allDecrypted: TDirectMessage[] = []
- // Filter to only messages in this conversation (excluding deleted)
- const decrypted = decryptedResults.filter((message): message is TDirectMessage => {
- if (!message) return false
- const partner =
- message.senderPubkey === pubkey ? message.recipientPubkey : message.senderPubkey
- if (partner !== targetConversation) return false
- // Filter out deleted messages
- return !isMessageDeleted(message.id, targetConversation, message.createdAt, deletedState)
- })
+ await decryptMessagesInBatches(
+ events,
+ encryption,
+ pubkey,
+ 10, // batch size
+ (batchMessages) => {
+ // Check if still on same conversation before updating
+ if (loadingConversationRef.current !== targetConversation) return
+
+ // Filter to only messages in this conversation (excluding deleted)
+ const validMessages = batchMessages.filter((message) => {
+ const partner =
+ message.senderPubkey === pubkey ? message.recipientPubkey : message.senderPubkey
+ if (partner !== targetConversation) return false
+ return !isMessageDeleted(message.id, targetConversation, message.createdAt, deletedState)
+ })
+
+ allDecrypted.push(...validMessages)
+
+ // Sort and update progressively
+ const sorted = [...allDecrypted].sort((a, b) => a.createdAt - b.createdAt)
+ setMessages(sorted)
+ setConversationMessages((prev) => new Map(prev).set(targetConversation, sorted))
+ }
+ )
// Check again after decryption (which can take time)
if (loadingConversationRef.current !== targetConversation) {
return // Abort - user switched conversations
}
- // Sort by time
- const sorted = decrypted.sort((a, b) => a.createdAt - b.createdAt)
+ // Final sort
+ const sorted = allDecrypted.sort((a, b) => a.createdAt - b.createdAt)
// Update state only if still on same conversation
setConversationMessages((prev) => new Map(prev).set(targetConversation, sorted))
@@ -287,21 +326,120 @@ export function DMProvider({ children }: { children: React.ReactNode }) {
loadConversation()
}, [currentConversation, pubkey, encryption, relayList, deletedState])
+ // Background refresh - merges new data without clearing existing cache
+ const backgroundRefreshConversations = useCallback(async () => {
+ if (!pubkey || !encryption) return
+
+ try {
+ // Get relay URLs
+ const relayUrls = relayList?.read || []
+
+ // Fetch recent DM events (raw, not decrypted)
+ const events = await dmService.fetchRecentDMEvents(pubkey, relayUrls)
+
+ // Separate NIP-04 events and gift wraps
+ const nip04Events = events.filter((e) => e.kind === 4)
+ const giftWraps = events.filter((e) => e.kind === 1059)
+
+ // Build conversation map from existing conversations
+ const conversationMap = new Map()
+ allConversations.forEach((c) => conversationMap.set(c.partnerPubkey, c))
+
+ // Add NIP-04 conversations
+ const nip04Convs = dmService.groupEventsIntoConversations(nip04Events, pubkey)
+ nip04Convs.forEach((conv, key) => {
+ const existing = conversationMap.get(key)
+ if (!existing || conv.lastMessageAt > existing.lastMessageAt) {
+ conversationMap.set(key, conv)
+ }
+ })
+
+ // Update UI with NIP-04 data (filtered by deleted state)
+ const updateAndShowConversations = () => {
+ const validConversations = Array.from(conversationMap.values())
+ .filter((conv) => conv.partnerPubkey && typeof conv.partnerPubkey === 'string')
+ .filter((conv) => !isConversationDeleted(conv.partnerPubkey, conv.lastMessageAt, deletedState))
+ const sortedConversations = validConversations.sort(
+ (a, b) => b.lastMessageAt - a.lastMessageAt
+ )
+ setAllConversations(sortedConversations)
+ setConversations(sortedConversations.slice(0, CONVERSATIONS_PER_PAGE))
+ setHasMoreConversations(sortedConversations.length > CONVERSATIONS_PER_PAGE)
+ }
+
+ updateAndShowConversations()
+
+ // Process gift wraps in background (progressive, no UI blocking)
+ const sortedGiftWraps = giftWraps.sort((a, b) => b.created_at - a.created_at)
+
+ for (const giftWrap of sortedGiftWraps) {
+ try {
+ const message = await dmService.decryptMessage(giftWrap, encryption, pubkey)
+ if (message && message.senderPubkey && message.recipientPubkey) {
+ const partnerPubkey =
+ message.senderPubkey === pubkey ? message.recipientPubkey : message.senderPubkey
+
+ if (!partnerPubkey || partnerPubkey === '__reaction__') continue
+
+ const existing = conversationMap.get(partnerPubkey)
+ if (!existing || message.createdAt > existing.lastMessageAt) {
+ conversationMap.set(partnerPubkey, {
+ partnerPubkey,
+ lastMessageAt: message.createdAt,
+ lastMessagePreview: message.content.substring(0, 100),
+ unreadCount: 0,
+ preferredEncryption: 'nip17'
+ })
+ updateAndShowConversations()
+ }
+
+ // Cache conversation metadata
+ indexedDb
+ .putDMConversation(
+ pubkey,
+ partnerPubkey,
+ message.createdAt,
+ message.content.substring(0, 100),
+ 'nip17'
+ )
+ .catch(() => {})
+ }
+ } catch {
+ // Skip failed decryptions silently
+ }
+ }
+
+ // Final update and cache all conversations
+ updateAndShowConversations()
+ const finalConversations = Array.from(conversationMap.values())
+ Promise.all(
+ finalConversations.map((conv) =>
+ indexedDb.putDMConversation(
+ pubkey,
+ conv.partnerPubkey,
+ conv.lastMessageAt,
+ conv.lastMessagePreview,
+ conv.preferredEncryption
+ )
+ )
+ ).catch(() => {})
+ } catch {
+ // Background refresh failed silently - cached data still shown
+ }
+ }, [pubkey, encryption, relayList, deletedState, allConversations])
+
+ // Full refresh - clears caches and reloads everything (manual action)
const refreshConversations = useCallback(async () => {
if (!pubkey || !encryption) return
setIsLoading(true)
setError(null)
- // Clear all local state
- setConversations([])
- setAllConversations([])
- setConversationMessages(new Map())
- setLoadedConversations(new Set())
-
try {
- // Clear all DM caches for a fresh start
+ // Clear caches for fresh start (only on manual refresh)
await indexedDb.clearAllDMCaches()
+ setConversationMessages(new Map())
+ setLoadedConversations(new Set())
// Get relay URLs
const relayUrls = relayList?.read || []
@@ -705,9 +843,9 @@ export function DMProvider({ children }: { children: React.ReactNode }) {
// Publish to relays
await publishDeletedState(newDeletedState)
- // Trigger a refresh of conversations
- await refreshConversations()
- }, [pubkey, currentConversation, deletedState, publishDeletedState, refreshConversations])
+ // Trigger a background refresh of conversations
+ await backgroundRefreshConversations()
+ }, [pubkey, currentConversation, deletedState, publishDeletedState, backgroundRefreshConversations])
// Filter out deleted conversations from the list
const filteredConversations = useMemo(() => {
diff --git a/src/services/dm.service.ts b/src/services/dm.service.ts
index 6ec07a99..e05bbe5a 100644
--- a/src/services/dm.service.ts
+++ b/src/services/dm.service.ts
@@ -11,6 +11,74 @@ import { Event, kinds, VerifiedEvent } from 'nostr-tools'
import client from './client.service'
import indexedDb from './indexed-db.service'
+// In-memory plaintext cache for fast access (avoids async IndexedDB lookups on re-render)
+const plaintextCache = new Map()
+const MAX_CACHE_SIZE = 1000
+
+/**
+ * Get plaintext from in-memory cache
+ */
+export function getCachedPlaintext(eventId: string): string | undefined {
+ return plaintextCache.get(eventId)
+}
+
+/**
+ * Set plaintext in in-memory cache (with LRU eviction)
+ */
+export function setCachedPlaintext(eventId: string, plaintext: string): void {
+ // Simple LRU: if cache is full, delete oldest entries
+ if (plaintextCache.size >= MAX_CACHE_SIZE) {
+ const keysToDelete = Array.from(plaintextCache.keys()).slice(0, 100)
+ keysToDelete.forEach(k => plaintextCache.delete(k))
+ }
+ plaintextCache.set(eventId, plaintext)
+}
+
+/**
+ * Clear the plaintext cache (e.g., on logout)
+ */
+export function clearPlaintextCache(): void {
+ plaintextCache.clear()
+}
+
+/**
+ * Decrypt messages in batches to avoid blocking the UI
+ * Yields control back to the event loop between batches
+ */
+export async function decryptMessagesInBatches(
+ events: Event[],
+ encryption: IDMEncryption,
+ myPubkey: string,
+ batchSize: number = 10,
+ onBatchComplete?: (messages: TDirectMessage[], progress: number) => void
+): Promise {
+ const allMessages: TDirectMessage[] = []
+ const total = events.length
+
+ for (let i = 0; i < events.length; i += batchSize) {
+ const batch = events.slice(i, i + batchSize)
+
+ // Process batch
+ const batchResults = await Promise.all(
+ batch.map((event) => dmService.decryptMessage(event, encryption, myPubkey))
+ )
+
+ const validMessages = batchResults.filter((m): m is TDirectMessage => m !== null)
+ allMessages.push(...validMessages)
+
+ // Report progress
+ const progress = Math.min((i + batchSize) / total, 1)
+ onBatchComplete?.(validMessages, progress)
+
+ // Yield to event loop between batches (prevents UI blocking)
+ if (i + batchSize < events.length) {
+ await new Promise(resolve => setTimeout(resolve, 0))
+ }
+ }
+
+ return allMessages
+}
+
/**
* Create and publish a kind 5 delete request for own messages
* This requests relays to delete the original event
@@ -141,6 +209,10 @@ class DMService {
): Promise {
const allRelays = [...new Set([...relayUrls, ...BIG_RELAY_URLS])]
+ // Get partner's inbox relays for better NIP-17 discovery
+ const partnerInboxRelays = await this.fetchPartnerInboxRelays(partnerPubkey)
+ const inboxRelays = [...new Set([...relayUrls, ...partnerInboxRelays, ...BIG_RELAY_URLS])]
+
// Fetch NIP-04 messages between user and partner (with timeout)
const [incomingNip04, outgoingNip04, giftWraps] = await Promise.all([
// Messages FROM partner TO user
@@ -163,9 +235,9 @@ class DMService {
}),
DM_FETCH_TIMEOUT_MS
),
- // Gift wraps addressed to user (we'll filter by sender after decryption)
+ // Gift wraps addressed to user - check both regular relays and inbox relays
withTimeout(
- client.fetchEvents(allRelays, {
+ client.fetchEvents(inboxRelays, {
kinds: [KIND_GIFT_WRAP],
'#p': [pubkey],
limit: 500
@@ -192,10 +264,18 @@ class DMService {
): Promise {
try {
if (event.kind === KIND_ENCRYPTED_DM) {
- // NIP-04 decryption - check content cache first
- const cached = await indexedDb.getDecryptedContent(event.id)
- if (cached) {
- return this.buildDirectMessage(event, cached, myPubkey, 'nip04')
+ // NIP-04 decryption - check in-memory cache first (fastest)
+ const memCached = getCachedPlaintext(event.id)
+ if (memCached) {
+ return this.buildDirectMessage(event, memCached, myPubkey, 'nip04')
+ }
+
+ // Check IndexedDB cache (slower but persistent)
+ const dbCached = await indexedDb.getDecryptedContent(event.id)
+ if (dbCached) {
+ // Populate in-memory cache for next access
+ setCachedPlaintext(event.id, dbCached)
+ return this.buildDirectMessage(event, dbCached, myPubkey, 'nip04')
}
const otherPubkey = this.getOtherPartyPubkey(event, myPubkey)
@@ -203,18 +283,44 @@ class DMService {
const decryptedContent = await encryption.nip04Decrypt(otherPubkey, event.content)
- // Cache the decrypted content
+ // Cache in both layers
+ setCachedPlaintext(event.id, decryptedContent)
indexedDb.putDecryptedContent(event.id, decryptedContent).catch(() => {})
return this.buildDirectMessage(event, decryptedContent, myPubkey, 'nip04')
} else if (event.kind === KIND_GIFT_WRAP) {
- // NIP-17 - check unwrapped cache first (includes sender info)
+ // NIP-17 - check in-memory cache first
+ const memCached = getCachedPlaintext(event.id)
+ if (memCached) {
+ // We stored "pubkey|recipient|content" format in memory for NIP-17
+ const parts = memCached.split('|', 3)
+ if (parts.length === 3) {
+ const [senderPubkey, recipientPubkey, content] = parts
+ if (recipientPubkey === '__reaction__') return null
+ const seenOnRelays = client.getSeenEventRelayUrls(event.id)
+ return {
+ id: event.id,
+ senderPubkey,
+ recipientPubkey,
+ content,
+ createdAt: event.created_at,
+ encryptionType: 'nip17',
+ event,
+ decryptedContent: content,
+ seenOnRelays: seenOnRelays.length > 0 ? seenOnRelays : undefined
+ }
+ }
+ }
+
+ // Check IndexedDB cache (includes sender info)
const cachedUnwrapped = await indexedDb.getUnwrappedGiftWrap(event.id)
if (cachedUnwrapped) {
// Skip reactions in cache for now (they're stored but not returned as messages)
if (cachedUnwrapped.recipientPubkey === '__reaction__') {
return null
}
+ // Populate in-memory cache
+ setCachedPlaintext(event.id, `${cachedUnwrapped.pubkey}|${cachedUnwrapped.recipientPubkey}|${cachedUnwrapped.content}`)
const seenOnRelays = client.getSeenEventRelayUrls(event.id)
return {
id: event.id,
@@ -253,7 +359,8 @@ class DMService {
const recipientPubkey = this.getRecipientFromTags(innerEvent.tags) || myPubkey
- // Cache the unwrapped inner event (includes sender info)
+ // Cache in both layers
+ setCachedPlaintext(event.id, `${innerEvent.pubkey}|${recipientPubkey}|${unwrapped.content}`)
indexedDb
.putUnwrappedGiftWrap(event.id, {
pubkey: innerEvent.pubkey,
@@ -369,8 +476,14 @@ class DMService {
const sentEvents: Event[] = []
// Get recipient's relays for better delivery
- const recipientRelays = await this.fetchPartnerRelays(recipientPubkey)
- const allRelays = [...new Set([...relayUrls, ...recipientRelays])]
+ // Use inbox relays for NIP-17 (where recipient receives messages)
+ // Use write relays for NIP-04 (where recipient publishes from)
+ const [recipientInboxRelays, recipientWriteRelays] = await Promise.all([
+ this.fetchPartnerInboxRelays(recipientPubkey),
+ this.fetchPartnerRelays(recipientPubkey)
+ ])
+ const allRelays = [...new Set([...relayUrls, ...recipientWriteRelays])]
+ const inboxRelays = [...new Set([...relayUrls, ...recipientInboxRelays])]
if (existingEncryption === null) {
// No existing conversation - send in BOTH formats
@@ -388,11 +501,12 @@ class DMService {
try {
if (encryption.nip44Encrypt) {
+ // Use inbox relays for NIP-17 delivery
const nip17Event = await this.createAndPublishNip17DM(
recipientPubkey,
content,
encryption,
- allRelays
+ inboxRelays
)
sentEvents.push(nip17Event)
}
@@ -414,7 +528,7 @@ class DMService {
throw error // Re-throw so caller knows it failed
}
} else if (existingEncryption === 'nip17') {
- // Match existing NIP-17 encryption
+ // Match existing NIP-17 encryption - use inbox relays
if (!encryption.nip44Encrypt) {
throw new Error('Encryption does not support NIP-44')
}
@@ -423,7 +537,7 @@ class DMService {
recipientPubkey,
content,
encryption,
- allRelays
+ inboxRelays
)
sentEvents.push(nip17Event)
} catch (error) {
@@ -533,7 +647,7 @@ class DMService {
// Try to get relay list from IndexedDB first
const cachedEvent = await indexedDb.getReplaceableEvent(pubkey, kinds.RelayList)
if (cachedEvent) {
- return this.parseRelayList(cachedEvent)
+ return this.parseWriteRelays(cachedEvent)
}
// Fetch from relays
@@ -546,7 +660,7 @@ class DMService {
if (relayListEvents.length > 0) {
const event = relayListEvents[0]
await indexedDb.putReplaceableEvent(event)
- return this.parseRelayList(event)
+ return this.parseWriteRelays(event)
}
// Fallback to archive relay
@@ -557,9 +671,41 @@ class DMService {
}
/**
- * Parse relay list from kind 10002 event
+ * Fetch partner's inbox (read) relays for NIP-17 DM delivery
+ * NIP-65: Inbox relays are where a user receives messages
*/
- private parseRelayList(event: Event): string[] {
+ async fetchPartnerInboxRelays(pubkey: string): Promise {
+ try {
+ // Try to get relay list from IndexedDB first
+ const cachedEvent = await indexedDb.getReplaceableEvent(pubkey, kinds.RelayList)
+ if (cachedEvent) {
+ return this.parseInboxRelays(cachedEvent)
+ }
+
+ // Fetch from relays
+ const relayListEvents = await client.fetchEvents(BIG_RELAY_URLS, {
+ kinds: [kinds.RelayList],
+ authors: [pubkey],
+ limit: 1
+ })
+
+ if (relayListEvents.length > 0) {
+ const event = relayListEvents[0]
+ await indexedDb.putReplaceableEvent(event)
+ return this.parseInboxRelays(event)
+ }
+
+ // Fallback to big relays
+ return BIG_RELAY_URLS
+ } catch {
+ return BIG_RELAY_URLS
+ }
+ }
+
+ /**
+ * Parse write (outbox) relays from kind 10002 event
+ */
+ private parseWriteRelays(event: Event): string[] {
const writeRelays: string[] = []
for (const tag of event.tags) {
@@ -576,6 +722,27 @@ class DMService {
return writeRelays.length > 0 ? writeRelays : [ARCHIVE_RELAY_URL]
}
+ /**
+ * Parse inbox (read) relays from kind 10002 event
+ * These are where the user receives DMs
+ */
+ private parseInboxRelays(event: Event): string[] {
+ const inboxRelays: string[] = []
+
+ for (const tag of event.tags) {
+ if (tag[0] === 'r') {
+ const url = tag[1]
+ const scope = tag[2]
+ // Include if it's a read relay or has no scope (both)
+ if (!scope || scope === 'read') {
+ inboxRelays.push(url)
+ }
+ }
+ }
+
+ return inboxRelays.length > 0 ? inboxRelays : BIG_RELAY_URLS
+ }
+
/**
* Check other relays for an event and return which ones have it
*/