1 Commits

Author SHA1 Message Date
woikos
2e3b854037 DM inbox caching and linkification improvements (v0.2.4)
- Add MessageContent component for linkified DM messages
- URLs open in new tab, nostr: entities open in secondary pane
- Implement background refresh that merges instead of clearing cache
- Show cached conversations immediately on page load
- Fix navigation not reloading conversations unnecessarily

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 13:26:17 +01:00
6 changed files with 516 additions and 51 deletions

View File

@@ -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",

View File

@@ -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 (
<span className={cn('whitespace-pre-wrap break-words', className)}>
{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 (
<a
key={index}
href={url}
target="_blank"
rel="noreferrer"
className={linkClass}
onClick={(e) => e.stopPropagation()}
>
{truncateUrl(url)}
</a>
)
}
// YouTube and X posts - open in new tab
if (node.type === 'youtube' || node.type === 'x-post') {
const url = node.data as string
return (
<a
key={index}
href={url}
target="_blank"
rel="noreferrer"
className={linkClass}
onClick={(e) => e.stopPropagation()}
>
{truncateUrl(url)}
</a>
)
}
// nostr: mention (npub/nprofile) - open profile in secondary pane
if (node.type === 'mention') {
const bech32 = (node.data as string).replace('nostr:', '')
return (
<button
key={index}
className={linkClass}
onClick={(e) => {
e.stopPropagation()
push(toProfile(bech32))
}}
>
@{bech32.slice(0, 12)}...
</button>
)
}
// 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 (
<button
key={index}
className={linkClass}
onClick={(e) => {
e.stopPropagation()
push(toNote(bech32))
}}
>
{prefix}:{bech32.slice(prefix.length, prefix.length + 8)}...
</button>
)
}
return null
})}
</span>
)
}

View File

@@ -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) {
</div>
) : (
<div className="space-y-3">
{/* Load more button at top */}
{hasMoreMessages && (
<div className="flex justify-center py-2">
<Button
variant="ghost"
size="sm"
onClick={loadMoreMessages}
className="text-xs text-muted-foreground"
>
<ChevronUp className="size-4 mr-1" />
{t('Load older messages')} ({messages.length - visibleLimit} more)
</Button>
</div>
)}
{isLoadingConversation && (
<div className="flex justify-center py-2">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
)}
{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'
)}
>
<p className="text-sm whitespace-pre-wrap break-words">{message.content}</p>
<MessageContent
content={message.content}
className="text-sm"
isOwnMessage={isOwn}
/>
<div
className={cn(
'flex items-center justify-between gap-2 mt-1 text-xs',

View File

@@ -63,7 +63,9 @@ export const StorageKey = {
export const ApplicationDataKey = {
NOTIFICATIONS_SEEN_AT: 'seen_notifications_at',
SETTINGS: 'smesh_settings',
DM_DELETED_MESSAGES: 'dm_deleted_messages'
DM_DELETED_MESSAGES: 'dm_deleted_messages',
// Relay hint for DMs - contains bech32-encoded relays (nrelay1...) that smesh clients should check first
DM_RELAY_HINT: 'smesh_dm_relays'
}
export const BIG_RELAY_URLS = [

View File

@@ -1,6 +1,12 @@
import { ApplicationDataKey, BIG_RELAY_URLS } from '@/constants'
import { createDeletedMessagesDraftEvent } from '@/lib/draft-event'
import dmService, { IDMEncryption, isConversationDeleted, isMessageDeleted } from '@/services/dm.service'
import dmService, {
clearPlaintextCache,
decryptMessagesInBatches,
IDMEncryption,
isConversationDeleted,
isMessageDeleted
} from '@/services/dm.service'
import indexedDb from '@/services/indexed-db.service'
import storage, { dispatchSettingsChanged } from '@/services/local-storage.service'
import client from '@/services/client.service'
@@ -89,6 +95,9 @@ export function DMProvider({ children }: { children: React.ReactNode }) {
// Track which conversation load is in progress to prevent race conditions
const loadingConversationRef = useRef<string | null>(null)
// Track if we've already initialized to avoid reloading on navigation
const hasInitializedRef = useRef(false)
const lastPubkeyRef = useRef<string | null>(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<string, TConversation>()
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(() => {

View File

@@ -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<string, string>()
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<TDirectMessage[]> {
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<Event[]> {
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<TDirectMessage | null> {
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<string[]> {
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
*/