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 */